# Copyright (C) 2015 Dominik Picheta # MIT License - Look at license.txt for details. import net, strtabs, re, tables, parseutils, os, strutils, uri, times, mimetypes, asyncnet, asyncdispatch, macros, md5, logging, httpcore, asyncfile, macrocache, json, options, strformat import jester/private/[errorpages, utils] import jester/[request, patterns] from cgi import decodeData, decodeUrl, CgiError export request export strtabs export tables export httpcore export MultiData export HttpMethod export asyncdispatch export SameSite when useHttpBeast: import httpbeast except Settings, Request import options else: import asynchttpserver except Request type MatchProc* = proc (request: Request): Future[ResponseData] {.gcsafe, closure.} MatchProcSync* = proc (request: Request): ResponseData{.gcsafe, closure.} Matcher = object case async: bool of false: syncProc: MatchProcSync of true: asyncProc: MatchProc ErrorProc* = proc ( request: Request, error: RouteError ): Future[ResponseData] {.gcsafe, closure.} Jester* = object when not useHttpBeast: httpServer*: AsyncHttpServer settings: Settings matchers: seq[Matcher] errorHandlers: seq[ErrorProc] MatchType* = enum MRegex, MSpecial, MStatic RawHeaders* = seq[tuple[key, val: string]] ResponseData* = tuple[ action: CallbackAction, code: HttpCode, headers: Option[RawHeaders], content: string, matched: bool ] CallbackAction* = enum TCActionNothing, TCActionSend, TCActionRaw, TCActionPass RouteErrorKind* = enum RouteException, RouteCode RouteError* = object case kind*: RouteErrorKind of RouteException: exc: ref Exception of RouteCode: data: ResponseData const jesterVer = "0.4.0" proc toStr(headers: Option[RawHeaders]): string = return $newHttpHeaders(headers.get(@({:}))) proc createHeaders(headers: RawHeaders): string = result = "" if headers != nil: for header in headers: let (key, value) = header result.add(key & ": " & value & "\c\L") result = result[0 .. ^3] # Strip trailing \c\L proc createResponse(status: HttpCode, headers: RawHeaders): string = return "HTTP/1.1 " & $status & "\c\L" & createHeaders(headers) & "\c\L\c\L" proc unsafeSend(request: Request, content: string) = when useHttpBeast: request.getNativeReq.unsafeSend(content) else: # TODO: This may cause issues if we send too fast. asyncCheck request.getNativeReq.client.send(content) proc send( request: Request, code: HttpCode, headers: Option[RawHeaders], body: string ) = when useHttpBeast: let h = if headers.isNone: "" else: headers.get().createHeaders request.getNativeReq.send(code, body, h) else: # TODO: This may cause issues if we send too fast. asyncCheck request.getNativeReq.respond( code, body, newHttpHeaders(headers.get(@({:}))) ) proc statusContent(request: Request, status: HttpCode, content: string, headers: Option[RawHeaders]) = try: send(request, status, headers, content) when not defined(release): logging.debug(" $1 $2" % [$status, toStr(headers)]) except: logging.error("Could not send response: $1" % osErrorMsg(osLastError())) # TODO: Add support for proper Future Streams instead of this weird raw mode. template enableRawMode* = # TODO: Use the effect system to make this implicit? result.action = TCActionRaw proc send*(request: Request, content: string) = ## Sends ``content`` immediately to the client socket. ## ## Routes using this procedure must enable raw mode. unsafeSend(request, content) proc sendHeaders*(request: Request, status: HttpCode, headers: RawHeaders) = ## Sends ``status`` and ``headers`` to the client socket immediately. ## The user is then able to send the content immediately to the client on ## the fly through the use of ``response.client``. let headerData = createResponse(status, headers) try: request.send(headerData) logging.debug(" $1 $2" % [$status, $headers]) except: logging.error("Could not send response: $1" % [osErrorMsg(osLastError())]) proc sendHeaders*(request: Request, status: HttpCode) = ## Sends ``status`` and ``Content-Type: text/html`` as the headers to the ## client socket immediately. let headers = @({"Content-Type": "text/html;charset=utf-8"}) request.sendHeaders(status, headers) proc sendHeaders*(request: Request) = ## Sends ``Http200`` and ``Content-Type: text/html`` as the headers to the ## client socket immediately. request.sendHeaders(Http200) proc send*(request: Request, status: HttpCode, headers: RawHeaders, content: string) = ## Sends out a HTTP response comprising of the ``status``, ``headers`` and ## ``content`` specified. var headers = headers & @({"Content-Length": $content.len}) request.sendHeaders(status, headers) request.send(content) # TODO: Cannot capture 'paths: varargs[string]' here. proc sendStaticIfExists( req: Request, paths: seq[string] ): Future[HttpCode] {.async.} = result = Http200 for p in paths: if existsFile(p): var fp = getFilePermissions(p) if not fp.contains(fpOthersRead): return Http403 let fileSize = getFileSize(p) let mimetype = req.settings.mimes.getMimetype(p.splitFile.ext[1 .. ^1]) if fileSize < 10_000_000: # 10 mb var file = readFile(p) var hashed = getMD5(file) # If the user has a cached version of this file and it matches our # version, let them use it if req.headers.hasKey("If-None-Match") and req.headers["If-None-Match"] == hashed: req.statusContent(Http304, "", none[RawHeaders]()) else: req.statusContent(Http200, file, some(@({ "Content-Type": mimetype, "ETag": hashed }))) else: let headers = @({ "Content-Type": mimetype, "Content-Length": $fileSize }) req.statusContent(Http200, "", some(headers)) var fileStream = newFutureStream[string]("sendStaticIfExists") var file = openAsync(p, fmRead) # Let `readToStream` write file data into fileStream in the # background. asyncCheck file.readToStream(fileStream) # The `writeFromStream` proc will complete once all the data in the # `bodyStream` has been written to the file. while true: let (hasValue, value) = await fileStream.read() if hasValue: req.unsafeSend(value) else: break file.close() return # If we get to here then no match could be found. return Http404 proc defaultErrorFilter(error: RouteError): ResponseData = case error.kind of RouteException: let e = error.exc let traceback = getStackTrace(e) var errorMsg = e.msg if errorMsg.isNil: errorMsg = "(nil)" let error = traceback & errorMsg logging.error(error) result.headers = some(@({ "Content-Type": "text/html;charset=utf-8" })) result.content = routeException( error.replace("\n", "
\n"), jesterVer ) result.code = Http502 result.matched = true result.action = TCActionSend of RouteCode: result.headers = some(@({ "Content-Type": "text/html;charset=utf-8" })) result.content = error( $error.data.code, jesterVer ) result.code = error.data.code result.matched = true result.action = TCActionSend proc initRouteError(exc: ref Exception): RouteError = RouteError( kind: RouteException, exc: exc ) proc initRouteError(data: ResponseData): RouteError = RouteError( kind: RouteCode, data: data ) proc dispatchError( jes: Jester, request: Request, error: RouteError ): Future[ResponseData] {.async.} = for errorProc in jes.errorHandlers: let data = await errorProc(request, error) if data.matched: return data return defaultErrorFilter(error) proc dispatch( self: Jester, req: Request ): Future[ResponseData] {.async.} = for matcher in self.matchers: if matcher.async: let data = await matcher.asyncProc(req) if data.matched: return data else: let data = matcher.syncProc(req) if data.matched: return data proc handleFileRequest( jes: Jester, req: Request ): Future[ResponseData] {.async.} = # Find static file. # TODO: Caching. let path = normalizedPath( jes.settings.staticDir / cgi.decodeUrl(req.pathInfo) ) # Verify that this isn't outside our static` dir. var status = Http400 if path.splitFile.dir.startsWith(jes.settings.staticDir): if existsDir(path): status = await sendStaticIfExists( req, @[path / "index.html", path / "index.htm"] ) else: status = await sendStaticIfExists(req, @[path]) # Http200 means that the data was sent so there is nothing else to do. if status == Http200: result[0] = TCActionRaw when not defined(release): logging.debug(" -> $1" % path) return return (TCActionSend, status, none[seq[(string, string)]](), "", true) proc handleRequestSlow( jes: Jester, req: Request, respDataFut: Future[ResponseData] | ResponseData, dispatchedError: bool ): Future[void] {.async.} = var dispatchedError = dispatchedError var respData: ResponseData # httpReq.send(Http200, "Hello, World!", "") try: when respDataFut is Future[ResponseData]: respData = await respDataFut else: respData = respDataFut except: # Handle any errors by showing them in the browser. # TODO: Improve the look of this. let exc = getCurrentException() respData = await dispatchError(jes, req, initRouteError(exc)) dispatchedError = true # TODO: Put this in a custom matcher? if not respData.matched: respData = await handleFileRequest(jes, req) case respData.action of TCActionSend: if (respData.code.is4xx or respData.code.is5xx) and not dispatchedError and respData.content.len == 0: respData = await dispatchError(jes, req, initRouteError(respData)) statusContent( req, respData.code, respData.content, respData.headers ) else: when not defined(release): logging.debug(" $1" % [$respData.action]) # Cannot close the client socket. AsyncHttpServer may be keeping it alive. proc handleRequest(jes: Jester, httpReq: NativeRequest): Future[void] = var req = initRequest(httpReq, jes.settings) try: when not defined(release): logging.debug("$1 $2" % [$req.reqMethod, req.pathInfo]) if likely(jes.matchers.len == 1 and not jes.matchers[0].async): let respData = jes.matchers[0].syncProc(req) if likely(respData.matched): statusContent( req, respData.code, respData.content, respData.headers ) else: return handleRequestSlow(jes, req, respData, false) else: return handleRequestSlow(jes, req, dispatch(jes, req), false) except: let exc = getCurrentException() let respDataFut = dispatchError(jes, req, initRouteError(exc)) return handleRequestSlow(jes, req, respDataFut, true) proc newSettings*( port = Port(5000), staticDir = getCurrentDir() / "public", appName = "", bindAddr = "", reusePort = false, futureErrorHandler: proc (fut: Future[void]) {.closure, gcsafe.} = nil ): Settings = result = Settings( staticDir: staticDir, appName: appName, port: port, bindAddr: bindAddr, reusePort: reusePort, futureErrorHandler: futureErrorHandler ) proc register*(self: var Jester, matcher: MatchProc) = ## Adds the specified matcher procedure to the specified Jester instance. self.matchers.add( Matcher( async: true, asyncProc: matcher ) ) proc register*(self: var Jester, matcher: MatchProcSync) = ## Adds the specified matcher procedure to the specified Jester instance. self.matchers.add( Matcher( async: false, syncProc: matcher ) ) proc register*(self: var Jester, errorHandler: ErrorProc) = ## Adds the specified error handler procedure to the specified Jester instance. self.errorHandlers.add(errorHandler) proc initJester*( settings: Settings = newSettings() ): Jester = result.settings = settings result.settings.mimes = newMimetypes() result.matchers = @[] result.errorHandlers = @[] proc initJester*( matcher: MatchProc, settings: Settings = newSettings() ): Jester = result = initJester(settings) result.register(matcher) proc initJester*( matcher: MatchProcSync, # TODO: Annoying nim bug: `MatchProc | MatchProcSync` doesn't work. settings: Settings = newSettings() ): Jester = result = initJester(settings) result.register(matcher) proc serve*( self: var Jester ) = ## Creates a new async http server instance and registers ## it with the dispatcher. ## ## The event loop is executed by this function, so it will block forever. # Ensure we have at least one logger enabled, defaulting to console. if logging.getHandlers().len == 0: addHandler(logging.newConsoleLogger()) setLogFilter(when defined(release): lvlInfo else: lvlDebug) if self.settings.bindAddr.len > 0: logging.info("Jester is making jokes at http://$1:$2$3" % [ self.settings.bindAddr, $self.settings.port, self.settings.appName ] ) else: logging.info("Jester is making jokes at http://0.0.0.0:$1$2" % [$self.settings.port, self.settings.appName]) var jes = self when useHttpBeast: run( proc (req: httpbeast.Request): Future[void] = result = handleRequest(jes, req), httpbeast.initSettings(self.settings.port, self.settings.bindAddr) ) else: self.httpServer = newAsyncHttpServer(reusePort=self.settings.reusePort) let serveFut = self.httpServer.serve( self.settings.port, proc (req: asynchttpserver.Request): Future[void] {.gcsafe, closure.} = result = handleRequest(jes, req), self.settings.bindAddr) if not self.settings.futureErrorHandler.isNil: serveFut.callback = self.settings.futureErrorHandler else: asyncCheck serveFut runForever() template resp*(code: HttpCode, headers: openarray[tuple[key, value: string]], content: string): typed = ## Sets ``(code, headers, content)`` as the response. bind TCActionSend, newHttpHeaders result = (TCActionSend, code, headers.newHttpHeaders.some(), content, true) break route template setHeader(headers: var Option[RawHeaders], key, value: string): typed = bind isNone if isNone(headers): headers = some(@({key: value})) else: block outer: # Overwrite key if it exists. var h = headers.get() for i in 0 ..< h.len: if h[i][0] == key: h[i][1] = value headers = some(h) break outer # Add key if it doesn't exist. headers = some(h & @({key: value})) template resp*(content: string, contentType = "text/html;charset=utf-8"): typed = ## Sets ``content`` as the response; ``Http200`` as the status code ## and ``contentType`` as the Content-Type. bind TCActionSend, newHttpHeaders, strtabs.`[]=` result[0] = TCActionSend result[1] = Http200 setHeader(result[2], "Content-Type", contentType) result[3] = content # This will be set by our macro, so this is here for those not using it. result.matched = true break route template resp*(content: JsonNode): typed = ## Serializes ``content`` as the response, sets ``Http200`` as status code ## and "application/json" Content-Type. resp($content, contentType="application/json") template resp*(code: HttpCode, content: string, contentType = "text/html;charset=utf-8"): typed = ## Sets ``content`` as the response; ``code`` as the status code ## and ``contentType`` as the Content-Type. bind TCActionSend, newHttpHeaders result[0] = TCActionSend result[1] = code setHeader(result[2], "Content-Type", contentType) result[3] = content result.matched = true break route template resp*(code: HttpCode): typed = ## Responds with the specified ``HttpCode``. This ensures that error handlers ## are called. bind TCActionSend, newHttpHeaders result[0] = TCActionSend result[1] = code result.matched = true break route template redirect*(url: string): typed = ## Redirects to ``url``. Returns from this request handler immediately. ## Any set response headers are preserved for this request. bind TCActionSend, newHttpHeaders result[0] = TCActionSend result[1] = Http303 setHeader(result[2], "Location", url) result[3] = "" result.matched = true break route template pass*(): typed = ## Skips this request handler. ## ## If you want to stop this request from going further use ``halt``. result.action = TCActionPass break outerRoute template cond*(condition: bool): typed = ## If ``condition`` is ``False`` then ``pass`` will be called, ## i.e. this request handler will be skipped. if not condition: break outerRoute template halt*(code: HttpCode, headers: openarray[tuple[key, val: string]], 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] = TCActionSend result[1] = code result[2] = some(@headers) result[3] = content result.matched = true break allRoutes template halt*(): typed = ## Halts the execution of this request immediately. Returns a 404. ## All previously set values are **discarded**. halt(Http404, {"Content-Type": "text/html;charset=utf-8"}, error($Http404, jesterVer)) template halt*(code: HttpCode): typed = halt(code, {"Content-Type": "text/html;charset=utf-8"}, error($code, jesterVer)) template halt*(content: string): typed = halt(Http404, {"Content-Type": "text/html;charset=utf-8"}, content) template halt*(code: HttpCode, content: string): typed = halt(code, {"Content-Type": "text/html;charset=utf-8"}, content) template attachment*(filename = ""): typed = ## Instructs the browser that the response should be stored on disk ## rather than displayed in the browser. var disposition = "attachment" if filename != "": disposition.add("; filename=\"" & extractFilename(filename) & "\"") let ext = splitFile(filename).ext let contentTypeSet = isSome(result[2]) and result[2].get().toTable.hasKey("Content-Type") if not contentTypeSet and ext != "": setHeader(result[2], "Content-Type", getMimetype(request.settings.mimes, ext)) setHeader(result[2], "Content-Disposition", disposition) template sendFile*(filename: string): typed = ## Sends the file at the specified filename as the response. result[0] = TCActionRaw let sendFut = sendStaticIfExists(request, @[filename]) yield sendFut let status = sendFut.read() if status != Http200: raise newException(JesterError, "Couldn't send requested file: " & filename) # This will be set by our macro, so this is here for those not using it. result.matched = true break route template `@`*(s: string): untyped = ## Retrieves the parameter ``s`` from ``request.params``. ``""`` will be ## returned if parameter doesn't exist. if s in params(request): # TODO: Why does request.params not work? :( # TODO: This is some weird bug with macros/templates, I couldn't # TODO: reproduce it easily. params(request)[s] else: "" proc setStaticDir*(request: Request, dir: string) = ## Sets the directory in which Jester will look for static files. It is ## ``./public`` by default. ## ## The files will be served like so: ## ## ./public/css/style.css ``->`` http://example.com/css/style.css ## ## (``./public`` is not included in the final URL) request.settings.staticDir = dir proc getStaticDir*(request: Request): string = ## Gets the directory in which Jester will look for static files. ## ## ``./public`` by default. return request.settings.staticDir proc makeUri*(request: Request, address = "", absolute = true, addScriptName = true): string = ## Creates a URI based on the current request. If ``absolute`` is true it will ## add the scheme (Usually 'http://'), `request.host` and `request.port`. ## If ``addScriptName`` is true `request.appName` will be prepended before ## ``address``. # Check if address already starts with scheme:// var uri = parseUri(address) if uri.scheme != "": return address uri.path = "/" uri.query = "" uri.anchor = "" if absolute: uri.hostname = request.host uri.scheme = (if request.secure: "https" else: "http") if request.port != (if request.secure: 443 else: 80): uri.port = $request.port if addScriptName: uri = uri / request.appName if address != "": uri = uri / address else: uri = uri / request.pathInfo return $uri template uri*(address = "", absolute = true, addScriptName = true): untyped = ## Convenience template which can be used in a route. request.makeUri(address, absolute, addScriptName) proc daysForward*(days: int): DateTime = ## Returns a DateTime object referring to the current time plus ``days``. return getTime().utc + initInterval(days = days) template setCookie*(name, value: string, expires="", sameSite: SameSite=Lax): typed = ## Creates a cookie which stores ``value`` under ``name``. ## ## The SameSite argument determines the level of CSRF protection that ## you wish to adopt for this cookie. It's set to Lax by default which ## should protect you from most vulnerabilities. Note that this is only ## supported by some browsers: ## https://caniuse.com/#feat=same-site-cookie-attribute let newCookie = makeCookie(name, value, expires) if isSome(result[2]) and (let headers = result[2].get(); headers.toTable.hasKey("Set-Cookie")): result[2] = some(headers & @({"Set-Cookie": newCookie})) else: setHeader(result[2], "Set-Cookie", newCookie) template setCookie*(name, value: string, expires: DateTime, sameSite: SameSite=Lax): typed = ## Creates a cookie which stores ``value`` under ``name``. setCookie(name, value, format(expires, "ddd',' dd MMM yyyy HH:mm:ss 'GMT'")) proc normalizeUri*(uri: string): string = ## Remove any trailing ``/``. if uri[uri.len-1] == '/': result = uri[0 .. uri.len-2] else: result = uri # -- Macro proc checkAction*(respData: var ResponseData): bool = case respData.action of TCActionSend, TCActionRaw: result = true of TCActionPass: result = false of TCActionNothing: assert(false) proc skipDo(node: NimNode): NimNode {.compiletime.} = if node.kind == nnkDo: result = node[6] else: result = node proc ctParsePattern(pattern, pathPrefix: string): NimNode {.compiletime.} = result = newNimNode(nnkPrefix) result.add newIdentNode("@") result.add newNimNode(nnkBracket) proc addPattNode(res: var NimNode, typ, text, optional: NimNode) {.compiletime.} = var objConstr = newNimNode(nnkObjConstr) objConstr.add bindSym("Node") objConstr.add newNimNode(nnkExprColonExpr).add( newIdentNode("typ"), typ) objConstr.add newNimNode(nnkExprColonExpr).add( newIdentNode("text"), text) objConstr.add newNimNode(nnkExprColonExpr).add( newIdentNode("optional"), optional) res[1].add objConstr var patt = parsePattern(pattern) if pathPrefix.len > 0: result.addPattNode( bindSym("NodeText"), # Node kind newStrLitNode(pathPrefix), # Text newIdentNode("false") # Optional? ) for node in patt: result.addPattNode( case node.typ of NodeText: bindSym("NodeText") of NodeField: bindSym("NodeField"), newStrLitNode(node.text), newIdentNode(if node.optional: "true" else: "false")) template setDefaultResp*(): typed = # TODO: bindSym this in the 'routes' macro and put it in each route bind TCActionNothing, newHttpHeaders result.action = TCActionNothing result.code = Http200 result.content = "" template declareSettings(): typed {.dirty.} = when not declaredInScope(settings): var settings = newSettings() proc createJesterPattern( routeNode, patternMatchSym: NimNode, pathPrefix: string ): NimNode {.compileTime.} = var ctPattern = ctParsePattern(routeNode[1].strVal, pathPrefix) # -> let = .match(request.path) return newLetStmt(patternMatchSym, newCall(bindSym"match", ctPattern, parseExpr("request.pathInfo"))) proc escapeRegex(s: string): string = result = "" for i in s: case i # https://stackoverflow.com/a/400316/492186 of '.', '^', '$', '*', '+', '?', '(', ')', '[', '{', '\\', '|': result.add('\\') result.add(i) else: result.add(i) proc createRegexPattern( routeNode, reMatchesSym, patternMatchSym: NimNode, pathPrefix: string ): NimNode {.compileTime.} = # -> let = find(request.pathInfo, , ) var strNode = routeNode[1].copyNimTree() strNode[1].strVal = escapeRegex(pathPrefix) & strNode[1].strVal return newLetStmt( patternMatchSym, newCall( bindSym"find", parseExpr("request.pathInfo"), strNode, reMatchesSym ) ) proc determinePatternType(pattern: NimNode): MatchType {.compileTime.} = case pattern.kind of nnkStrLit: var patt = parsePattern(pattern.strVal) if patt.len == 1 and patt[0].typ == NodeText: return MStatic else: return MSpecial of nnkCallStrLit: expectKind(pattern[0], nnkIdent) case ($pattern[0]).normalize of "re": return MRegex else: macros.error("Invalid pattern type: " & $pattern[0]) else: macros.error("Unexpected node kind: " & $pattern.kind) proc createCheckActionIf(): NimNode = var checkActionIf = parseExpr( "if checkAction(result): result.matched = true; break routesList" ) checkActionIf[0][0][0] = bindSym"checkAction" return checkActionIf proc createGlobalMetaRoute(routeNode, dest: NimNode) {.compileTime.} = ## Creates a ``before`` or ``after`` route with no pattern, i.e. one which ## will be always executed. # -> block route: var innerBlockStmt = newStmtList( newNimNode(nnkBlockStmt).add(newIdentNode("route"), routeNode[1].skipDo()) ) # -> block outerRoute: var blockStmt = newNimNode(nnkBlockStmt).add( newIdentNode("outerRoute"), innerBlockStmt) dest.add blockStmt proc createRoute( routeNode, dest: NimNode, pathPrefix: string, isMetaRoute: bool = false ) {.compileTime.} = ## Creates code which checks whether the current request path ## matches a route. ## ## The `isMetaRoute` parameter determines whether the route to be created is ## one of either a ``before`` or an ``after`` route. var patternMatchSym = genSym(nskLet, "patternMatchRet") # Only used for Regex patterns. var reMatchesSym = genSym(nskVar, "reMatches") var reMatches = parseExpr("var reMatches: array[20, string]") reMatches[0][0] = reMatchesSym reMatches[0][1][1] = bindSym("MaxSubpatterns") let patternType = determinePatternType(routeNode[1]) case patternType of MStatic: discard of MSpecial: dest.add createJesterPattern(routeNode, patternMatchSym, pathPrefix) of MRegex: dest.add reMatches dest.add createRegexPattern( routeNode, reMatchesSym, patternMatchSym, pathPrefix ) var ifStmtBody = newStmtList() case patternType of MStatic: discard of MSpecial: # -> setPatternParams(request, ret.params) ifStmtBody.add newCall(bindSym"setPatternParams", newIdentNode"request", newDotExpr(patternMatchSym, newIdentNode"params")) of MRegex: # -> setReMatches(request, ) ifStmtBody.add newCall(bindSym"setReMatches", newIdentNode"request", reMatchesSym) ifStmtBody.add routeNode[2].skipDo() let checkActionIf = if isMetaRoute: parseExpr("break routesList") else: createCheckActionIf() # -> block route: ; var innerBlockStmt = newStmtList( newNimNode(nnkBlockStmt).add(newIdentNode("route"), ifStmtBody), checkActionIf ) let ifCond = case patternType of MStatic: infix( parseExpr("request.pathInfo"), "==", newStrLitNode(pathPrefix & routeNode[1].strVal) ) of MSpecial: newDotExpr(patternMatchSym, newIdentNode("matched")) of MRegex: infix(patternMatchSym, "!=", newIntLitNode(-1)) # -> if .matched: var ifStmt = newIfStmt((ifCond, innerBlockStmt)) # -> block outerRoute: var blockStmt = newNimNode(nnkBlockStmt).add( newIdentNode("outerRoute"), ifStmt) dest.add blockStmt proc createError( errorNode: NimNode, httpCodeBranches, exceptionBranches: var seq[tuple[cond, body: NimNode]] ) = if errorNode.len != 3: error("Missing error condition or body.", errorNode) let routeIdent = newIdentNode("route") let outerRouteIdent = newIdentNode("outerRoute") let checkActionIf = createCheckActionIf() let exceptionIdent = newIdentNode("exception") let errorIdent = newIdentNode("error") # TODO: Ugh. I shouldn't need these... let errorCond = errorNode[1] let errorBody = errorNode[2] let body = quote do: block `outerRouteIdent`: block `routeIdent`: `errorBody` `checkActionIf` case errorCond.kind of nnkIdent: let name = errorCond.strVal if name.len == 7 and name.startsWith("Http"): # HttpCode. httpCodeBranches.add( ( infix(parseExpr("error.data.code"), "==", errorCond), body ) ) else: # Exception exceptionBranches.add( ( infix(parseExpr("error.exc"), "of", errorCond), quote do: let `exceptionIdent` = (ref `errorCond`)(`errorIdent`.exc) `body` ) ) of nnkCurly: expectKind(errorCond[0], nnkInfix) httpCodeBranches.add( ( infix(parseExpr("error.data.code"), "in", errorCond), body ) ) else: error("Expected exception type or set[HttpCode].", errorCond) const definedRoutes = CacheTable"jester.routes" proc processRoutesBody( body: NimNode, # For HTTP methods. caseStmtGetBody, caseStmtPostBody, caseStmtPutBody, caseStmtDeleteBody, caseStmtHeadBody, caseStmtOptionsBody, caseStmtTraceBody, caseStmtConnectBody, caseStmtPatchBody: var NimNode, # For `error`. httpCodeBranches, exceptionBranches: var seq[tuple[cond, body: NimNode]], # For before/after stmts. beforeStmts, afterStmts: var NimNode, # For other statements. outsideStmts: var NimNode, pathPrefix: string ) = for i in 0.. 1: extend[2].strVal else: "" if prefix.len != 0 and prefix[0] != '/': error("Path prefix for extended route must start with '/'", extend[2]) processRoutesBody( definedRoutes[extend[1].strVal], caseStmtGetBody, caseStmtPostBody, caseStmtPutBody, caseStmtDeleteBody, caseStmtHeadBody, caseStmtOptionsBody, caseStmtTraceBody, caseStmtConnectBody, caseStmtPatchBody, httpCodeBranches, exceptionBranches, beforeStmts, afterStmts, outsideStmts, pathPrefix & prefix ) else: outsideStmts.add(body[i]) of nnkCommentStmt: discard of nnkPragma: if body[i][0].strVal.normalize notin ["async", "sync"]: outsideStmts.add(body[i]) else: outsideStmts.add(body[i]) type NeedsAsync = enum ImplicitTrue, ImplicitFalse, ExplicitTrue, ExplicitFalse proc needsAsync(node: NimNode): NeedsAsync = result = ImplicitFalse case node.kind of nnkCommand, nnkCall: if node[0].kind == nnkIdent: case node[0].strVal.normalize of "await", "sendfile": return ImplicitTrue of "resp", "halt", "attachment", "pass", "redirect", "cond", "get", "post", "patch", "delete": # This is just a simple heuristic. It's by no means meant to be # exhaustive. discard else: return ImplicitTrue of nnkYieldStmt: return ImplicitTrue of nnkPragma: if node[0].kind == nnkIdent: case node[0].strVal.normalize of "sync": return ExplicitFalse of "async": return ExplicitTrue else: discard else: discard for c in node: let r = needsAsync(c) if r in {ImplicitTrue, ExplicitTrue, ExplicitFalse}: return r proc routesEx(name: string, body: NimNode): NimNode = # echo(treeRepr(body)) # echo(treeRepr(name)) # Save this route's body so that it can be incorporated into another route. definedRoutes[name] = body.copyNimTree result = newStmtList() # -> declareSettings() result.add newCall(bindSym"declareSettings") var outsideStmts = newStmtList() var matchBody = newNimNode(nnkStmtList) let setDefaultRespIdent = bindSym"setDefaultResp" matchBody.add newCall(setDefaultRespIdent) # TODO: This diminishes the performance. Would be nice to only include it # TODO: when setPatternParams or setReMatches is used. matchBody.add parseExpr("var request = request") # HTTP router case statement nodes: var caseStmt = newNimNode(nnkCaseStmt) caseStmt.add parseExpr("request.reqMethod") var caseStmtGetBody = newNimNode(nnkStmtList) var caseStmtPostBody = newNimNode(nnkStmtList) var caseStmtPutBody = newNimNode(nnkStmtList) var caseStmtDeleteBody = newNimNode(nnkStmtList) var caseStmtHeadBody = newNimNode(nnkStmtList) var caseStmtOptionsBody = newNimNode(nnkStmtList) var caseStmtTraceBody = newNimNode(nnkStmtList) var caseStmtConnectBody = newNimNode(nnkStmtList) var caseStmtPatchBody = newNimNode(nnkStmtList) # Error handler nodes: var httpCodeBranches: seq[tuple[cond, body: NimNode]] = @[] var exceptionBranches: seq[tuple[cond, body: NimNode]] = @[] # Before/After nodes: var beforeRoutes = newStmtList() var afterRoutes = newStmtList() processRoutesBody( body, caseStmtGetBody, caseStmtPostBody, caseStmtPutBody, caseStmtDeleteBody, caseStmtHeadBody, caseStmtOptionsBody, caseStmtTraceBody, caseStmtConnectBody, caseStmtPatchBody, httpCodeBranches, exceptionBranches, beforeRoutes, afterRoutes, outsideStmts, "" ) var ofBranchGet = newNimNode(nnkOfBranch) ofBranchGet.add newIdentNode("HttpGet") ofBranchGet.add caseStmtGetBody caseStmt.add ofBranchGet var ofBranchPost = newNimNode(nnkOfBranch) ofBranchPost.add newIdentNode("HttpPost") ofBranchPost.add caseStmtPostBody caseStmt.add ofBranchPost var ofBranchPut = newNimNode(nnkOfBranch) ofBranchPut.add newIdentNode("HttpPut") ofBranchPut.add caseStmtPutBody caseStmt.add ofBranchPut var ofBranchDelete = newNimNode(nnkOfBranch) ofBranchDelete.add newIdentNode("HttpDelete") ofBranchDelete.add caseStmtDeleteBody caseStmt.add ofBranchDelete var ofBranchHead = newNimNode(nnkOfBranch) ofBranchHead.add newIdentNode("HttpHead") ofBranchHead.add caseStmtHeadBody caseStmt.add ofBranchHead var ofBranchOptions = newNimNode(nnkOfBranch) ofBranchOptions.add newIdentNode("HttpOptions") ofBranchOptions.add caseStmtOptionsBody caseStmt.add ofBranchOptions var ofBranchTrace = newNimNode(nnkOfBranch) ofBranchTrace.add newIdentNode("HttpTrace") ofBranchTrace.add caseStmtTraceBody caseStmt.add ofBranchTrace var ofBranchConnect = newNimNode(nnkOfBranch) ofBranchConnect.add newIdentNode("HttpConnect") ofBranchConnect.add caseStmtConnectBody caseStmt.add ofBranchConnect var ofBranchPatch = newNimNode(nnkOfBranch) ofBranchPatch.add newIdentNode("HttpPatch") ofBranchPatch.add caseStmtPatchBody caseStmt.add ofBranchPatch # Wrap the routes inside ``routesList`` blocks accordingly, and add them to # the `match` procedure body. let routesListIdent = newIdentNode("routesList") matchBody.add( quote do: block `routesListIdent`: `beforeRoutes` ) matchBody.add( quote do: block `routesListIdent`: `caseStmt` ) matchBody.add( quote do: block `routesListIdent`: `afterRoutes` ) let matchIdent = newIdentNode(name) let reqIdent = newIdentNode("request") let needsAsync = needsAsync(body) case needsAsync of ImplicitFalse, ExplicitFalse: hint(fmt"Synchronous route `{name}` has been optimised. Use `{{.async.}}` to change.") of ImplicitTrue, ExplicitTrue: hint(fmt"Asynchronous route: {name}.") var matchProc = if needsAsync in {ImplicitTrue, ExplicitTrue}: quote do: proc `matchIdent`( `reqIdent`: Request ): Future[ResponseData] {.async, gcsafe.} = discard else: quote do: proc `matchIdent`( `reqIdent`: Request ): ResponseData {.gcsafe.} = discard # The following `block` is for `halt`. (`return` didn't work :/) let allRoutesBlock = newTree( nnkBlockStmt, newIdentNode("allRoutes"), matchBody ) matchProc[6] = newTree(nnkStmtList, allRoutesBlock) result.add(outsideStmts) result.add(matchProc) # Error handler proc let errorHandlerIdent = newIdentNode(name & "ErrorHandler") let errorIdent = newIdentNode("error") let exceptionIdent = newIdentNode("exception") let resultIdent = newIdentNode("result") var errorHandlerProc = quote do: proc `errorHandlerIdent`( `reqIdent`: Request, `errorIdent`: RouteError ): Future[ResponseData] {.gcsafe, async.} = block `routesListIdent`: `setDefaultRespIdent`() case `errorIdent`.kind of RouteException: discard of RouteCode: discard if exceptionBranches.len != 0: var stmts = newStmtList() for branch in exceptionBranches: stmts.add(newIfStmt(branch)) errorHandlerProc[6][0][1][^1][1][1][0] = stmts if httpCodeBranches.len > 1: var stmts = newStmtList() for branch in httpCodeBranches: stmts.add(newIfStmt(branch)) errorHandlerProc[6][0][1][^1][2][1][0] = stmts result.add(errorHandlerProc) # TODO: Replace `body`, `headers`, `code` in routes with `result[i]` to # get these shortcuts back without sacrificing usability. # TODO2: Make sure you replace what `guessAction` used to do for this. # echo toStrLit(result) # echo treeRepr(result) macro routes*(body: untyped): typed = result = routesEx("match", body) let jesIdent = genSym(nskVar, "jes") let matchIdent = newIdentNode("match") let errorHandlerIdent = newIdentNode("matchErrorHandler") let settingsIdent = newIdentNode("settings") result.add( quote do: var `jesIdent` = initJester(`matchIdent`, `settingsIdent`) `jesIdent`.register(`errorHandlerIdent`) ) result.add( quote do: serve(`jesIdent`) ) macro router*(name: untyped, body: untyped): typed = if name.kind != nnkIdent: error("Need an ident.", name) routesEx($name.ident, body) macro settings*(body: untyped): typed = #echo(treeRepr(body)) expectKind(body, nnkStmtList) result = newStmtList() # var settings = newSettings() let settingsIdent = newIdentNode("settings") result.add newVarStmt(settingsIdent, newCall("newSettings")) for asgn in body.children: expectKind(asgn, nnkAsgn) result.add newAssignment(newDotExpr(settingsIdent, asgn[0]), asgn[1])