Refactor to add API layer.

This commit is contained in:
Jonathan Bernard 2018-10-03 04:00:42 -05:00
parent 03da2e9cd9
commit 0d4827453a
6 changed files with 160 additions and 38 deletions

@ -2,14 +2,17 @@
## =================================== ## ===================================
import asyncdispatch, base64, bcrypt, cliutils, docopt, jester, json, logging, import asyncdispatch, base64, bcrypt, cliutils, docopt, jester, json, logging,
ospaths, sequtils, strutils, os ospaths, sequtils, strutils, os, times, uuids
import nre except toSeq import nre except toSeq
import ./models import ./models
import ./util
import ./version
type type
PtkUser* = object PtkUser* = object
username*, salt*, pwdhash*, timelinePath: string username*, salt*, pwdhash*, timelinePath*: string
isAdmin*: bool isAdmin*: bool
PtkApiCfg* = object PtkApiCfg* = object
@ -20,7 +23,19 @@ type
const TXT = "text/plain" const TXT = "text/plain"
const JSON = "application/json" const JSON = "application/json"
proc raiseEx(reason: string): void = raise newException(Exception, reason) proc parseUser(json: JsonNode): PtkUser =
result = Ptkuser(
username: json.getOrFail("username").getStr,
salt: json.getOrFail("salt").getStr,
pwdHash: json.getOrFail("pwdhash").getStr,
timelinePath: json.getOrFail("timelinePath").getStr,
isAdmin: json.getIfExists("isAdmin").getBool(false))
proc loadApiConfig*(json: JsonNode): PtkApiCfg =
result = PtkApiCfg(
port: parseInt(json.getIfExists("port").getStr("3280")),
dataDir: json.getOrFail("dataDir").getStr,
users: json.getIfExists("users").getElems(@[]).mapIt(parseUser(it)))
template checkAuth(cfg: PtkApiCfg) = template checkAuth(cfg: PtkApiCfg) =
## Check this request for authentication and authorization information. ## Check this request for authentication and authorization information.
@ -69,18 +84,8 @@ proc parseAndRun(user: PtkUser, cmd: string, params: StringTableRef): string =
if execResult[2] != 0: raiseEx(stripAnsi($execResult[0] & "\n" & $execResult[1])) if execResult[2] != 0: raiseEx(stripAnsi($execResult[0] & "\n" & $execResult[1]))
else: return stripAnsi(execResult[0]) else: return stripAnsi(execResult[0])
proc getOrFail(n: JsonNode, key: string, objName: string = ""): JsonNode = proc apiParseUser(json: JsonNode): PtkUser =
## convenience method to get a key from a JObject or raise an exception let salt = genSalt(12)
if not n.hasKey(key): raiseEx objName & " missing key '" & key & "'"
return n[key]
proc getIfExists(n: JsonNode, key: string): JsonNode =
## convenience method to get a key from a JObject or return null
result = if n.hasKey(key): n[key]
else: newJNull()
proc parseUser(json: JsonNode): PtkUser =
let salt = gensalt(12)
return PtkUser( return PtkUser(
username: json.getOrFail("username").getStr, username: json.getOrFail("username").getStr,
@ -89,7 +94,24 @@ proc parseUser(json: JsonNode): PtkUser =
timelinePath: json.getIfExists("timelinePath").getStr(""), timelinePath: json.getIfExists("timelinePath").getStr(""),
isAdmin: false) isAdmin: false)
proc start*(cfg: PtkApiCfg) = proc apiParseMark(json: JsonNode): Mark =
if not json.hasKey("id"): json["id"] = %($genUUID())
if not json.hasKey("summary"): raiseEx "cannot parse mark: missing 'summary'"
if not json.hasKey("time"): json["time"] = %(getTime().local.format(ISO_TIME_FORMAT))
return parseMark(json)
proc patchMark(m: Mark, j: JsonNode): Mark =
result = m
if j.hasKey("summary"): result.summary = j["summary"].getStr
if j.hasKey("notes"): result.notes = j["notes"].getStr
if j.hasKey("tags"):
result.tags = j["tags"].getElems(@[]).map(proc (t: JsonNode): string = t.getStr())
if j.hasKey("time"): result.time = parse(j["time"].getStr(), ISO_TIME_FORMAT)
proc start_api*(cfg: PtkApiCfg) =
var stopFuture = newFuture[void]() var stopFuture = newFuture[void]()
@ -99,7 +121,7 @@ proc start*(cfg: PtkApiCfg) =
routes: routes:
get "/ping": resp("pong", TXT) get "/version": resp("ptk v" & PTK_VERSION, TXT)
get "/marks": get "/marks":
checkAuth(cfg); if not authed: return true checkAuth(cfg); if not authed: return true
@ -107,21 +129,98 @@ proc start*(cfg: PtkApiCfg) =
try: resp(parseAndRun(user, "list", request.params), TXT) try: resp(parseAndRun(user, "list", request.params), TXT)
except: resp(Http500, getCurrentExceptionMsg(), TXT) except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/continue":
checkAuth(cfg); if not authed: return true
try: resp(parseAndRun(user, "continue", request.params), TXT)
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/sum-time":
checkAuth(cfg); if not authed: return true
try: resp(parseAndRun(user, "sum-time", request.params), TXT)
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/mark": post "/mark":
checkAuth(cfg); if not authed: return true checkAuth(cfg); if not authed: return true
var newMark: Mark var newMark: Mark
try: newMark = parseMark(parseJson(request.body)) try: newMark = apiParseMark(parseJson(request.body))
except: resp(Http400, getCurrentExceptionMsg(), TXT) except: resp(Http400, getCurrentExceptionMsg(), TXT)
var params = newStringTable try:
var timeline = loadTimeline(user.timelinePath)
timeline.marks.add(newMark)
saveTimeline(timeline, user.timelinePath)
resp(Http201, "ok", TXT)
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/stop":
checkAuth(cfg); if not authed: return true
var newMark: Mark
try:
var json = parseJson(request.body)
json["summary"] = %STOP_MSG
json["id"] = %($genUUID())
newMark = apiParseMark(json)
except: resp(Http400, getCurrentExceptionMsg(), TXT)
try:
var timeline = loadTimeline(user.timelinePath)
timeline.marks.add(newMark)
saveTimeline(timeline, user.timelinePath)
resp(Http201, "ok", TXT)
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/resume/@id":
checkAuth(cfg); if not authed: return true
var timeline: Timeline
try: timeline = loadTimeline(user.timelinePath)
except: resp(Http500, getCurrentExceptionMsg(), TXT)
var newMark: Mark
try:
let origMarkIdx = timeline.marks.findById(@"id")
if origMarkIdx < 0: resp(Http404, "no mark for id: " & @"id", TXT)
let origMark = timeline.marks[origMarkIdx]
newMark = origMark
newMark.id = genUUID()
newMark.time = getTime().local
newMark = newMark.patchMark(parseJson(request.body))
except: resp(Http400, getCurrentExceptionMsg(), TXT)
try:
timeline.marks.add(newMark)
timeline.saveTimeline(user.timelinePath)
resp(Http201, "ok", TXT)
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/amend/@id":
checkAuth(cfg); if not authed: return true
try:
var timeline = loadTimeline(user.timelinePath)
let idx = timeline.marks.findById(@"id")
if idx < 0: resp(Http404, "no mark for id: " & @"id", TXT)
timeline.marks[idx] = timeline.marks[idx].patchMark(parseJson(request.body))
timeline.saveTimeline(user.timelinePath)
resp(Http202, $(%timeline.marks[idx]), JSON)
except: resp(Http500, getCurrentExceptionMsg(), TXT)
post "/users": post "/users":
checkAuth(cfg); if not authed: return true checkAuth(cfg); if not authed: return true
if not user.isAdmin: resp(Http403, "insufficient permission", TXT) if not user.isAdmin: resp(Http403, "insufficient permission", TXT)
var newUser: PtkUser var newUser: PtkUser
try: newUser = parseUser(parseJson(request.body)) try: newUser = apiParseUser(parseJson(request.body))
except: resp(Http400, getCurrentExceptionMsg(), TXT) except: resp(Http400, getCurrentExceptionMsg(), TXT)
if cfg.users.anyIt(it.username == newUser.username): if cfg.users.anyIt(it.username == newUser.username):
@ -136,3 +235,5 @@ proc start*(cfg: PtkApiCfg) =
resp(Http200, "ok", TXT) resp(Http200, "ok", TXT)
except: resp(Http500, "could not init new user timeline", TXT) except: resp(Http500, "could not init new user timeline", TXT)
waitFor(stopFuture)

@ -1,5 +1,7 @@
import algorithm, json, sequtils, strutils, times, timeutils, uuids import algorithm, json, sequtils, strutils, times, timeutils, uuids
import ./util
type type
Mark* = tuple[id: UUID, time: DateTime, summary: string, notes: string, tags: seq[string]] Mark* = tuple[id: UUID, time: DateTime, summary: string, notes: string, tags: seq[string]]
## Representation of a single mark on the timeline. ## Representation of a single mark on the timeline.
@ -25,9 +27,18 @@ const TIME_FORMATS* = @[
"HH:mm:ss", "H:mm:ss", "H:mm", "HH:mm" ] "HH:mm:ss", "H:mm:ss", "H:mm", "HH:mm" ]
## Other time formats that PTK will accept as input. ## Other time formats that PTK will accept as input.
proc getOrFail*(n: JsonNode, key: string, objName: string = ""): JsonNode =
## convenience method to get a key from a JObject or raise an exception
if not n.hasKey(key): raiseEx objName & " missing key '" & key & "'"
return n[key]
proc getIfExists*(n: JsonNode, key: string): JsonNode =
## convenience method to get a key from a JObject or return null
result = if n.hasKey(key): n[key]
else: newJNull()
proc parseTime*(timeStr: string): DateTime = proc parseTime*(timeStr: string): DateTime =
## Helper to parse time strings trying multiple known formats. ## Helper to parse time strings trying multiple known formats.
for fmt in TIME_FORMATS: for fmt in TIME_FORMATS:
try: return parse(timeStr, fmt) try: return parse(timeStr, fmt)
except: discard nil except: discard nil
@ -36,16 +47,9 @@ proc parseTime*(timeStr: string): DateTime =
proc parseMark*(json: JsonNode): Mark = proc parseMark*(json: JsonNode): Mark =
# TODO: an incorrect time format that was used in version 0.6 and prior.
# Version 0.7 between 1.0 support this format on read only and will write
# out the correct format (so they can be used to convert older timelines).
var time: DateTime
try: time = parse(json["time"].getStr(), ISO_TIME_FORMAT)
except: time = parse(json["time"].getStr(), "yyyy:MM:dd'T'HH:mm:ss")
return ( return (
id: parseUUID(json["id"].getStr()), id: parseUUID(json["id"].getStr()),
time: time, #parse(json["time"].getStr(), ISO_TIME_FORMAT), time: parse(json["time"].getStr(), ISO_TIME_FORMAT),
summary: json["summary"].getStr(), summary: json["summary"].getStr(),
notes: json["notes"].getStr(), notes: json["notes"].getStr(),
tags: json["tags"].getElems(@[]).map(proc (t: JsonNode): string = t.getStr())) tags: json["tags"].getElems(@[]).map(proc (t: JsonNode): string = t.getStr()))

@ -6,3 +6,6 @@ proc flatten*[T](a: seq[seq[T]]): seq[T] =
result = @[] result = @[]
for subseq in a: for subseq in a:
result.add(subseq) result.add(subseq)
proc raiseEx*(reason: string): void = raise newException(Exception, reason)

1
private/version.nim Normal file

@ -0,0 +1 @@
const PTK_VERSION* = "1.0.0"

26
ptk.nim

@ -9,6 +9,7 @@ import algorithm, docopt, json, langutils, logging, os, nre, sequtils,
import private/util import private/util
import private/api import private/api
import private/models import private/models
import private/version
#proc `$`*(mark: Mark): string = #proc `$`*(mark: Mark): string =
#return (($mark.uuid)[ #return (($mark.uuid)[
@ -255,6 +256,7 @@ Usage:
ptk current ptk current
ptk sum-time --ids <ids>... ptk sum-time --ids <ids>...
ptk sum-time [options] [<firstId>] [<lastId>] ptk sum-time [options] [<firstId>] [<lastId>]
ptk serve-api <svcCfg> [--port <port>]
ptk (-V | --version) ptk (-V | --version)
ptk (-h | --help) ptk (-h | --help)
@ -287,7 +289,7 @@ Options:
let now = getTime().local let now = getTime().local
# Parse arguments # Parse arguments
let args = docopt(doc, version = "ptk 0.12.4") let args = docopt(doc, version = PTK_VERSION)
if args["--echo-args"]: echo $args if args["--echo-args"]: echo $args
@ -377,7 +379,7 @@ Options:
summary: STOP_MSG, summary: STOP_MSG,
notes: args["--notes"] ?: "", notes: args["--notes"] ?: "",
tags: (args["--tags"] ?: "").split({',', ';'}).filterIt(not it.isNilOrWhitespace)) tags: (args["--tags"] ?: "").split({',', ';'}).filterIt(not it.isNilOrWhitespace))
timeline.marks.add(newMark) timeline.marks.add(newMark)
timeline.writeMarks( timeline.writeMarks(
@ -442,7 +444,7 @@ Options:
markToResumeIdx = timeline.marks.getLastIndex() markToResumeIdx = timeline.marks.getLastIndex()
if markToResumeIdx < 0: exitErr "No mark to resume." if markToResumeIdx < 0: exitErr "No mark to resume."
var markToResume = timeline.marks[markToResumeIdx] var markToResume = timeline.marks[markToResumeIdx]
var newMark: Mark = ( var newMark: Mark = (
id: genUUID(), id: genUUID(),
time: if args["--time"]: parseTime($args["--time"]) else: now, time: if args["--time"]: parseTime($args["--time"]) else: now,
@ -456,7 +458,7 @@ Options:
timeline.writeMarks( timeline.writeMarks(
indices = sequtils.toSeq(markToResumeIdx..<timeline.marks.len), indices = sequtils.toSeq(markToResumeIdx..<timeline.marks.len),
includeNotes = args["--verbose"]) includeNotes = args["--verbose"])
saveTimeline(timeline, timelineLocation) saveTimeline(timeline, timelineLocation)
if args["amend"]: if args["amend"]:
@ -472,7 +474,7 @@ Options:
if markIdx < 0: exitErr "No mark to amend." if markIdx < 0: exitErr "No mark to amend."
var mark = timeline.marks[markIdx] var mark = timeline.marks[markIdx]
if args["<summary>"]: mark.summary = $args["<summary>"] if args["<summary>"]: mark.summary = $args["<summary>"]
if args["--notes"]: mark.notes = $args["<notes>"] if args["--notes"]: mark.notes = $args["<notes>"]
if args["--tags"]: if args["--tags"]:
@ -530,7 +532,7 @@ Options:
includeNotes = true) includeNotes = true)
if args["sum-time"]: if args["sum-time"]:
var intervals: seq[TimeInterval] = @[] var intervals: seq[TimeInterval] = @[]
if args["--ids"]: if args["--ids"]:
@ -558,7 +560,17 @@ Options:
else: else:
let total = intervals.foldl(a + b) let total = intervals.foldl(a + b)
echo flexFormat(total) echo flexFormat(total)
if args["serve-api"]:
if not fileExists($args["<svcCfg>"]):
exitErr "cannot find service config file: '" & $args["<svcCfg>"]
var apiCfg = loadApiConfig(parseFile($args["<svcCfg>"]))
if args["--port"]: apiCfg.port = parseInt($args["--port"])
start_api(apiCfg)
except: except:
fatal "ptk: " & getCurrentExceptionMsg() fatal "ptk: " & getCurrentExceptionMsg()
quit(QuitFailure) quit(QuitFailure)

@ -1,6 +1,7 @@
# Package # Package
include "private/version.nim"
version = "0.12.4" version = PTK_VERSION
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "Personal Time Keeper" description = "Personal Time Keeper"
license = "MIT" license = "MIT"
@ -8,5 +9,5 @@ bin = @["ptk"]
# Dependencies # Dependencies
requires @["nim >= 0.18.0", "docopt >= 0.6.4", "uuids", "langutils", "tempfile", "timeutils >= 0.2.2", "isaac >= 0.1.2", "bcrypt", "cliutils", "jester" ] requires @["nim >= 0.18.0", "docopt >= 0.6.4", "uuids", "langutils", "tempfile", "timeutils >= 0.2.2", "isaac >= 0.1.2", "bcrypt", "cliutils >= 0.5.0", "jester 0.2.0"]