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:
Jonathan Bernard 2018-05-18 16:06:58 -05:00
parent 6f247032a3
commit 29959a6a8d
6 changed files with 246 additions and 67 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
*.sw*
nimcache/
/pit
/pit_api

View File

@ -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" ]

View File

@ -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
View 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)

View File

@ -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

View File

@ -0,0 +1 @@
const PIT_VERSION = "4.1.0"