From 0d4827453a54476c879e9ccad5aeb139079ca973 Mon Sep 17 00:00:00 2001 From: Jonathan Bernard Date: Wed, 3 Oct 2018 04:00:42 -0500 Subject: [PATCH] Refactor to add API layer. --- private/api.nim | 141 +++++++++++++++++++++++++++++++++++++------- private/models.nim | 22 ++++--- private/util.nim | 3 + private/version.nim | 1 + ptk.nim | 26 +++++--- ptk.nimble | 5 +- 6 files changed, 160 insertions(+), 38 deletions(-) create mode 100644 private/version.nim diff --git a/private/api.nim b/private/api.nim index 56c4141..a7c87e3 100644 --- a/private/api.nim +++ b/private/api.nim @@ -2,14 +2,17 @@ ## =================================== import asyncdispatch, base64, bcrypt, cliutils, docopt, jester, json, logging, - ospaths, sequtils, strutils, os + ospaths, sequtils, strutils, os, times, uuids + import nre except toSeq import ./models +import ./util +import ./version type PtkUser* = object - username*, salt*, pwdhash*, timelinePath: string + username*, salt*, pwdhash*, timelinePath*: string isAdmin*: bool PtkApiCfg* = object @@ -20,7 +23,19 @@ type const TXT = "text/plain" 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) = ## 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])) else: return stripAnsi(execResult[0]) -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 parseUser(json: JsonNode): PtkUser = - let salt = gensalt(12) +proc apiParseUser(json: JsonNode): PtkUser = + let salt = genSalt(12) return PtkUser( username: json.getOrFail("username").getStr, @@ -89,7 +94,24 @@ proc parseUser(json: JsonNode): PtkUser = timelinePath: json.getIfExists("timelinePath").getStr(""), 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]() @@ -99,7 +121,7 @@ proc start*(cfg: PtkApiCfg) = routes: - get "/ping": resp("pong", TXT) + get "/version": resp("ptk v" & PTK_VERSION, TXT) get "/marks": checkAuth(cfg); if not authed: return true @@ -107,21 +129,98 @@ proc start*(cfg: PtkApiCfg) = try: resp(parseAndRun(user, "list", request.params), 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": checkAuth(cfg); if not authed: return true var newMark: Mark - try: newMark = parseMark(parseJson(request.body)) + try: newMark = apiParseMark(parseJson(request.body)) 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": checkAuth(cfg); if not authed: return true if not user.isAdmin: resp(Http403, "insufficient permission", TXT) var newUser: PtkUser - try: newUser = parseUser(parseJson(request.body)) + try: newUser = apiParseUser(parseJson(request.body)) except: resp(Http400, getCurrentExceptionMsg(), TXT) if cfg.users.anyIt(it.username == newUser.username): @@ -136,3 +235,5 @@ proc start*(cfg: PtkApiCfg) = resp(Http200, "ok", TXT) except: resp(Http500, "could not init new user timeline", TXT) + + waitFor(stopFuture) diff --git a/private/models.nim b/private/models.nim index 6879a3a..2dd02ef 100644 --- a/private/models.nim +++ b/private/models.nim @@ -1,5 +1,7 @@ import algorithm, json, sequtils, strutils, times, timeutils, uuids +import ./util + type Mark* = tuple[id: UUID, time: DateTime, summary: string, notes: string, tags: seq[string]] ## 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" ] ## 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 = - ## Helper to parse time strings trying multiple known formats. + ## Helper to parse time strings trying multiple known formats. for fmt in TIME_FORMATS: try: return parse(timeStr, fmt) except: discard nil @@ -36,16 +47,9 @@ proc parseTime*(timeStr: string): DateTime = 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 ( 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(), notes: json["notes"].getStr(), tags: json["tags"].getElems(@[]).map(proc (t: JsonNode): string = t.getStr())) diff --git a/private/util.nim b/private/util.nim index 57fef5b..faac44c 100644 --- a/private/util.nim +++ b/private/util.nim @@ -6,3 +6,6 @@ proc flatten*[T](a: seq[seq[T]]): seq[T] = result = @[] for subseq in a: result.add(subseq) + + +proc raiseEx*(reason: string): void = raise newException(Exception, reason) diff --git a/private/version.nim b/private/version.nim new file mode 100644 index 0000000..49c11e1 --- /dev/null +++ b/private/version.nim @@ -0,0 +1 @@ +const PTK_VERSION* = "1.0.0" diff --git a/ptk.nim b/ptk.nim index 517b7ec..e35653b 100644 --- a/ptk.nim +++ b/ptk.nim @@ -9,6 +9,7 @@ import algorithm, docopt, json, langutils, logging, os, nre, sequtils, import private/util import private/api import private/models +import private/version #proc `$`*(mark: Mark): string = #return (($mark.uuid)[ @@ -255,6 +256,7 @@ Usage: ptk current ptk sum-time --ids ... ptk sum-time [options] [] [] + ptk serve-api [--port ] ptk (-V | --version) ptk (-h | --help) @@ -287,7 +289,7 @@ Options: let now = getTime().local # Parse arguments - let args = docopt(doc, version = "ptk 0.12.4") + let args = docopt(doc, version = PTK_VERSION) if args["--echo-args"]: echo $args @@ -377,7 +379,7 @@ Options: summary: STOP_MSG, notes: args["--notes"] ?: "", tags: (args["--tags"] ?: "").split({',', ';'}).filterIt(not it.isNilOrWhitespace)) - + timeline.marks.add(newMark) timeline.writeMarks( @@ -442,7 +444,7 @@ Options: markToResumeIdx = timeline.marks.getLastIndex() if markToResumeIdx < 0: exitErr "No mark to resume." var markToResume = timeline.marks[markToResumeIdx] - + var newMark: Mark = ( id: genUUID(), time: if args["--time"]: parseTime($args["--time"]) else: now, @@ -456,7 +458,7 @@ Options: timeline.writeMarks( indices = sequtils.toSeq(markToResumeIdx.."]: mark.summary = $args[""] if args["--notes"]: mark.notes = $args[""] if args["--tags"]: @@ -530,7 +532,7 @@ Options: includeNotes = true) if args["sum-time"]: - + var intervals: seq[TimeInterval] = @[] if args["--ids"]: @@ -558,7 +560,17 @@ Options: else: let total = intervals.foldl(a + b) echo flexFormat(total) - + + if args["serve-api"]: + + if not fileExists($args[""]): + exitErr "cannot find service config file: '" & $args[""] + + var apiCfg = loadApiConfig(parseFile($args[""])) + if args["--port"]: apiCfg.port = parseInt($args["--port"]) + + start_api(apiCfg) + except: fatal "ptk: " & getCurrentExceptionMsg() quit(QuitFailure) diff --git a/ptk.nimble b/ptk.nimble index ca54dd9..f26ed2b 100644 --- a/ptk.nimble +++ b/ptk.nimble @@ -1,6 +1,7 @@ # Package +include "private/version.nim" -version = "0.12.4" +version = PTK_VERSION author = "Jonathan Bernard" description = "Personal Time Keeper" license = "MIT" @@ -8,5 +9,5 @@ bin = @["ptk"] # 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"]