├── .github └── workflows │ └── build.yaml ├── .gitignore ├── LICENSE.txt ├── README.md ├── phoon.nimble ├── src ├── phoon.nim └── phoon │ ├── context │ ├── context.nim │ ├── request.nim │ └── response.nim │ └── routing │ ├── errors.nim │ ├── route.nim │ ├── router.nim │ └── tree.nim └── tests ├── config.nims ├── integration ├── config.nims ├── simple_server.nim └── test_simple_server.nim ├── test_app.nim ├── test_request.nim ├── test_router.nim ├── test_tree.nim └── utils.nim /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Github Actions 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | strategy: 6 | matrix: 7 | os: [ubuntu-latest, windows-latest] 8 | 9 | runs-on: ${{ matrix.os }} 10 | 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: jiro4989/setup-nim-action@v1 14 | - name: "Unit tests" 15 | run: nimble test -y 16 | - name: "Integration tests" 17 | run: nimble integration 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Windows executable artifacts 2 | *.exe 3 | 4 | # Integration tests executable artifacts 5 | tests/integration/bin/ 6 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The BSD Zero Clause License (0BSD) 2 | 3 | Copyright (c) 2020 ducdetronquito 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 9 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 10 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 11 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 12 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 13 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 14 | PERFORMANCE OF THIS SOFTWARE. 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Phoon 🐇⚡ 3 | 4 | ![Github Actions](https://github.com/ducdetronquito/phoon/workflows/Github%20Actions/badge.svg) [![License](https://img.shields.io/badge/License-BSD%200--Clause-ff69b4.svg)](https://github.com/ducdetronquito/h11#license) 5 | 6 | 7 | A simple web framework for Nim. 8 | 9 | ## Usage 10 | 11 | Nota Bene: *Phoon is in its early stage, so every of its aspects is subject to changes* 🌪️ 12 | 13 | ### Create an application: 14 | 15 | ```nim 16 | import phoon 17 | 18 | var app = new App 19 | 20 | app.get("/", 21 | proc (ctx: Context) {.async.} = 22 | ctx.response.body("I am a boring home page") 23 | ) 24 | 25 | app.post("/users", 26 | proc (ctx: Context) {.async.} = 27 | ctx.response.status(Http201).body("You just created a new user !") 28 | ) 29 | 30 | app.get("/us*", 31 | proc (ctx: Context) {.async.} = 32 | ctx.response.body("Every URL starting with 'us' falls back here.") 33 | ) 34 | 35 | app.get("/books/{title}", 36 | proc (ctx: Context) {.async.} = 37 | # You can retrieve parameters of the URL path 38 | var bookTitle = ctx.parameters.get("title") 39 | 40 | # You can also retrieve url-decoded query parameters 41 | let count = ctx.request.query("count") 42 | if count.isNone: 43 | ctx.response.body("Of course I read '" & bookTitle & "' !") 44 | else: 45 | ctx.response.body( 46 | "Of course I read '" & bookTitle & "', " 47 | "at least " & count & " times!" 48 | ) 49 | ) 50 | 51 | app.get("/cookies/", 52 | proc (ctx: Context) {.async.} = 53 | # You can send a cookie along the response 54 | ctx.response.cookie("size", "A big one 🍪") 55 | ) 56 | 57 | 58 | # Chaining of callbacks for a given path 59 | app.route("/hacks") 60 | .get( 61 | proc (ctx: Context) {.async.} = 62 | ctx.response.body("I handle GET requests") 63 | ) 64 | .patch( 65 | proc (ctx: Context) {.async.} = 66 | ctx.response.body("I handle PATCH requests") 67 | ) 68 | .delete( 69 | proc (ctx: Context) {.async.} = 70 | ctx.response.body("I handle DELETE requests") 71 | ) 72 | 73 | 74 | app.serve(port=8080) 75 | ``` 76 | 77 | ### Create a nested router 78 | 79 | ```nim 80 | import phoon 81 | 82 | var router = Router() 83 | 84 | router.get("/users", 85 | proc (ctx: Context) {.async.} = 86 | ctx.response.body("Here are some nice users") 87 | ) 88 | 89 | app.mount("/nice", router) 90 | ``` 91 | 92 | ### Register a middleware 93 | 94 | ```nim 95 | import phoon 96 | 97 | proc SimpleAuthMiddleware(next: Callback): Callback = 98 | return proc (ctx: Context) {.async.} = 99 | if ctx.request.headers.hasKey("simple-auth"): 100 | await next(ctx) 101 | else: 102 | ctx.response.status(Http401) 103 | 104 | app.use(SimpleAuthMiddleware) 105 | ``` 106 | 107 | 108 | ### Error handling 109 | 110 | ```nim 111 | import phoon 112 | 113 | # Define a custom callback that is called when no registered route matched the incoming request path. 114 | app.on404( 115 | proc (ctx: Context) {.async.} = 116 | ctx.response.status(Http404).body("Not Found ¯\\_(ツ)_/¯") 117 | ) 118 | 119 | # Define a custom callback that is called when an unhandled exception is raised within your code. 120 | app.onError( 121 | proc (ctx: Context, error: ref Exception) {.async.} = 122 | ctx.response.status(Http500).body("Oops ¯\\_(ツ)_/¯\r\n" & error.msg) 123 | ) 124 | ``` 125 | 126 | ## License 127 | 128 | **Phoon** is released under the [BSD Zero clause license](https://choosealicense.com/licenses/0bsd/). 🎉🍻 129 | -------------------------------------------------------------------------------- /phoon.nimble: -------------------------------------------------------------------------------- 1 | # Package 2 | 3 | version = "0.4.0" 4 | author = "Guillaume Paulet" 5 | description = "A web framework inspired by ExpressJS 🐇⚡" 6 | license = "Public Domain" 7 | 8 | srcDir = "src" 9 | skipDirs = @["tests"] 10 | 11 | requires "nim >= 1.6" 12 | 13 | task integration, "Runs the integration test suite.": 14 | exec "nim c tests/integration/simple_server.nim" 15 | exec "nim c -r tests/integration/test_simple_server.nim" 16 | -------------------------------------------------------------------------------- /src/phoon.nim: -------------------------------------------------------------------------------- 1 | from asynchttpserver import nil 2 | import asyncdispatch except Callback 3 | import logging, httpcore, options, strformat 4 | import phoon/context/[context, request, response] 5 | import phoon/routing/[errors, route, router, tree] 6 | 7 | 8 | type 9 | ErrorCallback = proc(ctx: Context, error: ref Exception): Future[void] 10 | 11 | App* = ref object 12 | router: Router 13 | routingTable: Tree[Route] 14 | errorCallback: ErrorCallback 15 | routeNotFound: Callback 16 | 17 | 18 | proc defaultErrorCallback(ctx: Context, error: ref Exception) {.async.} = 19 | ctx.response.status(Http500) 20 | 21 | 22 | proc default404callback(ctx: Context) {.async.} = 23 | ctx.response.status(Http404) 24 | 25 | 26 | proc new*(appType: type[App]): App = 27 | return App( 28 | router: Router(), 29 | routingTable: new Tree[Route], 30 | errorCallback: defaultErrorCallback, 31 | routeNotFound: default404callback, 32 | ) 33 | 34 | 35 | proc head*(self: App, path: string, callback: Callback) = 36 | self.router.head(path, callback) 37 | 38 | 39 | proc delete*(self: App, path: string, callback: Callback) = 40 | self.router.delete(path, callback) 41 | 42 | 43 | proc get*(self: App, path: string, callback: Callback) = 44 | self.router.get(path, callback) 45 | 46 | 47 | proc options*(self: App, path: string, callback: Callback) = 48 | self.router.options(path, callback) 49 | 50 | 51 | proc patch*(self: App, path: string, callback: Callback) = 52 | self.router.patch(path, callback) 53 | 54 | 55 | proc post*(self: App, path: string, callback: Callback) = 56 | self.router.post(path, callback) 57 | 58 | 59 | proc put*(self: App, path: string, callback: Callback) = 60 | self.router.put(path, callback) 61 | 62 | 63 | proc route*(self: App, path: string): Route {.discardable.} = 64 | return self.router.route(path) 65 | 66 | 67 | proc mount*(self: App, path: string, router: Router) = 68 | self.router.mount(path, router) 69 | 70 | 71 | proc use*(self: App, middleware: Middleware) = 72 | self.router.use(middleware) 73 | 74 | 75 | proc onError*(self: App, callback: ErrorCallback) = 76 | self.errorCallback = callback 77 | 78 | 79 | proc on404*(self: App, callback: Callback) = 80 | self.routeNotFound = callback 81 | 82 | 83 | proc compileRoutes*(self: App) = 84 | let middlewares = self.router.getMiddlewares() 85 | 86 | for path, route in self.router.getRoutePairs(): 87 | var compiledRoute = route.apply(middlewares) 88 | self.routingTable.insert(path, compiledRoute) 89 | 90 | 91 | proc unsafeDispatch(self: App, ctx: Context) {.async.} = 92 | let match = self.routingTable.match(ctx.request.path()) 93 | if match.isNone: 94 | await self.routeNotFound(ctx) 95 | return 96 | 97 | let (route, parameters) = match.unsafeGet() 98 | let callback = route.getCallback(ctx.request.httpMethod()) 99 | if callback.isNone: 100 | ctx.response.status(Http405) 101 | return 102 | 103 | ctx.parameters = parameters 104 | await callback.unsafeGet()(ctx) 105 | return 106 | 107 | 108 | proc dispatch*(self: App, ctx: Context) {.async.} = 109 | try: 110 | await self.unsafeDispatch(ctx) 111 | except Exception as error: 112 | await self.errorCallback(ctx, error) 113 | 114 | 115 | proc serve*(self: App, address: string = "", port: int) = 116 | self.compileRoutes() 117 | 118 | proc dispatch(request: asynchttpserver.Request) {.async.} = 119 | var ctx = Context.new(request) 120 | {.gcsafe.}: 121 | await self.dispatch(ctx) 122 | let response = ctx.response 123 | await asynchttpserver.respond(request, response.getStatus(), response.getBody(), response.getHeaders()) 124 | 125 | # Setup a default console logger if none exists already 126 | if logging.getHandlers().len == 0: 127 | addHandler(logging.newConsoleLogger()) 128 | setLogFilter(when defined(release): lvlInfo else: lvlDebug) 129 | 130 | if address == "": 131 | when defined(windows): 132 | logging.info(&"Bunny hopping at http://127.0.0.1:{port}") 133 | else: 134 | logging.info(&"Bunny hopping at http://0.0.0.0:{port}") 135 | else: 136 | logging.info(&"Bunny hopping at http://{address}:{port}") 137 | 138 | let server = asynchttpserver.newAsyncHttpServer() 139 | waitFor asynchttpserver.serve(server, port = Port(port), callback = dispatch, address = address) 140 | 141 | 142 | export asyncdispatch except Callback 143 | export context 144 | export errors 145 | export httpcore 146 | export request 147 | export response except compile 148 | export route 149 | export router 150 | export tree 151 | -------------------------------------------------------------------------------- /src/phoon/context/context.nim: -------------------------------------------------------------------------------- 1 | from asynchttpserver import nil 2 | import request 3 | import response 4 | import ../routing/tree 5 | 6 | 7 | type 8 | Context* = ref object 9 | request*: request.Request 10 | parameters*: Parameters 11 | response*: Response 12 | 13 | 14 | proc new*(contextType: type[Context], request: asynchttpserver.Request): Context = 15 | return Context( 16 | request: Request.new(request = request, headers = request.headers), 17 | response: Response.new() 18 | ) 19 | -------------------------------------------------------------------------------- /src/phoon/context/request.nim: -------------------------------------------------------------------------------- 1 | from asynchttpserver import nil 2 | import cgi 3 | import options 4 | import strtabs 5 | 6 | type 7 | Request* = ref object 8 | request: asynchttpserver.Request 9 | headers*: asynchttpserver.HttpHeaders 10 | query: Option[StringTableRef] 11 | 12 | 13 | proc new*(responseType: type[Request], request: asynchttpserver.Request, headers: asynchttpserver.HttpHeaders): Request = 14 | return Request(request: request, headers: headers) 15 | 16 | 17 | proc path*(self: Request): string = 18 | return self.request.url.path 19 | 20 | 21 | proc httpMethod*(self: Request): asynchttpserver.HttpMethod = 22 | return self.request.reqMethod 23 | 24 | 25 | proc query*(self: Request, field: string): Option[string] = 26 | if self.query.isNone: 27 | self.query = some(self.request.url.query.readData()) 28 | 29 | var value = self.query.unsafeGet().getOrDefault(field) 30 | if value == "": 31 | return none(string) 32 | else: 33 | return some(value) 34 | 35 | 36 | export options 37 | -------------------------------------------------------------------------------- /src/phoon/context/response.nim: -------------------------------------------------------------------------------- 1 | import asynchttpserver 2 | 3 | 4 | type 5 | Response* = ref object 6 | status: HttpCode 7 | body: string 8 | headers: HttpHeaders 9 | 10 | 11 | proc getBody*(self: Response): string = 12 | return self.body 13 | 14 | 15 | proc getStatus*(self: Response): HttpCode = 16 | return self.status 17 | 18 | 19 | proc getHeaders*(self: Response): HttpHeaders = 20 | return self.headers 21 | 22 | 23 | proc new*(responseType: type[Response]): Response = 24 | return Response(status: Http200, body: "", headers: newHttpHeaders()) 25 | 26 | 27 | proc body*(self: Response, body: string): Response {.discardable.} = 28 | self.body = body 29 | return self 30 | 31 | 32 | proc status*(self: Response, status: HttpCode): Response {.discardable.} = 33 | self.status = status 34 | return self 35 | 36 | 37 | proc headers*(self: Response, key: string, value: string): Response {.discardable.} = 38 | self.headers.add(key, value) 39 | return self 40 | 41 | 42 | proc cookie*(self: Response, name: string, value: string): Response {.discardable.} = 43 | self.headers.add("Set-Cookie", name & "=" & value) 44 | return self 45 | -------------------------------------------------------------------------------- /src/phoon/routing/errors.nim: -------------------------------------------------------------------------------- 1 | type 2 | InvalidPathError* = ref object of ValueError 3 | -------------------------------------------------------------------------------- /src/phoon/routing/route.nim: -------------------------------------------------------------------------------- 1 | import asyncdispatch 2 | import asynchttpserver 3 | import ../context/context 4 | import options 5 | 6 | 7 | type 8 | Callback* = proc(ctx: Context): Future[void] 9 | 10 | Middleware* = proc (next: Callback): Callback 11 | 12 | Route* = ref object 13 | onDelete*: Option[Callback] 14 | onGet*: Option[Callback] 15 | onHead*: Option[Callback] 16 | onOptions*: Option[Callback] 17 | onPatch*: Option[Callback] 18 | onPost*: Option[Callback] 19 | onPut*: Option[Callback] 20 | 21 | 22 | proc delete*(self: Route, callback: Callback): Route {.discardable.} = 23 | self.onDelete = some(callback) 24 | return self 25 | 26 | 27 | proc get*(self: Route, callback: Callback): Route {.discardable.} = 28 | self.onGet = some(callback) 29 | return self 30 | 31 | 32 | proc head*(self: Route, callback: Callback): Route {.discardable.} = 33 | self.onHead = some(callback) 34 | return self 35 | 36 | 37 | proc options*(self: Route, callback: Callback): Route {.discardable.} = 38 | self.onOptions = some(callback) 39 | return self 40 | 41 | 42 | proc patch*(self: Route, callback: Callback): Route {.discardable.} = 43 | self.onPatch = some(callback) 44 | return self 45 | 46 | 47 | proc post*(self: Route, callback: Callback): Route {.discardable.} = 48 | self.onPost = some(callback) 49 | return self 50 | 51 | 52 | proc put*(self: Route, callback: Callback): Route {.discardable.} = 53 | self.onPut = some(callback) 54 | return self 55 | 56 | 57 | proc getCallback*(route: Route, httpMethod: HttpMethod): Option[Callback] = 58 | case httpMethod 59 | of HttpMethod.HttpDelete: 60 | return route.onDelete 61 | of HttpMethod.HttpGet: 62 | return route.onGet 63 | of HttpMethod.HttpHead: 64 | return route.onHead 65 | of HttpMethod.HttpOptions: 66 | return route.onOptions 67 | of HttpMethod.HttpPatch: 68 | return route.onPatch 69 | of HttpMethod.HttpPost: 70 | return route.onPost 71 | of HttpMethod.HttpPut: 72 | return route.onPut 73 | else: 74 | return none(Callback) 75 | 76 | 77 | proc apply*(self: Callback, middlewares: seq[Middleware]): Callback = 78 | if middlewares.len() == 0: 79 | return self 80 | 81 | result = self 82 | for middleware in middlewares: 83 | result = middleware(result) 84 | 85 | return result 86 | 87 | 88 | proc apply*(self: Route, middlewares: seq[Middleware]): Route = 89 | result = new Route 90 | 91 | if self.onDelete.isSome: 92 | let callback = self.onDelete.unsafeGet().apply(middlewares) 93 | result.onDelete = some(callback) 94 | 95 | if self.onGet.isSome: 96 | let callback = self.onGet.unsafeGet().apply(middlewares) 97 | result.onGet = some(callback) 98 | 99 | if self.onHead.isSome: 100 | let callback = self.onHead.unsafeGet().apply(middlewares) 101 | result.onHead = some(callback) 102 | 103 | if self.onOptions.isSome: 104 | let callback = self.onOptions.unsafeGet().apply(middlewares) 105 | result.onOptions = some(callback) 106 | 107 | if self.onPatch.isSome: 108 | let callback = self.onPatch.unsafeGet().apply(middlewares) 109 | result.onPatch = some(callback) 110 | 111 | if self.onPost.isSome: 112 | let callback = self.onPost.unsafeGet().apply(middlewares) 113 | result.onPost = some(callback) 114 | 115 | if self.onPut.isSome: 116 | let callback = self.onPut.unsafeGet().apply(middlewares) 117 | result.onPut = some(callback) 118 | 119 | return result 120 | -------------------------------------------------------------------------------- /src/phoon/routing/router.nim: -------------------------------------------------------------------------------- 1 | import errors 2 | import options 3 | import route 4 | import strutils 5 | import tables 6 | 7 | 8 | type 9 | Router* = ref object 10 | routes: Table[string, Route] 11 | middlewares: seq[Middleware] 12 | 13 | 14 | proc delete*(self: Router, path: string, callback: Callback) = 15 | if self.routes.hasKey(path): 16 | self.routes[path].onDelete = some(callback) 17 | return 18 | 19 | var route = Route(onDelete: some(callback)) 20 | self.routes[path] = route 21 | 22 | 23 | proc get*(self: Router, path: string, callback: Callback) = 24 | if self.routes.hasKey(path): 25 | self.routes[path].onGet = some(callback) 26 | return 27 | 28 | var route = Route(onGet: some(callback)) 29 | self.routes[path] = route 30 | 31 | 32 | proc head*(self: Router, path: string, callback: Callback) = 33 | if self.routes.hasKey(path): 34 | self.routes[path].onHead = some(callback) 35 | return 36 | 37 | var route = Route(onHead: some(callback)) 38 | self.routes[path] = route 39 | 40 | 41 | proc options*(self: Router, path: string, callback: Callback) = 42 | if self.routes.hasKey(path): 43 | self.routes[path].onOptions = some(callback) 44 | return 45 | 46 | var route = Route(onOptions: some(callback)) 47 | self.routes[path] = route 48 | 49 | 50 | proc patch*(self: Router, path: string, callback: Callback) = 51 | if self.routes.hasKey(path): 52 | self.routes[path].onPatch = some(callback) 53 | return 54 | 55 | var route = Route(onPatch: some(callback)) 56 | self.routes[path] = route 57 | 58 | 59 | proc post*(self: Router, path: string, callback: Callback) = 60 | if self.routes.hasKey(path): 61 | self.routes[path].onPost = some(callback) 62 | return 63 | 64 | var route = Route(onPost: some(callback)) 65 | self.routes[path] = route 66 | 67 | 68 | proc put*(self: Router, path: string, callback: Callback) = 69 | if self.routes.hasKey(path): 70 | self.routes[path].onPut = some(callback) 71 | return 72 | 73 | var route = Route(onPut: some(callback)) 74 | self.routes[path] = route 75 | 76 | 77 | proc route*(self: Router, path: string): Route {.discardable.} = 78 | var route = Route() 79 | self.routes[path] = route 80 | return route 81 | 82 | 83 | iterator getRoutePairs*(self: Router): tuple[path: string, route: Route] = 84 | for path, route in self.routes.pairs: 85 | yield (path, route) 86 | 87 | 88 | proc getMiddlewares*(self: Router): seq[Middleware] = 89 | return self.middlewares 90 | 91 | 92 | proc mount*(self: Router, path: string, router: Router) = 93 | if path.contains("*"): 94 | raise InvalidPathError(msg: "Cannot mount a sub-router on a wildcard route.") 95 | 96 | let middlewares = router.getMiddlewares() 97 | 98 | for subPath, route in router.getRoutePairs(): 99 | let compiledRoute = route.apply(middlewares) 100 | self.routes[path & subPath] = compiledRoute 101 | 102 | 103 | proc use*(self: Router, middleware: Middleware) = 104 | self.middlewares.add(middleware) 105 | -------------------------------------------------------------------------------- /src/phoon/routing/tree.nim: -------------------------------------------------------------------------------- 1 | import algorithm 2 | import errors 3 | import options 4 | import strutils 5 | import tables 6 | 7 | 8 | type 9 | PathType* = enum 10 | Strict, 11 | Wildcard, 12 | Parametrized 13 | 14 | Node[T] = ref object 15 | path*: char 16 | children*: seq[Node[T]] 17 | value*: Option[T] 18 | 19 | case pathType*: PathType 20 | of PathType.Parametrized: 21 | parameterName*: string 22 | else: 23 | discard 24 | 25 | case isLeaf*: bool 26 | of true: 27 | parameters*: seq[string] 28 | of false: 29 | discard 30 | 31 | Tree*[T] = ref object 32 | root*: Node[T] 33 | 34 | Parameters* = ref object 35 | data: Table[string, string] 36 | 37 | Result*[T]= tuple 38 | value: T 39 | parameters: Parameters 40 | 41 | 42 | proc fromKeys(table: type[Parameters], keys: seq[string], values: seq[string]): Parameters = 43 | result = Parameters() 44 | 45 | for index, key in keys: 46 | result.data[key] = values[index] 47 | 48 | return result 49 | 50 | proc get*(self: Parameters, name: string): string = 51 | return self.data[name] 52 | 53 | 54 | proc contains*(self: Parameters, name: string): bool = 55 | return self.data.hasKey(name) 56 | 57 | 58 | proc new*[T](treeType: type[Tree[T]]): Tree[T] = 59 | var root = Node[T](path: '~') 60 | return Tree[T](root: root) 61 | 62 | 63 | proc toDiagram*[T](self: Tree[T]): string = 64 | # Display a tree for debugging purposes 65 | # 66 | # Example: 67 | # For a tree defined with the 3 following routes: 68 | # - /a 69 | # - /bc 70 | # - /d 71 | # 72 | # It outputs a corresponding diagram where ★ describe leaf nodes: 73 | # +- / 74 | # +- a★ 75 | # +- b 76 | # +- d★ 77 | # +- d★ 78 | 79 | proc toDiagram[T](self: Node[T], indentation: string = "", isLastChildren: bool = true): string = 80 | result = "" 81 | 82 | var indentation = deepCopy(indentation) 83 | var row = indentation & "+- " & self.path 84 | if self.isLeaf: 85 | row.add("★") 86 | 87 | result.add(row & "\n") 88 | 89 | if isLastChildren: 90 | indentation.add(" ") 91 | else: 92 | indentation.add("| ") 93 | 94 | for index, child in self.children: 95 | let childDiagram = child.toDiagram(indentation, index == self.children.len() - 1) 96 | result.add(childDiagram) 97 | 98 | return result 99 | 100 | return self.root.toDiagram() 101 | 102 | 103 | proc findChildByPath[T](self: Node[T], path: char): Option[Node[T]] = 104 | for child in self.children: 105 | if child.path == path: 106 | return some(child) 107 | 108 | return none(Node[T]) 109 | 110 | 111 | proc removeChildByPath[T](self: Node[T], path: char) = 112 | for index, child in self.children: 113 | if child.path == path: 114 | self.children.del(index) 115 | return 116 | 117 | 118 | proc checkIllegalPatterns(path: string) = 119 | if not path.contains("*"): 120 | return 121 | 122 | let afterWildcardPart = path.rsplit("*", maxsplit = 1)[1] 123 | if afterWildcardPart.len != 0: 124 | raise InvalidPathError(msg: "A path cannot defined character after a wildcard.") 125 | 126 | 127 | proc byPathTypeOrder[T](x: Node[T], y: Node[T]): int = 128 | # Comparison function to order nodes by prioritizing path types as follow: 129 | # wildcard, parametrized and strict. 130 | if y.path == '*': 131 | return 1 132 | 133 | if x.path != '*' and y.path == '{': 134 | return 1 135 | 136 | return -1 137 | 138 | 139 | proc addChildren[T](self: Tree[T], parent: Node[T], child: Node[T]) = 140 | parent.children.add(child) 141 | parent.children.sort(byPathTypeOrder[T]) 142 | 143 | 144 | proc insert*[T](self: Tree, path: string, value: T) = 145 | path.checkIllegalPatterns() 146 | 147 | var currentNode = self.root 148 | var parameterParsingEnabled: bool = false 149 | var parameterName: string 150 | var parameters: seq[string] 151 | 152 | for index, character in path: 153 | var character: char = character 154 | # ----- Collect paramater name ---- 155 | if character == '{': 156 | if not parameterParsingEnabled: 157 | parameterParsingEnabled = true 158 | continue 159 | else: 160 | raise InvalidPathError(msg: "Cannot define a route with a parameter name containing the character '{'.") 161 | 162 | if character == '}': 163 | if parameterParsingEnabled: 164 | parameterParsingEnabled = false 165 | parameters.add(parameterName) 166 | else: 167 | raise InvalidPathError(msg: "A parameter name in a route must start with a '{' character.") 168 | 169 | if parameterParsingEnabled: 170 | parameterName.add(character) 171 | continue 172 | # --------------------------------- 173 | 174 | if parameterName.len() > 0: 175 | character = '{' 176 | 177 | let isLastCharacter = index == path.len() - 1 178 | let potentialChild = currentNode.findChildByPath(character) 179 | if potentialChild.isSome: 180 | var nextChild = potentialChild.unsafeGet() 181 | if nextChild.pathType == PathType.Strict and isLastCharacter: 182 | currentNode.removeChildByPath(character) 183 | self.addChildren( 184 | currentNode, 185 | Node[T]( 186 | path: character, 187 | pathType: PathType.Strict, 188 | isLeaf: true, 189 | parameters: parameters, 190 | children: nextChild.children, 191 | value: some(value) 192 | ) 193 | ) 194 | break 195 | 196 | if nextChild.pathType == PathType.Parametrized and nextChild.parameterName != parameterName: 197 | raise InvalidPathError(msg : "You cannot define the same route with two different parameter names.") 198 | else: 199 | currentNode = nextChild 200 | continue 201 | 202 | var child: Node[T] 203 | if parameterName.len() > 0: 204 | if isLastCharacter: 205 | child = Node[T](path: character, pathType: PathType.Parametrized, parameterName: parameterName, isLeaf: true, parameters: parameters) 206 | else: 207 | child = Node[T](path: character, pathType: PathType.Parametrized, parameterName: parameterName) 208 | parameterName = "" 209 | elif character == '*': 210 | if isLastCharacter: 211 | child = Node[T](path: character, pathType: PathType.Wildcard, isLeaf: true, parameters: parameters) 212 | else: 213 | child = Node[T](path: character, pathType: PathType.Wildcard) 214 | else: 215 | if isLastCharacter: 216 | child = Node[T](path: character, pathType: PathType.Strict, isLeaf: true, parameters: parameters) 217 | else: 218 | child = Node[T](path: character, pathType: PathType.Strict) 219 | 220 | self.addChildren(currentNode, child) 221 | currentNode = child 222 | 223 | currentNode.value = some(value) 224 | 225 | 226 | type 227 | LookupContext[T] = ref object 228 | path: string 229 | currentPathIndex: int 230 | collectedParameters: seq[string] 231 | 232 | 233 | proc pathIsFullyParsed[T](self: LookupContext[T]): bool = 234 | return self.path.len() == self.currentPathIndex 235 | 236 | 237 | proc match[T](self: Node[T], context: LookupContext[T]): bool = 238 | case self.pathType: 239 | of PathType.Strict: 240 | if self.path == context.path[context.currentPathIndex]: 241 | context.currentPathIndex += 1 242 | return true 243 | of PathType.Wildcard: 244 | context.currentPathIndex = context.path.len() 245 | return true 246 | of PathType.Parametrized: 247 | var parameter = "" 248 | while context.currentPathIndex < context.path.len() and context.path[context.currentPathIndex] != '/': 249 | parameter.add(context.path[context.currentPathIndex]) 250 | context.currentPathIndex += 1 251 | context.collectedParameters.add(parameter) 252 | return true 253 | 254 | return false 255 | 256 | 257 | proc match*[T](self: Tree[T], path: string): Option[Result[T]] = 258 | var nodesToVisit = @[self.root.children[0]] 259 | var currentNode: Node[T] 260 | 261 | var context = LookupContext[T](path: path, currentPathIndex: 0) 262 | 263 | while nodesToVisit.len() > 0: 264 | currentNode = nodesToVisit.pop() 265 | let matched = currentNode.match(context) 266 | if not matched: 267 | continue 268 | 269 | if context.pathIsFullyParsed(): 270 | if currentNode.isLeaf: 271 | let parameters= Parameters.fromKeys(currentNode.parameters, context.collectedParameters) 272 | let value = currentNode.value.unsafeGet() 273 | return some((value, parameters)) 274 | else: 275 | return none(Result[T]) 276 | else: 277 | for child in currentNode.children: 278 | nodesToVisit.add(child) 279 | continue 280 | 281 | return none(Result[T]) 282 | -------------------------------------------------------------------------------- /tests/config.nims: -------------------------------------------------------------------------------- 1 | switch("path", "$projectDir/../src") 2 | switch("verbosity", "0") 3 | switch("hints", "off") -------------------------------------------------------------------------------- /tests/integration/config.nims: -------------------------------------------------------------------------------- 1 | switch("path", "$projectDir/../../src") 2 | switch("outdir", "$projectDir/bin") 3 | -------------------------------------------------------------------------------- /tests/integration/simple_server.nim: -------------------------------------------------------------------------------- 1 | import phoon 2 | import strutils 3 | 4 | var app = new App 5 | 6 | app.get("/", 7 | proc (ctx: Context) {.async.} = 8 | ctx.response.body("I am a boring home page") 9 | ) 10 | 11 | app.post("/about", 12 | proc (ctx: Context) {.async.} = 13 | ctx.response.status(Http201).body("What are you talking about ?") 14 | ) 15 | 16 | app.get("/ab*", 17 | proc (ctx: Context) {.async.} = 18 | ctx.response.body("I am a wildard page !") 19 | ) 20 | 21 | 22 | app.get("/books/{title}", 23 | proc (ctx: Context) {.async.} = 24 | var bookTitle = ctx.parameters.get("title") 25 | ctx.response.body("Of course I read '" & bookTitle & "' !") 26 | ) 27 | 28 | app.get("/json", 29 | proc (ctx: Context) {.async.} = 30 | ctx.response.headers("Content-Type", "application/json") 31 | ctx.response.body("{}") 32 | ) 33 | 34 | app.get("/query_parameters/", 35 | proc (ctx: Context) {.async.} = 36 | let name = ctx.request.query("name").get() 37 | ctx.response.body(name) 38 | ) 39 | 40 | 41 | app.get("/cookies/", 42 | proc (ctx: Context) {.async.} = 43 | ctx.response.cookie("name", "Yay") 44 | ) 45 | 46 | app.get("/error/", 47 | proc (ctx: Context) {.async.} = 48 | discard parseInt("Some business logic that should have been an int") 49 | ) 50 | 51 | 52 | var router = Router() 53 | 54 | router.get("/users", 55 | proc (ctx: Context) {.async.} = 56 | ctx.response.body("Here are some nice users") 57 | ) 58 | 59 | app.mount("/nice", router) 60 | 61 | 62 | var authenticatedRouter = Router() 63 | 64 | authenticatedRouter.get("/", 65 | proc (ctx: Context) {.async.} = 66 | ctx.response.body("Admins, he is doing it sideways !") 67 | ) 68 | 69 | 70 | proc SimpleAuthMiddleware(next: Callback): Callback = 71 | return proc (ctx: Context) {.async.} = 72 | if ctx.request.headers.hasKey("simple-auth"): 73 | await next(ctx) 74 | else: 75 | ctx.response.status(Http401) 76 | 77 | 78 | authenticatedRouter.use(SimpleAuthMiddleware) 79 | app.mount("/admins", authenticatedRouter) 80 | app.onError( 81 | proc (ctx: Context, error: ref Exception) {.async.} = 82 | ctx.response.body(error.msg).status(Http500) 83 | ) 84 | app.serve("0.0.0.0", 3000) 85 | -------------------------------------------------------------------------------- /tests/integration/test_simple_server.nim: -------------------------------------------------------------------------------- 1 | import httpClient 2 | import os 3 | import osproc 4 | import strutils 5 | import unittest 6 | 7 | 8 | echo "Start Server" 9 | var server = startProcess("tests/integration/bin/simple_server") 10 | 11 | sleep(1000 * 5) 12 | 13 | 14 | suite "Integration tests": 15 | var client = newHttpClient() 16 | 17 | test "Get request": 18 | let response = client.get("http://127.0.0.1:3000/") 19 | check(response.status == "200 OK") 20 | check(response.body == "I am a boring home page") 21 | 22 | test "Get request on an endpoint defined in a sub-router": 23 | let response = client.get("http://127.0.0.1:3000/nice/users") 24 | check(response.status == "200 OK") 25 | check(response.body == "Here are some nice users") 26 | 27 | test "Get request on a wildcard endpoint": 28 | let response = client.get("http://127.0.0.1:3000/abstract") 29 | check(response.status == "200 OK") 30 | check(response.body == "I am a wildard page !") 31 | 32 | test "Get request on a parametrized endpoint": 33 | let response = client.get("http://127.0.0.1:3000/books/how-to-poop-in-the-wood") 34 | check(response.status == "200 OK") 35 | check(response.body == "Of course I read 'how-to-poop-in-the-wood' !") 36 | 37 | test "Post request": 38 | let response = client.post("http://127.0.0.1:3000/about") 39 | check(response.status == "201 Created") 40 | check(response.body == "What are you talking about ?") 41 | 42 | test "Endpoint Not Found": 43 | let response = client.get("http://127.0.0.1:3000/an-undefined-url") 44 | check(response.status == "404 Not Found") 45 | check(response.body == "") 46 | 47 | test "Method not allowed on endpoint": 48 | let response = client.post("http://127.0.0.1:3000/") 49 | check(response.status == "405 Method Not Allowed") 50 | check(response.body == "") 51 | 52 | test "Fail to pass an authentication middleware": 53 | let response = client.get("http://127.0.0.1:3000/admins/") 54 | check(response.status == "401 Unauthorized") 55 | check(response.body == "") 56 | 57 | test "Succeed to pass an authentication middleware": 58 | client.headers = newHttpHeaders({ "simple-auth": "trust me" }) 59 | let response = client.get("http://127.0.0.1:3000/admins/") 60 | check(response.status == "200 OK") 61 | check(response.body == "Admins, he is doing it sideways !") 62 | 63 | test "Can retrieve the response headers": 64 | let response = client.get("http://127.0.0.1:3000/json") 65 | check(response.headers["content-type"] == "application/json") 66 | 67 | test "Can access query parameters": 68 | let response = client.get("http://127.0.0.1:3000/query_parameters/?name=G%C3%BCnter") 69 | check(response.body == "Günter") 70 | 71 | test "Can access cookies": 72 | let response = client.get("http://127.0.0.1:3000/cookies/") 73 | check(response.headers["set-cookie"] == "name=Yay") 74 | 75 | test "Unhandled error": 76 | let response = client.get("http://127.0.0.1:3000/error/") 77 | check(response.code() == Http500) 78 | check(response.body.startsWith("invalid integer: Some business logic that should have been an int")) 79 | 80 | 81 | echo "Close server" 82 | server.kill() 83 | server.close() 84 | -------------------------------------------------------------------------------- /tests/test_app.nim: -------------------------------------------------------------------------------- 1 | import phoon 2 | import strutils 3 | import unittest 4 | import utils 5 | 6 | 7 | suite "Endpoints": 8 | 9 | test "DELETE endpoint": 10 | var ctx = Context.new(Request(HttpMethod.HttpDelete, "https://yumad.bro/")) 11 | var app = App.new() 12 | app.delete("/", 13 | proc (ctx: Context) {.async.} = 14 | discard 15 | ) 16 | app.compileRoutes() 17 | waitFor app.dispatch(ctx) 18 | check(ctx.response.getStatus() == Http200) 19 | 20 | test "GET endpoint": 21 | var ctx = Context.new(GetRequest("https://yumad.bro/")) 22 | var app = App.new() 23 | app.get("/", 24 | proc (ctx: Context) {.async.} = 25 | discard 26 | ) 27 | app.compileRoutes() 28 | waitFor app.dispatch(ctx) 29 | check(ctx.response.getStatus() == Http200) 30 | 31 | test "HEAD endpoint": 32 | var ctx = Context.new(Request(HttpMethod.HttpHead, "https://yumad.bro/")) 33 | var app = App.new() 34 | app.head("/", 35 | proc (ctx: Context) {.async.} = 36 | discard 37 | ) 38 | app.compileRoutes() 39 | waitFor app.dispatch(ctx) 40 | check(ctx.response.getStatus() == Http200) 41 | 42 | test "OPTIONS endpoint": 43 | var ctx = Context.new(Request(HttpMethod.HttpOptions, "https://yumad.bro/")) 44 | var app = App.new() 45 | app.options("/", 46 | proc (ctx: Context) {.async.} = 47 | ctx.response.status(Http204) 48 | ) 49 | app.compileRoutes() 50 | waitFor app.dispatch(ctx) 51 | check(ctx.response.getStatus() == Http204) 52 | 53 | test "PATCH endpoint": 54 | var ctx = Context.new(Request(HttpMethod.HttpPatch, "https://yumad.bro/")) 55 | var app = App.new() 56 | app.patch("/", 57 | proc (ctx: Context) {.async.} = 58 | discard 59 | ) 60 | app.compileRoutes() 61 | waitFor app.dispatch(ctx) 62 | check(ctx.response.getStatus() == Http200) 63 | 64 | test "POST endpoint": 65 | var ctx = Context.new(PostRequest("https://yumad.bro/")) 66 | var app = App.new() 67 | app.post("/", 68 | proc (ctx: Context) {.async.} = 69 | ctx.response.status(Http201) 70 | ) 71 | app.compileRoutes() 72 | waitFor app.dispatch(ctx) 73 | check(ctx.response.getStatus() == Http201) 74 | 75 | test "PUT endpoint": 76 | var ctx = Context.new(Request(HttpMethod.HttpPut, "https://yumad.bro/")) 77 | var app = App.new() 78 | app.put("/", 79 | proc (ctx: Context) {.async.} = 80 | ctx.response.status(Http201) 81 | ) 82 | app.compileRoutes() 83 | waitFor app.dispatch(ctx) 84 | check(ctx.response.getStatus() == Http201) 85 | 86 | test "Can GET a endpoint already defined to handle POST requests": 87 | var ctx = Context.new(GetRequest("https://yumad.bro/memes")) 88 | var app = App.new() 89 | app.post("/memes", 90 | proc (ctx: Context) {.async.} = 91 | ctx.response.status(Http201) 92 | ) 93 | app.get("/memes", 94 | proc (ctx: Context) {.async.} = 95 | discard 96 | ) 97 | app.compileRoutes() 98 | waitFor app.dispatch(ctx) 99 | check(ctx.response.getStatus() == Http200) 100 | 101 | test "Can POST a endpoint already defined to handle GET requests": 102 | var ctx = Context.new(PostRequest("https://yumad.bro/memes")) 103 | var app = App.new() 104 | app.get("/memes", 105 | proc (ctx: Context) {.async.} = 106 | discard 107 | ) 108 | app.post("/memes", 109 | proc (ctx: Context) {.async.} = 110 | ctx.response.status(Http201) 111 | ) 112 | app.compileRoutes() 113 | waitFor app.dispatch(ctx) 114 | check(ctx.response.getStatus() == Http201) 115 | 116 | 117 | test "Can chain route definitions": 118 | var app = App.new() 119 | app.route("/memes") 120 | .get( 121 | proc (ctx: Context) {.async.} = 122 | discard 123 | ) 124 | .post( 125 | proc (ctx: Context) {.async.} = 126 | ctx.response.status(Http201) 127 | ) 128 | app.compileRoutes() 129 | 130 | var ctx = Context.new(PostRequest("https://yumad.bro/memes")) 131 | waitFor app.dispatch(ctx) 132 | check(ctx.response.getStatus() == Http201) 133 | 134 | ctx = Context.new(GetRequest("https://yumad.bro/memes")) 135 | waitFor app.dispatch(ctx) 136 | check(ctx.response.getStatus() == Http200) 137 | 138 | 139 | suite "Nested router": 140 | test "Can define a nested router": 141 | var ctx = Context.new(GetRequest("https://yumad.bro/api/v1/users")) 142 | var app = App.new() 143 | 144 | var router = Router.new() 145 | router.get("/users", 146 | proc (ctx: Context) {.async.} = 147 | discard 148 | ) 149 | 150 | app.mount("/api/v1", router) 151 | 152 | app.compileRoutes() 153 | waitFor app.dispatch(ctx) 154 | check(ctx.response.getStatus() == Http200) 155 | 156 | test "Cannot define a nested router on a wildcard route": 157 | var ctx = Context.new(GetRequest("https://yumad.bro/api/v1/users")) 158 | var app = App.new() 159 | 160 | var router = Router.new() 161 | router.get("/users", 162 | proc (ctx: Context) {.async.} = 163 | discard 164 | ) 165 | 166 | doAssertRaises(InvalidPathError): 167 | app.mount("/api/*", router) 168 | 169 | 170 | suite "Middlewares": 171 | test "Can register a middleware": 172 | var ctx = Context.new(GetRequest("https://yumad.bro/")) 173 | var app = App.new() 174 | 175 | app.get("/", 176 | proc (ctx: Context) {.async.} = 177 | discard 178 | ) 179 | 180 | proc TeapotMiddleware(next: Callback): Callback = 181 | return proc (ctx: Context) {.async.} = 182 | if ctx.request.path() != "teapot": 183 | ctx.response.status(Http418) 184 | return 185 | await next(ctx) 186 | 187 | app.use(TeapotMiddleware) 188 | 189 | app.compileRoutes() 190 | 191 | waitFor app.dispatch(ctx) 192 | check(ctx.response.getStatus() == Http418) 193 | 194 | test "Can register a middleware on a sub-router": 195 | var ctx = Context.new(GetRequest("https://yumad.bro/users/")) 196 | var app = App.new() 197 | 198 | var router = Router.new() 199 | router.get("/", 200 | proc (ctx: Context) {.async.} = 201 | discard 202 | ) 203 | 204 | proc TeapotMiddleware(callback: Callback): Callback = 205 | return proc (ctx: Context) {.async.} = 206 | if ctx.request.path() != "teapot": 207 | ctx.response.status(Http418) 208 | return 209 | await callback(ctx) 210 | 211 | router.use(TeapotMiddleware) 212 | app.mount("/users", router) 213 | 214 | app.compileRoutes() 215 | waitFor app.dispatch(ctx) 216 | check(ctx.response.getStatus() == Http418) 217 | 218 | 219 | suite "Error handling": 220 | test "Unhandled exception return an HTTP 500 Bad Request": 221 | var ctx = Context.new(GetRequest("https://yumad.bro/")) 222 | var app = App.new() 223 | app.get("/", 224 | proc (ctx: Context) {.async.} = 225 | discard parseInt("Some business logic that should have been an int") 226 | ) 227 | app.compileRoutes() 228 | waitFor app.dispatch(ctx) 229 | check(ctx.response.getStatus() == Http500) 230 | check(ctx.response.getBody() == "") 231 | 232 | test "Define a custom HTTP 500 handler": 233 | var ctx = Context.new(GetRequest("https://yumad.bro/")) 234 | var app = App.new() 235 | app.get("/", 236 | proc (ctx: Context) {.async.} = 237 | discard parseInt("Some business logic that should have been an int") 238 | ) 239 | app.onError( 240 | proc (ctx: Context, error: ref Exception) {.async.} = 241 | ctx.response.status(Http500).body("¯\\_(ツ)_/¯") 242 | ) 243 | app.compileRoutes() 244 | waitFor app.dispatch(ctx) 245 | check(ctx.response.getStatus() == Http500) 246 | check(ctx.response.getBody() == "¯\\_(ツ)_/¯") 247 | 248 | test "Not found endpoint returns a 404 status code.": 249 | var ctx = Context.new(GetRequest("https://yumad.bro/an-undefined-url")) 250 | var app = App.new() 251 | app.get("/", 252 | proc (ctx: Context) {.async.} = 253 | discard 254 | ) 255 | app.compileRoutes() 256 | waitFor app.dispatch(ctx) 257 | check(ctx.response.getStatus() == Http404) 258 | check(ctx.response.getBody() == "") 259 | 260 | test "Define a custom HTTP 404 handler": 261 | var ctx = Context.new(GetRequest("https://yumad.bro/an-undefined-url")) 262 | var app = App.new() 263 | app.get("/", 264 | proc (ctx: Context) {.async.} = 265 | discard 266 | ) 267 | app.on404( 268 | proc (ctx: Context) {.async.} = 269 | ctx.response.status(Http404).body("¯\\_(ツ)_/¯") 270 | ) 271 | app.compileRoutes() 272 | waitFor app.dispatch(ctx) 273 | check(ctx.response.getStatus() == Http404) 274 | check(ctx.response.getBody() == "¯\\_(ツ)_/¯") 275 | 276 | test "Wrong HTTP method on a defined endpoint returns a 405 status code.": 277 | var ctx = Context.new(GetRequest("https://yumad.bro/")) 278 | var app = App.new() 279 | app.post("/", 280 | proc (ctx: Context) {.async.} = 281 | ctx.response.status(Http201) 282 | ) 283 | app.compileRoutes() 284 | waitFor app.dispatch(ctx) 285 | check(ctx.response.getStatus() == Http405) 286 | check(ctx.response.getBody() == "") 287 | 288 | 289 | suite "Cookies": 290 | 291 | test "Add a cookie": 292 | var ctx = Context.new(GetRequest("https://yumad.bro/")) 293 | var app = App.new() 294 | app.get("/", 295 | proc (ctx: Context) {.async.} = 296 | ctx.response.cookie("name", "Yay") 297 | ) 298 | app.compileRoutes() 299 | waitFor app.dispatch(ctx) 300 | check(ctx.response.getStatus() == Http200) 301 | check(ctx.response.getHeaders()["set-cookie"] == "name=Yay") 302 | 303 | test "Add multiple cookies": 304 | var ctx = Context.new(GetRequest("https://yumad.bro/")) 305 | var app = App.new() 306 | app.get("/", 307 | proc (ctx: Context) {.async.} = 308 | ctx.response.cookie("name", "Yay") 309 | ctx.response.cookie("surname", "Nay") 310 | ) 311 | app.compileRoutes() 312 | waitFor app.dispatch(ctx) 313 | 314 | check(ctx.response.getStatus() == Http200) 315 | var headers = ctx.response.getHeaders() 316 | check(headers["set-cookie", 0] == "name=Yay") 317 | check(headers["set-cookie", 1] == "surname=Nay") 318 | -------------------------------------------------------------------------------- /tests/test_request.nim: -------------------------------------------------------------------------------- 1 | from asynchttpserver import nil 2 | import phoon/context/request 3 | import unittest 4 | import uri 5 | 6 | 7 | suite "Request": 8 | 9 | test "Get query parameter": 10 | var stdRequest = asynchttpserver.Request( 11 | reqMethod: asynchttpserver.HttpMethod.HttpGet, 12 | url: parseUri("https://yumad.bro/?name=bob") 13 | ) 14 | var request = Request.new(request = stdRequest, headers = nil) 15 | check(request.query("name").get() == "bob") 16 | 17 | test "Get multiple query parameters": 18 | var stdRequest = asynchttpserver.Request( 19 | reqMethod: asynchttpserver.HttpMethod.HttpGet, 20 | url: parseUri("https://yumad.bro/?name=bob&age=42") 21 | ) 22 | var request = Request.new(request = stdRequest, headers = nil) 23 | check(request.query("name").get() == "bob") 24 | check(request.query("age").get() == "42") 25 | 26 | test "Get missing query parameter": 27 | var stdRequest = asynchttpserver.Request( 28 | reqMethod: asynchttpserver.HttpMethod.HttpGet, 29 | url: parseUri("https://yumad.bro/?name=bob") 30 | ) 31 | var request = Request.new(request = stdRequest, headers = nil) 32 | check(request.query("age").isNone) 33 | 34 | test "Url parameter are decoded on the fly": 35 | var stdRequest = asynchttpserver.Request( 36 | reqMethod: asynchttpserver.HttpMethod.HttpGet, 37 | url: parseUri("https://yumad.bro/?name=G%C3%BCnter") 38 | ) 39 | var request = Request.new(request = stdRequest, headers = nil) 40 | check(request.query("name").get() == "Günter") 41 | -------------------------------------------------------------------------------- /tests/test_router.nim: -------------------------------------------------------------------------------- 1 | import phoon 2 | import phoon/routing/route 3 | import unittest 4 | 5 | 6 | suite "Router": 7 | 8 | test "Can chain http method": 9 | let router = Router() 10 | var route = router.route("/a-route/") 11 | .delete( 12 | proc (ctx: Context) {.async.} = 13 | discard 14 | ) 15 | .get( 16 | proc (ctx: Context) {.async.} = 17 | discard 18 | ) 19 | .head( 20 | proc (ctx: Context) {.async.} = 21 | discard 22 | ) 23 | .options( 24 | proc (ctx: Context) {.async.} = 25 | discard 26 | ) 27 | .patch( 28 | proc (ctx: Context) {.async.} = 29 | discard 30 | ) 31 | .post( 32 | proc (ctx: Context) {.async.} = 33 | discard 34 | ) 35 | .put( 36 | proc (ctx: Context) {.async.} = 37 | discard 38 | ) 39 | 40 | check(route.onDelete.isSome) 41 | check(route.onGet.isSome) 42 | check(route.onHead.isSome) 43 | check(route.onOptions.isSome) 44 | check(route.onPatch.isSome) 45 | check(route.onPost.isSome) 46 | check(route.onPut.isSome) 47 | -------------------------------------------------------------------------------- /tests/test_tree.nim: -------------------------------------------------------------------------------- 1 | import phoon/routing/[errors, tree] 2 | import options 3 | import unittest 4 | 5 | 6 | suite "Tree": 7 | 8 | test "Node children are prioratized": 9 | var tree = new Tree[string] 10 | tree.insert("/*", "wildcard") 11 | tree.insert("/{id}", "parametrized") 12 | tree.insert("/a", "strict") 13 | 14 | let children = tree.root.children[0].children 15 | check(children.len() == 3) 16 | check(children[0].pathType == PathType.Wildcard) 17 | check(children[1].pathType == PathType.Parametrized) 18 | check(children[2].pathType == PathType.Strict) 19 | 20 | test "A leaf node knows it's available parameters": 21 | var tree = new Tree[string] 22 | tree.insert("/{api_version}/users/{id}", "Bobby") 23 | 24 | let lastNode = tree.root.children[0].children[0].children[0].children[0].children[0].children[0].children[0].children[0].children[0].children[0] 25 | check(lastNode.isLeaf == true) 26 | check(lastNode.parameters == ["api_version", "id"]) 27 | 28 | 29 | suite "Strict routes": 30 | 31 | test "Insert root route": 32 | var tree = Tree[string].new() 33 | tree.insert("/", "Home") 34 | let result = tree.match("/").get() 35 | check(result.value == "Home") 36 | 37 | test "Insert route": 38 | var tree = Tree[string].new() 39 | tree.insert("/users", "Bobby") 40 | let result = tree.match("/users").get() 41 | check(result.value == "Bobby") 42 | 43 | test "Can insert longer overlapping routes afterward": 44 | var tree = Tree[string].new() 45 | 46 | tree.insert("/", "Home") 47 | tree.insert("/users", "Bobby") 48 | tree.insert("/users/age", "42") 49 | 50 | check(tree.match("/").get().value == "Home") 51 | check(tree.match("/users").get().value == "Bobby") 52 | check(tree.match("/users/age").get().value == "42") 53 | 54 | test "Can insert longer overlapping routes beforehand": 55 | var tree = Tree[string].new() 56 | 57 | tree.insert("/users/age", "42") 58 | tree.insert("/users", "Bobby") 59 | tree.insert("/", "Home") 60 | 61 | check(tree.match("/").get().value == "Home") 62 | check(tree.match("/users").get().value == "Bobby") 63 | check(tree.match("/users/age").get().value == "42") 64 | 65 | test "Fail to retrieve an undefined route.": 66 | var tree = Tree[string].new() 67 | tree.insert("/users", "Bobby") 68 | check(tree.match("/admins").isNone == true) 69 | 70 | test "Fail to match when a path is fully parsed but the route is partially matched": 71 | var tree = Tree[string].new() 72 | tree.insert("/users-are-sexy", "Bobby") 73 | check(tree.match("/users").isNone == true) 74 | 75 | test "Fail to match when a path is not fully parsed but a route is found": 76 | var tree = Tree[string].new() 77 | tree.insert("/users", "Bobby") 78 | check(tree.match("/users-are-sexy").isNone == true) 79 | 80 | 81 | suite "Wilcard routes": 82 | 83 | test "Cannot insert route with characters after wildcard": 84 | var tree = Tree[string].new() 85 | 86 | doAssertRaises(InvalidPathError): 87 | tree.insert("/user*-that-are-sexy", "Bobby") 88 | 89 | test "Insert a route with a wildcard": 90 | var tree = Tree[string].new() 91 | tree.insert("/user*", "Bobby") 92 | let lastNode = tree.root.children[0].children[0].children[0].children[0].children[0].children[0] 93 | check(lastNode.path == '*') 94 | check(lastNode.pathType == PathType.Wildcard) 95 | 96 | test "Fail to match when a path is fully parsed but the route is partially matched": 97 | var tree = Tree[string].new() 98 | tree.insert("/users*", "Bobby") 99 | check(tree.match("/users").isNone == true) 100 | 101 | test "Match": 102 | var tree = Tree[string].new() 103 | tree.insert("/users*", "Bobby") 104 | let result = tree.match("/users-are-sexy").get() 105 | check(result.value == "Bobby") 106 | 107 | test "Match the longest prefix": 108 | var tree = Tree[string].new() 109 | tree.insert("/*", "Catch all") 110 | tree.insert("/users*", "Users") 111 | tree.insert("/users-are*", "Users are") 112 | tree.insert("/users-are-sex*", "Bobby") 113 | let result = tree.match("/users-are-sexy").get() 114 | check(result.value == "Bobby") 115 | 116 | test "Match wilcard route if a longer static path is not matched": 117 | var tree = Tree[string].new() 118 | tree.insert("/users-are-sexy-and-*", "Wilcard") 119 | tree.insert("/users-are-sexy-and-i-know-it", "Bobby") 120 | let result = tree.match("/users-are-sexy-and-i-know-nothing-john-snow").get() 121 | check(result.value == "Wilcard") 122 | 123 | test "Match wilcard route after failing to match a parametrized route": 124 | var tree = Tree[string].new() 125 | tree.insert("/users*", "Wilcard") 126 | tree.insert("/users/{id}/books", "Harry Potter") 127 | let result = tree.match("/users/10/bowls").get() 128 | check(result.value == "Wilcard") 129 | 130 | 131 | suite "Parametrized routes": 132 | 133 | test "Fail to insert a route with a parameter name that contains the character {": 134 | var tree = new Tree[string] 135 | doAssertRaises(InvalidPathError): 136 | tree.insert("/users/{i{d}", "Bobby") 137 | 138 | test "Fail to insert a route with a parameter name that does not start with the character {": 139 | var tree = new Tree[string] 140 | doAssertRaises(InvalidPathError): 141 | tree.insert("/users/id}", "Bobby") 142 | 143 | test "Fail to insert a parametrized route if one already exists": 144 | var tree = new Tree[string] 145 | tree.insert("/users/{id}", "1") 146 | 147 | doAssertRaises(InvalidPathError): 148 | tree.insert("/users/{name}", "Bobby") 149 | 150 | test "Insert a route with a parameter": 151 | var tree = new Tree[string] 152 | tree.insert("/users/{id}", "Bobby") 153 | let parameterNode = tree.root.children[0].children[0].children[0].children[0].children[0].children[0].children[0].children[0] 154 | check(parameterNode.path == '{') 155 | check(parameterNode.pathType == PathType.Parametrized) 156 | check(parameterNode.parameterName == "id") 157 | check(parameterNode.parameters == ["id"]) 158 | 159 | test "Insert a route with two parameters": 160 | var tree = new Tree[string] 161 | tree.insert("/users/{id}/books/{title}", "Bobby") 162 | let id = tree.root.children[0].children[0].children[0].children[0].children[0].children[0].children[0].children[0] 163 | let books = id.children[0].children[0].children[0].children[0].children[0].children[0].children[0] 164 | let title = books.children[0] 165 | check(title.path == '{') 166 | check(title.pathType == PathType.Parametrized) 167 | check(title.parameterName == "title") 168 | check(title.parameters == ["id", "title"]) 169 | 170 | test "Fail to match when a path is fully parsed but the route is partially matched": 171 | var tree = Tree[string].new() 172 | tree.insert("/users/{id}/books", "Harry Potter") 173 | check(tree.match("/users/10/").isNone == true) 174 | 175 | test "Fail to match when a path is not fully parsed but a route is found": 176 | var tree = Tree[string].new() 177 | tree.insert("/users/{id}/", "Bobby") 178 | check(tree.match("/users/10/books").isNone == true) 179 | 180 | test "Match an ending parameter": 181 | var tree = Tree[string].new() 182 | tree.insert("/users/{id}", "Bobby") 183 | let result = tree.match("/users/10").get() 184 | check(result.value == "Bobby") 185 | check(result.parameters.get("id") == "10") 186 | 187 | test "Match a parameter suffixed by a static path": 188 | var tree = Tree[string].new() 189 | tree.insert("/users/{id}/books", "Harry Potter") 190 | let result = tree.match("/users/10/books").get() 191 | check(result.value == "Harry Potter") 192 | check(result.parameters.get("id") == "10") 193 | 194 | test "Retrieve a route with a parameter suffixed by a wildcard path": 195 | var tree = Tree[string].new() 196 | tree.insert("/users/{id}/boo*", "A boo") 197 | 198 | let result = tree.match("/users/10/booking").get() 199 | check(result.value == "A boo") 200 | check(result.parameters.get("id") == "10") 201 | 202 | test "Match several parameters": 203 | var tree = Tree[string].new() 204 | tree.insert("/users/{id}/books/{title}", "I have read this one !") 205 | let result = tree.match("/users/10/books/harry-potter").get() 206 | check(result.value == "I have read this one !") 207 | check(result.parameters.get("id") == "10") 208 | check(result.parameters.get("title") == "harry-potter") 209 | -------------------------------------------------------------------------------- /tests/utils.nim: -------------------------------------------------------------------------------- 1 | import asynchttpserver 2 | import uri 3 | 4 | 5 | proc Request*(httpMethod: HttpMethod, path: string): Request = 6 | let uri = parseUri(path) 7 | return Request(reqMethod: httpMethod, url: uri) 8 | 9 | proc GetRequest*(path: string): Request = 10 | let uri = parseUri(path) 11 | return Request(HttpMethod.HttpGet, path) 12 | 13 | proc PostRequest*(path: string): Request = 14 | return Request(HttpMethod.HttpPost, path) 15 | --------------------------------------------------------------------------------