WIP Refactor to support API.
This commit is contained in:
parent
e62a4e31de
commit
03da2e9cd9
138
private/api.nim
Normal file
138
private/api.nim
Normal file
@ -0,0 +1,138 @@
|
||||
## Personal Time Keeping API Interface
|
||||
## ===================================
|
||||
|
||||
import asyncdispatch, base64, bcrypt, cliutils, docopt, jester, json, logging,
|
||||
ospaths, sequtils, strutils, os
|
||||
import nre except toSeq
|
||||
|
||||
import ./models
|
||||
|
||||
type
|
||||
PtkUser* = object
|
||||
username*, salt*, pwdhash*, timelinePath: string
|
||||
isAdmin*: bool
|
||||
|
||||
PtkApiCfg* = object
|
||||
users*: seq[PtkUser]
|
||||
port*: int
|
||||
dataDir*: string
|
||||
|
||||
const TXT = "text/plain"
|
||||
const JSON = "application/json"
|
||||
|
||||
proc raiseEx(reason: string): void = raise newException(Exception, reason)
|
||||
|
||||
template checkAuth(cfg: PtkApiCfg) =
|
||||
## 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
|
||||
var user {.inject.}: PtkUser = PtkUser()
|
||||
|
||||
try:
|
||||
if not request.headers.hasKey("Authorization"):
|
||||
raiseEx "No auth token."
|
||||
|
||||
let headerVal = request.headers["Authorization"]
|
||||
if not headerVal.startsWith("Basic "):
|
||||
raiseEx "Invalid Authorization type (only 'Basic' is supported)."
|
||||
|
||||
let authVals = headerVal[6..^1].decode().split(":")
|
||||
|
||||
let candidates = cfg.users.filterIt(it.username.compare(authVals[0]))
|
||||
if candidates.len != 1: raiseEx "Invalid Authorization: unknown username/password combination."
|
||||
|
||||
let foundUser: PtkUser = candidates[0]
|
||||
if not compare(foundUser.pwdhash, hash(authVals[1], foundUser.salt)):
|
||||
raiseEx "Invalid Authorization: unknown username/password combination."
|
||||
|
||||
user = foundUser
|
||||
authed = true
|
||||
|
||||
except:
|
||||
stderr.writeLine "Auth failed: " & getCurrentExceptionMsg()
|
||||
response.data[0] = CallbackAction.TCActionSend
|
||||
response.data[1] = Http401
|
||||
response.data[2]["WWW_Authenticate"] = "Basic"
|
||||
response.data[2]["Content-Type"] = TXT
|
||||
response.data[3] = getCurrentExceptionMsg()
|
||||
|
||||
proc parseAndRun(user: PtkUser, cmd: string, params: StringTableRef): string =
|
||||
|
||||
var args = queryParamsToCliArgs(params)
|
||||
args = @[cmd, "--file", user.timelinePath] & args
|
||||
|
||||
info "args: \n" & args.join(" ")
|
||||
|
||||
let execResult = execWithOutput("ptk", ".", args)
|
||||
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)
|
||||
|
||||
return PtkUser(
|
||||
username: json.getOrFail("username").getStr,
|
||||
pwdhash: json.getOrFail("password").getStr.hash(salt),
|
||||
salt: salt,
|
||||
timelinePath: json.getIfExists("timelinePath").getStr(""),
|
||||
isAdmin: false)
|
||||
|
||||
proc start*(cfg: PtkApiCfg) =
|
||||
|
||||
var stopFuture = newFuture[void]()
|
||||
|
||||
settings:
|
||||
port = Port(cfg.port)
|
||||
appName = "/api"
|
||||
|
||||
routes:
|
||||
|
||||
get "/ping": resp("pong", TXT)
|
||||
|
||||
get "/marks":
|
||||
checkAuth(cfg); if not authed: return true
|
||||
|
||||
try: resp(parseAndRun(user, "list", 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))
|
||||
except: resp(Http400, getCurrentExceptionMsg(), TXT)
|
||||
|
||||
var params = newStringTable
|
||||
|
||||
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))
|
||||
except: resp(Http400, getCurrentExceptionMsg(), TXT)
|
||||
|
||||
if cfg.users.anyIt(it.username == newUser.username):
|
||||
resp(Http409, "user already exists", TXT)
|
||||
|
||||
newUser.timelinePath = cfg.dataDir / newUser.username & ".timeline.json"
|
||||
|
||||
try:
|
||||
discard parseAndRun(newUser, "init", newStringTable())
|
||||
# TODO: save updated config!
|
||||
# cfg.users.add(newUser)
|
||||
resp(Http200, "ok", TXT)
|
||||
|
||||
except: resp(Http500, "could not init new user timeline", TXT)
|
112
private/models.nim
Normal file
112
private/models.nim
Normal file
@ -0,0 +1,112 @@
|
||||
import algorithm, json, sequtils, strutils, times, timeutils, uuids
|
||||
|
||||
type
|
||||
Mark* = tuple[id: UUID, time: DateTime, summary: string, notes: string, tags: seq[string]]
|
||||
## Representation of a single mark on the timeline.
|
||||
|
||||
Timeline* = tuple[name: string, marks: seq[Mark]]
|
||||
## Representation of a timeline: a name and sequence of Marks.
|
||||
|
||||
const STOP_MSG* = "STOP"
|
||||
|
||||
let NO_MARK*: Mark = (
|
||||
id: parseUUID("00000000-0000-0000-0000-000000000000"),
|
||||
time: fromUnix(0).local,
|
||||
summary: "", notes: "", tags: @[])
|
||||
|
||||
const ISO_TIME_FORMAT* = "yyyy-MM-dd'T'HH:mm:ss"
|
||||
## The canonical time format used by PTK.
|
||||
|
||||
const TIME_FORMATS* = @[
|
||||
"yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd HH:mm:ss",
|
||||
"yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd HH:mm",
|
||||
"MM-dd'T'HH:mm:ss", "MM-dd HH:mm:ss",
|
||||
"MM-dd'T'HH:mm", "MM-dd HH:mm",
|
||||
"HH:mm:ss", "H:mm:ss", "H:mm", "HH:mm" ]
|
||||
## Other time formats that PTK will accept as input.
|
||||
|
||||
|
||||
proc parseTime*(timeStr: string): DateTime =
|
||||
## Helper to parse time strings trying multiple known formats.
|
||||
for fmt in TIME_FORMATS:
|
||||
try: return parse(timeStr, fmt)
|
||||
except: discard nil
|
||||
|
||||
raise newException(Exception, "unable to interpret as a date: " & timeStr)
|
||||
|
||||
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),
|
||||
summary: json["summary"].getStr(),
|
||||
notes: json["notes"].getStr(),
|
||||
tags: json["tags"].getElems(@[]).map(proc (t: JsonNode): string = t.getStr()))
|
||||
|
||||
template `%`*(mark: Mark): JsonNode =
|
||||
%* {
|
||||
"id": $(mark.id),
|
||||
"time": mark.time.format(ISO_TIME_FORMAT),
|
||||
"summary": mark.summary,
|
||||
"notes": mark.notes,
|
||||
"tags": mark.tags
|
||||
}
|
||||
|
||||
template `%`*(timeline: Timeline): JsonNode =
|
||||
%* { "name": timeline.name, "marks": timeline.marks }
|
||||
|
||||
proc loadTimeline*(filename: string): Timeline =
|
||||
## Load a timeline from a file. Expects a path to a file (can be relative or
|
||||
## absolute) and returns a Timeline. The marks in the timeline are guaranteed
|
||||
## to be ordered by time.
|
||||
|
||||
var timelineJson: JsonNode
|
||||
try: timelineJson = parseFile(filename)
|
||||
except:
|
||||
raise newException(ValueError,
|
||||
"unable to parse the timeline file as JSON: " & filename)
|
||||
|
||||
var timeline: Timeline = (name: timelineJson["name"].getStr(), marks: @[])
|
||||
|
||||
for markJson in timelineJson["marks"]: timeline.marks.add(parseMark(markJson))
|
||||
|
||||
timeline.marks = timeline.marks.sorted(
|
||||
proc(a, b: Mark): int = cmp(a.time, b.time))
|
||||
|
||||
return timeline
|
||||
|
||||
proc saveTimeline*(timeline: Timeline, location: string): void =
|
||||
## Write the timeline to disk at the file location given.
|
||||
|
||||
var timelineFile: File
|
||||
try:
|
||||
timelineFile = open(location, fmWrite)
|
||||
timelineFile.writeLine(pretty(%timeline))
|
||||
except: raise newException(IOError, "unable to save changes to " & location)
|
||||
finally: close(timelineFile)
|
||||
|
||||
proc findById*(marks: seq[Mark], id: string): int =
|
||||
var idx = 0
|
||||
for mark in marks:
|
||||
if startsWith($mark.id, id): return idx
|
||||
inc(idx)
|
||||
|
||||
return -1
|
||||
|
||||
proc getLastIndex*(marks: seq[Mark]): int =
|
||||
## Find and return the index of the last Mark that was not a STOP mark.
|
||||
## Returns -1 if there is no such last mark.
|
||||
|
||||
var idx = marks.len - 1
|
||||
while idx >= 0 and marks[idx].summary == STOP_MSG: idx -= 1
|
||||
if idx < 0: result = -1
|
||||
else: result = idx
|
||||
|
||||
|
110
ptk.nim
110
ptk.nim
@ -6,32 +6,9 @@
|
||||
import algorithm, docopt, json, langutils, logging, os, nre, sequtils,
|
||||
sets, strutils, tempfile, terminal, times, timeutils, uuids
|
||||
|
||||
import private/ptkutil
|
||||
|
||||
type
|
||||
Mark* = tuple[id: UUID, time: DateTime, summary: string, notes: string, tags: seq[string]]
|
||||
## Representation of a single mark on the timeline.
|
||||
|
||||
Timeline* = tuple[name: string, marks: seq[Mark]]
|
||||
## Representation of a timeline: a name and sequence of Marks.
|
||||
|
||||
const STOP_MSG = "STOP"
|
||||
|
||||
let NO_MARK: Mark = (
|
||||
id: parseUUID("00000000-0000-0000-0000-000000000000"),
|
||||
time: fromUnix(0).local,
|
||||
summary: "", notes: "", tags: @[])
|
||||
|
||||
const ISO_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"
|
||||
## The canonical time format used by PTK.
|
||||
|
||||
const TIME_FORMATS = @[
|
||||
"yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd HH:mm:ss",
|
||||
"yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd HH:mm",
|
||||
"MM-dd'T'HH:mm:ss", "MM-dd HH:mm:ss",
|
||||
"MM-dd'T'HH:mm", "MM-dd HH:mm",
|
||||
"HH:mm:ss", "H:mm:ss", "H:mm", "HH:mm" ]
|
||||
## Other time formats that PTK will accept as input.
|
||||
import private/util
|
||||
import private/api
|
||||
import private/models
|
||||
|
||||
#proc `$`*(mark: Mark): string =
|
||||
#return (($mark.uuid)[
|
||||
@ -40,70 +17,6 @@ proc exitErr(msg: string): void =
|
||||
fatal "ptk: " & msg
|
||||
quit(QuitFailure)
|
||||
|
||||
proc parseTime(timeStr: string): DateTime =
|
||||
## Helper to parse time strings trying multiple known formats.
|
||||
for fmt in TIME_FORMATS:
|
||||
try: return parse(timeStr, fmt)
|
||||
except: discard nil
|
||||
|
||||
raise newException(Exception, "unable to interpret as a date: " & timeStr)
|
||||
|
||||
template `%`(mark: Mark): JsonNode =
|
||||
%* {
|
||||
"id": $(mark.id),
|
||||
"time": mark.time.format(ISO_TIME_FORMAT),
|
||||
"summary": mark.summary,
|
||||
"notes": mark.notes,
|
||||
"tags": mark.tags
|
||||
}
|
||||
|
||||
template `%`(timeline: Timeline): JsonNode =
|
||||
%* { "name": timeline.name, "marks": timeline.marks }
|
||||
|
||||
proc loadTimeline(filename: string): Timeline =
|
||||
## Load a timeline from a file. Expects a path to a file (can be relative or
|
||||
## absolute) and returns a Timeline. The marks in the timeline are guaranteed
|
||||
## to be ordered by time.
|
||||
|
||||
var timelineJson: JsonNode
|
||||
try: timelineJson = parseFile(filename)
|
||||
except:
|
||||
raise newException(ValueError,
|
||||
"unable to parse the timeline file as JSON: " & filename)
|
||||
|
||||
var timeline: Timeline = (name: timelineJson["name"].getStr(), marks: @[])
|
||||
|
||||
for markJson in timelineJson["marks"]:
|
||||
|
||||
# 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(markJson["time"].getStr(), ISO_TIME_FORMAT)
|
||||
except: time = parse(markJson["time"].getStr(), "yyyy:MM:dd'T'HH:mm:ss")
|
||||
|
||||
timeline.marks.add((
|
||||
id: parseUUID(markJson["id"].getStr()),
|
||||
time: time, #parse(markJson["time"].getStr(), ISO_TIME_FORMAT),
|
||||
summary: markJson["summary"].getStr(),
|
||||
notes: markJson["notes"].getStr(),
|
||||
tags: markJson["tags"].getElems(@[]).map(proc (t: JsonNode): string = t.getStr())))
|
||||
|
||||
timeline.marks = timeline.marks.sorted(
|
||||
proc(a, b: Mark): int = cmp(a.time, b.time))
|
||||
|
||||
return timeline
|
||||
|
||||
proc saveTimeline(timeline: Timeline, location: string): void =
|
||||
## Write the timeline to disk at the file location given.
|
||||
|
||||
var timelineFile: File
|
||||
try:
|
||||
timelineFile = open(location, fmWrite)
|
||||
timelineFile.writeLine(pretty(%timeline))
|
||||
except: raise newException(IOError, "unable to save changes to " & location)
|
||||
finally: close(timelineFile)
|
||||
|
||||
proc flexFormat(i: TimeInterval): string =
|
||||
## Pretty-format a time interval.
|
||||
|
||||
@ -188,23 +101,6 @@ proc writeMarks(timeline: Timeline, indices: seq[int], includeNotes = false): vo
|
||||
writeLine(stdout, spaces(notesPrefixLen) & line)
|
||||
writeLine(stdout, "")
|
||||
|
||||
proc findById(marks: seq[Mark], id: string): int =
|
||||
var idx = 0
|
||||
for mark in marks:
|
||||
if startsWith($mark.id, id): return idx
|
||||
inc(idx)
|
||||
|
||||
return -1
|
||||
|
||||
proc getLastIndex(marks: seq[Mark]): int =
|
||||
## Find and return the index of the last Mark that was not a STOP mark.
|
||||
## Returns -1 if there is no such last mark.
|
||||
|
||||
var idx = marks.len - 1
|
||||
while idx >= 0 and marks[idx].summary == STOP_MSG: idx -= 1
|
||||
if idx < 0: result = -1
|
||||
else: result = idx
|
||||
|
||||
proc doInit(timelineLocation: string): void =
|
||||
## Interactively initialize a new timeline at the given file path.
|
||||
|
||||
|
@ -8,5 +8,5 @@ bin = @["ptk"]
|
||||
|
||||
# Dependencies
|
||||
|
||||
requires @["nim >= 0.18.0", "docopt >= 0.6.4", "uuids", "langutils", "tempfile", "timeutils >= 0.2.2", "isaac >= 0.1.2"]
|
||||
requires @["nim >= 0.18.0", "docopt >= 0.6.4", "uuids", "langutils", "tempfile", "timeutils >= 0.2.2", "isaac >= 0.1.2", "bcrypt", "cliutils", "jester" ]
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user