WIP Refactor API into multiple sub-modules.
This commit is contained in:
parent
7bba4a0ad7
commit
1337d17105
7
api/personal_measure.config.json
Normal file
7
api/personal_measure.config.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"authSecret":"change me",
|
||||
"dbConnString":"host=localhost port=5500 dbname=personal_measure user=postgres password=password",
|
||||
"debug":true,
|
||||
"port":8080,
|
||||
"pwdCost":11
|
||||
}
|
@ -8,9 +8,11 @@ description = "JDB\'s Personal Measures API"
|
||||
license = "MIT"
|
||||
srcDir = "src/main/nim"
|
||||
bin = @["personal_measure_api"]
|
||||
skipExt = @["nim"]
|
||||
|
||||
|
||||
# Dependencies
|
||||
|
||||
requires @["nim >= 0.19.4", "bcrypt", "docopt >= 0.6.8", "isaac >= 0.1.3", "jester >= 0.4.1", "jwt", "tempfile",
|
||||
"timeutils >= 0.4.0", "uuids >= 0.1.10" ]
|
||||
requires @["nim >= 0.19.4", "bcrypt", "cliutils >= 0.6.3", "docopt >= 0.6.8",
|
||||
"isaac >= 0.1.3", "jester >= 0.4.1", "jwt", "tempfile", "timeutils >= 0.4.0",
|
||||
"uuids >= 0.1.10" ]
|
||||
|
@ -1,192 +1,83 @@
|
||||
import asyncdispatch, bcrypt, docopt, jester, json, jwt, options, times, timeutils
|
||||
import cliutils, docopt, logging, jester, json, os, strutils, tables
|
||||
|
||||
import personal_measure_apipkg/models
|
||||
import personal_measure_apipkg/db
|
||||
import personal_measure_apipkg/configuration
|
||||
import personal_measure_apipkg/version
|
||||
import personal_measure_apipkg/api
|
||||
|
||||
const JSON = "application/json"
|
||||
const DEFAULT_CONFIG = PMApiConfig(
|
||||
authSecret: "change me",
|
||||
dbConnString: "",
|
||||
debug: false,
|
||||
port: 8080,
|
||||
pwdCost: 11)
|
||||
|
||||
type
|
||||
PersonalMeasureApiConfig = object
|
||||
authSecret*: string
|
||||
dbConnString*: string
|
||||
debug*: bool
|
||||
port*: int
|
||||
pwdCost*: int
|
||||
proc loadConfig*(args: Table[string, docopt.Value] = initTable[string, docopt.Value]()): PMApiConfig =
|
||||
|
||||
Session = object
|
||||
user*: User
|
||||
issuedAt*, expires*: Time
|
||||
let filePath =
|
||||
if args["--config"]: $args["--config"]
|
||||
else: "personal_measure.config.json"
|
||||
|
||||
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)
|
||||
var json: JsonNode
|
||||
try: json = parseFile(filePath)
|
||||
except:
|
||||
debug "Auth failed: " & getCurrentExceptionMsg()
|
||||
jsonResp(Http401, "Unauthorized", @{"WWW-Authenticate": "Bearer"})
|
||||
|
||||
|
||||
proc start*(cfg: PersonalMeasureApiConfig): void =
|
||||
|
||||
var stopFuture = newFuture[void]()
|
||||
|
||||
settings:
|
||||
port = Port(cfg.port)
|
||||
appName = "/api"
|
||||
|
||||
routes:
|
||||
|
||||
get "/version":
|
||||
resp($(%("personal_measure_api v" & PM_API_VERSION)), JSON)
|
||||
|
||||
post "/service/debug/stop":
|
||||
if not cfg.debug: jsonResp(Http404)
|
||||
json = %DEFAULT_CONFIG
|
||||
if not existsFile(filePath):
|
||||
info "created new configuration file \"" & filePath & "\""
|
||||
filePath.writeFile($json)
|
||||
else:
|
||||
let shutdownFut = sleepAsync(100)
|
||||
shutdownFut.callback = proc(): void = complete(stopFuture)
|
||||
resp($(%"shutting down"), JSON)
|
||||
warn "Cannot read configuration file \"" & filePath & "\":\n\t" &
|
||||
getCurrentExceptionMsg()
|
||||
|
||||
waitFor(stopFuture)
|
||||
let cfg = CombinedConfig(docopt: args, json: json)
|
||||
|
||||
result = PMApiConfig(
|
||||
authSecret: cfg.getVal("authSecret"),
|
||||
dbConnString: cfg.getVal("dbConnString"),
|
||||
debug: "true".startsWith(cfg.getVal("debug", "false").toLower()),
|
||||
port: parseInt(cfg.getVal("port", "8080")),
|
||||
pwdCost: parseInt(cfg.getVal("pwdCost", "11")))
|
||||
|
||||
proc initContext(args: Table[string, docopt.Value]): PMApiContext =
|
||||
|
||||
var cfg: PMApiConfig
|
||||
var db: PMApiDb
|
||||
|
||||
try: cfg = loadConfig(args)
|
||||
except: raiseEx "Unable to load configuration: \n\t" & getCurrentExceptionMsg()
|
||||
|
||||
try: db = connect(cfg.dbConnString)
|
||||
except: raiseEx "Unable to connect to the database:\n\t" & getCurrentExceptionMsg()
|
||||
|
||||
result = PMApiContext(cfg: cfg, db: db)
|
||||
|
||||
when isMainModule:
|
||||
start(PersonalMeasureApiConfig(
|
||||
|
||||
try:
|
||||
let doc = """
|
||||
Usage:
|
||||
personal_measure_api test [options]
|
||||
personal_measure_api serve [options]
|
||||
|
||||
Options:
|
||||
|
||||
-C, --config <cfgFile> Location of the config file (defaults to personal_measure.config.json)
|
||||
"""
|
||||
logging.addHandler(newConsoleLogger())
|
||||
|
||||
# Initialize our service context
|
||||
let args = docopt(doc, version = PM_API_VERSION)
|
||||
let ctx = initContext(args)
|
||||
|
||||
if args["test"]:
|
||||
echo "Test"
|
||||
|
||||
if args["serve"]:
|
||||
start(PMApiConfig(
|
||||
debug: true,
|
||||
port: 8090,
|
||||
pwdCost: 11,
|
||||
dbConnString: "host=localhost port=5500 username=postgres password=password dbname=personal_measure"))
|
||||
except:
|
||||
fatal "pit: " & getCurrentExceptionMsg()
|
||||
#raise getCurrentException()
|
||||
quit(QuitFailure)
|
||||
|
146
api/src/main/nim/personal_measure_apipkg/api.nim
Normal file
146
api/src/main/nim/personal_measure_apipkg/api.nim
Normal file
@ -0,0 +1,146 @@
|
||||
import asyncdispatch, jester, json, jwt, strutils, times, timeutils, uuids
|
||||
|
||||
import ./db
|
||||
import ./configuration
|
||||
import ./models
|
||||
import ./service
|
||||
|
||||
const JSON = "application/json"
|
||||
|
||||
type
|
||||
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)
|
||||
|
||||
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*(ctx: PMApiContext, 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(ctx.cfg.authSecret)
|
||||
result = $jwt
|
||||
|
||||
proc fromJWT*(ctx: PMApiContext, strTok: string): Session =
|
||||
## Validate a given JWT and extract the session data.
|
||||
let jwt = toJWT(strTok)
|
||||
var secret = ctx.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
|
||||
var user: User
|
||||
try: user = ctx.db.getUser(parseUUID(userId))
|
||||
except: raiseEx "unknown user"
|
||||
|
||||
result = Session(
|
||||
user: user,
|
||||
issuedAt: fromUnix(jwt.claims["iat"].node.num),
|
||||
expires: fromUnix(jwt.claims["exp"].node.num))
|
||||
|
||||
proc extractSession(ctx: PMApiContext, 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(ctx, headerVal[7..^1])
|
||||
|
||||
proc makeAuthToken*(ctx: PMApiContext, email, pwd: string): string =
|
||||
## Given a user's email and pwd, validate the combination and generate a JWT
|
||||
## token string.
|
||||
|
||||
if email.len == 0 or pwd.len == 0:
|
||||
raiseEx "fields 'username' and 'password' required"
|
||||
|
||||
# find the user record
|
||||
var user: User
|
||||
try: user = ctx.db.getUserByEmail(email)
|
||||
except: raiseEx "invalid username or password"
|
||||
|
||||
if not validatePwd(user, pwd): raiseEx "invalid username or password"
|
||||
|
||||
let session = newSession(user)
|
||||
|
||||
result = toJWT(ctx, 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: PMApiConfig): void =
|
||||
|
||||
var stopFuture = newFuture[void]()
|
||||
|
||||
settings:
|
||||
port = Port(cfg.port)
|
||||
appName = "/api"
|
||||
|
||||
routes:
|
||||
|
||||
get "/version":
|
||||
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)
|
||||
|
27
api/src/main/nim/personal_measure_apipkg/configuration.nim
Normal file
27
api/src/main/nim/personal_measure_apipkg/configuration.nim
Normal file
@ -0,0 +1,27 @@
|
||||
import json
|
||||
|
||||
import ./db
|
||||
|
||||
type
|
||||
PMApiConfig* = object
|
||||
authSecret*: string
|
||||
dbConnString*: string
|
||||
debug*: bool
|
||||
port*: int
|
||||
pwdCost*: int
|
||||
|
||||
PMApiContext* = object
|
||||
cfg*: PMApiConfig
|
||||
db*: PMApiDb
|
||||
|
||||
proc `%`*(cfg: PMApiConfig): JsonNode =
|
||||
result = %* {
|
||||
"authSecret": cfg.authSecret,
|
||||
"dbConnString": cfg.dbConnString,
|
||||
"debug": cfg.debug,
|
||||
"port": cfg.port,
|
||||
"pwdCost": cfg.pwdCost }
|
||||
|
||||
proc raiseEx*(reason: string): void =
|
||||
raise newException(Exception, reason)
|
||||
|
@ -4,14 +4,32 @@ import db_postgres, macros, options, postgres, sequtils, strutils, times,
|
||||
import ./models
|
||||
import ./db_common
|
||||
|
||||
# proc create: Typed create methods for specific records
|
||||
proc createUser*(db: DbConn, user: User): User = return db.createRecord(user)
|
||||
proc createApiToken*(db: DbConn, token: ApiToken): ApiToken = return db.createRecord(token)
|
||||
proc createMeasure*(db: DbConn, measure: Measure): Measure = return db.createRecord(measure)
|
||||
proc createValue*(db: DbConn, value: Value): Value = return db.createRecord(value)
|
||||
type
|
||||
PMApiDb* = ref object
|
||||
conn: DbConn
|
||||
|
||||
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)
|
||||
|
||||
proc connect*(connString: string): PMApiDb =
|
||||
result = PMApiDb(conn: open("", "", "", connString))
|
||||
|
||||
macro makeGetRecord(modelType: type): untyped =
|
||||
echo modelType.getType.treeRepr
|
||||
#[
|
||||
let procIdent = ident("get" & $modelType.getType[1])
|
||||
return quote do:
|
||||
proc `procIdent`*(db: PMApiDb, id: UUID): `modelType` = return db.conn.getRecord(`modelType`, id)
|
||||
]#
|
||||
|
||||
proc createUser*(db: PMApiDb, user: User): User = return db.conn.createRecord(user)
|
||||
proc createApiToken*(db: PMApiDb, token: ApiToken): ApiToken = return db.conn.createRecord(token)
|
||||
proc createMeasure*(db: PMApiDb, measure: Measure): Measure = return db.conn.createRecord(measure)
|
||||
proc createValue*(db: PMApiDb, value: Value): Value = return db.conn.createRecord(value)
|
||||
|
||||
proc getUser*(db: PMApiDb, id: UUID): User = return db.conn.getRecord(User, id)
|
||||
proc getUser*(db: PMApiDb, id: string): User = return db.conn.getRecord(User, parseUUID(id))
|
||||
proc getApiToken*(db: PMApiDb, id: UUID): ApiToken = return db.conn.getRecord(ApiToken, id)
|
||||
proc getMeasure*(db: PMApiDb, id: UUID): Measure = return db.conn.getRecord(Measure, id)
|
||||
proc getValue*(db: PMApiDb, id: UUID): Value = return db.conn.getRecord(Value, id)
|
||||
|
||||
#proc getUsersWhere*(db: PMApiDb, whereClause: string, values: varargs[string, dbFormat]): User =
|
||||
# return db.conn.getRecordsWhere(User, whereClause, values)
|
||||
|
@ -40,8 +40,19 @@ template getRecord*(db: DbConn, modelType: type, id: UUID): untyped =
|
||||
"SELECT " & columnNamesForModel(modelType).join(",") &
|
||||
" FROM " & tableName(modelType) &
|
||||
" WHERE id = ?"), @[$id])
|
||||
|
||||
if row.allIt(it.len == 0):
|
||||
raise newException(KeyError, "no record for id " & $id)
|
||||
|
||||
rowToModel(modelType, row)
|
||||
|
||||
template getRecordsWhere*(db: DbConn, modelType: type, whereClause: string, values: varargs[string, dbFormat]): untyped =
|
||||
db.getAllRows(sql(
|
||||
"SELECT " & columnNamesForModel(modelType).join(",") &
|
||||
" FROM " & tableName(modelType) &
|
||||
" WHERE " & whereClause), values)
|
||||
.mapIt(rowToModel(modelType, it))
|
||||
|
||||
template getAllRecords*(db: DbConn, modelType: type): untyped =
|
||||
db.getAllRows(sql(
|
||||
"SELECT " & columnNamesForModel(modelType).join(",") &
|
||||
|
14
api/src/main/nim/personal_measure_apipkg/service.nim
Normal file
14
api/src/main/nim/personal_measure_apipkg/service.nim
Normal file
@ -0,0 +1,14 @@
|
||||
import bcrypt
|
||||
|
||||
import ./configuration
|
||||
import ./db
|
||||
import ./models
|
||||
|
||||
proc hashPwd*(pwd: string, cost: int8): string =
|
||||
let salt = genSalt(cost)
|
||||
result = hash(pwd, salt)
|
||||
|
||||
proc validatePwd*(u: User, givenPwd: string): bool =
|
||||
let salt = u.hashedPwd[0..28] # TODO: magic numbers
|
||||
result = compare(u.hashedPwd, hash(givenPwd, salt))
|
||||
|
Loading…
x
Reference in New Issue
Block a user