From 7bba4a0ad75d8f3531e731ebe4617146d688993c Mon Sep 17 00:00:00 2001 From: Jonathan Bernard Date: Mon, 18 Feb 2019 10:14:42 -0600 Subject: [PATCH] WIP Refactor DB module to separate out common from PM_API=specific code. Continued work on parsing. --- api/src/main/nim/personal_measure_api.nim | 177 +++++++++++++++++- .../main/nim/personal_measure_apipkg/db.nim | 80 +------- .../nim/personal_measure_apipkg/db_common.nim | 50 +++++ .../nim/personal_measure_apipkg/db_util.nim | 7 +- 4 files changed, 229 insertions(+), 85 deletions(-) create mode 100644 api/src/main/nim/personal_measure_apipkg/db_common.nim diff --git a/api/src/main/nim/personal_measure_api.nim b/api/src/main/nim/personal_measure_api.nim index 4166868..9f4cf45 100644 --- a/api/src/main/nim/personal_measure_api.nim +++ b/api/src/main/nim/personal_measure_api.nim @@ -1,17 +1,169 @@ -import asyncdispatch, bcrypt, docopt, jester, json, jwt +import asyncdispatch, bcrypt, docopt, jester, json, jwt, options, times, timeutils +import personal_measure_apipkg/models import personal_measure_apipkg/db +import personal_measure_apipkg/version + +const JSON = "application/json" type PersonalMeasureApiConfig = object + authSecret*: string + dbConnString*: string + debug*: bool port*: int pwdCost*: int - dbConnString*: string -proc start*(cfg: StrawBossConfig): void = + Session = object + user*: User + issuedAt*, expires*: Time + +proc newSession*(user: User): Session = + result = Session( + user: user, + issuedAt: getTime().utc.trimNanoSec.toTime, + expires: daysForward(1).trimNanoSec.toTime) + +proc raiseEx*(reason: string): void = + raise newException(Exception, reason) + + +template halt(code: HttpCode, + headers: RawHeaders, + content: string): typed = + ## Immediately replies with the specified request. This means any further + ## code will not be executed after calling this template in the current + ## route. + bind TCActionSend, newHttpHeaders + result[0] = CallbackAction.TCActionSend + result[1] = code + result[2] = some(headers) + result[3] = content + result.matched = true + break allRoutes + +template jsonResp(code: HttpCode, details: string = "", headers: RawHeaders = @{:} ) = + halt( + code, + headers & @{"Content-Type": JSON}, + $(%* { + "statusCode": code.int, + "status": $code, + "details": details + }) + ) + +template json500Resp(ex: ref Exception, details: string = ""): void = + when not defined(release): debug ex.getStackTrace() + error details & ":\n" & ex.msg + jsonResp(Http500) + +proc toJWT*(cfg: PersonalMeasureApiConfig, session: Session): string = + ## Make a JST token for this session. + var jwt = JWT( + header: JOSEHeader(alg: HS256, typ: "jwt"), + claims: toClaims(%*{ + "sub": $(session.user.id), + "iat": session.issuedAt.toUnix.int, + "exp": session.expires.toUnix.int })) + + jwt.sign(cfg.authSecret) + result = $jwt + +proc fromJWT*(cfg: PersonalMeasureApiConfig, strTok: string): Session = + ## Validate a given JWT and extract the session data. + let jwt = toJWT(strTok) + var secret = cfg.authSecret + if not jwt.verify(secret): raiseEx "Unable to verify auth token." + jwt.verifyTimeClaims() + + # Find the user record (if authenticated) + let userId = jwt.claims["sub"].node.str + let user = db.getUser(userId) + if users.len != 1: raiseEx "Could not find session user." + + result = Session( + user: user, + issuedAt: fromUnix(jwt.claims["iat"].node.num), + expires: fromUnix(jwt.claims["exp"].node.num)) + +proc extractSession(cfg: PersonalMeasureApiConfig, request: Request): Session = + ## Helper to extract a session from a reqest. + + # Find the auth header + if not request.headers.hasKey("Authorization"): + raiseEx "No auth token." + + # Read and verify the JWT token + let headerVal = request.headers["Authorization"] + if not headerVal.startsWith("Bearer "): + raiseEx "Invalid Authentication type (only 'Bearer' is supported)." + + result = fromJWT(cfg, headerVal[7..^1]) + +proc hashPwd*(pwd: string, cost: int8): string = + let salt = genSalt(cost) + result = hash(pwd, salt) + +proc validatePwd*(u: ref User, givenPwd: string): bool = + let salt = u.hashedPwd[0..28] # TODO: magic numbers + result = compare(u.hashedPwd, hash(givenPwd, salt)) + +proc makeAuthToken*(cfg: PersonalMeasureApiConfig, uname, pwd: string): string = + ## Given a username and pwd, validate the combination and generate a JWT + ## token string. + + if uname.len == 0 or pwd.len == 0: + raiseEx "fields 'username' and 'password' required" + + # find the user record + let users = cfg.users.filterIt(it.name == uname) + if users.len != 1: raiseEx "invalid username or password" + + let user = users[0] + + if not validatePwd(user, pwd): raiseEx "invalid username or password" + + let session = newSession(user) + + result = toJWT(cfg, session) + +proc makeApiKey*(cfg: PersonalMeasureApiConfig, uname: string): string = + ## Given a username, make an API token (JWT token string that does not + ## expire). Note that this does not validate the username/pwd combination. It + ## is not intended to be exposed publicly via the API, but serve as a utility + ## function for an administrator to setup a unsupervised account (git access + ## for example). + + if uname.len == 0: raiseEx "no username given" + + # find the user record + let users = cfg.users.filterIt(it.name == uname) + if users.len != 1: raiseEx "invalid username" + + let session = Session( + user: users[0], + issuedAt: getTime(), + expires: daysForward(365 * 1000).toTime()) + + result = toJWT(cfg, session); + +template checkAuth() = + ## Check this request for authentication and authorization information. + ## Injects the session into the running context. If the request is not + ## authorized, this template returns an appropriate 401 response. + + var session {.inject.}: Session + + try: session = extractSession(cfg, request) + except: + debug "Auth failed: " & getCurrentExceptionMsg() + jsonResp(Http401, "Unauthorized", @{"WWW-Authenticate": "Bearer"}) + + +proc start*(cfg: PersonalMeasureApiConfig): void = var stopFuture = newFuture[void]() - var workers: seq[Worker] = @[] settings: port = Port(cfg.port) @@ -20,6 +172,21 @@ proc start*(cfg: StrawBossConfig): void = routes: get "/version": - resp($(%("strawboss v" & SB_VERSION)), JSON) + resp($(%("personal_measure_api v" & PM_API_VERSION)), JSON) + + post "/service/debug/stop": + if not cfg.debug: jsonResp(Http404) + else: + let shutdownFut = sleepAsync(100) + shutdownFut.callback = proc(): void = complete(stopFuture) + resp($(%"shutting down"), JSON) + + waitFor(stopFuture) +when isMainModule: + start(PersonalMeasureApiConfig( + debug: true, + port: 8090, + pwdCost: 11, + dbConnString: "host=localhost port=5500 username=postgres password=password dbname=personal_measure")) diff --git a/api/src/main/nim/personal_measure_apipkg/db.nim b/api/src/main/nim/personal_measure_apipkg/db.nim index e1eb4f6..ed4ebd7 100644 --- a/api/src/main/nim/personal_measure_apipkg/db.nim +++ b/api/src/main/nim/personal_measure_apipkg/db.nim @@ -2,53 +2,7 @@ import db_postgres, macros, options, postgres, sequtils, strutils, times, timeutils, unicode, uuids import ./models -import ./db_util - -proc newMutateClauses(): MutateClauses = - return MutateClauses( - columns: @[], - placeholders: @[], - values: @[]) - -proc createRecord*[T](db: DbConn, rec: T): T = - var mc = newMutateClauses() - populateMutateClauses(rec, true, mc) - - # Confusingly, getRow allows inserts and updates. We use it to get back the ID - # we want from the row. - let newIdStr = db.getValue(sql( - "INSERT INTO " & tableName(rec) & - " (" & mc.columns.join(",") & ") " & - " VALUES (" & mc.placeholders.join(",") & ") " & - " RETURNING id"), mc.values) - - result = rec - result.id = parseUUID(newIdStr) - -proc updateRecord*[T](db: DbConn, rec: T): bool = - var mc = newMutateClauses() - populateMutateClauses(rec, false, mc) - - let setClause = zip(mc.columns, mc.placeholders).mapIt(it.a & " = " it.b).join(',') - let numRowsUpdated = db.execAffectedRows(sql( - "UPDATE " & tableName(rec) & - " SET " & setClause & - " WHERE id = ? "), mc.values.concat(@[rec.id])) - - return numRowsUpdated > 0; - -template getRecord*(db: DbConn, modelType: type, id: UUID): untyped = - let row = db.getRow(sql( - "SELECT " & columnNamesForModel(modelType).join(",") & - " FROM " & tableName(modelType) & - " WHERE id = ?"), @[$id]) - rowToModel(modelType, row) - -template getAllRecords*(db: DbConn, modelType: type): untyped = - db.getAllRows(sql( - "SELECT " & columnNamesForModel(modelType).join(",") & - " FROM " & tableName(modelType))) - .mapIt(rowToModel(modelType, it)) +import ./db_common # proc create: Typed create methods for specific records proc createUser*(db: DbConn, user: User): User = return db.createRecord(user) @@ -57,35 +11,7 @@ proc createMeasure*(db: DbConn, measure: Measure): Measure = return db.createRec proc createValue*(db: DbConn, value: Value): Value = return db.createRecord(value) proc getUser*(db: DbConn, id: UUID): User = return db.getRecord(User, id) +proc getUser*(db: DbConn, id: string): User = return db.getRecord(User, parseUUID(id)) proc getApiToken*(db: DbConn, id: UUID): ApiToken = return db.getRecord(ApiToken, id) proc getMeasure*(db: DbConn, id: UUID): Measure = return db.getRecord(Measure, id) -#proc getValue*(db: DbConn, id: UUID): Value = return db.getRecord(Value, id) - -when isMainModule: - - let db = open("", "", "", "host=localhost port=5500 dbname=personal_measure user=postgres password=password") - - echo "Users:" - echo $db.getAllRecords(User) - - echo "\nApiTokens:" - echo $db.getAllRecords(ApiToken) - - echo "\nMeasures:" - let measures = db.getAllRecords(Measure) - echo $measures - echo "\tanalysis: ", measures[0].analysis[0] - - #[ - #echo tableName(ApiToken) - #echo $rowToModel(ApiToken, @["47400441-5c3a-4119-8acf-f616ae25c16c", "9e5460dd-b580-4071-af97-c1cbdedaae12", "Test Token", "5678", ""]) - for row in db.fastRows(sql"SELECT * FROM api_tokens"): - echo $rowToModel(ApiToken, row); - - let u = User( - displayName: "Bob", - email: "bob@bobsco.com", - hashedPwd: "test") - - ]# - +proc getValue*(db: DbConn, id: UUID): Value = return db.getRecord(Value, id) diff --git a/api/src/main/nim/personal_measure_apipkg/db_common.nim b/api/src/main/nim/personal_measure_apipkg/db_common.nim new file mode 100644 index 0000000..8ab14fd --- /dev/null +++ b/api/src/main/nim/personal_measure_apipkg/db_common.nim @@ -0,0 +1,50 @@ +import db_postgres, macros, options, sequtils, uuids + +import ./db_util + +proc newMutateClauses(): MutateClauses = + return MutateClauses( + columns: @[], + placeholders: @[], + values: @[]) + +proc createRecord*[T](db: DbConn, rec: T): T = + var mc = newMutateClauses() + populateMutateClauses(rec, true, mc) + + # Confusingly, getRow allows inserts and updates. We use it to get back the ID + # we want from the row. + let newIdStr = db.getValue(sql( + "INSERT INTO " & tableName(rec) & + " (" & mc.columns.join(",") & ") " & + " VALUES (" & mc.placeholders.join(",") & ") " & + " RETURNING id"), mc.values) + + result = rec + result.id = parseUUID(newIdStr) + +proc updateRecord*[T](db: DbConn, rec: T): bool = + var mc = newMutateClauses() + populateMutateClauses(rec, false, mc) + + let setClause = zip(mc.columns, mc.placeholders).mapIt(it.a & " = " it.b).join(',') + let numRowsUpdated = db.execAffectedRows(sql( + "UPDATE " & tableName(rec) & + " SET " & setClause & + " WHERE id = ? "), mc.values.concat(@[rec.id])) + + return numRowsUpdated > 0; + +template getRecord*(db: DbConn, modelType: type, id: UUID): untyped = + let row = db.getRow(sql( + "SELECT " & columnNamesForModel(modelType).join(",") & + " FROM " & tableName(modelType) & + " WHERE id = ?"), @[$id]) + rowToModel(modelType, row) + +template getAllRecords*(db: DbConn, modelType: type): untyped = + db.getAllRows(sql( + "SELECT " & columnNamesForModel(modelType).join(",") & + " FROM " & tableName(modelType))) + .mapIt(rowToModel(modelType, it)) + diff --git a/api/src/main/nim/personal_measure_apipkg/db_util.nim b/api/src/main/nim/personal_measure_apipkg/db_util.nim index a0dac45..9f01032 100644 --- a/api/src/main/nim/personal_measure_apipkg/db_util.nim +++ b/api/src/main/nim/personal_measure_apipkg/db_util.nim @@ -140,10 +140,11 @@ proc createParseStmt*(t, value: NimNode): NimNode = elif t.typeKind == ntySequence: let innerType = t[1] + let parseStmts = createParseStmt(innerType, ident("it")) + + echo parseStmts.treeRepr result.add quote do: - # TODO: for each value call the type-specific parsing logic. - #parseDbArray(`value`).mapIt(createParseStmt(it)) - parseDbArray(`value`) + parseDbArray(`value`).mapIt(`parseStmts`) elif t.typeKind == ntyString: result.add quote do: