Add REST API. Refactor config logic.
The REST API is simply a wrapper around the command line (and actually invokes the command line). It relies on the command line tool validating its input. Currently only the `/list` endpoint is implemented, exposing the `list` command.
This commit is contained in:
parent
6f247032a3
commit
29959a6a8d
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
*.sw*
|
||||
nimcache/
|
||||
/pit
|
||||
/pit_api
|
||||
|
@ -1,12 +1,15 @@
|
||||
# Package
|
||||
|
||||
version = "4.0.7"
|
||||
include "src/pitpkg/private/version.nim"
|
||||
|
||||
version = PIT_VERSION
|
||||
author = "Jonathan Bernard"
|
||||
description = "Personal issue tracker."
|
||||
license = "MIT"
|
||||
srcDir = "src"
|
||||
bin = @["pit"]
|
||||
bin = @["pit", "pit_api"]
|
||||
|
||||
# Dependencies
|
||||
|
||||
requires @["nim >= 0.18.0", "uuids 0.1.9", "docopt 0.6.5", "cliutils 0.3.4", "timeutils 0.3.0"]
|
||||
requires @[ "nim >= 0.18.0", "cliutils 0.3.5", "docopt 0.6.5", "jester 0.2.0",
|
||||
"timeutils 0.3.0", "uuids 0.1.9" ]
|
||||
|
113
src/pit.nim
113
src/pit.nim
@ -1,6 +1,5 @@
|
||||
## Personal Issue Tracker
|
||||
## ======================
|
||||
##
|
||||
## Personal Issue Tracker CLI interface
|
||||
## ====================================
|
||||
|
||||
import cliutils, docopt, json, logging, options, os, ospaths, sequtils,
|
||||
tables, terminal, times, timeutils, unicode, uuids
|
||||
@ -9,58 +8,35 @@ import strutils except capitalize, toUpper, toLower
|
||||
import pitpkg/private/libpit
|
||||
export libpit
|
||||
|
||||
include "pitpkg/private/version.nim"
|
||||
|
||||
type
|
||||
CliContext = ref object
|
||||
autoList, triggerPtk: bool
|
||||
tasksDir*: string
|
||||
autoList, triggerPtk, verbose: bool
|
||||
cfg*: PitConfig
|
||||
contexts*: TableRef[string, string]
|
||||
defaultContext*, tasksDir*: string
|
||||
issues*: TableRef[IssueState, seq[Issue]]
|
||||
termWidth*: int
|
||||
|
||||
proc initContext(args: Table[string, Value]): CliContext =
|
||||
let pitrcLocations = @[
|
||||
if args["--config"]: $args["--config"] else: "",
|
||||
".pitrc", $getEnv("PITRC"), $getEnv("HOME") & "/.pitrc"]
|
||||
let pitCfg = loadConfig(args)
|
||||
|
||||
var pitrcFilename: string =
|
||||
foldl(pitrcLocations, if len(a) > 0: a elif existsFile(b): b else: "")
|
||||
let cliJson =
|
||||
if pitCfg.cfg.json.hasKey("cli"): pitCfg.cfg.json["cli"]
|
||||
else: newJObject()
|
||||
|
||||
if not existsFile(pitrcFilename):
|
||||
warn "pit: could not find .pitrc file: " & pitrcFilename
|
||||
if isNilOrWhitespace(pitrcFilename):
|
||||
pitrcFilename = $getEnv("HOME") & "/.pitrc"
|
||||
var cfgFile: File
|
||||
try:
|
||||
cfgFile = open(pitrcFilename, fmWrite)
|
||||
cfgFile.write("{\"tasksDir\": \"/path/to/tasks\"}")
|
||||
except: warn "pit: could not write default .pitrc to " & pitrcFilename
|
||||
finally: close(cfgFile)
|
||||
|
||||
var cfgJson: JsonNode
|
||||
try: cfgJson = parseFile(pitrcFilename)
|
||||
except: raise newException(IOError,
|
||||
"unable to read config file: " & pitrcFilename &
|
||||
"\x0D\x0A" & getCurrentExceptionMsg())
|
||||
|
||||
let cfg = CombinedConfig(docopt: args, json: cfgJson)
|
||||
let cliCfg = CombinedConfig(docopt: args, json: cliJson)
|
||||
|
||||
result = CliContext(
|
||||
autoList: cfgJson.getOrDefault("autoList").getBool(false),
|
||||
contexts: newTable[string,string](),
|
||||
autoList: cliJson.getOrDefault("autoList").getBool(false),
|
||||
contexts: pitCfg.contexts,
|
||||
defaultContext: cliJson.getOrDefault("defaultContext").getStr(""),
|
||||
verbose: parseBool(cliCfg.getVal("verbose", "false")) and not args["--quiet"],
|
||||
issues: newTable[IssueState, seq[Issue]](),
|
||||
tasksDir: cfg.getVal("tasks-dir", ""),
|
||||
termWidth: parseInt(cfg.getVal("term-width", "80")),
|
||||
triggerPtk: cfgJson.getOrDefault("triggerPtk").getBool(false))
|
||||
|
||||
if cfgJson.hasKey("contexts"):
|
||||
for k, v in cfgJson["contexts"]:
|
||||
result.contexts[k] = v.getStr()
|
||||
|
||||
if isNilOrWhitespace(result.tasksDir):
|
||||
raise newException(Exception, "no tasks directory configured")
|
||||
|
||||
if not existsDir(result.tasksDir):
|
||||
raise newException(Exception, "cannot find tasks dir: " & result.tasksDir)
|
||||
tasksDir: pitCfg.tasksDir,
|
||||
termWidth: parseInt(cliCfg.getVal("term-width", "80")),
|
||||
triggerPtk: cliJson.getOrDefault("triggerPtk").getBool(false))
|
||||
|
||||
proc getIssueContextDisplayName(ctx: CliContext, context: string): string =
|
||||
if not ctx.contexts.hasKey(context):
|
||||
@ -79,7 +55,7 @@ proc writeIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
|
||||
wrappedSummary = wrappedSummary[(6 + indent.len)..^1]
|
||||
stdout.setForegroundColor(fgBlack, true)
|
||||
stdout.write(indent & ($issue.id)[0..<6])
|
||||
stdout.setForegroundColor(fgCyan, false)
|
||||
stdout.setForegroundColor(fgWhite, false)
|
||||
stdout.write(wrappedSummary)
|
||||
|
||||
if issue.tags.len > 0:
|
||||
@ -90,17 +66,20 @@ proc writeIssue(ctx: CliContext, issue: Issue, width: int, indent = "",
|
||||
else:
|
||||
stdout.writeLine("\n" & indent & " " & tagsStr)
|
||||
else: stdout.writeLine("")
|
||||
stdout.resetAttributes
|
||||
|
||||
if issue.hasProp("pending"):
|
||||
let startIdx = "Pending: ".len
|
||||
var pendingText = issue["pending"].wordWrap(width - startIdx - 2)
|
||||
.indent(startIdx)
|
||||
pendingText = ("Pending: " & pendingText[startIdx..^1]).indent(indent.len + 2)
|
||||
stdout.setForegroundColor(fgCyan, false)
|
||||
stdout.writeLine(pendingText)
|
||||
|
||||
if showDetails: stdout.writeLine(issue.details.indent(indent.len + 2))
|
||||
if showDetails:
|
||||
stdout.setForegroundColor(fgCyan, false)
|
||||
stdout.writeLine(issue.details.indent(indent.len + 2))
|
||||
|
||||
stdout.resetAttributes
|
||||
|
||||
proc writeSection(ctx: CliContext, issues: seq[Issue], state: IssueState,
|
||||
indent = "", verbose = false) =
|
||||
@ -228,7 +207,7 @@ when isMainModule:
|
||||
Usage:
|
||||
pit ( new | add) <summary> [<state>] [options]
|
||||
pit list [<listable>] [options]
|
||||
pit ( start | done | pending | do-today | todo | suspend ) <id>...
|
||||
pit ( start | done | pending | do-today | todo | suspend ) <id>... [options]
|
||||
pit edit <id>
|
||||
pit ( delete | rm ) <id>...
|
||||
|
||||
@ -243,7 +222,7 @@ Options:
|
||||
|
||||
-c, --context <ctxName> Shorthand for '-p context:<ctxName>'
|
||||
|
||||
-t, --tags <tags> Specify tags for an issue.
|
||||
-g, --tags <tags> Specify tags for an issue.
|
||||
|
||||
-T, --today Limit to today's issues.
|
||||
|
||||
@ -251,22 +230,24 @@ Options:
|
||||
|
||||
-v, --verbose Show issue details when listing issues.
|
||||
|
||||
-q, --quiet Suppress verbose output.
|
||||
|
||||
-y, --yes Automatically answer "yes" to any prompts.
|
||||
|
||||
-C, --config <cfgFile> Location of the config file (defaults to $HOME/.pitrc)
|
||||
|
||||
-E, --echo-args Echo arguments (for debug purposes).
|
||||
|
||||
--tasks-dir Path to the tasks directory (defaults to the value
|
||||
-d, --tasks-dir Path to the tasks directory (defaults to the value
|
||||
configured in the .pitrc file)
|
||||
|
||||
--term-width Manually set the terminal width to use.
|
||||
--term-width <width> Manually set the terminal width to use.
|
||||
"""
|
||||
|
||||
logging.addHandler(newConsoleLogger())
|
||||
|
||||
# Parse arguments
|
||||
let args = docopt(doc, version = "pit 4.0.7")
|
||||
let args = docopt(doc, version = PIT_VERSION)
|
||||
|
||||
if args["--echo-args"]: stderr.writeLine($args)
|
||||
|
||||
@ -276,20 +257,21 @@ Options:
|
||||
|
||||
let ctx = initContext(args)
|
||||
|
||||
# Create our tasks directory structure if needed
|
||||
for s in IssueState:
|
||||
if not existsDir(ctx.tasksDir / $s):
|
||||
(ctx.tasksDir / $s).createDir
|
||||
|
||||
var propertiesOption = none(TableRef[string,string])
|
||||
|
||||
if args["--properties"] or args["--context"]:
|
||||
if args["--properties"] or args["--context"] or
|
||||
not ctx.defaultContext.isNilOrWhitespace:
|
||||
|
||||
var props =
|
||||
if args["--properties"]: parsePropertiesOption($args["--properties"])
|
||||
else: newTable[string,string]()
|
||||
|
||||
if args["--context"]: props["context"] = $args["--context"]
|
||||
if args["--context"] and $args["--context"] != "all":
|
||||
props["context"] = $args["--context"]
|
||||
elif not args["--context"] and not ctx.defaultContext.isNilOrWhitespace:
|
||||
stderr.writeLine("Limiting to default context: " & ctx.defaultContext)
|
||||
props["context"] = ctx.defaultContext
|
||||
|
||||
propertiesOption = some(props)
|
||||
|
||||
## Actual command runners
|
||||
@ -306,7 +288,7 @@ Options:
|
||||
summary: $args["<summary>"],
|
||||
properties: issueProps,
|
||||
tags:
|
||||
if args["--tags"]: ($args["tags"]).split(",").mapIt(it.strip)
|
||||
if args["--tags"]: ($args["--tags"]).split(",").mapIt(it.strip)
|
||||
else: newSeq[string]())
|
||||
|
||||
ctx.tasksDir.store(issue, state)
|
||||
@ -322,13 +304,17 @@ Options:
|
||||
var targetState: IssueState
|
||||
if args["done"]: targetState = Done
|
||||
elif args["do-today"]: targetState = TodoToday
|
||||
elif args["pending"]: targetState = Todo
|
||||
elif args["pending"]: targetState = Pending
|
||||
elif args["start"]: targetState = Current
|
||||
elif args["todo"]: targetState = Todo
|
||||
elif args["suspend"]: targetState = Dormant
|
||||
|
||||
for id in @(args["<id>"]):
|
||||
ctx.tasksDir.loadIssueById(id).changeState(ctx.tasksDir, targetState)
|
||||
var issue = ctx.tasksDir.loadIssueById(id)
|
||||
if propertiesOption.isSome:
|
||||
for k,v in propertiesOption.get:
|
||||
issue[k] = v
|
||||
issue.changeState(ctx.tasksDir, targetState)
|
||||
|
||||
if ctx.triggerPtk:
|
||||
if targetState == Current:
|
||||
@ -337,7 +323,8 @@ Options:
|
||||
if issue.tags.len > 0: cmd &= "-g \"" & issue.tags.join(",") & "\""
|
||||
cmd &= " \"" & issue.summary & "\""
|
||||
discard execShellCmd(cmd)
|
||||
elif targetState == Done: discard execShellCmd("ptk stop")
|
||||
elif targetState == Done or targetState == Pending:
|
||||
discard execShellCmd("ptk stop")
|
||||
|
||||
elif args["delete"] or args["rm"]:
|
||||
for id in @(args["<id>"]):
|
||||
@ -375,7 +362,7 @@ Options:
|
||||
let showBoth = args["--today"] == args["--future"]
|
||||
ctx.list(filterOption, stateOption, showBoth or args["--today"],
|
||||
showBoth or args["--future"],
|
||||
args["--verbose"])
|
||||
ctx.verbose)
|
||||
|
||||
if ctx.autoList and not args["list"]:
|
||||
ctx.loadAllIssues()
|
||||
|
132
src/pit_api.nim
Normal file
132
src/pit_api.nim
Normal file
@ -0,0 +1,132 @@
|
||||
## Personal Issue Tracker API Interface
|
||||
## ====================================
|
||||
|
||||
import asyncdispatch, cliutils, docopt, jester, json, logging, sequtils, strutils
|
||||
import nre except toSeq
|
||||
|
||||
import pitpkg/private/libpit
|
||||
|
||||
include "pitpkg/private/version.nim"
|
||||
|
||||
type
|
||||
PitApiCfg* = object
|
||||
apiKeys*: seq[string]
|
||||
global*: PitConfig
|
||||
port*: int
|
||||
|
||||
const TXT = "text/plain"
|
||||
|
||||
proc raiseEx(reason: string): void = raise newException(Exception, reason)
|
||||
|
||||
template checkAuth(cfg: PitApiCfg) =
|
||||
## Check this request for authentication and authorization information.
|
||||
## If the request is not authorized, this template sets up the 401 response
|
||||
## correctly. The calling context needs only to return from the route.
|
||||
|
||||
var authed {.inject.} = false
|
||||
|
||||
try:
|
||||
if not request.headers.hasKey("Authorization"):
|
||||
raiseEx "No auth token."
|
||||
|
||||
let headerVal = request.headers["Authorization"]
|
||||
if not headerVal.startsWith("Bearer "):
|
||||
raiseEx "Invalid Authentication type (only 'Bearer' is supported)."
|
||||
|
||||
if not cfg.apiKeys.contains(headerVal[7..^1]):
|
||||
raiseEx "Invalid API key."
|
||||
|
||||
authed = true
|
||||
|
||||
except:
|
||||
stderr.writeLine "Auth failed: " & getCurrentExceptionMsg()
|
||||
response.data[0] = CallbackAction.TCActionSend
|
||||
response.data[1] = Http401
|
||||
response.data[2]["WWW-Authenticate"] = "Bearer"
|
||||
response.data[2]["Content-Type"] = TXT
|
||||
response.data[3] = getCurrentExceptionMsg()
|
||||
|
||||
proc paramsToArgs(params: StringTableRef): tuple[stripAnsi: bool, args: seq[string]] =
|
||||
result = (false, @[])
|
||||
|
||||
if params.hasKey("color"):
|
||||
if params["color"] != "true":
|
||||
result[0] = true
|
||||
|
||||
for k,v in params:
|
||||
if k == "color": continue
|
||||
elif k.startsWith("arg"): result[1].add(v) # support ?arg1=val1&arg2=val2 -> cmd val1 val2
|
||||
else :
|
||||
result[1].add("--" & k)
|
||||
if v != "true": result[1].add(v) # support things like ?verbose=true -> cmd --verbose
|
||||
|
||||
let STRIP_ANSI_REGEX = re"\x1B\[([0-9]{1,2}(;[0-9]{1,2})?)?[m|K]"
|
||||
|
||||
proc stripAnsi(str: string): string =
|
||||
return str.replace(STRIP_ANSI_REGEX, "")
|
||||
|
||||
proc start*(cfg: PitApiCfg) =
|
||||
|
||||
var stopFuture = newFuture[void]()
|
||||
|
||||
settings:
|
||||
port = Port(cfg.port)
|
||||
appName = "/api"
|
||||
|
||||
routes:
|
||||
|
||||
get "/ping":
|
||||
resp("pong", TXT)
|
||||
|
||||
get "/issues":
|
||||
checkAuth(cfg); if not authed: return true
|
||||
|
||||
var (stripAnsi, args) = paramsToArgs(request.params)
|
||||
args = @["list"] & args
|
||||
|
||||
info "args: \n" & args.join(" ")
|
||||
let execResult = execWithOutput("pit", ".", args)
|
||||
if execResult[2] != 0: resp(Http500, stripAnsi($execResult[0] & "\n" & $execResult[1]), TXT)
|
||||
else:
|
||||
if stripAnsi: resp(stripAnsi(execResult[0]), TXT)
|
||||
else: resp(execResult[0], TXT)
|
||||
|
||||
post "/issues":
|
||||
checkAuth(cfg); if not authed: return true
|
||||
|
||||
waitFor(stopFuture)
|
||||
|
||||
proc loadApiConfig(args: Table[string, Value]): PitApiCfg =
|
||||
let pitCfg = loadConfig(args)
|
||||
let apiJson =
|
||||
if pitCfg.cfg.json.hasKey("api"): pitCfg.cfg.json["api"]
|
||||
else: newJObject()
|
||||
|
||||
let apiCfg = CombinedConfig(docopt: args, json: apiJson)
|
||||
|
||||
let apiKeysArray =
|
||||
if apiJson.hasKey("apiKeys"): apiJson["apiKeys"]
|
||||
else: newJArray()
|
||||
|
||||
result = PitApiCfg(
|
||||
apiKeys: toSeq(apiKeysArray).mapIt(it.getStr),
|
||||
global: pitCfg,
|
||||
port: parseInt(apiCfg.getVal("port", "8123")))
|
||||
|
||||
when isMainModule:
|
||||
|
||||
let doc = """\
|
||||
Usage:
|
||||
pit_api [options]
|
||||
|
||||
Options:
|
||||
|
||||
-c, --config <cfgFile> Path to the pit_api config file.
|
||||
-d, --tasks-dir Path to the tasks directory.
|
||||
-p, --port Port to listen on (defaults to 8123)
|
||||
"""
|
||||
|
||||
let args = docopt(doc, version = PIT_VERSION)
|
||||
|
||||
let apiCfg = loadApiConfig(args)
|
||||
start(apiCfg)
|
@ -1,4 +1,5 @@
|
||||
import cliutils, options, os, ospaths, sequtils, strutils, tables, times, timeutils, uuids
|
||||
import cliutils, docopt, json, logging, options, os, ospaths, sequtils,
|
||||
strutils, tables, times, timeutils, uuids
|
||||
|
||||
from nre import re, match
|
||||
type
|
||||
@ -21,6 +22,11 @@ type
|
||||
properties*: TableRef[string, string]
|
||||
completedRange*: tuple[b, e: DateTime]
|
||||
|
||||
PitConfig* = ref object
|
||||
tasksDir*: string
|
||||
contexts*: TableRef[string, string]
|
||||
cfg*: CombinedConfig
|
||||
|
||||
const DONE_FOLDER_FORMAT* = "yyyy-MM"
|
||||
|
||||
let ISSUE_FILE_PATTERN = re"[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}\.txt"
|
||||
@ -189,3 +195,52 @@ proc filter*(issues: seq[Issue], filter: IssueFilter): seq[Issue] =
|
||||
filter.completedRange.b,
|
||||
filter.completedRange.e))
|
||||
|
||||
### Configuration utilities
|
||||
proc loadConfig*(args: Table[string, Value] = initTable[string, Value]()): PitConfig =
|
||||
let pitrcLocations = @[
|
||||
if args["--config"]: $args["--config"] else: "",
|
||||
".pitrc", $getEnv("PITRC"), $getEnv("HOME") & "/.pitrc"]
|
||||
|
||||
var pitrcFilename: string =
|
||||
foldl(pitrcLocations, if len(a) > 0: a elif existsFile(b): b else: "")
|
||||
|
||||
if not existsFile(pitrcFilename):
|
||||
warn "pit: could not find .pitrc file: " & pitrcFilename
|
||||
if isNilOrWhitespace(pitrcFilename):
|
||||
pitrcFilename = $getEnv("HOME") & "/.pitrc"
|
||||
var cfgFile: File
|
||||
try:
|
||||
cfgFile = open(pitrcFilename, fmWrite)
|
||||
cfgFile.write("{\"tasksDir\": \"/path/to/tasks\"}")
|
||||
except: warn "pit: could not write default .pitrc to " & pitrcFilename
|
||||
finally: close(cfgFile)
|
||||
|
||||
var cfgJson: JsonNode
|
||||
try: cfgJson = parseFile(pitrcFilename)
|
||||
except: raise newException(IOError,
|
||||
"unable to read config file: " & pitrcFilename &
|
||||
"\x0D\x0A" & getCurrentExceptionMsg())
|
||||
|
||||
let cfg = CombinedConfig(docopt: args, json: cfgJson)
|
||||
|
||||
result = PitConfig(
|
||||
cfg: cfg,
|
||||
contexts: newTable[string,string](),
|
||||
tasksDir: cfg.getVal("tasks-dir", ""))
|
||||
|
||||
if cfgJson.hasKey("contexts"):
|
||||
for k, v in cfgJson["contexts"]:
|
||||
result.contexts[k] = v.getStr()
|
||||
|
||||
if isNilOrWhitespace(result.tasksDir):
|
||||
raise newException(Exception, "no tasks directory configured")
|
||||
|
||||
if not existsDir(result.tasksDir):
|
||||
raise newException(Exception, "cannot find tasks dir: " & result.tasksDir)
|
||||
|
||||
# Create our tasks directory structure if needed
|
||||
for s in IssueState:
|
||||
if not existsDir(result.tasksDir / $s):
|
||||
(result.tasksDir / $s).createDir
|
||||
|
||||
|
||||
|
1
src/pitpkg/private/version.nim
Normal file
1
src/pitpkg/private/version.nim
Normal file
@ -0,0 +1 @@
|
||||
const PIT_VERSION = "4.1.0"
|
Loading…
x
Reference in New Issue
Block a user