├── .gitattributes ├── shakespeare.jpg ├── .gitignore ├── test.sh ├── rosencrantz.nim ├── rosencrantz.nimble ├── rosencrantz ├── streamingsupport.nim ├── custom.nim ├── jsonsupport.nim ├── corssupport.nim ├── staticsupport.nim ├── headersupport.nim ├── core.nim ├── handlers.nim └── formsupport.nim ├── tests ├── todo.nim ├── server.nim └── client.nim ├── LICENSE ├── README.md └── htmldocs ├── rosencrantz.html └── rosencrantz ├── staticsupport.html └── custom.html /.gitattributes: -------------------------------------------------------------------------------- 1 | htmldocs/* linguist-generated=true -------------------------------------------------------------------------------- /shakespeare.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andreaferretti/rosencrantz/HEAD/shakespeare.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | nimcache 2 | /web 3 | nimsuggest.log 4 | tests/client 5 | tests/server 6 | tests/rosencrantz 7 | tests/todo 8 | *.ndb 9 | *.dSYM -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | set -e 4 | set -u 5 | 6 | nimble server 7 | tests/rosencrantz & 8 | PID="$!" 9 | nimble client 10 | kill "$PID" -------------------------------------------------------------------------------- /rosencrantz.nim: -------------------------------------------------------------------------------- 1 | import rosencrantz/core, rosencrantz/handlers, rosencrantz/headersupport, 2 | rosencrantz/custom, rosencrantz/jsonsupport, rosencrantz/formsupport, 3 | rosencrantz/streamingsupport, rosencrantz/staticsupport, rosencrantz/corssupport 4 | 5 | export core, handlers, headersupport, custom, jsonsupport, formsupport, 6 | streamingsupport, staticsupport, corssupport -------------------------------------------------------------------------------- /rosencrantz.nimble: -------------------------------------------------------------------------------- 1 | mode = ScriptMode.Verbose 2 | 3 | packageName = "rosencrantz" 4 | version = "0.4.3" 5 | author = "Andrea Ferretti" 6 | description = "Web server DSL" 7 | license = "Apache2" 8 | skipDirs = @["tests", "htmldocs"] 9 | skipFiles = @["test.sh"] 10 | 11 | requires "nim >= 0.19.0" 12 | 13 | --forceBuild 14 | 15 | proc configForTests() = 16 | --hints: off 17 | --linedir: on 18 | --stacktrace: on 19 | --linetrace: on 20 | --debuginfo 21 | --path: "." 22 | 23 | 24 | task server, "compile server": 25 | configForTests() 26 | switch("out", "tests/rosencrantz") 27 | setCommand "c", "tests/server.nim" 28 | 29 | task client, "run client": 30 | configForTests() 31 | --run 32 | setCommand "c", "tests/client.nim" 33 | 34 | task gendoc, "generate documentation": 35 | --docSeeSrcUrl: https://github.com/andreaferretti/rosencrantz/blob/master 36 | --project 37 | setCommand "doc", "rosencrantz" 38 | 39 | task todo, "run todo example": 40 | --path: "." 41 | --run 42 | setCommand "c", "tests/todo.nim" 43 | 44 | task tests, "run tests": 45 | exec "./test.sh" 46 | 47 | task test, "run tests": 48 | setCommand "tests" -------------------------------------------------------------------------------- /rosencrantz/streamingsupport.nim: -------------------------------------------------------------------------------- 1 | import asynchttpserver, asyncdispatch, asyncfutures, asyncnet, asyncstreams, 2 | httpcore, strutils, tables 3 | import ./core 4 | 5 | proc sendChunk*(req: ref Request, s: string): Future[void] {.async.} = 6 | var chunk = s.len.toHex 7 | chunk.add("\c\L") 8 | chunk.add(s) 9 | chunk.add("\c\L") 10 | await req[].client.send(chunk) 11 | 12 | proc streaming*(fs: FutureStream[string], contentType = "text/plain"): Handler = 13 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 14 | let code = Http200 15 | var hs = {"Content-Type": contentType}.newHttpHeaders 16 | # Should traverse in reverse order 17 | for h in ctx.headers: 18 | hs[h.k] = h.v 19 | if not ctx.log.isNil: 20 | debugEcho ctx.log[].format(req.reqMethod, req.url.path, req.headers.table, req.body, code, hs.table) 21 | var start = "HTTP/1.1 " & $code & "\c\L" 22 | await req[].client.send(start) 23 | await req[].sendHeaders(hs) 24 | await req[].client.send("Transfer-Encoding: Chunked\c\L\c\L") 25 | while not finished(fs): 26 | let (moreData, chunk) = await fs.read() 27 | if moreData: 28 | await req.sendChunk(chunk) 29 | else: 30 | await req.sendChunk("") 31 | break 32 | return ctx 33 | 34 | return h -------------------------------------------------------------------------------- /rosencrantz/custom.nim: -------------------------------------------------------------------------------- 1 | import asyncHttpServer, asyncDispatch, macros 2 | import ./core 3 | 4 | proc getRequest*(p: proc(req: ref Request): Handler): Handler = 5 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 6 | let handler = p(req) 7 | return (await handler(req, ctx)) 8 | 9 | return h 10 | 11 | proc before(p: proc(): Handler): Handler = 12 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 13 | let h1 = p() 14 | return await h1(req, ctx) 15 | 16 | return h 17 | 18 | template scope*(body: untyped): untyped = 19 | proc inner: auto {.gensym.} = body 20 | 21 | before(inner) 22 | 23 | proc before(p: proc(): Future[Handler]): Handler = 24 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 25 | let h1 = await p() 26 | return await h1(req, ctx) 27 | 28 | return h 29 | 30 | template scopeAsync*(body: untyped): untyped = 31 | proc outer(): auto {.gensym.} = 32 | proc inner: Future[Handler] {.async.} = body 33 | return inner() 34 | 35 | before(outer) 36 | 37 | macro makeHandler*(body: untyped): untyped = 38 | template inner(body: untyped): untyped {.dirty.} = 39 | proc innerProc(): auto = 40 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 41 | body 42 | 43 | return h 44 | 45 | innerProc() 46 | 47 | 48 | result = getAst(inner(body)) 49 | echo result.toStrLit 50 | -------------------------------------------------------------------------------- /rosencrantz/jsonsupport.nim: -------------------------------------------------------------------------------- 1 | import json, asynchttpserver, asyncdispatch, httpcore 2 | import ./core, ./handlers 3 | 4 | type 5 | JsonReadable* = concept x 6 | var j: JsonNode 7 | parseFromJson(j, type(x)) is type(x) 8 | JsonWritable* = concept x 9 | renderToJson(x) is JsonNode 10 | 11 | proc ok*(j: JsonNode, pretty=false): Handler = 12 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 13 | var headers = {"Content-Type": "application/json"}.newHttpHeaders 14 | # Should traverse in reverse order 15 | for h in ctx.headers: 16 | headers[h.k] = h.v 17 | let body = if pretty: pretty(j) else: $j 18 | await req[].respond(Http200, body, headers) 19 | return ctx 20 | 21 | return h 22 | 23 | proc ok*[A: JsonWritable](a: A, pretty=false): Handler = 24 | ok(a.renderToJson, pretty) 25 | 26 | proc jsonBody*(p: proc(j: JsonNode): Handler): Handler = 27 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 28 | var j: JsonNode 29 | try: 30 | j = req.body.parseJson 31 | except JsonParsingError: 32 | return ctx.reject() 33 | let handler = p(j) 34 | let newCtx = await handler(req, ctx) 35 | return newCtx 36 | 37 | return h 38 | 39 | proc jsonBody*[A: JsonReadable](p: proc(a: A): Handler): Handler = 40 | jsonBody(proc(j: JsonNode): Handler = 41 | var a: A 42 | try: 43 | a = j.parseFromJson(A) 44 | except: 45 | return reject() 46 | return p(a) 47 | ) -------------------------------------------------------------------------------- /rosencrantz/corssupport.nim: -------------------------------------------------------------------------------- 1 | import strutils, sequtils, httpcore 2 | import ./core, ./handlers, ./headersupport 3 | 4 | 5 | proc accessControlAllowOrigin*(origin: string): auto = 6 | headers(("Access-Control-Allow-Origin", origin)) 7 | 8 | let accessControlAllowAllOrigins* = accessControlAllowOrigin("*") 9 | 10 | proc accessControlExposeHeaders*(headers: openarray[string]): auto = 11 | headers(("Access-Control-Expose-Headers", headers.join(", "))) 12 | 13 | proc accessControlMaxAge*(seconds: int): auto = 14 | headers(("Access-Control-Max-Age", $seconds)) 15 | 16 | proc accessControlAllowCredentials*(allow: bool): auto = 17 | headers(("Access-Control-Allow-Credentials", $allow)) 18 | 19 | proc accessControlAllowMethods*(methods: openarray[HttpMethod]): auto = 20 | headers(("Access-Control-Allow-Methods", methods.map(proc(m: auto): auto = $m).join(", "))) 21 | 22 | proc accessControlAllowHeaders*(headers: openarray[string]): auto = 23 | headers(("Access-Control-Allow-Headers", headers.join(", "))) 24 | 25 | proc accessControlAllow*(origin: string, methods: openarray[HttpMethod], headers: openarray[string]): auto = 26 | headers( 27 | ("Access-Control-Allow-Origin", origin), 28 | ("Access-Control-Allow-Methods", methods.map(proc(m: auto): auto = $m).join(", ")), 29 | ("Access-Control-Allow-Headers", headers.join(", ")) 30 | ) 31 | 32 | proc stringToMethod(s: string): HttpMethod = 33 | case s.toUpperAscii 34 | of "GET": return HttpGet 35 | of "POST": return HttpPost 36 | of "PUT": return HttpPut 37 | of "DELETE": return HttpDelete 38 | of "HEAD": return HttpHead 39 | of "PATCH": return HttpPatch 40 | of "OPTIONS": return HttpOptions 41 | of "TRACE": return HttpTrace 42 | of "CONNECT": return HttpConnect 43 | else: raise newException(ValueError, "Unknown method name") 44 | 45 | proc readAccessControl*(p: proc(origin: string, m: HttpMethod, headers: seq[string]): Handler): Handler = 46 | tryReadHeaders("Origin", "Access-Control-Allow-Method", "Access-Control-Allow-Headers", proc(s1, s2, s3: string): Handler = 47 | try: 48 | return p(s1, stringToMethod(s2), s3.split(",")) 49 | except: 50 | return reject() 51 | ) -------------------------------------------------------------------------------- /rosencrantz/staticsupport.nim: -------------------------------------------------------------------------------- 1 | import asynchttpserver, asyncdispatch, asyncnet, asyncfile, httpcore, os, 2 | mimetypes, strutils, tables 3 | import ./core, ./streamingsupport 4 | 5 | proc getContentType(fileName: string, mime: MimeDB): string {.inline.} = 6 | let (_, _, ext) = splitFile(fileName) 7 | if ext == "": return "text/plain" 8 | let extension = if ext[0] == '.': ext[1 .. ext.high] else: ext 9 | return mime.getMimetype(extension.toLowerAscii) 10 | 11 | 12 | proc file*(path: string): Handler = 13 | let 14 | mime = newMimetypes() 15 | mimeType = getContentType(path, mime) 16 | 17 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 18 | if fileExists(path): 19 | let f = openAsync(path) 20 | let content = await f.readAll 21 | close(f) 22 | var hs = {"Content-Type": mimeType}.newHttpHeaders 23 | # Should traverse in reverse order 24 | for h in ctx.headers: 25 | hs[h.k] = h.v 26 | await req[].respond(Http200, content, hs) 27 | return ctx 28 | else: 29 | return ctx.reject 30 | 31 | return h 32 | 33 | proc fileAsync*(path: string, chunkSize = 4096): Handler = 34 | let 35 | mime = newMimetypes() 36 | mimeType = getContentType(path, mime) 37 | 38 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 39 | if fileExists(path): 40 | let f = openAsync(path) 41 | var hs = {"Content-Type": mimeType}.newHttpHeaders 42 | # Should traverse in reverse order 43 | for h in ctx.headers: 44 | hs[h.k] = h.v 45 | let code = Http200 46 | if not ctx.log.isNil: 47 | debugEcho ctx.log[].format(req.reqMethod, req.url.path, req.headers.table, req.body, code, hs.table) 48 | var start = "HTTP/1.1 " & $code & "\c\L" 49 | await req[].client.send(start) 50 | await req[].sendHeaders(hs) 51 | await req[].client.send("Transfer-Encoding: Chunked\c\L\c\L") 52 | var done = false 53 | while not done: 54 | let chunk = await f.read(chunkSize) 55 | if chunk == "": 56 | done = true 57 | await req.sendChunk(chunk) 58 | close(f) 59 | return ctx 60 | else: 61 | return ctx.reject 62 | 63 | return h 64 | 65 | proc dir*(path: string): Handler = 66 | let mime = newMimetypes() 67 | 68 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 69 | template p: auto = req.url.path 70 | 71 | let 72 | fileName = p[ctx.position+1 .. p.high] 73 | completeFileName = path / fileName 74 | if fileExists(completeFileName): 75 | let 76 | mimeType = getContentType(completeFileName, mime) 77 | f = openAsync(completeFileName) 78 | let content = await f.readAll 79 | close(f) 80 | var hs = {"Content-Type": mimeType}.newHttpHeaders 81 | # Should traverse in reverse order 82 | for h in ctx.headers: 83 | hs[h.k] = h.v 84 | await req[].respond(Http200, content, hs) 85 | return ctx 86 | else: 87 | return ctx.reject 88 | 89 | return h -------------------------------------------------------------------------------- /tests/todo.nim: -------------------------------------------------------------------------------- 1 | import asynchttpserver, asyncdispatch, httpcore, sequtils, json, random, rosencrantz 2 | 3 | type Todo = object 4 | title: string 5 | completed: bool 6 | url: string 7 | order: int 8 | 9 | proc `%`(todo: Todo): JsonNode = 10 | %{"title": %(todo.title), "completed": %(todo.completed), "url": %(todo.url), 11 | "order": %(todo.order)} 12 | 13 | proc `%`(todos: seq[Todo]): JsonNode = %(todos.map(`%`)) 14 | 15 | template renderToJson(x: auto): JsonNode = %x 16 | 17 | proc makeUrl(n: int): string = "http://localhost:8080/todos/" & $n 18 | 19 | proc parseFromJson(j: JsonNode, m: typedesc[Todo]): Todo = 20 | let 21 | title = j["title"].getStr 22 | completed = if j.hasKey("completed"): j["completed"].getBool else: false 23 | url = if j.hasKey("url"): j["url"].getStr else: rand(1000000).makeUrl 24 | order = if j.hasKey("order"): j["order"].getInt else: 0 25 | return Todo(title: title, completed: completed, url: url, order: order) 26 | 27 | proc merge(todo: Todo, j: JsonNode): Todo = 28 | let 29 | title = if j.hasKey("title"): j["title"].getStr else: todo.title 30 | completed = if j.hasKey("completed"): j["completed"].getBool else: todo.completed 31 | url = if j.hasKey("url"): j["url"].getStr else: todo.url 32 | order = if j.hasKey("order"): j["order"].getInt else: todo.order 33 | return Todo(title: title, completed: completed, url: url, order: order) 34 | 35 | var todos: seq[Todo] = @[] 36 | 37 | let handler = accessControlAllow( 38 | origin = "*", 39 | headers = ["Content-Type"], 40 | methods = [HttpGet, HttpPost, HttpDelete, HttpPatch, HttpOptions] 41 | )[ 42 | get[ 43 | path("/todos")[ 44 | scope do: 45 | return ok(todos) 46 | ] ~ 47 | pathChunk("/todos")[ 48 | intSegment(proc(n: int): auto = 49 | let url = makeUrl(n) 50 | for todo in todos: 51 | if todo.url == url: return ok(todo) 52 | return notFound() 53 | ) 54 | ] 55 | ] ~ 56 | post[ 57 | path("/todos")[ 58 | jsonBody(proc(todo: Todo): auto = 59 | todos.add(todo) 60 | return ok(todo) 61 | ) 62 | ] 63 | ] ~ 64 | rosencrantz.delete[ 65 | path("/todos")[ 66 | scope do: 67 | todos = @[] 68 | return ok(todos) 69 | ] ~ 70 | pathChunk("/todos")[ 71 | intSegment(proc(n: int): auto = 72 | let url = makeUrl(n) 73 | for i, todo in todos: 74 | if todo.url == url: 75 | todos.delete(i) 76 | return ok(todo) 77 | ) 78 | ] 79 | ] ~ 80 | patch[ 81 | pathChunk("/todos")[ 82 | intSegment(proc(n: int): auto = 83 | jsonBody(proc(j: JsonNode): auto = 84 | let url = makeUrl(n) 85 | for i, todo in todos: 86 | if todo.url == url: 87 | let updatedTodo = todo.merge(j) 88 | todos[i] = updatedTodo 89 | return ok(updatedTodo) 90 | return notFound() 91 | ) 92 | ) 93 | ] 94 | ] ~ 95 | options[ok("")] 96 | ] 97 | 98 | let server = newAsyncHttpServer() 99 | 100 | waitFor server.serve(Port(8080), handler) -------------------------------------------------------------------------------- /rosencrantz/headersupport.nim: -------------------------------------------------------------------------------- 1 | import asynchttpserver, asyncdispatch, httpcore, times 2 | import ./core 3 | 4 | proc headers*(hs: varargs[StrPair]): Handler = 5 | let headerSeq = @hs 6 | 7 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 8 | return ctx.withHeaders(headerSeq) 9 | 10 | return h 11 | 12 | proc contentType*(s: string): Handler = headers(("Content-Type", s)) 13 | 14 | proc readAllHeaders*(p: proc(headers: HttpHeaders): Handler): Handler = 15 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 16 | let handler = p(req.headers) 17 | let newCtx = await handler(req, ctx) 18 | return newCtx 19 | 20 | return h 21 | 22 | proc readHeaders*(s1: string, p: proc(h1: string): Handler): Handler = 23 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 24 | if req.headers.hasKey(s1): 25 | let handler = p(req.headers[s1]) 26 | let newCtx = await handler(req, ctx) 27 | return newCtx 28 | else: 29 | return ctx.reject() 30 | 31 | return h 32 | 33 | proc readHeaders*(s1, s2: string, p: proc(h1, h2: string): Handler): Handler = 34 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 35 | if req.headers.hasKey(s1) and req.headers.hasKey(s2): 36 | let handler = p(req.headers[s1], req.headers[s2]) 37 | let newCtx = await handler(req, ctx) 38 | return newCtx 39 | else: 40 | return ctx.reject() 41 | 42 | return h 43 | 44 | proc readHeaders*(s1, s2, s3: string, p: proc(h1, h2, h3: string): Handler): Handler = 45 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 46 | if req.headers.hasKey(s1) and req.headers.hasKey(s2) and req.headers.hasKey(s3): 47 | let handler = p(req.headers[s1], req.headers[s2], req.headers[s3]) 48 | let newCtx = await handler(req, ctx) 49 | return newCtx 50 | else: 51 | return ctx.reject() 52 | 53 | return h 54 | 55 | proc tryReadHeaders*(s1: string, p: proc(h1: string): Handler): Handler = 56 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 57 | let handler = p(req.headers.getOrDefault(s1)) 58 | let newCtx = await handler(req, ctx) 59 | return newCtx 60 | 61 | return h 62 | 63 | proc tryReadHeaders*(s1, s2: string, p: proc(h1, h2: string): Handler): Handler = 64 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 65 | let handler = p(req.headers.getOrDefault(s1), req.headers.getOrDefault(s2)) 66 | let newCtx = await handler(req, ctx) 67 | return newCtx 68 | 69 | return h 70 | 71 | proc tryReadHeaders*(s1, s2, s3: string, p: proc(h1, h2, h3: string): Handler): Handler = 72 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 73 | let handler = p(req.headers.getOrDefault(s1), req.headers.getOrDefault(s2) ,req.headers.getOrDefault(s3)) 74 | let newCtx = await handler(req, ctx) 75 | return newCtx 76 | 77 | return h 78 | 79 | proc checkHeaders*(hs: varargs[StrPair]): Handler = 80 | let headers = @hs 81 | 82 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 83 | for pair in headers: 84 | let (k, v) = pair 85 | if req.headers.getOrDefault(k) != v: 86 | return ctx.reject() 87 | return ctx 88 | 89 | return h 90 | 91 | proc accept*(s: string): Handler = checkHeaders(("Accept", s)) 92 | 93 | proc addDate*(): Handler = 94 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 95 | # https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html 96 | let now = getTime().utc().format("ddd, dd MMM yyyy HH:mm:ss 'GMT'") 97 | return ctx.withHeaders([("Date", now)]) 98 | 99 | return h -------------------------------------------------------------------------------- /rosencrantz/core.nim: -------------------------------------------------------------------------------- 1 | import asynchttpserver, asyncdispatch, httpcore 2 | 3 | type 4 | List[A] = ref object 5 | value: A 6 | next: List[A] 7 | StrPair* = tuple[k, v: string] 8 | # TODO: replace these by httpcore-HttpMethod 9 | Context* = object 10 | position*: int 11 | accept*: bool 12 | log*: ref string 13 | error*: ref string 14 | headers*: List[StrPair] 15 | 16 | 17 | proc emptyList[A](): List[A] = nil 18 | 19 | proc `+`[A](a: A, list: List[A]): List[A] = 20 | new result 21 | result.value = a 22 | result.next = list 23 | 24 | iterator items*[A](list: List[A]): A = 25 | var node = list 26 | while node != nil: 27 | yield node.value 28 | node = node.next 29 | 30 | proc reject*(ctx: Context): Context = 31 | Context( 32 | position: ctx.position, 33 | accept: false, 34 | log: ctx.log, 35 | error: ctx.error, 36 | headers: ctx.headers 37 | ) 38 | 39 | proc addPosition*(ctx: Context, n: int): Context = 40 | Context( 41 | position: ctx.position + n, 42 | accept: ctx.accept, 43 | log: ctx.log, 44 | error: ctx.error, 45 | headers: ctx.headers 46 | ) 47 | 48 | proc withPosition*(ctx: Context, n: int): Context = 49 | Context( 50 | position: n, 51 | accept: ctx.accept, 52 | log: ctx.log, 53 | error: ctx.error, 54 | headers: ctx.headers 55 | ) 56 | 57 | proc withLogging*(ctx: Context, s: ref string): Context = 58 | Context( 59 | position: ctx.position, 60 | accept: ctx.accept, 61 | log: s, 62 | error: ctx.error, 63 | headers: ctx.headers 64 | ) 65 | 66 | proc withError*(ctx: Context, s: string): Context = 67 | var x: ref string 68 | new x 69 | x[] = s 70 | Context( 71 | position: ctx.position, 72 | accept: ctx.accept, 73 | log: ctx.log, 74 | error: x, 75 | headers: ctx.headers 76 | ) 77 | 78 | proc withHeaders*(ctx: Context, hs: openarray[StrPair]): Context = 79 | var headers = ctx.headers 80 | for h in hs: 81 | headers = h + headers 82 | return Context( 83 | position: ctx.position, 84 | accept: ctx.accept, 85 | log: ctx.log, 86 | error: ctx.error, 87 | headers: headers 88 | ) 89 | 90 | type Handler* = proc(req: ref Request, ctx: Context): Future[Context] 91 | 92 | proc handle*(h: Handler): auto = 93 | proc server(req: Request): Future[void] {.async, closure, gcsafe.} = 94 | let emptyCtx = Context( 95 | position: 0, 96 | accept: true, 97 | headers: emptyList[StrPair]() 98 | ) 99 | var reqHeap = new(Request) 100 | reqHeap[] = req 101 | var 102 | f: Future[Context] 103 | ctx: Context 104 | {.gcsafe.}: 105 | try: 106 | f = h(reqHeap, emptyCtx) 107 | ctx = await f 108 | except: 109 | discard 110 | if f.failed: 111 | await req.respond(Http500, "Server Error", {"Content-Type": "text/plain;charset=utf-8"}.newHttpHeaders) 112 | else: 113 | if not ctx.accept: 114 | await req.respond(Http404, "Not Found", {"Content-Type": "text/plain;charset=utf-8"}.newHttpHeaders) 115 | 116 | return server 117 | 118 | proc `~`*(h1, h2: Handler): Handler = 119 | proc h3(req: ref Request, ctx: Context): Future[Context] {.async.} = 120 | let newCtx = await h1(req, ctx) 121 | if newCtx.accept: 122 | return newCtx 123 | else: 124 | return await h2(req, ctx) 125 | 126 | return h3 127 | 128 | proc `->`*(h1, h2: Handler): Handler = 129 | proc h3(req: ref Request, ctx: Context): Future[Context] {.async.} = 130 | let newCtx = await h1(req, ctx) 131 | if newCtx.accept: 132 | return await h2(req, newCtx) 133 | else: 134 | return newCtx 135 | 136 | return h3 137 | 138 | template `[]`*(h1, h2: Handler): auto = h1 -> h2 139 | 140 | proc serve*(server: AsyncHttpServer, port: Port, handler: Handler, address = ""): Future[void] = 141 | if not defined(silence_rosencrantz): 142 | echo "My most dear lord!" 143 | echo "Rosencrantz ready on port ", port.int16 144 | serve(server, port, handle(handler), address) 145 | -------------------------------------------------------------------------------- /rosencrantz/handlers.nim: -------------------------------------------------------------------------------- 1 | import asynchttpserver, asyncdispatch, httpcore, strutils, tables 2 | import ./core 3 | 4 | proc reject*(): Handler = 5 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 6 | return ctx.reject() 7 | 8 | return h 9 | 10 | proc accept*(): Handler = 11 | ## Helper proc for when you need to return a Handler, but already 12 | ## know that you are not reject()-ing the request 13 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 14 | return ctx 15 | 16 | return h 17 | 18 | proc acceptOrReject*(b: bool) : Handler = 19 | ## Helper proc for creating a Handler that will accept or reject 20 | ## based on a single boolean 21 | if b: 22 | return accept() 23 | else: 24 | return reject() 25 | 26 | proc complete*(code: HttpCode, body: string, headers = newHttpHeaders()): Handler = 27 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 28 | var hs = headers 29 | # Should traverse in reverse order 30 | for h in ctx.headers: 31 | hs[h.k] = h.v 32 | if not ctx.log.isNil: 33 | debugEcho ctx.log[].format(req.reqMethod, req.url.path, req.headers.table, req.body, code, headers.table, body) 34 | if not ctx.error.isNil: 35 | stderr.write(ctx.error[]) 36 | await req[].respond(code, body, hs) 37 | return ctx 38 | 39 | return h 40 | 41 | proc ok*(s: string): Handler = 42 | complete(Http200, s, {"Content-Type": "text/plain;charset=utf-8"}.newHttpHeaders) 43 | 44 | proc notFound*(s: string = "Not Found"): Handler = 45 | complete(Http404, s, {"Content-Type": "text/plain;charset=utf-8"}.newHttpHeaders) 46 | 47 | proc path*(s: string): Handler = 48 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 49 | if req.url.path == s: 50 | return ctx 51 | else: 52 | return ctx.reject() 53 | 54 | return h 55 | 56 | proc pathChunk*(s: string): Handler = 57 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 58 | let n = s.len 59 | if req.url.path.substr(ctx.position, ctx.position + n - 1) == s: 60 | return ctx.addPosition(n) 61 | else: 62 | return ctx.reject() 63 | 64 | return h 65 | 66 | proc pathEnd*(p: proc(s: string): Handler): Handler = 67 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 68 | template path: auto = req.url.path 69 | 70 | let s = path[ctx.position .. path.high] 71 | let handler = p(s) 72 | let newCtx = await handler(req, ctx.withPosition(path.high)) 73 | return newCtx 74 | 75 | return h 76 | 77 | proc matchText(s1, s2: string, caseSensitive=true) : bool = 78 | result = if caseSensitive: 79 | s1 == s2 80 | else: 81 | s1.cmpIgnoreCase(s2) == 0 82 | 83 | proc pathEnd*(s = "", caseSensitive=true) : Handler = 84 | ## Matches if the remaining path matches ''s''. The default 85 | ## is an empty string for the common scenario of ensuring 86 | ## that there is no trailing path. You can supply your own 87 | ## value to override this (e.g. ''pathEnd("/")'' to ensure 88 | ## a trailing slash on the URL) 89 | ## 90 | ## The matching defaults to case sensitive, but you can override 91 | ## this if needed. 92 | proc inner(remaining: string) : Handler = 93 | return acceptOrReject(matchText(s, remaining, caseSensitive)) 94 | 95 | return pathEnd(inner) 96 | 97 | proc segment*(p: proc(s: string): Handler): Handler = 98 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 99 | template path: auto = req.url.path 100 | 101 | let pos = ctx.position 102 | if pos >= path.len or path[pos] != '/': 103 | return ctx.reject() 104 | let nextSlash = path.find('/', pos + 1) 105 | let final = if nextSlash == -1: path.len - 1 else: nextSlash - 1 106 | let s = path[(pos + 1) .. final] 107 | let handler = p(s) 108 | let newCtx = await handler(req, ctx.addPosition(final - pos + 1)) 109 | return newCtx 110 | 111 | return h 112 | 113 | proc segment*(s : string, caseSensitive=true) : Handler = 114 | ## Matches a path segment if the entire segment matches ''s'' 115 | ## For example ''segment("hello")'' will match a request 116 | ## like ''/hello'', but not ''/helloworld''. 117 | ## 118 | ## The matching defaults to case sensitive, but you can override 119 | ## this if needed. 120 | proc inner(segmentTxt: string): Handler = 121 | return acceptOrReject(matchText(s, segmentTxt, caseSensitive)) 122 | 123 | return segment(inner) 124 | 125 | proc intSegment*(p: proc(n: int): Handler): Handler = 126 | proc inner(s: string): Handler = 127 | var n: int 128 | try: 129 | n = s.parseInt 130 | except OverflowError, ValueError: 131 | return reject() 132 | 133 | return p(n) 134 | 135 | return segment(inner) 136 | 137 | proc body*(p: proc(s: string): Handler): Handler = 138 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 139 | let handler = p(req.body) 140 | let newCtx = await handler(req, ctx) 141 | return newCtx 142 | 143 | return h 144 | 145 | proc verb*(m: HttpMethod): Handler = 146 | let verbName = $m 147 | 148 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 149 | # TODO: remove the call to `$` when switching to httpcore.HttpMethod 150 | if $(req.reqMethod) == verbName: 151 | return ctx 152 | else: 153 | return ctx.reject() 154 | 155 | return h 156 | 157 | let 158 | get* = verb(HttpGet) 159 | post* = verb(HttpPost) 160 | put* = verb(HttpPut) 161 | delete* = verb(HttpDelete) 162 | head* = verb(HttpHead) 163 | patch* = verb(HttpPatch) 164 | options* = verb(HttpOptions) 165 | trace* = verb(HttpTrace) 166 | connect* = verb(HttpConnect) 167 | 168 | proc logResponse*(s: string): Handler = 169 | let x = new(string) 170 | x[] = s 171 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 172 | return ctx.withLogging(x) 173 | 174 | return h 175 | 176 | proc logRequest*(s: string): Handler = 177 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 178 | debugEcho s.format(req.reqMethod, req.url.path, req.headers.table, req.body) 179 | return ctx 180 | 181 | return h 182 | 183 | proc failWith*(code: HttpCode, s: string): auto = 184 | proc inner(handler: Handler): Handler = 185 | handler ~ complete(code, s) 186 | 187 | return inner 188 | 189 | proc crashWith*(code = Http500, s = "Server Error", logError = true): auto = 190 | let failSafeHandler = complete(code, s) 191 | 192 | proc inner(handler: Handler): Handler = 193 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 194 | try: 195 | let newCtx = await handler(req, ctx) 196 | return newCtx 197 | except Exception as e: 198 | let newCtx = if logError: ctx.withError(e.msg) else: ctx 199 | return await failSafeHandler(req, newCtx) 200 | 201 | return h 202 | 203 | return inner -------------------------------------------------------------------------------- /rosencrantz/formsupport.nim: -------------------------------------------------------------------------------- 1 | import strtabs, strutils, parseutils, tables, asynchttpserver, asyncdispatch, cgi 2 | import ./core, ./handlers 3 | 4 | type 5 | MultiPartFile* = object 6 | filename*, contentType*, content*: string 7 | MultiPart* = object 8 | fields*: StringTableRef 9 | files*: TableRef[string, MultiPartFile] 10 | 11 | proc parseUrlEncoded(body: string): StringTableRef {.inline.} = 12 | result = {:}.newStringTable 13 | var i = 0 14 | let c = body.decodeUrl 15 | while i < c.len - 1: 16 | var k, v: string 17 | i += c.parseUntil(k, '=', i) 18 | i += 1 19 | i += c.parseUntil(v, '&', i) 20 | i += 1 21 | result[k] = v 22 | 23 | proc parseUrlEncodedMulti(body: string): TableRef[string, seq[string]] {.inline.} = 24 | new result 25 | result[] = initTable[string, seq[string]]() 26 | 27 | var i = 0 28 | let c = body.decodeUrl 29 | while i < c.len - 1: 30 | var k, v: string 31 | i += c.parseUntil(k, '=', i) 32 | i += 1 33 | i += c.parseUntil(v, '&', i) 34 | i += 1 35 | if result.hasKey(k): 36 | result[k].add(v) 37 | else: 38 | result[k] = @[v] 39 | 40 | type 41 | UrlDecodable* = concept x 42 | var s: StringTableRef 43 | parseFromUrl(s, type(x)) is type(x) 44 | UrlMultiDecodable* = concept x 45 | var s: TableRef[string, seq[string]] 46 | parseFromUrl(s, type(x)) is type(x) 47 | 48 | proc queryString*(p: proc(s: string): Handler): Handler = 49 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 50 | let handler = p(req.url.query) 51 | let newCtx = await handler(req, ctx) 52 | return newCtx 53 | 54 | return h 55 | 56 | proc queryString*(p: proc(s: StringTableRef): Handler): Handler = 57 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 58 | var s: StringTableRef 59 | try: 60 | s = req.url.query.parseUrlEncoded 61 | except: 62 | return ctx.reject() 63 | let handler = p(s) 64 | let newCtx = await handler(req, ctx) 65 | return newCtx 66 | 67 | return h 68 | 69 | proc queryString*[A: UrlDecodable](p: proc(a: A): Handler): Handler = 70 | queryString(proc(s: StringTableRef): Handler = 71 | var a: A 72 | try: 73 | a = s.parseFromUrl(A) 74 | except: 75 | return reject() 76 | return p(a) 77 | ) 78 | 79 | proc queryString*(p: proc(s: TableRef[string, seq[string]]): Handler): Handler = 80 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 81 | var s: TableRef[string, seq[string]] 82 | try: 83 | s = req.url.query.parseUrlEncodedMulti 84 | except: 85 | return ctx.reject() 86 | let handler = p(s) 87 | let newCtx = await handler(req, ctx) 88 | return newCtx 89 | 90 | return h 91 | 92 | proc queryString*[A: UrlMultiDecodable](p: proc(a: A): Handler): Handler = 93 | queryString(proc(s: TableRef[string, seq[string]]): Handler = 94 | var a: A 95 | try: 96 | a = s.parseFromUrl(A) 97 | except: 98 | return reject() 99 | return p(a) 100 | ) 101 | 102 | proc formBody*(p: proc(s: StringTableRef): Handler): Handler = 103 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 104 | var s: StringTableRef 105 | try: 106 | s = req.body.parseUrlEncoded 107 | except: 108 | return ctx.reject() 109 | let handler = p(s) 110 | let newCtx = await handler(req, ctx) 111 | return newCtx 112 | 113 | return h 114 | 115 | proc formBody*[A: UrlDecodable](p: proc(a: A): Handler): Handler = 116 | formBody(proc(s: StringTableRef): Handler = 117 | var a: A 118 | try: 119 | a = s.parseFromUrl(A) 120 | except: 121 | return reject() 122 | return p(a) 123 | ) 124 | 125 | proc formBody*(p: proc(s: TableRef[string, seq[string]]): Handler): Handler = 126 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 127 | var s: TableRef[string, seq[string]] 128 | try: 129 | s = req.body.parseUrlEncodedMulti 130 | except: 131 | return ctx.reject() 132 | let handler = p(s) 133 | let newCtx = await handler(req, ctx) 134 | return newCtx 135 | 136 | return h 137 | 138 | proc formBody*[A: UrlMultiDecodable](p: proc(a: A): Handler): Handler = 139 | formBody(proc(s: TableRef[string, seq[string]]): Handler = 140 | var a: A 141 | try: 142 | a = s.parseFromUrl(A) 143 | except: 144 | return reject() 145 | return p(a) 146 | ) 147 | 148 | const sep = "\c\L" 149 | 150 | template doSkip(s, token, start: auto): auto = 151 | let x = s.skip(token, start) 152 | doAssert x != 0 153 | x 154 | 155 | template doSkipIgnoreCase(s, token, start: auto): auto = 156 | let x = s.skipIgnoreCase(token, start) 157 | doAssert x != 0 158 | x 159 | 160 | proc parseChunk(chunk: var string, accum: var MultiPart) {.inline.} = 161 | var 162 | k, name, filename, contentType: string 163 | j = chunk.skipWhiteSpace(0) 164 | lineEnd = chunk.find(sep, j) 165 | j += chunk.doSkipIgnoreCase("Content-Disposition:", j) 166 | j += chunk.skipWhiteSpace(j) 167 | j += chunk.doSkip("form-data;", j) 168 | while j < lineEnd: 169 | j += chunk.skipWhile({' '}, j) 170 | j += chunk.parseUntil(k, '=', j) 171 | if k == "name": 172 | j += 1 173 | j += chunk.doSkip("\"", j) 174 | j += chunk.parseUntil(name, '"', j) 175 | j += 1 176 | j += chunk.skip(";", j) 177 | elif k == "filename": 178 | j += 1 179 | j += chunk.doSkip("\"", j) 180 | j += chunk.parseUntil(filename, '"', j) 181 | j += 1 182 | j += chunk.skip(";", j) 183 | doAssert name != "" 184 | j += chunk.doSkip(sep, j) 185 | # if filename found, parse next line for Content-Type 186 | if filename != "": 187 | lineEnd = chunk.find(sep, j) 188 | j += chunk.doSkipIgnoreCase("Content-Type:", j) 189 | j += chunk.skipWhiteSpace(j) 190 | j += chunk.parseUntil(contentType, sep[0], j) 191 | j += chunk.doSkip(sep & sep, j) 192 | accum.files[name] = MultiPartFile( 193 | filename: filename, 194 | contentType: contentType, 195 | content: chunk[j .. chunk.high - sep.len] 196 | ) 197 | else: 198 | j += chunk.doSkip(sep, j) 199 | accum.fields[name] = chunk[j .. chunk.high - sep.len] 200 | 201 | 202 | proc multipart*(p: proc(s: MultiPart): Handler): Handler = 203 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 204 | var accum = MultiPart( 205 | fields: {:}.newStringTable, 206 | files: newTable[string, MultiPartFile]() 207 | ) 208 | let 209 | contentType: string = req.headers["Content-Type"] 210 | skip = "multipart/form-data; boundary=".len 211 | boundary = "--" & contentType[skip .. contentType.high] 212 | template c: string = req.body 213 | 214 | var i = 0 215 | while i < c.len - 1: 216 | var chunk: string 217 | i += c.doSkip(boundary, i) 218 | i += c.parseUntil(chunk, boundary, i) 219 | 220 | if chunk != ("--" & sep): 221 | parseChunk(chunk, accum) 222 | let handler = p(accum) 223 | let newCtx = await handler(req, ctx) 224 | return newCtx 225 | 226 | return h -------------------------------------------------------------------------------- /tests/server.nim: -------------------------------------------------------------------------------- 1 | import asynchttpserver, asyncdispatch, asyncstreams, httpcore, json, random, 2 | strtabs, strutils, sequtils, tables, rosencrantz 3 | 4 | type 5 | Message = object 6 | message: string 7 | count: int 8 | Messages = object 9 | message1: string 10 | message2: string 11 | message3: string 12 | 13 | proc renderToJson(m: Message): JsonNode = 14 | %{"msg": %(m.message), "count": %(m.count)} 15 | 16 | proc parseFromUrl(s: StringTableRef, m: typedesc[Message]): Message = 17 | Message(message: s["msg"], count: s["count"].parseInt) 18 | 19 | proc parseFromUrl(s: TableRef[string, seq[string]], m: typedesc[Messages]): Messages = 20 | Messages(message1: s["msg"][0], message2: s["msg"][1], message3: s["msg"][2]) 21 | 22 | proc parseFromJson(j: JsonNode, m: typedesc[Message]): Message = 23 | let 24 | s = j["msg"].getStr 25 | c = j["count"].getInt 26 | return Message(message: s, count: c) 27 | 28 | let handler = get[ 29 | path("/hello")[ 30 | logRequest("$1 $2\n$3")[ 31 | ok("Hello, World!") 32 | ] 33 | ] ~ 34 | path("/nested/hello")[ 35 | logResponse("$1 $2 $5\n$6\n$7")[ 36 | ok("Hello, World!") 37 | ] 38 | ] ~ 39 | pathChunk("/nested")[ 40 | pathChunk("/hello-again")[ 41 | ok("Hello, World!") 42 | ] 43 | ] ~ 44 | pathChunk("/error")[ 45 | pathChunk("/not-found")[ 46 | notFound("Not found") 47 | ] ~ 48 | pathChunk("/unauthorized")[ 49 | complete(Http401, "Authorization failed", 50 | {"Content-Type": "text/plain"}.newHttpHeaders) 51 | ] 52 | ] ~ 53 | crashWith(Http500, "Sorry :-(")( 54 | path("/custom-crash")[ 55 | scope do: 56 | if rand(5) < 6: 57 | raise newException(Exception, "crash") 58 | return ok("hello") 59 | ] 60 | ) ~ 61 | pathChunk("/echo")[ 62 | pathEnd(proc(rest: string): auto = ok(rest)) 63 | ] ~ 64 | pathChunk("/repeat")[ 65 | segment(proc(msg: string): auto = 66 | intSegment(proc(n: int): auto = 67 | ok(sequtils.repeat(msg, n).join(",")) 68 | ) 69 | ) 70 | ] ~ 71 | path("/query-echo")[ 72 | queryString(proc(s: string): auto = 73 | ok(s) 74 | ) 75 | ] ~ 76 | path("/query-repeat")[ 77 | queryString(proc(s: StringTableRef): auto = 78 | let 79 | msg = s["msg"] 80 | count = s["count"].parseInt 81 | ok(sequtils.repeat(msg, count).join(",")) 82 | ) 83 | ] ~ 84 | path("/query-typeclass")[ 85 | queryString(proc(m: Message): auto = 86 | ok(sequtils.repeat(m.message, m.count).join(",")) 87 | ) 88 | ] ~ 89 | path("/query-multi")[ 90 | queryString(proc(s: TableRef[string, seq[string]]): auto = 91 | ok(s["msg"].join(" ")) 92 | ) 93 | ] ~ 94 | path("/query-multi-typeclass")[ 95 | queryString(proc(m: Messages): auto = 96 | ok(m.message1 & " " & m.message2 & " " & m.message3) 97 | ) 98 | ] ~ 99 | pathChunk("/emit-headers")[ 100 | headers(("Content-Type", "text/html"), ("Date", "Today"))[ 101 | ok("Hi there") 102 | ] 103 | ] ~ 104 | path("/content-negotiation")[ 105 | accept("text/html")[ 106 | contentType("text/html")[ 107 | ok("hi") 108 | ] 109 | ] ~ 110 | accept("text/plain")[ 111 | contentType("text/plain")[ 112 | ok("hi") 113 | ] 114 | ] 115 | ] ~ 116 | path("/read-all-headers")[ 117 | readAllHeaders(proc(hs: HttpHeaders): auto = 118 | ok(hs["First"] & ", " & hs["Second"]) 119 | ) 120 | ] ~ 121 | path("/read-headers")[ 122 | readHeaders("First", "Second", proc(first, second: string): auto = 123 | ok(first & ", " & second) 124 | ) 125 | ] ~ 126 | path("/try-read-headers")[ 127 | tryReadHeaders("First", "Second", "Third", proc(first, second, third: string): auto = 128 | ok(first & ", " & second & third) 129 | ) 130 | ] ~ 131 | path("/check-headers")[ 132 | checkHeaders(("First", "Hello"), ("Second", "World!"))[ 133 | ok("Hello, World!") 134 | ] 135 | ] ~ 136 | path("/date")[ 137 | addDate()[ 138 | ok("Hello, World!") 139 | ] 140 | ] ~ 141 | path("/crash")[ 142 | readAllHeaders(proc(hs: HttpHeaders): auto = 143 | ok(hs["Missing"]) 144 | ) 145 | ] ~ 146 | path("/custom-failure")[ 147 | failWith(Http401, "Unauthorized")( 148 | checkHeaders(("First", "Hello"))[ 149 | ok("Hello, World!") 150 | ] 151 | ) 152 | ] ~ 153 | path("/write-json")[ 154 | ok(%{"msg": %"hi there", "count": %5}) 155 | ] ~ 156 | path("/write-json-pretty")[ 157 | ok(%{"msg": %"hi there", "count": %5}, pretty=true) 158 | ] ~ 159 | path("/write-json-non-pretty")[ 160 | ok(%{"msg": %"hi there", "count": %5}, pretty=false) 161 | ] ~ 162 | path("/write-json-typeclass")[ 163 | ok(Message(message: "hi there", count: 5)) 164 | ] ~ 165 | path("/serve-file")[ 166 | file("LICENSE") 167 | ] ~ 168 | path("/serve-missing-file")[ 169 | file("LICENS") 170 | ] ~ 171 | path("/serve-image")[ 172 | file("shakespeare.jpg") 173 | ] ~ 174 | path("/serve-file-async")[ 175 | fileAsync("LICENSE") 176 | ] ~ 177 | path("/serve-stream")[ 178 | scopeAsync do: 179 | var fs = newFutureStream[string]("serve-stream") 180 | await fs.write("Hello") 181 | await sleepAsync(50) 182 | await fs.write("World") 183 | # TODO: Sending an empty string 184 | # should not be necessary 185 | await sleepAsync(50) 186 | await fs.write("") 187 | let s = sleepAsync(50) 188 | s.callback = (proc() = echo "closing"; fs.complete()) 189 | await s 190 | return streaming(fs) 191 | ] ~ 192 | pathChunk("/serve-dir")[ 193 | dir(".") 194 | ] ~ 195 | path("/custom-block")[ 196 | scope do: 197 | let x = "Hello, World!" 198 | return ok(x) 199 | ] ~ 200 | path("/custom-block-async")[ 201 | scopeAsync do: 202 | let x = "Hello, World!" 203 | await sleepAsync(50) 204 | return ok(x) 205 | ] ~ 206 | path("/custom-handler")[ 207 | getRequest(proc(req: ref Request): auto = 208 | let x = req.url.path 209 | return ok(x) 210 | ) 211 | ] ~ 212 | path("/handler-macro")[ 213 | makeHandler do: 214 | let x = req.url.path 215 | await req[].respond(Http200, x, {"Content-Type": "text/plain;charset=utf-8"}.newHttpHeaders) 216 | return ctx 217 | ] ~ 218 | path("/cors/allow-origin")[ 219 | accessControlAllowOrigin("http://localhost")[ 220 | ok("Hi") 221 | ] 222 | ] ~ 223 | path("/cors/allow-all-origins")[ 224 | accessControlAllowAllOrigins[ 225 | ok("Hi") 226 | ] 227 | ] ~ 228 | path("/cors/expose-headers")[ 229 | accessControlExposeHeaders(["X-PING", "X-CUSTOM"])[ 230 | ok("Hi") 231 | ] 232 | ] ~ 233 | path("/cors/max-age")[ 234 | accessControlMaxAge(86400)[ 235 | ok("Hi") 236 | ] 237 | ] ~ 238 | path("/cors/allow-credentials")[ 239 | accessControlAllowCredentials(true)[ 240 | ok("Hi") 241 | ] 242 | ] ~ 243 | path("/cors/allow-methods")[ 244 | accessControlAllowMethods([HttpGet, HttpPost])[ 245 | ok("Hi") 246 | ] 247 | ] ~ 248 | path("/cors/allow-headers")[ 249 | accessControlAllowHeaders(["X-PING", "Content-Type"])[ 250 | ok("Hi") 251 | ] 252 | ] ~ 253 | path("/cors/access-control")[ 254 | accessControlAllow( 255 | origin = "*", 256 | methods = [HttpGet, HttpPost], 257 | headers = ["X-PING", "Content-Type"] 258 | )[ 259 | ok("Hi") 260 | ] 261 | ] ~ 262 | path("/cors/read-headers")[ 263 | readAccessControl(proc(origin: string, m: HttpMethod, headers: seq[string]): auto = 264 | ok(@[origin, $m, headers.join(",")].join(";")) 265 | ) 266 | ] ~ 267 | segment("segment-text")[ 268 | ok("Matched /segment-text") 269 | ] ~ 270 | segment("segment-text-case-insensitive", caseSensitive=false)[ 271 | ok("Matched /segment-text-case-insensitive") 272 | ] ~ 273 | pathChunk("/no-trailing-slash")[ 274 | pathEnd()[ 275 | ok("Matched /no-trailing-slash") 276 | ] 277 | ] ~ 278 | pathChunk("/path-end-")[ 279 | pathEnd("rest/of/path")[ 280 | ok("Matched /path-end-rest/of/path") 281 | ] 282 | ] 283 | ] ~ post[ 284 | path("/hello-post")[ 285 | ok("Hello, World!") 286 | ] ~ 287 | path("/echo")[ 288 | body(proc(s: string): auto = 289 | ok(s) 290 | ) 291 | ] ~ 292 | path("/read-json")[ 293 | jsonBody(proc(j: JsonNode): auto = 294 | ok(j["msg"].getStr) 295 | ) 296 | ] ~ 297 | path("/read-json-typeclass")[ 298 | jsonBody(proc(m: Message): auto = 299 | ok(m.message) 300 | ) 301 | ] ~ 302 | path("/read-form")[ 303 | formBody(proc(s: StringTableRef): auto = 304 | ok(s["msg"]) 305 | ) 306 | ] ~ 307 | path("/read-form-typeclass")[ 308 | formBody(proc(m: Message): auto = 309 | ok(m.message) 310 | ) 311 | ] ~ 312 | path("/read-multi-form")[ 313 | formBody(proc(s: TableRef[string, seq[string]]): auto = 314 | ok(s["msg"][0] & " " & s["msg"][1]) 315 | ) 316 | ] ~ 317 | path("/read-multi-form-typeclass")[ 318 | formBody(proc(m: Messages): auto = 319 | ok(m.message1 & m.message2 & m.message3) 320 | ) 321 | ] ~ 322 | path("/multipart-form")[ 323 | multipart(proc(s: MultiPart): auto = 324 | queryString(proc(params: StringTableRef): auto = 325 | if params["echo"] == "field": 326 | ok(s.fields["field"]) 327 | elif params["echo"] == "file": 328 | ok(s.files["file"].content) 329 | elif params["echo"] == "filename": 330 | ok(s.files["file"].filename) 331 | elif params["echo"] == "content-type": 332 | ok(s.files["file"].contentType) 333 | else: 334 | reject() 335 | ) 336 | ) 337 | ] 338 | ] ~ put[ 339 | path("/hello-put")[ 340 | ok("Hello, World!") 341 | ] ~ 342 | path("/echo")[ 343 | body(proc(s: string): auto = 344 | ok(s) 345 | ) 346 | ] 347 | ] 348 | 349 | let server = newAsyncHttpServer() 350 | 351 | waitFor server.serve(Port(8080), handler) 352 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /tests/client.nim: -------------------------------------------------------------------------------- 1 | import unittest, httpcore, strutils, times, json 2 | import httpclient except get, post 3 | 4 | const 5 | baseUrl = "http://localhost:8080" 6 | ct = "Content-Type" 7 | cl = "Content-Length" 8 | 9 | proc request(url: string, httpMethod: HttpMethod, headers = newHttpHeaders(), body = ""): Response = 10 | var client = newHttpClient() 11 | client.headers = headers 12 | return client.request(url, httpMethod = httpMethod, body = body) 13 | 14 | proc get(url: string, headers = newHttpHeaders()): Response = 15 | request(url, HttpGet, headers = headers) 16 | 17 | proc post(url: string, headers = newHttpHeaders(), body = ""): Response = 18 | request(url, HttpPost, headers = headers, body = body) 19 | 20 | proc post(url: string, multipart: MultipartData): Response = 21 | let client = newHttpClient() 22 | return httpclient.post(client, url, multipart = multipart) 23 | 24 | proc put(url: string, headers = newHttpHeaders(), body = ""): Response = 25 | request(url, HttpPut, headers = headers, body = body) 26 | 27 | proc hasContentType(resp: Response, t: string): bool = 28 | resp.headers[ct].startsWith(t) 29 | 30 | proc hasCorrectContentLength(resp: Response): bool = 31 | parseInt(resp.headers[cl]) == resp.body.len 32 | 33 | proc hasStatus(resp: Response, code: int): bool = 34 | resp.status.split(" ")[0].parseInt == code 35 | 36 | proc isOkTextPlain(resp: Response): bool = 37 | resp.hasStatus(200) and resp.hasCorrectContentLength and 38 | resp.hasContentType("text/plain") 39 | 40 | proc isOkJson(resp: Response): bool = 41 | resp.hasStatus(200) and resp.hasCorrectContentLength and 42 | resp.hasContentType("application/json") 43 | 44 | suite "basic functionality": 45 | test "simple text": 46 | let resp = get(baseUrl & "/hello") 47 | check resp.body == "Hello, World!" 48 | check resp.isOkTextPlain 49 | test "nested route": 50 | let resp = get(baseUrl & "/nested/hello") 51 | check resp.body == "Hello, World!" 52 | check resp.isOkTextPlain 53 | test "nested route handlers": 54 | let resp = get(baseUrl & "/nested/hello-again") 55 | check resp.body == "Hello, World!" 56 | check resp.isOkTextPlain 57 | test "not found response": 58 | let resp = get(baseUrl & "/error/not-found") 59 | check resp.body == "Not found" 60 | check resp.hasStatus(404) 61 | check resp.hasCorrectContentLength 62 | check resp.hasContentType("text/plain") 63 | test "unauthorized response": 64 | let resp = get(baseUrl & "/error/unauthorized") 65 | check resp.body == "Authorization failed" 66 | check resp.hasStatus(401) 67 | check resp.hasCorrectContentLength 68 | check resp.hasContentType("text/plain") 69 | test "post request": 70 | let resp = post(baseUrl & "/hello-post") 71 | check resp.body == "Hello, World!" 72 | check resp.isOkTextPlain 73 | test "post body extraction": 74 | let resp = post(baseUrl & "/echo", body = "Hi there") 75 | check resp.body == "Hi there" 76 | check resp.isOkTextPlain 77 | test "put request": 78 | let resp = put(baseUrl & "/hello-put") 79 | check resp.body == "Hello, World!" 80 | check resp.isOkTextPlain 81 | test "put body extraction": 82 | let resp = put(baseUrl & "/echo", body = "Hi there") 83 | check resp.body == "Hi there" 84 | check resp.isOkTextPlain 85 | test "path end extraction": 86 | let resp = get(baseUrl & "/echo/hi-there") 87 | check resp.body == "/hi-there" 88 | check resp.isOkTextPlain 89 | test "segments extraction": 90 | let resp = get(baseUrl & "/repeat/hello/3") 91 | check resp.body == "hello,hello,hello" 92 | check resp.isOkTextPlain 93 | test "segment exact match": 94 | let resp = get(baseUrl & "/segment-text") 95 | check resp.body == "Matched /segment-text" 96 | check resp.isOkTextPlain 97 | test "segment exact match does not match trailing": 98 | let resp = get(baseUrl & "/segment-text2") 99 | check resp.body == "Not Found" 100 | check resp.hasStatus(404) 101 | test "segment exact match case insensitive": 102 | let resp = get(baseUrl & "/sEgMeNt-TeXt-cAse-iNseNsiTive") 103 | check resp.body == "Matched /segment-text-case-insensitive" 104 | check resp.isOkTextPlain 105 | test "pathEnd default": 106 | let resp = get(baseUrl & "/no-trailing-slash") 107 | check resp.body == "Matched /no-trailing-slash" 108 | check resp.isOkTextPlain 109 | test "pathEnd default does not match trailing slash": 110 | let resp = get(baseUrl & "/no-trailing-slash/") 111 | check resp.body == "Not Found" 112 | check resp.hasStatus(404) 113 | test "pathEnd specific": 114 | let resp = get(baseUrl & "/path-end-rest/of/path") 115 | check resp.body == "Matched /path-end-rest/of/path" 116 | check resp.isOkTextPlain 117 | test "pathEnd specific does not match with trailing": 118 | let resp = get(baseUrl & "/path-end-rest/of/path2") 119 | check resp.body == "Not Found" 120 | check resp.hasStatus(404) 121 | 122 | suite "handling headers": 123 | test "producing headers": 124 | let resp = get(baseUrl & "/emit-headers") 125 | check resp.body == "Hi there" 126 | check resp.hasStatus(200) 127 | check resp.hasContentType("text/html") 128 | check seq[string](resp.headers["date"]) == @["Today"] 129 | test "content negotiation": 130 | let 131 | headers1 = newHttpHeaders({"Accept": "text/html"}) 132 | resp1 = get(baseUrl & "/content-negotiation", headers = headers1) 133 | check resp1.body == "hi" 134 | check resp1.hasStatus(200) 135 | check resp1.hasContentType("text/html") 136 | let 137 | headers2 = newHttpHeaders({"Accept": "text/plain"}) 138 | resp2 = get(baseUrl & "/content-negotiation", headers = headers2) 139 | check resp2.body == "hi" 140 | check resp2.hasStatus(200) 141 | check resp2.hasContentType("text/plain") 142 | test "read all headers": 143 | let 144 | headers = newHttpHeaders({"First": "Hello", "Second": "World!"}) 145 | resp = get(baseUrl & "/read-all-headers", headers = headers) 146 | check resp.body == "Hello, World!" 147 | check resp.isOkTextPlain 148 | test "read some headers": 149 | let 150 | headers = newHttpHeaders({"First": "Hello", "Second": "World!"}) 151 | resp = get(baseUrl & "/read-headers", headers = headers) 152 | check resp.body == "Hello, World!" 153 | check resp.isOkTextPlain 154 | test "sending less headers than expected should not match": 155 | let 156 | headers = newHttpHeaders({"First": "Hello"}) 157 | resp = get(baseUrl & "/read-headers", headers = headers) 158 | check resp.hasStatus(404) 159 | test "try read some headers": 160 | let 161 | headers = newHttpHeaders({"First": "Hello", "Second": "World!"}) 162 | resp = get(baseUrl & "/try-read-headers", headers = headers) 163 | check resp.body == "Hello, World!" 164 | check resp.isOkTextPlain 165 | test "checking headers": 166 | let 167 | headers = newHttpHeaders({"First": "Hello", "Second": "World!"}) 168 | resp = get(baseUrl & "/check-headers", headers = headers) 169 | check resp.body == "Hello, World!" 170 | check resp.isOkTextPlain 171 | test "failing to match headers": 172 | let 173 | headers = newHttpHeaders({"First": "Hi", "Second": "World!"}) 174 | resp = get(baseUrl & "/check-headers", headers = headers) 175 | check resp.hasStatus(404) 176 | test "date header": 177 | let resp = get(baseUrl & "/date") 178 | let date = parse(resp.headers["Date"], "ddd, dd MMM yyyy HH:mm:ss 'GMT'") 179 | let now = getTime().utc() 180 | check resp.isOkTextPlain 181 | check now.yearday == date.yearday 182 | 183 | suite "handling failures": 184 | test "missing page": 185 | let resp = get(baseUrl & "/missing") 186 | check resp.body == "Not Found" 187 | check resp.hasStatus(404) 188 | test "server error": 189 | let resp = get(baseUrl & "/crash") 190 | check resp.body == "Server Error" 191 | check resp.hasStatus(500) 192 | test "custom failure": 193 | let resp = get(baseUrl & "/custom-failure") 194 | check resp.body == "Unauthorized" 195 | check resp.hasStatus(401) 196 | test "crash containment": 197 | let resp = get(baseUrl & "/custom-crash") 198 | check resp.body == "Sorry :-(" 199 | check resp.hasStatus(500) 200 | 201 | suite "writing custom handlers": 202 | test "scope template": 203 | let resp = get(baseUrl & "/custom-block") 204 | check resp.body == "Hello, World!" 205 | check resp.isOkTextPlain 206 | test "scope async template": 207 | let resp = get(baseUrl & "/custom-block-async") 208 | check resp.body == "Hello, World!" 209 | check resp.isOkTextPlain 210 | test "request extractor": 211 | let resp = get(baseUrl & "/custom-handler") 212 | check resp.body == "/custom-handler" 213 | check resp.isOkTextPlain 214 | test "handler macros": 215 | let resp = get(baseUrl & "/handler-macro") 216 | check resp.body == "/handler-macro" 217 | check resp.isOkTextPlain 218 | 219 | suite "json support": 220 | test "producing json": 221 | let resp = get(baseUrl & "/write-json") 222 | check resp.body.parseJson["msg"].getStr == "hi there" 223 | check resp.isOkJson 224 | test "producing pretty json": 225 | let resp = get(baseUrl & "/write-json-pretty") 226 | check resp.body.parseJson["msg"].getStr == "hi there" 227 | check resp.isOkJson 228 | check resp.body.find('\n') != -1 229 | test "producing non-pretty json": 230 | let resp = get(baseUrl & "/write-json-non-pretty") 231 | check resp.body.parseJson["msg"].getStr == "hi there" 232 | check resp.isOkJson 233 | check resp.body.find('\n') == -1 234 | test "reading json": 235 | let resp = post(baseUrl & "/read-json", body = $(%{"msg": %"hi there", "count": %5})) 236 | check resp.body == "hi there" 237 | check resp.isOkTextPlain 238 | test "producing json via typeclasses": 239 | let resp = get(baseUrl & "/write-json-typeclass") 240 | check resp.body.parseJson["msg"].getStr == "hi there" 241 | check resp.isOkJson 242 | test "reading json via typeclasses": 243 | let resp = post(baseUrl & "/read-json-typeclass", body = $(%{"msg": %"hi there", "count": %5})) 244 | check resp.body == "hi there" 245 | check resp.isOkTextPlain 246 | 247 | suite "form and querystring support": 248 | test "reading form as x-www-form-urlencoded": 249 | let resp = post(baseUrl & "/read-form", body = "msg=hi there&count=5") 250 | check resp.body == "hi there" 251 | check resp.isOkTextPlain 252 | test "reading form as x-www-form-urlencoded with multiple params": 253 | let resp = post(baseUrl & "/read-multi-form", body = "msg=Hello&foo=bar&msg=World") 254 | check resp.body == "Hello World" 255 | check resp.isOkTextPlain 256 | test "reading form via typeclasses": 257 | let resp = post(baseUrl & "/read-form-typeclass", body = "msg=hi there&count=5") 258 | check resp.body == "hi there" 259 | check resp.isOkTextPlain 260 | test "reading form as x-www-form-urlencoded with multiple params via typeclasses": 261 | let resp = post(baseUrl & "/read-multi-form-typeclass", body = "msg=Hello&msg=, &msg=World") 262 | check resp.body == "Hello, World" 263 | check resp.isOkTextPlain 264 | test "querystring extraction": 265 | let resp = get(baseUrl & "/query-echo?hello") 266 | check resp.body == "hello" 267 | check resp.isOkTextPlain 268 | test "querystring parameters extraction": 269 | let resp = get(baseUrl & "/query-repeat?msg=hello&count=3") 270 | check resp.body == "hello,hello,hello" 271 | check resp.isOkTextPlain 272 | test "querystring parameters extraction via typeclasses": 273 | let resp = get(baseUrl & "/query-typeclass?msg=hello&count=3") 274 | check resp.body == "hello,hello,hello" 275 | check resp.isOkTextPlain 276 | test "querystring parameters with spaces extraction": 277 | let resp = get(baseUrl & "/query-repeat?msg=hello%20world&count=3") 278 | check resp.body == "hello world,hello world,hello world" 279 | check resp.isOkTextPlain 280 | test "querystring multiple parameters extraction": 281 | let resp = get(baseUrl & "/query-multi?msg=Hello&msg=World") 282 | check resp.body == "Hello World" 283 | check resp.isOkTextPlain 284 | test "querystring multiple parameters extraction via typeclasses": 285 | let resp = get(baseUrl & "/query-multi-typeclass?msg=Hello&msg=my&msg=World") 286 | check resp.body == "Hello my World" 287 | check resp.isOkTextPlain 288 | test "querystring multiple parameters extraction with comma": 289 | let resp = get(baseUrl & "/query-multi?msg=Hello%2C&msg=World") 290 | check resp.body == "Hello, World" 291 | check resp.isOkTextPlain 292 | test "multipart forms: extracting key/value pairs": 293 | var mp = newMultipartData() 294 | mp["field"] = "hi there" 295 | mp["file"] = ("text.txt", "text/plain", "Hello, world!") 296 | let resp = post(baseUrl & "/multipart-form?echo=field", multipart = mp) 297 | check resp.body == "hi there" 298 | check resp.isOkTextPlain 299 | test "multipart forms: file content": 300 | var mp = newMultipartData() 301 | mp["field"] = "hi there" 302 | mp["file"] = ("text.txt", "text/plain", "Hello, world!") 303 | let resp = post(baseUrl & "/multipart-form?echo=file", multipart = mp) 304 | check resp.body == "Hello, world!" 305 | check resp.isOkTextPlain 306 | test "multipart forms: file content type": 307 | var mp = newMultipartData() 308 | mp["field"] = "hi there" 309 | mp["file"] = ("text.txt", "text/plain", "Hello, world!") 310 | let resp = post(baseUrl & "/multipart-form?echo=content-type", multipart = mp) 311 | check resp.body == "text/plain" 312 | check resp.isOkTextPlain 313 | test "multipart forms: file name": 314 | var mp = newMultipartData() 315 | mp["field"] = "hi there" 316 | mp["file"] = ("text.txt", "text/plain", "Hello, world!") 317 | let resp = post(baseUrl & "/multipart-form?echo=filename", multipart = mp) 318 | check resp.body == "text.txt" 319 | check resp.isOkTextPlain 320 | 321 | suite "static file support": 322 | test "serving a single file": 323 | let resp = get(baseUrl & "/serve-file") 324 | check resp.body.contains("Apache License") 325 | check resp.isOkTextPlain 326 | test "serving a directory": 327 | let resp = get(baseUrl & "/serve-dir/LICENSE") 328 | check resp.body.contains("Apache License") 329 | check resp.isOkTextPlain 330 | test "error on a missing file": 331 | let resp = get(baseUrl & "/serve-missing-file") 332 | check resp.body == "Not Found" 333 | check resp.hasStatus(404) 334 | test "error on a missing file in a directory": 335 | let resp = get(baseUrl & "/serve-dir/LICENS") 336 | check resp.body == "Not Found" 337 | check resp.hasStatus(404) 338 | test "mimetype on a single file": 339 | let resp = get(baseUrl & "/serve-image") 340 | check resp.hasStatus(200) 341 | check resp.hasContentType("image/jpeg") 342 | test "mimetype on a directory": 343 | let resp = get(baseUrl & "/serve-dir/shakespeare.jpg") 344 | check resp.hasStatus(200) 345 | check resp.hasContentType("image/jpeg") 346 | 347 | suite "cors support": 348 | test "access control allow origin": 349 | let resp = get(baseUrl & "/cors/allow-origin") 350 | check resp.isOkTextPlain 351 | check seq[string](resp.headers["Access-Control-Allow-Origin"]) == @["http://localhost"] 352 | test "access control allow all origins": 353 | let resp = get(baseUrl & "/cors/allow-all-origins") 354 | check resp.isOkTextPlain 355 | check seq[string](resp.headers["Access-Control-Allow-Origin"]) == @["*"] 356 | test "access control expose headers": 357 | let resp = get(baseUrl & "/cors/expose-headers") 358 | check resp.isOkTextPlain 359 | check seq[string](resp.headers["Access-Control-Expose-Headers"]) == @["X-PING, X-CUSTOM"] 360 | test "access control max age": 361 | let resp = get(baseUrl & "/cors/max-age") 362 | check resp.isOkTextPlain 363 | check seq[string](resp.headers["Access-Control-Max-Age"]) == @["86400"] 364 | test "access control allow credentials": 365 | let resp = get(baseUrl & "/cors/allow-credentials") 366 | check resp.isOkTextPlain 367 | check seq[string](resp.headers["Access-Control-Allow-Credentials"]) == @["true"] 368 | test "access control allow methods": 369 | let resp = get(baseUrl & "/cors/allow-methods") 370 | check resp.isOkTextPlain 371 | check seq[string](resp.headers["Access-Control-Allow-Methods"]) == @["GET, POST"] 372 | test "access control allow headers": 373 | let resp = get(baseUrl & "/cors/allow-headers") 374 | check resp.isOkTextPlain 375 | check seq[string](resp.headers["Access-Control-Allow-Headers"]) == @["X-PING, Content-Type"] 376 | test "access control combined": 377 | let resp = get(baseUrl & "/cors/access-control") 378 | check resp.isOkTextPlain 379 | check seq[string](resp.headers["Access-Control-Allow-Origin"]) == @["*"] 380 | check seq[string](resp.headers["Access-Control-Allow-Methods"]) == @["GET, POST"] 381 | check seq[string](resp.headers["Access-Control-Allow-Headers"]) == @["X-PING, Content-Type"] 382 | test "access control read headers": 383 | let 384 | headers = newHttpHeaders({ 385 | "Origin": "http://localhost", 386 | "Access-Control-Allow-Method": "GET", 387 | "Access-Control-Allow-Headers": "X-PING" 388 | }) 389 | resp = get(baseUrl & "/cors/read-headers", headers = headers) 390 | check resp.isOkTextPlain 391 | check resp.body == "http://localhost;GET;X-PING" 392 | test "access control read some headers": 393 | let 394 | headers = newHttpHeaders({ 395 | "Origin": "http://localhost", 396 | "Access-Control-Allow-Method": "GET" 397 | }) 398 | resp = get(baseUrl & "/cors/read-headers", headers = headers) 399 | check resp.isOkTextPlain 400 | check resp.body == "http://localhost;GET;" 401 | test "access control read headers wrong method": 402 | let 403 | headers = newHttpHeaders({ 404 | "Origin": "http://localhost", 405 | "Access-Control-Allow-Method": "BET" 406 | }) 407 | resp = get(baseUrl & "/cors/read-headers", headers = headers) 408 | check resp.hasStatus(404) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 1. Rosencrantz 2 | 3 | ![shakespeare](https://raw.githubusercontent.com/andreaferretti/rosencrantz/master/shakespeare.jpg) 4 | 5 | Rosencrantz is a DSL to write web servers, inspired by [Spray](http://spray.io/) 6 | and its successor [Akka HTTP](http://doc.akka.io/docs/akka/2.4.2/scala/http/introduction.html). 7 | 8 | It sits on top of [asynchttpserver](http://nim-lang.org/docs/asynchttpserver.html) 9 | and provides a composable way to write HTTP handlers. 10 | 11 | Version 0.4 of Rosencrantz is tested with Nim 1.0.0, but is compatible with 12 | versions of Nim from 0.19.0 on. 13 | 14 | Table of contents 15 | ----------------- 16 | 17 | 18 | 19 | - Rosencrantz 20 | - Introduction 21 | - Composing handlers 22 | - Starting a server 23 | - Structure of the package 24 | - An example 25 | - Basic handlers 26 | - Path handling 27 | - HTTP methods 28 | - Failure containment 29 | - Logging 30 | - Working with headers 31 | - Writing custom handlers 32 | - JSON support 33 | - Form and querystring support 34 | - Static file support 35 | - CORS support 36 | - API stability 37 | 38 | 39 | 40 | ## 1.1. Introduction 41 | 42 | The core abstraction in Rosencrantz is the `Handler`, which is just an alias 43 | for a `proc(req: ref Request, ctx: Context): Future[Context]`. Here `Request` 44 | is the HTTP request from `asynchttpserver`, while `Context` is a place where 45 | we accumulate information such as: 46 | 47 | * what part of the path has been matched so far; 48 | * what headers to emit with the response; 49 | * whether the request has matched a route so far. 50 | 51 | A handler usually does one or more of the following: 52 | 53 | * filter the request, by returning `ctx.reject()` if some condition is not 54 | satisfied; 55 | * accumulate some headers; 56 | * actually respond to the request, by calling the `complete` function or one 57 | derived from it. 58 | 59 | Rosencrantz provides many of those handlers, which are described below. For the 60 | complete API, check [here](http://andreaferretti.github.io/rosencrantz/rosencrantz.html). 61 | 62 | ### 1.1.1. Composing handlers 63 | 64 | The nice thing about handlers is that they are composable. There are two ways 65 | to compose two headers `h1` and `h2`: 66 | 67 | * `h1 -> h2` (read `h1` **and** `h2`) returns a handler that passes the request 68 | through `h1` to update the context; then, if `h1` does not reject the request, 69 | it passes it, together with the new context, to `h2`. Think filtering first 70 | by HTTP method, then by path. 71 | * `h1 ~ h2` (read `h1` **or** `h2`) returns a handler that passes the request 72 | through `h1`; if it rejects the request, it tries again with `h2`. Think 73 | matching on two alternative paths. 74 | 75 | The combination `h1 -> h2` can also be written `h1[h2]`, which makes it nicer 76 | when composing many handlers one inside each other. Also remember that, 77 | according to Nim rules, `~` has higher precedence than `->` - use parentheses 78 | if necessary to compose your handlers. 79 | 80 | ### 1.1.2. Starting a server 81 | 82 | Once you have a handler, you can serve it using a server from `asynchttpserver`, 83 | like this: 84 | 85 | ```nim 86 | let server = newAsyncHttpServer() 87 | 88 | waitFor server.serve(Port(8080), handler) 89 | ``` 90 | 91 | ## 1.2. Structure of the package 92 | 93 | Rosencrantz can be fully imported with just 94 | 95 | ```nim 96 | import rosencrantz 97 | ``` 98 | 99 | The `rosencrantz` module just re-exports functionality from the submodules 100 | `rosencrantz/core`, `rosencrantz/handlers`, `rosencrantz/jsonsupport` and so 101 | on. These modules can be imported separately. The API is available 102 | [here](http://andreaferretti.github.io/rosencrantz/rosencrantz.html). 103 | 104 | ## 1.3. An example 105 | 106 | The following uses some of the predefined handlers and composes them together. 107 | We write a small piece of a fictionary API to save and retrieve messages, and 108 | we assume we have functions such as `getMessageById` that perform the actual 109 | business logic. This should give a feel of how the DSL looks like: 110 | 111 | ```nim 112 | let handler = get[ 113 | path("/api/status")[ 114 | ok(getStatus()) 115 | ] ~ 116 | pathChunk("/api/message")[ 117 | accept("application/json")[ 118 | intSegment(proc(id: int): auto = 119 | let message = getMessageById(id) 120 | ok(message) 121 | ) 122 | ] 123 | ] 124 | ] ~ post[ 125 | path("/api/new-message")[ 126 | jsonBody(proc(msg: Message): auto = 127 | let 128 | id = generateId() 129 | saved = saveMessage(id, msg) 130 | if saved: ok(id) 131 | else: complete(Http500, "save failed") 132 | ) 133 | ] 134 | ] 135 | ``` 136 | 137 | For more (actually working) examples, check the `tests` directory. In particular, 138 | [the server example](https://github.com/andreaferretti/rosencrantz/blob/master/tests/server.nim) 139 | tests every handler defined in Rosencrantz, while 140 | [the todo example](https://github.com/andreaferretti/rosencrantz/blob/master/tests/todo.nim) 141 | implements a server compliant with the [TODO backend project](http://www.todobackend.com/) 142 | specs. 143 | 144 | ## 1.4. Basic handlers 145 | 146 | In order to work with Rosencrantz, you can `import rosencrantz`. If you prefer 147 | a more fine-grained control, there are packages `rosencrantz/core` (which 148 | contains the definitions common to all handlers), `rosencrantz/handlers` (for 149 | the handlers we are about to show), and then more specialized handlers under 150 | `rosencrantz/jsonsupport`, `rosencrantz/formsupport` and so on. 151 | 152 | The simplest handlers are: 153 | 154 | * `complete(code, body, headers)` that actually responds to the request. Here 155 | `code` is an instance of `HttpCode` from `asynchttpserver`, `body` is a 156 | `string` and `headers` are an instance of `StringTableRef`. 157 | * `ok(body)`, which is a specialization of `complete` for a response of `200 Ok` 158 | with a content type of `text/plain`. 159 | * `notFound(body)`, which is a specialization of `complete` for a response of 160 | `404 Not Found` with a content type of `text/plain`. 161 | * `body(p)` extracts the body of the request. Here `p` is a 162 | `proc(s: string): Handler` which takes the extracted body as input and 163 | returns a handler. 164 | 165 | For instance, a simple handler that echoes back the body of the request would 166 | look like 167 | 168 | ```nim 169 | body(proc(s: string): auto = 170 | ok(s) 171 | ) 172 | ``` 173 | 174 | ### 1.4.1. Path handling 175 | 176 | There are a few handlers to filter by path and extract path parameters: 177 | 178 | * `path(s)` filters the requests where the path is equal to `s`. 179 | * `pathChunk(s)` does the same but only for a prefix of the path. This means 180 | that one can nest more path handlers after it, unlike `path`, that matches 181 | and consumes the whole path. 182 | * `pathEnd(p)` extracts whatever is not matched yet of the path and passes it 183 | to `p`. Here `p` is a `proc(s: string): Handler` that takes the final part of 184 | the path and returns a handler. 185 | * `pathEnd(s)` filters the requests where the remaining path is equal 186 | to `s`. Defaults to case sensitive matching, but you can use 187 | `pathEnd(s, caseSensitive=false)` to do a case insensitive match. 188 | * `segment(p)`, that extracts a segment of path among two `/` signs. Here `p` 189 | is a `proc(s: string): Handler` that takes the matched segment and return a 190 | handler. This fails if the position is not just before a `/` sign. 191 | * `segment(s)` filters the requests where the current path segment is equal 192 | to `s`. Defaults to case sensitive matching, but you can use 193 | `segment(s, caseSensitive=false)` to do a case insensitive match. 194 | This fails if the position is not just before a `/` sign. 195 | * `intSegment(p)`, works the same as `segment`, but extracts and parses an 196 | integer number. It fails if the segment does not represent an integer. Here 197 | `p` is a `proc(s: int): Handler`. 198 | 199 | For instance, to match and extract parameters out of a route like 200 | `repeat/$msg/$n`, one would nest the above to get 201 | 202 | ```nim 203 | pathChunk("/repeat")[ 204 | segment(proc(msg: string): auto = 205 | intSegment(proc(n: int): auto = 206 | someHandler 207 | ) 208 | ) 209 | ] 210 | ``` 211 | 212 | ### 1.4.2. HTTP methods 213 | 214 | To filter by HTTP method, one can use 215 | 216 | * `verb(m)`, where `m` is a member of the `HttpMethod` enum defined in 217 | the standard library `httpcore`. There are corresponding specializations 218 | * `get`, `post`, `put`, `delete`, `head`, `patch`, `options`, `trace` and 219 | `connect` 220 | 221 | ### 1.4.3. Failure containment 222 | 223 | When a requests falls through all routes without matching, Rosencrantz will 224 | return a standard response of `404 Not Found`. Similarly, whenever an 225 | exception arises, Rosencrantz will respond with `500 Server Error`. 226 | 227 | Sometimes, it can be useful to have more control over failure cases. For 228 | instance, you are able only to generate responses with type `application/json`: 229 | if the `Accept` header does not match it, you may want to return a status code 230 | of `406 Not Accepted`. 231 | 232 | One way to do this is to put the 406 response as an alternative, like this: 233 | 234 | ```nim 235 | accept("application/json")[ 236 | someResponse 237 | ] ~ complete(Http406, "JSON endpoint") 238 | ``` 239 | 240 | However, it can be more clear to use an equivalent combinators that wraps 241 | an existing handler and it returns a given failure message in case the inner 242 | handler fails to match. For this, there is 243 | 244 | * `failWith(code, s)`, to be used like this: 245 | 246 | ```nim 247 | failWith(Http406, "JSON endpoint")( 248 | accept("application/json")[ 249 | someResponse 250 | ] 251 | ) 252 | ``` 253 | 254 | Similarly, you may want to customize the behaviour of Rosencrantz when the 255 | application crashes. 256 | 257 | * `crashWith(code, s, logError)` can be used to wrap your handler: 258 | 259 | ```nim 260 | crashWith(Http500, "Sorry :-(")( 261 | accept("application/json")[ 262 | someResponse 263 | ] 264 | ) 265 | ``` 266 | 267 | ### 1.4.4. Logging 268 | 269 | Rosencrantz supports logging in two different moments: when a request arrives, 270 | or when a response is produced (of course you can also manually log at any other 271 | moment). In the first case, you will only have available the information about 272 | the current request, while in the latter both the request and the response 273 | will be available. 274 | 275 | The two basic handlers for logging are: 276 | 277 | * `logRequest(s)`, where `s` is a format string. The string is used inside 278 | the system `format` function, and it is passed the following arguments in 279 | order: 280 | - the HTTP method of the request 281 | - the path of the resource 282 | - the headers, as a table 283 | - the body of the request, if any. 284 | * `logResponse(s)`, where `s` is a format string. The first four arguments 285 | are the same as in `logRequest`; then there are 286 | - the HTTP code of the response 287 | - the headers of the response, as a table 288 | - the body of the response, if any. 289 | 290 | So for instance, in order to log the incoming method and path, as well as the 291 | HTTP code of the response, you can use the following handler: 292 | 293 | ```nim 294 | logResponse("$1 $2 - $5") 295 | ``` 296 | 297 | which will produce log strings such as 298 | 299 | ``` 300 | GET /api/users/181 - 200 OK 301 | ``` 302 | 303 | ## 1.5. Working with headers 304 | 305 | Under `rosencrantz/headersupport`, there are various handlers to read HTTP 306 | headers, filter requests by their values, or accumulate HTTP headers for the 307 | response. 308 | 309 | * `headers(h1, h2, ...)` adds headers for the response. Here each argument is 310 | a tuple of two strings, which are a key/value pair. 311 | * `contentType(s)` is a specialization to emit the `Content-Type` header, so 312 | is is equivalent to `headers(("Content-Type", s))`. 313 | * `readAllHeaders(p)` extract the headers as a string table. Here `p` is a 314 | `proc(hs: HttpHeaders): Handler`. 315 | * `readHeaders(s1, p)` extracts the value of the header with key `s1` and 316 | passes it to `p`, which is of type `proc(h1: string): Handler`. It rejects 317 | the request if the header `s1` is not defined. There are overloads 318 | `readHeaders(s1, s2, p)` and `readHeaders(s1, s2, s3, p)`, where `p` is a 319 | function of two arguments (resp. three arguments). To extract more than 320 | three headers, one can use `readAllHeaders` or nest `readHeaders` calls. 321 | * `tryReadHeaders(s1, p)` works the same as `readHeaders`, but it does not 322 | reject the request if header `s` is missing; instead, `p` receives an empty 323 | string as default. Again, there are overloads for two and three arguments. 324 | * `checkHeaders(h1, h2, ...)` filters the request for the header value. Here 325 | `h1` and the other are pairs of strings, representing a key and a value. If 326 | the request does not have the corresponding headers with these values, it 327 | will be rejected. 328 | * `accept(mimetype)` is equivalent to `checkHeaders(("Accept", mimetype))`. 329 | * `addDate()` returns a handler that adds the `Date` header, formatted as 330 | a GMT date in the [HTTP date format](https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html). 331 | 332 | For example, if you can return a result both as JSON or XML, according to the 333 | request, you can do 334 | 335 | ```nim 336 | accept("application/json")[ 337 | contentType("application/json")[ 338 | ok(someJsonValue) 339 | ] 340 | ] ~ accept("text/xml")[ 341 | contentType("text/xml")[ 342 | ok(someXmlValue) 343 | ] 344 | ] 345 | ``` 346 | 347 | ## 1.6. Writing custom handlers 348 | 349 | Sometimes, the need arises to write handlers that perform a little more custom 350 | logic than those shown above. For those cases, Rosencrantz provides a few 351 | procedures and templates (under `rosencrantz/custom`) that help creating 352 | your handlers. 353 | 354 | * `getRequest(p)`, where `p` is a `proc(req: ref Request): Handler`. This 355 | allows you to access the whole `Request` object, and as such allows more 356 | flexibility. 357 | * `scope` is a template that creates a local scope. It us useful when one needs 358 | to define a few variables to write a little logic inline before returning an 359 | actual handler. 360 | * `scopeAsync` is like scope, but allows asyncronous logic (for instance waiting 361 | on futures) in it. 362 | * `makeHandler` is a macro that removes some boilerplate in writing a custom 363 | handler. It accepts the body of a handler, and surrounds it with the proper 364 | function declaration, etc. 365 | 366 | An example of usage of `scope` is the following: 367 | 368 | ```nim 369 | path("/using-scope")[ 370 | scope do: 371 | let x = "Hello, World!" 372 | echo "We are returning: ", x 373 | return ok(x) 374 | ] 375 | ``` 376 | 377 | An example of usage of `scopeAsync` is the following: 378 | 379 | ```nim 380 | path("/using-scope")[ 381 | scopeAsync do: 382 | let x = "Hello, World!" 383 | echo "We are returning: ", x 384 | await sleepAsync(100) 385 | return ok(x) 386 | ] 387 | ``` 388 | 389 | An example of usage of `makeHandler` is the following: 390 | 391 | ```nim 392 | path("/custom-handler")[ 393 | makeHandler do: 394 | let x = "Hello, World!" 395 | await req[].respond(Http200, x, {"Content-Type": "text/plain;charset=utf-8"}.newStringTable) 396 | return ctx 397 | ] 398 | ``` 399 | 400 | That is expanded into something like: 401 | 402 | ```nim 403 | path("/custom-handler")[ 404 | proc innerProc() = 405 | proc h(req: ref Request, ctx: Context): Future[Context] {.async.} = 406 | let x = "Hello, World!" 407 | await req[].respond(Http200, x, {"Content-Type": "text/plain;charset=utf-8"}.newStringTable) 408 | return ctx 409 | 410 | return h 411 | 412 | innerProc() 413 | ] 414 | ``` 415 | 416 | Notice that `makeHandler` is a little lower-level than other parts of 417 | Rosencrantz, and requires you to know how to write a custom handler. 418 | 419 | ## 1.7. JSON support 420 | 421 | Rosencrantz has support to parse and respond with JSON, under the 422 | `rosencrantz/jsonsupport` module. It defines two typeclasses: 423 | 424 | * a type `T` is `JsonReadable` if there is function `readFromJson(json, T): T` 425 | where `json` is of type `JsonNode`; 426 | * a type `T` is `JsonWritable` if there is a function 427 | `renderToJson(t: T): JsonNode`. 428 | 429 | The module `rosencrantz/core` contains the following handlers: 430 | 431 | * `ok(j)`, where `j` is of type `JsonNode`, that will respond with a content 432 | type of `application/json`. 433 | * `ok(t)`, where `t` has a type `T` that is `JsonWritable`, that will respond 434 | with the JSON representation of `t` and a content type of `application/json`. 435 | * `jsonBody(p)`, where `p` is a `proc(j: JsonNode): Handler`, that extracts the 436 | body as a `JsonNode` and passes it to `p`, failing if the body is not valid 437 | JSON. 438 | * `jsonBody(p)`, where `p` is a `proc(t: T): Handler`, where `T` is a type that 439 | is `JsonReadable`; it extracts the body as a `T` and passes it to `p`, failing 440 | if the body is not valid JSON or cannot be converted to `T`. 441 | 442 | ## 1.8. Form and querystring support 443 | 444 | Rosencrantz has support to read the body of a form, either of type 445 | `application/x-www-form-urlencoded` or multipart. It also supports 446 | parsing the querystring as `application/x-www-form-urlencoded`. 447 | 448 | The `rosencrantz/formsupport` module defines two typeclasses: 449 | 450 | * a type `T` is `UrlDecodable` if there is function `parseFromUrl(s, T): T` 451 | where `s` is of type `StringTableRef`; 452 | * a type `T` is `UrlMultiDecodable` if there is a function 453 | `parseFromUrl(s, T): T` where `s` is of type `TableRef[string, seq[string]]`. 454 | 455 | The module `rosencrantz/formsupport` defines the following handlers: 456 | 457 | * `formBody(p)` where `p` is a `proc(s: StringTableRef): Handler`. It will 458 | parse the body as an URL-encoded form and pass the corresponding string 459 | table to `p`, rejecting the request if the body is not parseable. 460 | * `formBody(t)` where `t` has a type `T` that is `UrlDecodable`. It will 461 | parse the body as an URL-encoded form, convert it to `T`, and pass the 462 | resulting object to `p`. It will reject a request if the body is not parseable 463 | or if the conversion to `T` fails. 464 | * `formBody(p)` where `p` is a 465 | `proc(s: TableRef[string, seq[string]]): Handler`. It will parse the body as 466 | an URL-encoded form, accumulating repeated parameters into sequences, and pass 467 | table to `p`, rejecting the request if the body is not parseable. 468 | * `formBody(t)` where `t` has a type `T` that is `UrlMultiDecodable`. It will 469 | parse the body as an URL-encoded with repeated parameters form, convert it 470 | to `T`, and pass the resulting object to `p`. It will reject a request if the 471 | body is not parseable or if the conversion to `T` fails. 472 | 473 | There are similar handlers to extract the querystring from a request: 474 | 475 | * `queryString(p)`, where `p` is a `proc(s: string): Handler` allows to generate 476 | a handler from the raw querystring (not parsed into parameters yet) 477 | * `queryString(p)`, where `p` is a `proc(s: StringTableRef): Handler` allows to 478 | generate a handler from the querystring parameters, parsed as a string table. 479 | * `queryString(t)` where `t` has a type `T` that is `UrlDecodable`; works the 480 | same as `formBody`. 481 | * `queryString(p)`, where `p` is a 482 | `proc(s: TableRef[string, seq[string]]): Handler` allows to generate a handler 483 | from the querystring with repeated parameters, parsed as a table. 484 | * `queryString(t)` where `t` has a type `T` that is `UrlMultiDecodable`; works 485 | the same as `formBody`. 486 | 487 | Finally, there is a handler to parse multipart forms. The results are 488 | accumulated inside a `MultiPart` object, which is defined by 489 | 490 | ```nim 491 | type 492 | MultiPartFile* = object 493 | filename*, contentType*, content*: string 494 | MultiPart* = object 495 | fields*: StringTableRef 496 | files*: TableRef[string, MultiPartFile] 497 | ``` 498 | 499 | The handler for multipart forms is: 500 | 501 | * `multipart(p)`, where `p` is a `proc(m: MultiPart): Handler` is handed 502 | the result of parsing the form as multipart. In case of parsing error, an 503 | exception is raised - you can choose whether to let it propagate it and 504 | return a 500 error, or contain it using `failWith`. 505 | 506 | ## 1.9. Static file support 507 | 508 | Rosencrantz has support to serve static files or directories. For now, it is 509 | limited to small files, because it does not support streaming yet. 510 | 511 | The module `rosencrantz/staticsupport` defines the following handlers: 512 | 513 | * `file(path)`, where `path` is either absolute or relative to the current 514 | working directory. It will respond by serving the content of the file, if 515 | it exists and is a simple file, or reject the request if it does not exist 516 | or is a directory. 517 | * `dir(path)`, where `path` is either absolute or relative to the current 518 | working directory. It will respond by taking the part of the URL 519 | requested that is not matched yet, concatenate it to `path`, and serve the 520 | corresponding file. Again, if the file does not exist or is a directory, the 521 | handler will reject the request. 522 | 523 | To make things concrete, consider the following handler: 524 | 525 | ```nim 526 | path("/main")[ 527 | file("index.html") 528 | ] ~ 529 | pathChunk("/static")[ 530 | dir("public") 531 | ] 532 | ``` 533 | 534 | This will server the file `index.html` when the request is for the path `/main`, 535 | and it will serve the contents of the directory `public` under the URL `static`. 536 | So, for instance, a request for `/static/css/boostrap.css` will return the 537 | contents of the file `./public/css/boostrap.css`. 538 | 539 | All static handlers use the [mimetypes module](http://nim-lang.org/docs/mimetypes.html) 540 | to try to guess the correct content type depending on the file extension. This 541 | should be usually enough; if you need more control, you can wrap a `file` 542 | handler inside a `contentType` handler to override the content type. 543 | 544 | **Note** Due to a bug in Nim 0.14.2, the static handlers will not work on this 545 | version. They work just fine on Nim 0.14.0 or on devel. 546 | 547 | 548 | ## 1.10. CORS support 549 | 550 | Rosencrantz has support for [Cross-Origin requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS) 551 | under the module `rosencrantz/corssupport`. 552 | 553 | The following are essentially helper functions to produce headers related to 554 | handling cross-origin HTTP requests, as well as reading common headers in 555 | preflight requests. These handlers are available: 556 | 557 | * `accessControlAllowOrigin(origin)` produces the header `Access-Control-Allow-Origin` 558 | with the provided `origin` value. 559 | * `accessControlAllowAllOrigins` produces the header `Access-Control-Allow-Origin` 560 | with the value `*`, which amounts to accepting all origins. 561 | * `accessControlExposeHeaders(headers)` produces the header `Access-Control-Expose-Headers`, 562 | which is used to control which headers are exposed to the client. 563 | * `accessControlMaxAge(seconds)` produces the header `Access-Control-Max-Age`, 564 | which controls the time validity for the preflight request. 565 | * `accessControlAllowCredentials(b)`, where `b` is a boolean value, produces 566 | the header `Access-Control-Allow-Credentials`, which is used to allow the 567 | client to pass cookies and headers related to HTTP authentication. 568 | * `accessControlAllowMethods(methods)`, where `methods` is an openarray of 569 | `HttpMethod`, produces the header `Access-Control-Allow-Methods`, which is 570 | used in preflight requests to communicate which methods are allowed on the 571 | resource. 572 | * `accessControlAllowHeaders(headers)` produces the header `Access-Control-Allow-Headers`, 573 | which is used in the preflight request to control which headers can be added 574 | by the client. 575 | * `accessControlAllow(origin, methods, headers)` is used in preflight requests 576 | for the common combination of specifying the origin as well as methods and 577 | headers accepted. 578 | * `readAccessControl(p)` is used to extract information in the preflight request 579 | from the CORS related headers at once. 580 | Here `p` is a `proc(origin: string, m: HttpMethod, headers: seq[string]` 581 | that will receive the origin of the request, the desired method and the 582 | additional headers to be provided, and will return a suitable response. 583 | 584 | ## 1.11. API stability 585 | 586 | While the basic design is not going to change, the API is not completely 587 | stable yet. It is possible that the `Context` will change to accomodate some 588 | more information, or that it will be passed as a `ref` to handlers. 589 | 590 | As long as you compose the handlers defined above, everything will continue to 591 | work, but if you write your own handlers by hand, this is something to be 592 | aware of. 593 | -------------------------------------------------------------------------------- /htmldocs/rosencrantz.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Module rosencrantz 20 | 1183 | 1184 | 1185 | 1186 | 1211 | 1212 | 1213 | 1214 |
1215 |
1216 |

Module rosencrantz

1217 |
1218 |
1219 | 1223 |
1224 | Search: 1226 |
1227 |
1228 | Group by: 1229 | 1233 |
1234 |
    1235 |
  • 1236 | Imports 1237 |
      1238 | 1239 |
    1240 |
  • 1241 | 1242 |
1243 | 1244 |
1245 | 1255 |
1256 | 1257 |
1258 | 1263 |
1264 |
1265 |
1266 | 1267 | 1268 | 1269 | -------------------------------------------------------------------------------- /htmldocs/rosencrantz/staticsupport.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Module staticsupport 20 | 1183 | 1184 | 1185 | 1186 | 1211 | 1212 | 1213 | 1214 |
1215 |
1216 |

Module staticsupport

1217 |
1218 |
1219 | 1223 |
1224 | Search: 1226 |
1227 |
1228 | Group by: 1229 | 1233 |
1234 |
    1235 |
  • 1236 | Imports 1237 |
      1238 | 1239 |
    1240 |
  • 1241 |
  • 1242 | Procs 1243 | 1252 |
  • 1253 | 1254 |
1255 | 1256 |
1257 |
1258 |
1259 |

1260 | 1265 |
1266 |

Procs

1267 |
1268 |
proc file(path: string): Handler {.
raises: [], tags: []
.}
1269 |
1270 | 1271 | 1272 |
1273 |
proc fileAsync(path: string; chunkSize = 4096): Handler {.
raises: [], tags: []
.}
1274 |
1275 | 1276 | 1277 |
1278 |
proc dir(path: string): Handler {.
raises: [], tags: []
.}
1279 |
1280 | 1281 | 1282 |
1283 | 1284 |
1285 | 1286 |
1287 |
1288 | 1289 |
1290 | 1295 |
1296 |
1297 |
1298 | 1299 | 1300 | 1301 | -------------------------------------------------------------------------------- /htmldocs/rosencrantz/custom.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Module custom 20 | 1183 | 1184 | 1185 | 1186 | 1211 | 1212 | 1213 | 1214 |
1215 |
1216 |

Module custom

1217 |
1218 |
1219 | 1223 |
1224 | Search: 1226 |
1227 |
1228 | Group by: 1229 | 1233 |
1234 | 1269 | 1270 |
1271 |
1272 |
1273 |

1274 |
1275 |

Imports

1276 |
1277 | asyncHttpServer, asyncDispatch, macros, ./core 1278 |
1279 |
1280 |

Procs

1281 |
1282 |
proc getRequest(p: proc (req: ref Request): Handler): Handler {.
raises: [], tags: []
.}
1283 |
1284 | 1285 | 1286 |
1287 | 1288 |
1289 |
1290 |

Macros

1291 |
1292 |
macro makeHandler(body: untyped): untyped
1293 |
1294 | 1295 | 1296 |
1297 | 1298 |
1299 |
1300 |

Templates

1301 |
1302 |
template scope(body: untyped): untyped
1303 |
1304 | 1305 | 1306 |
1307 |
template scopeAsync(body: untyped): untyped
1308 |
1309 | 1310 | 1311 |
1312 | 1313 |
1314 | 1315 |
1316 |
1317 | 1318 |
1319 | 1324 |
1325 |
1326 |
1327 | 1328 | 1329 | 1330 | --------------------------------------------------------------------------------