Initial commit.
This commit is contained in:
		
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
			
		||||
.*.sw?
 | 
			
		||||
nimcache/
 | 
			
		||||
							
								
								
									
										19
									
								
								buffoonery.nimble
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								buffoonery.nimble
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,19 @@
 | 
			
		||||
# Package
 | 
			
		||||
 | 
			
		||||
version       = "0.1.0"
 | 
			
		||||
author        = "Jonathan Bernard"
 | 
			
		||||
description   = "JDB Software's opinionated extensions and auth layer for Jester."
 | 
			
		||||
license       = "MIT"
 | 
			
		||||
srcDir        = "src"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Dependencies
 | 
			
		||||
 | 
			
		||||
requires "nim >= 1.6.2"
 | 
			
		||||
requires @["bcrypt", "jester >= 0.5.0", "uuids"]
 | 
			
		||||
 | 
			
		||||
requires "https://git.jdb-software.com/jdb/nim-jwt-full.git"
 | 
			
		||||
requires "https://git.jdb-software.com/jdb/nim-namespaced-logging.git"
 | 
			
		||||
 | 
			
		||||
task unittest, "Runs the unit test suite.":
 | 
			
		||||
  exec "nim c -r test/runner"
 | 
			
		||||
							
								
								
									
										7
									
								
								src/buffoonery.nim
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/buffoonery.nim
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
# This is just an example to get you started. A typical library package
 | 
			
		||||
# exports the main API in this file. Note that you cannot rename this file
 | 
			
		||||
# but you can remove it if you wish.
 | 
			
		||||
 | 
			
		||||
proc add*(x, y: int): int =
 | 
			
		||||
  ## Adds two files together.
 | 
			
		||||
  return x + y
 | 
			
		||||
							
								
								
									
										28
									
								
								src/buffoonery/apierror.nim
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/buffoonery/apierror.nim
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
from strutils import isEmptyOrWhitespace
 | 
			
		||||
from httpclient import HttpCode
 | 
			
		||||
 | 
			
		||||
type ApiError* = object of CatchableError
 | 
			
		||||
  respMsg*: string
 | 
			
		||||
  respCode*: HttpCode
 | 
			
		||||
 | 
			
		||||
proc newApiError*(parent: ref Exception = nil, respCode: HttpCode, respMsg: string, msg = ""): ref ApiError =
 | 
			
		||||
  result = newException(ApiError, msg, parent)
 | 
			
		||||
  result.respCode = respCode
 | 
			
		||||
  result.respMsg = respMsg
 | 
			
		||||
 | 
			
		||||
proc raiseApiError*(respCode: HttpCode, respMsg: string, msg = "") =
 | 
			
		||||
  var apiError = newApiError(
 | 
			
		||||
    parent = nil,
 | 
			
		||||
    respCode = respCode,
 | 
			
		||||
    respMsg = respMsg,
 | 
			
		||||
    msg = if msg.isEmptyOrWhitespace: respMsg
 | 
			
		||||
          else: msg)
 | 
			
		||||
  raise apiError
 | 
			
		||||
 | 
			
		||||
proc raiseApiError*(parent: ref Exception, respCode: HttpCode, respMsg: string, msg = "") =
 | 
			
		||||
  var apiError = newApiError(
 | 
			
		||||
    parent = parent,
 | 
			
		||||
    respCode = respCode,
 | 
			
		||||
    respMsg = respMsg,
 | 
			
		||||
    msg = msg)
 | 
			
		||||
  raise apiError
 | 
			
		||||
							
								
								
									
										245
									
								
								src/buffoonery/auth.nim
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										245
									
								
								src/buffoonery/auth.nim
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,245 @@
 | 
			
		||||
import std/httpclient, std/json, std/logging, std/options, std/sequtils,
 | 
			
		||||
  std/strutils, std/tables, std/times
 | 
			
		||||
import jester, namespaced_logging
 | 
			
		||||
 | 
			
		||||
import jwt_full, jwt_full/encoding
 | 
			
		||||
 | 
			
		||||
import ./jsonutils
 | 
			
		||||
 | 
			
		||||
const SUPPORTED_SIGNATURE_ALGORITHMS = @[ HS256, RS256 ]
 | 
			
		||||
 | 
			
		||||
type
 | 
			
		||||
  AuthError* = object of CatchableError
 | 
			
		||||
 | 
			
		||||
  ApiAuthContext* = ref object
 | 
			
		||||
    cookiePrefix*: string         ## Prefix for the user and session cookies
 | 
			
		||||
    validAudiences*: seq[string]  ## Expected audience values for for `aud` JWT check
 | 
			
		||||
    issuer*: string               ## The JWT issuer for tokens created by this API
 | 
			
		||||
    trustedIssuers*: seq[string]  ## The list of trusted OAuth 2.0 issuers
 | 
			
		||||
 | 
			
		||||
    signingKid*: string
 | 
			
		||||
      ## The key id to use for signing JWT tokens created by this API. The JWK
 | 
			
		||||
      ## representing this signing key with the private or secret key value
 | 
			
		||||
      ## must be provided either when the ApiAuthContext is initialized (see
 | 
			
		||||
      ## `initApiAuthContext` or via `addSigningKeys`
 | 
			
		||||
 | 
			
		||||
    issuerKeys: TableRef[string, JwkSet]
 | 
			
		||||
 | 
			
		||||
var logNs {.threadvar.}: LoggingNamespace
 | 
			
		||||
 | 
			
		||||
template log(): untyped =
 | 
			
		||||
  if logNs.isNil: logNs = initLoggingNamespace("buffoonery/auth", lvlDebug)
 | 
			
		||||
  logNs
 | 
			
		||||
 | 
			
		||||
proc failAuth*(reason: string, parentException: ref Exception = nil) =
 | 
			
		||||
  raise newException(AuthError, reason, parentException)
 | 
			
		||||
 | 
			
		||||
proc validateSigningKey(k: JWK): void =
 | 
			
		||||
  if k.alg.isNone: failAuth "JWK is missing 'alg'"
 | 
			
		||||
  if k.kid.isNone: failAuth "JWK is missing 'kid'"
 | 
			
		||||
 | 
			
		||||
proc initApiAuthContext*(
 | 
			
		||||
    cookiePrefix: string,
 | 
			
		||||
    validAudiences: seq[string],
 | 
			
		||||
    issuer: string,
 | 
			
		||||
    trustedIssuers: seq[string],
 | 
			
		||||
    signingKid: string,
 | 
			
		||||
    signingKeys: JwkSet): ApiAuthContext =
 | 
			
		||||
 | 
			
		||||
  for k in signingKeys: validateSigningKey(k)
 | 
			
		||||
 | 
			
		||||
  result = ApiAuthContext(
 | 
			
		||||
    cookiePrefix: cookiePrefix,
 | 
			
		||||
    validAudiences: validAudiences,
 | 
			
		||||
    issuer: issuer,
 | 
			
		||||
    trustedIssuers: trustedIssuers,
 | 
			
		||||
    signingKid: signingKid,
 | 
			
		||||
    issuerKeys: newTable[string, JwkSet]([(issuer, signingKeys)]))
 | 
			
		||||
 | 
			
		||||
proc fetchJWKs(openIdConfigUrl: string): JwkSet {.gcsafe.} =
 | 
			
		||||
  ## Fetch signing keys for an OAuth issuer. `openIdConfigUrl` is expected to
 | 
			
		||||
  ## be a well-known URL (ISSUER_BASE/.well-known/openid-configuration)
 | 
			
		||||
  try:
 | 
			
		||||
    let http = newHttpClient()
 | 
			
		||||
 | 
			
		||||
    # Inspect the OAuth metadata via the well-known address.
 | 
			
		||||
    log.debug "fetchJwks: Fetching metadata from " & openIdConfigUrl
 | 
			
		||||
    let metadata = parseJson(http.getContent(openIdConfigUrl))
 | 
			
		||||
 | 
			
		||||
    # Fetch the keys from the jwk_keys URI.
 | 
			
		||||
    let jwksKeysURI = metadata.getOrFail("jwks_uri").getStr
 | 
			
		||||
    debug "fetchJwks: Fetching JWKs from " & jwksKeysURI
 | 
			
		||||
    let jwksKeys = parseJson(http.getContent(jwksKeysURI))
 | 
			
		||||
 | 
			
		||||
    # Parse and load the keys provided.
 | 
			
		||||
    return initJwkSet(jwksKeys)
 | 
			
		||||
 | 
			
		||||
  except:
 | 
			
		||||
    log.error "unable to fetch issuer signing keys: " & getCurrentExceptionMsg()
 | 
			
		||||
    failAuth "unable to fetch isser signing keys"
 | 
			
		||||
 | 
			
		||||
proc addSigningKeys*(ctx: ApiAuthContext, issuer: string, keySet: JwkSet): void =
 | 
			
		||||
  ## Manually add a set of signing keys associated with a given issuer.
 | 
			
		||||
  try:
 | 
			
		||||
    for k in keySet: validateSigningKey(k)
 | 
			
		||||
    if ctx.issuerKeys.isNil: ctx.issuerKeys = newTable[string, JwkSet]()
 | 
			
		||||
    ctx.issuerKeys[issuer] = keySet
 | 
			
		||||
  except:
 | 
			
		||||
    log.error "unable to add a set of signing keys: " & getCurrentExceptionMsg()
 | 
			
		||||
    raise getCurrentException()
 | 
			
		||||
 | 
			
		||||
proc findSigningKey*(ctx: ApiAuthContext, jwt: JWT, allowFetch = true): JWK {.gcsafe.} =
 | 
			
		||||
  ## Lookup the signing key for a given JWT. This method assumes that you trust
 | 
			
		||||
  ## the issuer named in the JWT.
 | 
			
		||||
 | 
			
		||||
  try:
 | 
			
		||||
    if jwt.claims.iss.isNone: failAuth "Missing 'iss' claim."
 | 
			
		||||
    if jwt.header.kid.isNone: failAuth "Missing 'kid' header."
 | 
			
		||||
 | 
			
		||||
    if ctx.issuerKeys.isNil: ctx.issuerKeys = newTable[string, JwkSet]()
 | 
			
		||||
 | 
			
		||||
    let jwtIssuer = jwt.claims.iss.get
 | 
			
		||||
 | 
			
		||||
    # Do we already have keys for this issuer in our cache?
 | 
			
		||||
    if ctx.issuerKeys.hasKey(jwtIssuer):
 | 
			
		||||
      # Do we have the key for this keyId?
 | 
			
		||||
      let foundKeys = ctx.issuerKeys[jwtIssuer]
 | 
			
		||||
        .filterIt(it.kid.isSome and it.kid.get == jwt.header.kid.get)
 | 
			
		||||
 | 
			
		||||
      if foundKeys.len == 1: return foundKeys[0]
 | 
			
		||||
 | 
			
		||||
    # If all of the above were true, we should have returned. If we reach this
 | 
			
		||||
    # point, we know that one of the above was false and we need to refresh our
 | 
			
		||||
    # cache of keys.
 | 
			
		||||
    if allowFetch:
 | 
			
		||||
      ctx.issuerKeys[jwtIssuer] =
 | 
			
		||||
        fetchJWKs(jwtIssuer & "/.well-known/openid-configuration")
 | 
			
		||||
      return ctx.findSigningKey(jwt, false)
 | 
			
		||||
 | 
			
		||||
    else: failAuth "unable to find JWT signing key"
 | 
			
		||||
 | 
			
		||||
  except:
 | 
			
		||||
    log.error "unable to find JWT signing key: " & getCurrentExceptionMsg()
 | 
			
		||||
    failAuth("unable to find JWT signing key", getCurrentException())
 | 
			
		||||
 | 
			
		||||
proc validateJWT*(ctx: ApiAuthContext, jwt: JWT) =
 | 
			
		||||
  ## Given a JWT, validate that it is a well-formed JWT, validate the issuer's
 | 
			
		||||
  ## signature on the token, and validate all the claims that it preesnts.
 | 
			
		||||
  try:
 | 
			
		||||
    log.debug "Validating JWT: " & $jwt
 | 
			
		||||
    if jwt.claims.iss.isNone: failAuth "Missing 'iss' claim."
 | 
			
		||||
    let jwtIssuer = jwt.claims.iss.get
 | 
			
		||||
 | 
			
		||||
    if not ctx.trustedIssuers.contains(jwtIssuer):
 | 
			
		||||
      failAuth "JWT is issued by $# but we only trust $#" %
 | 
			
		||||
        [jwtIssuer, $ctx.trustedIssuers]
 | 
			
		||||
 | 
			
		||||
    if jwt.header.alg.isNone: failAuth "Missing 'alg' header property."
 | 
			
		||||
 | 
			
		||||
    if jwt.claims.aud.isNone or
 | 
			
		||||
       not ctx.validAudiences.contains(jwt.claims.aud.get):
 | 
			
		||||
      failAuth "JWT is not for us (invalid audience)."
 | 
			
		||||
      failAuth "Issuer is trusted, but the token is not for the expected audience."
 | 
			
		||||
 | 
			
		||||
    let signingAlgorithm = jwt.header.alg.get
 | 
			
		||||
 | 
			
		||||
    if not SUPPORTED_SIGNATURE_ALGORITHMS.contains(signingAlgorithm):
 | 
			
		||||
      failAuth "unacceptable signature algorithm: " & $signingAlgorithm
 | 
			
		||||
 | 
			
		||||
    jwt.validate(
 | 
			
		||||
      sigAlg = signingAlgorithm,
 | 
			
		||||
      key = ctx.findSigningKey(jwt),
 | 
			
		||||
      validateTimeClaims = true)
 | 
			
		||||
 | 
			
		||||
  except:
 | 
			
		||||
    failAuth(getCurrentExceptionMsg(), getCurrentException())
 | 
			
		||||
 | 
			
		||||
proc extractValidJwt*(ctx: ApiAuthContext, req: Request): JWT =
 | 
			
		||||
  ## Extracts a valid JWT representing the user's authentication and
 | 
			
		||||
  ## authorization details, if present. If there are no valid credentials an
 | 
			
		||||
  ## exception is raised.
 | 
			
		||||
  ##
 | 
			
		||||
  ## We support two authentication flows:
 | 
			
		||||
  ##
 | 
			
		||||
  ## - Strict API via a JWT Bearer token in the Authorization header. This is
 | 
			
		||||
  ##   intended for API consumers (not the browser-based web-app). In this
 | 
			
		||||
  ##   case, the token is validated directly.
 | 
			
		||||
  ##
 | 
			
		||||
  ## - Split JWT via two cookies:
 | 
			
		||||
  ##
 | 
			
		||||
  ##   - `${cookiePrefix}-user`: Contains the JWT header and payload, but not the
 | 
			
		||||
  ##      signature.  This cookie is set Secure. The JWT payload contains a 30
 | 
			
		||||
  ##      minute expiry (and the Max-Age is set the same) and also contains a
 | 
			
		||||
  ##      CSRF token. This cookie is accessible by the web application.
 | 
			
		||||
  ##   - `${cookiePrefix}-session`: Contains the JWT signature. This cookie is
 | 
			
		||||
  ##      set Secure and HttpOnly. This serves as the session token (when the
 | 
			
		||||
  ##      user closes the browser this gets unset).
 | 
			
		||||
  ##
 | 
			
		||||
  ##   In this split-cookie mode, the API will also check for the presence of a
 | 
			
		||||
  ##   CSRF token on any mutation requests (PUT, POST, and DELETE requests).
 | 
			
		||||
  ##   The client must set the X-CSRF-TOKEN header with the same CSRF value
 | 
			
		||||
  ##   present in the `csrfToken` claim in the JWT presented in the
 | 
			
		||||
  ##   `${cookiePrefix}-user` cookie.
 | 
			
		||||
 | 
			
		||||
  try:
 | 
			
		||||
    if headers(req).hasKey("Authorization"):
 | 
			
		||||
      # Using a Bearer token.
 | 
			
		||||
      result = toJWT(headers(req)["Authorization"][7..^1])
 | 
			
		||||
 | 
			
		||||
    else:
 | 
			
		||||
      # Using a user/session cookie pair
 | 
			
		||||
      let userCookieName = ctx.cookiePrefix & "-user"
 | 
			
		||||
      let sessionCookieName = ctx.cookiePrefix & "-session"
 | 
			
		||||
 | 
			
		||||
      if not cookies(req).hasKey(userCookieName):
 | 
			
		||||
        failAuth "missing cookie '$#'" % userCookieName
 | 
			
		||||
      if not cookies(req).hasKey(sessionCookieName):
 | 
			
		||||
        failAuth "missing cookie '$#'" % sessionCookieName
 | 
			
		||||
 | 
			
		||||
      let userVal = cookies(req)[userCookieName]
 | 
			
		||||
      let sessionVal = cookies(req)[sessionCookieName]
 | 
			
		||||
      result = toJWT(userVal & "." & sessionVal)
 | 
			
		||||
 | 
			
		||||
      # Because this is a web session, check that the CSRF is present and
 | 
			
		||||
      # matches.
 | 
			
		||||
      if not headers(req).hasKey("X-CSRF-TOKEN") or
 | 
			
		||||
         not result.claims["csrfToken"].isSome:
 | 
			
		||||
        failAuth "missing CSRF token"
 | 
			
		||||
 | 
			
		||||
      if headers(req)["X-CSRF-TOKEN"] != result.claims["csrfToken"].get.getStr(""):
 | 
			
		||||
        failAuth "invalid CSRF token"
 | 
			
		||||
 | 
			
		||||
    ctx.validateJwt(result)
 | 
			
		||||
  except:
 | 
			
		||||
    failAuth(getCurrentExceptionMsg(), getCurrentException())
 | 
			
		||||
 | 
			
		||||
proc createSignedJWT*(ctx: ApiAuthContext, claims: JsonNode, kid: string): JWT =
 | 
			
		||||
  ## Given a set of claims, create a JWT using the given key for our issuer
 | 
			
		||||
  ## (as defined in the ApiAuthContext). This is an opinionated method that
 | 
			
		||||
  ## chooses the signing algorithm,
 | 
			
		||||
 | 
			
		||||
  let foundKeys = ctx.issuerKeys[ctx.issuer]
 | 
			
		||||
    .filterIt(it.kid.isSome and it.kid.get == kid)
 | 
			
		||||
 | 
			
		||||
  if foundKeys.len != 1:
 | 
			
		||||
    failAuth "cannot create signed JWT, unable to find key for kid " & kid
 | 
			
		||||
 | 
			
		||||
  let sigKey = foundKeys[0]
 | 
			
		||||
 | 
			
		||||
  result = createSignedJwt(
 | 
			
		||||
    initJoseHeader(%*{
 | 
			
		||||
      "alg": sigKey.alg.get,
 | 
			
		||||
      "typ": "JWT",
 | 
			
		||||
      "kid": sigKey.kid.get }),
 | 
			
		||||
    initJwtClaims(claims),
 | 
			
		||||
    sigKey)
 | 
			
		||||
 | 
			
		||||
proc newApiAccessToken*(ctx: ApiAuthContext, sub: string, duration = 1.hours): JWT =
 | 
			
		||||
  result = ctx.createSignedJWT(
 | 
			
		||||
    %*{
 | 
			
		||||
      "sub": sub,
 | 
			
		||||
      "iss": ctx.issuer,
 | 
			
		||||
      "iat": now().utc.toTime.toUnix.int,
 | 
			
		||||
      "aud": ctx.issuer,
 | 
			
		||||
      "exp": (now() + duration).utc.toTime.toUnix.int },
 | 
			
		||||
    ctx.signingKid)
 | 
			
		||||
							
								
								
									
										23
									
								
								src/buffoonery/jsonutils.nim
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/buffoonery/jsonutils.nim
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
## JSON parsing utils
 | 
			
		||||
import json, times, timeutils, uuids
 | 
			
		||||
 | 
			
		||||
const MONTH_FORMAT* = "YYYY-MM"
 | 
			
		||||
 | 
			
		||||
proc getOrFail*(n: JsonNode, key: string): JsonNode =
 | 
			
		||||
  ## convenience method to get a key from a JObject or raise an exception
 | 
			
		||||
  if not n.hasKey(key):
 | 
			
		||||
    raise newException(ValueError, "missing key '" & key & "'")
 | 
			
		||||
 | 
			
		||||
  return n[key]
 | 
			
		||||
 | 
			
		||||
proc parseUUID*(n: JsonNode, key: string): UUID =
 | 
			
		||||
  return parseUUID(n.getOrFail(key).getStr)
 | 
			
		||||
 | 
			
		||||
proc parseIso8601*(n: JsonNode, key: string): DateTime =
 | 
			
		||||
  return parseIso8601(n.getOrFail(key).getStr)
 | 
			
		||||
 | 
			
		||||
proc parseMonth*(n: JsonNode, key: string): DateTime =
 | 
			
		||||
  return parse(n.getOrFail(key).getStr, MONTH_FORMAT)
 | 
			
		||||
 | 
			
		||||
proc formatMonth*(dt: DateTime): string =
 | 
			
		||||
  return dt.format(MONTH_FORMAT)
 | 
			
		||||
							
								
								
									
										10
									
								
								src/buffoonery/testing.nim
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/buffoonery/testing.nim
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
import logging, os, sequtils, strutils
 | 
			
		||||
 | 
			
		||||
proc enableConsoleLoggingWithEnvVar*(envVar = "DEBUG"): void =
 | 
			
		||||
  let val = getEnv(envVar, "false").toLower()
 | 
			
		||||
  if ("true".startsWith(val) or
 | 
			
		||||
      "yes".startsWith(val) or
 | 
			
		||||
      "on".startsWith(val) or
 | 
			
		||||
      val == "1") and
 | 
			
		||||
     not logging.getHandlers().anyIt(typeof(it) is ConsoleLogger):
 | 
			
		||||
    logging.addHandler(newConsoleLogger(levelThreshold = lvlDebug))
 | 
			
		||||
							
								
								
									
										2
									
								
								test/config.nims
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								test/config.nims
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
			
		||||
switch("path", "../src")
 | 
			
		||||
switch("verbosity", "0")
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								test/runner
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								test/runner
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										3
									
								
								test/runner.nim
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								test/runner.nim
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
import unittest
 | 
			
		||||
 | 
			
		||||
import ./tauth, ./tjson_util
 | 
			
		||||
							
								
								
									
										0
									
								
								test/tauth.nim
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								test/tauth.nim
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										23
									
								
								test/tjsonutils.nim
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								test/tjsonutils.nim
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,23 @@
 | 
			
		||||
import json, times, unittest, uuids
 | 
			
		||||
import buffoonery/jsonutils
 | 
			
		||||
 | 
			
		||||
suite "jsonutils":
 | 
			
		||||
 | 
			
		||||
  let n = %*{
 | 
			
		||||
    "numVal": 12345,
 | 
			
		||||
    "strVal": "Test string",
 | 
			
		||||
    "uuid": $genUUID(),
 | 
			
		||||
    "isoDate": "2021-07-19T16:20:32+00:00",
 | 
			
		||||
    "month": "2021-07"
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  test "getOrFail":
 | 
			
		||||
    check:
 | 
			
		||||
      n.getOrFail("strVal").getStr == "Test string"
 | 
			
		||||
      n.getOrFail("numVal").getInt == 12345
 | 
			
		||||
 | 
			
		||||
    expect(ValueError):
 | 
			
		||||
      discard n.getOrFail("missingVal")
 | 
			
		||||
 | 
			
		||||
  test "parseMonth":
 | 
			
		||||
    check n.parseMonth("month") == "2021-07".parse(MONTH_FORMAT)
 | 
			
		||||
		Reference in New Issue
	
	Block a user