├── tests ├── public │ └── root │ │ ├── test_file.txt │ │ └── index.html ├── config.nims ├── nim-in-action-code │ └── Chapter7 │ │ └── Tweeter │ │ ├── tests │ │ ├── database_test.nim.cfg │ │ └── database_test.nim │ │ ├── src │ │ ├── createDatabase.nim │ │ ├── views │ │ │ ├── general.nim │ │ │ └── user.nim │ │ ├── tweeter.nim │ │ └── database.nim │ │ ├── Tweeter.nimble │ │ └── public │ │ └── style.css ├── nim-in-action-code copy │ └── Chapter7 │ │ └── Tweeter │ │ ├── tests │ │ ├── database_test.nim.cfg │ │ └── database_test.nim │ │ ├── src │ │ ├── tweeter │ │ ├── createDatabase.nim │ │ ├── views │ │ │ ├── general.nim │ │ │ └── user.nim │ │ ├── tweeter.nim │ │ └── database.nim │ │ ├── Tweeter.nimble │ │ └── public │ │ └── style.css ├── example.nim ├── alltest_router2.nim ├── closures.nim ├── example2.nim ├── issue296.nim ├── issue150.nim ├── customRouter.nim ├── fileupload.nim ├── benchmark.nim ├── issue247.nim ├── techempower │ └── techempower.nim ├── alltest.nim └── tester.nim ├── .gitignore ├── todo.markdown ├── .github └── workflows │ └── main.yml ├── jesterfork.nimble ├── jesterfork ├── private │ ├── errorpages.nim │ └── utils.nim ├── patterns.nim └── request.nim ├── license.txt ├── changelog.markdown ├── readme.markdown └── jesterfork.nim /tests/public/root/test_file.txt: -------------------------------------------------------------------------------- 1 | Hello World! -------------------------------------------------------------------------------- /tests/config.nims: -------------------------------------------------------------------------------- 1 | switch("path", "..") 2 | switch("path", ".") 3 | -------------------------------------------------------------------------------- /tests/public/root/index.html: -------------------------------------------------------------------------------- 1 | This should be available at /root/. 2 | -------------------------------------------------------------------------------- /tests/nim-in-action-code/Chapter7/Tweeter/tests/database_test.nim.cfg: -------------------------------------------------------------------------------- 1 | --path:"./src" 2 | --path:"../src" 3 | -------------------------------------------------------------------------------- /tests/nim-in-action-code copy/Chapter7/Tweeter/tests/database_test.nim.cfg: -------------------------------------------------------------------------------- 1 | --path:"./src" 2 | --path:"../src" 3 | -------------------------------------------------------------------------------- /tests/example.nim: -------------------------------------------------------------------------------- 1 | import jesterfork, asyncdispatch, htmlgen 2 | 3 | routes: 4 | get "/": 5 | resp h1("Hello world") 6 | 7 | runForever() 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /tests/* 2 | !/tests/*.nim 3 | !/tests/*/ 4 | !/tests/nim-in-action-code/* 5 | /tests/nim-in-action-code/Chapter7/Tweeter/src/tweeter 6 | 7 | nimcache 8 | -------------------------------------------------------------------------------- /tests/nim-in-action-code copy/Chapter7/Tweeter/src/tweeter: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasTJdev/jester_fork/HEAD/tests/nim-in-action-code copy/Chapter7/Tweeter/src/tweeter -------------------------------------------------------------------------------- /tests/nim-in-action-code/Chapter7/Tweeter/src/createDatabase.nim: -------------------------------------------------------------------------------- 1 | import database 2 | 3 | var db = newDatabase() 4 | db.setup() 5 | echo("Database created successfully!") 6 | db.close() -------------------------------------------------------------------------------- /tests/nim-in-action-code copy/Chapter7/Tweeter/src/createDatabase.nim: -------------------------------------------------------------------------------- 1 | import database 2 | 3 | var db = newDatabase() 4 | db.setup() 5 | echo("Database created successfully!") 6 | db.close() -------------------------------------------------------------------------------- /tests/alltest_router2.nim: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import jesterfork 4 | 5 | router external: 6 | get "/simple": 7 | resp "Works!" 8 | 9 | get "/params/@foo": 10 | resp @"foo" 11 | 12 | get re"/\(foobar\)/(.+)/": 13 | resp request.matches[0] -------------------------------------------------------------------------------- /tests/closures.nim: -------------------------------------------------------------------------------- 1 | # Issue #63 2 | 3 | import jesterfork, asyncdispatch 4 | 5 | proc configRoutes(closureVal: string) = 6 | routes: 7 | get "/": 8 | # should respond "This value is in closure" 9 | resp closureVal 10 | 11 | configRoutes("This value is in closure") 12 | runForever() 13 | -------------------------------------------------------------------------------- /tests/nim-in-action-code/Chapter7/Tweeter/Tweeter.nimble: -------------------------------------------------------------------------------- 1 | # Package 2 | 3 | version = "0.1.0" 4 | author = "Dominik Picheta" 5 | description = "A simple Twitter clone developed in Nim in Action." 6 | license = "MIT" 7 | 8 | # Dependencies 9 | 10 | requires "nim >= 0.13.1" 11 | requires "jesterfork >= 0.0.1" 12 | -------------------------------------------------------------------------------- /tests/example2.nim: -------------------------------------------------------------------------------- 1 | import jesterfork, asyncdispatch, asyncnet 2 | 3 | proc match(request: Request): Future[ResponseData] {.async.} = 4 | block route: 5 | case request.pathInfo 6 | of "/": 7 | resp "Hello World!" 8 | else: 9 | resp Http404, "Not found!" 10 | 11 | var server = initJester(match) 12 | server.serve() -------------------------------------------------------------------------------- /tests/nim-in-action-code copy/Chapter7/Tweeter/Tweeter.nimble: -------------------------------------------------------------------------------- 1 | # Package 2 | 3 | version = "0.1.0" 4 | author = "Dominik Picheta" 5 | description = "A simple Twitter clone developed in Nim in Action." 6 | license = "MIT" 7 | 8 | # Dependencies 9 | 10 | requires "nim >= 0.13.1" 11 | requires "jesterfork >= 0.0.1" 12 | -------------------------------------------------------------------------------- /tests/issue296.nim: -------------------------------------------------------------------------------- 1 | # Note, this isn't ran as part of the test suite as it relies on randomness too much. 2 | 3 | import jesterfork, asyncdispatch, random, logging 4 | 5 | setLogFilter(lvlInfo) 6 | routes: 7 | before "/": 8 | setLogFilter(lvlInfo) 9 | get "/": 10 | let dur = rand(2000) 11 | await sleepAsync(dur) 12 | resp "hi" -------------------------------------------------------------------------------- /tests/issue150.nim: -------------------------------------------------------------------------------- 1 | from net import Port 2 | 3 | import jesterfork 4 | 5 | settings: 6 | port = Port(5454) 7 | bindAddr = "127.0.0.1" 8 | 9 | routes: 10 | get "/": 11 | resp "Hello world" 12 | 13 | get "/raise": 14 | raise newException(Exception, "Foobar") 15 | 16 | error Http404: 17 | resp Http404, "Looks you took a wrong turn somewhere." 18 | 19 | error Exception: 20 | resp Http500, "Something bad happened: " & exception.msg -------------------------------------------------------------------------------- /tests/customRouter.nim: -------------------------------------------------------------------------------- 1 | import jesterfork 2 | 3 | router myrouter: 4 | get "/": 5 | resp "Hello world" 6 | 7 | get "/404": 8 | resp "you got 404" 9 | 10 | get "/raise": 11 | raise newException(Exception, "Foobar") 12 | 13 | error Exception: 14 | resp Http500, "Something bad happened: " & exception.msg 15 | 16 | error Http404: 17 | redirect uri("/404") 18 | 19 | when isMainModule: 20 | let s = newSettings( 21 | Port(5454), 22 | bindAddr="127.0.0.1", 23 | ) 24 | var jest = initJester(myrouter, s) 25 | jest.serve() 26 | -------------------------------------------------------------------------------- /todo.markdown: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | ## Important 4 | 5 | * More information for the TRequest object. 6 | * request.accepts "text/xml" 7 | * Anything else? 8 | * Check file permissions for static files 9 | 10 | ## Less important 11 | 12 | * Ability to override default 404/503 message. 13 | * PUT/PATCH/DELETE/OPTIONS 14 | * Regex matching for POST. 15 | * Triggering another route. 16 | * Better, more adjustable logging. 17 | * Cache control 18 | * Sending files: ``sendFile`` 19 | 20 | ## Not sure about 21 | 22 | * Before filters 23 | * After filters 24 | * Streaming responses 25 | 26 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main test 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | tests: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | nim: 15 | - 1.6.18 16 | - 2.0.2 17 | os: 18 | - ubuntu-latest 19 | - macOS-latest 20 | - windows-latest 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Setup nim 24 | uses: jiro4989/setup-nim-action@v1 25 | with: 26 | nim-version: ${{ matrix.nim }} 27 | - run: | 28 | nimble test -Y 29 | nimble refresh 30 | -------------------------------------------------------------------------------- /tests/fileupload.nim: -------------------------------------------------------------------------------- 1 | # Issue #22 2 | 3 | import os, re, jesterfork, asyncdispatch, htmlgen, asyncnet 4 | 5 | routes: 6 | get "/": 7 | var html = "" 8 | for file in walkFiles("*.*"): 9 | html.add "
  • " & file & "
  • " 10 | html.add "
    " 11 | html.add "" 12 | html.add "" 13 | html.add "
    " 14 | resp(html) 15 | 16 | post "/upload": 17 | writeFile("uploaded.png", request.formData.getOrDefault("file").body) 18 | resp(request.formData.getOrDefault("file").body) 19 | runForever() 20 | -------------------------------------------------------------------------------- /jesterfork.nimble: -------------------------------------------------------------------------------- 1 | # Package 2 | 3 | version = "1.0.0" # Be sure to update jester.jesterVer too! 4 | author = "Dominik Picheta" 5 | description = "A sinatra-like web framework for Nim." 6 | license = "MIT" 7 | 8 | skipFiles = @["todo.markdown"] 9 | skipDirs = @["tests"] 10 | 11 | # Deps 12 | 13 | requires "nim >= 1.6.18" 14 | 15 | when not defined(windows): 16 | requires "https://github.com/ThomasTJdev/httpbeast_fork >= 1.0.0" 17 | 18 | task test, "Runs the test suite.": 19 | when NimMajor >= 2: 20 | # Due to tests/nim-in-action-code/Chapter7 21 | exec "nimble install -y db_connector@0.1.0" 22 | exec "nimble install -y asynctools@#0e6bdc3ed5bae8c7cc9" 23 | exec "nim c -r tests/tester" 24 | -------------------------------------------------------------------------------- /tests/benchmark.nim: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Dominik Picheta 2 | # MIT License - Look at license.txt for details. 3 | 4 | ## Benchmark.nim - This file is meant to be compared to Golang's stdlib 5 | ## HTTP server. The headers it sends match here. 6 | 7 | import jesterfork, asyncdispatch, asyncnet 8 | 9 | when true: 10 | routes: 11 | get "/": 12 | resp Http200, "Hello World" 13 | else: 14 | proc match(request: Request): Future[ResponseData] {.async.} = 15 | case request.path 16 | of "/": 17 | result = (TCActionSend, Http200, {:}.newHttpHeaders, "Hello World!", true) 18 | else: 19 | result = (TCActionSend, Http404, {:}.newHttpHeaders, "Y'all got lost", true) 20 | 21 | var j = initJester(match) 22 | j.serve() 23 | -------------------------------------------------------------------------------- /jesterfork/private/errorpages.nim: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2012 Dominik Picheta 2 | # MIT License - Look at license.txt for details. 3 | import htmlgen 4 | func error*(err, jesterVer: string): string = 5 | return html(head(title(err)), 6 | body(h1(err), 7 | "
    ", 8 | p("Jester " & jesterVer), 9 | style = "text-align: center;" 10 | ), 11 | xmlns="http://www.w3.org/1999/xhtml") 12 | 13 | func routeException*(error: string, jesterVer: string): string = 14 | return html(head(title("Jester route exception")), 15 | body( 16 | h1("An error has occured in one of your routes."), 17 | p(b("Detail: "), error) 18 | ), 19 | xmlns="http://www.w3.org/1999/xhtml") 20 | -------------------------------------------------------------------------------- /tests/nim-in-action-code/Chapter7/Tweeter/tests/database_test.nim: -------------------------------------------------------------------------------- 1 | import database, os, times 2 | 3 | when isMainModule: 4 | removeFile("tweeter_test.db") 5 | var db = newDatabase("tweeter_test.db") 6 | db.setup() 7 | 8 | db.create(User(username: "d0m96")) 9 | db.create(User(username: "nim_lang")) 10 | 11 | db.post(Message(username: "nim_lang", time: getTime() - 4.seconds, 12 | msg: "Hello Nim in Action readers")) 13 | db.post(Message(username: "nim_lang", time: getTime(), 14 | msg: "99.9% off Nim in Action for everyone, for the next minute only!")) 15 | 16 | var dom: User 17 | doAssert db.findUser("d0m96", dom) 18 | var nim: User 19 | doAssert db.findUser("nim_lang", nim) 20 | db.follow(dom, nim) 21 | 22 | doAssert db.findUser("d0m96", dom) 23 | 24 | let messages = db.findMessages(dom.following) 25 | echo(messages) 26 | doAssert(messages[0].msg == "99.9% off Nim in Action for everyone, for the next minute only!") 27 | doAssert(messages[1].msg == "Hello Nim in Action readers") 28 | echo("All tests finished successfully!") 29 | -------------------------------------------------------------------------------- /tests/nim-in-action-code copy/Chapter7/Tweeter/tests/database_test.nim: -------------------------------------------------------------------------------- 1 | import database, os, times 2 | 3 | when isMainModule: 4 | removeFile("tweeter_test.db") 5 | var db = newDatabase("tweeter_test.db") 6 | db.setup() 7 | 8 | db.create(User(username: "d0m96")) 9 | db.create(User(username: "nim_lang")) 10 | 11 | db.post(Message(username: "nim_lang", time: getTime() - 4.seconds, 12 | msg: "Hello Nim in Action readers")) 13 | db.post(Message(username: "nim_lang", time: getTime(), 14 | msg: "99.9% off Nim in Action for everyone, for the next minute only!")) 15 | 16 | var dom: User 17 | doAssert db.findUser("d0m96", dom) 18 | var nim: User 19 | doAssert db.findUser("nim_lang", nim) 20 | db.follow(dom, nim) 21 | 22 | doAssert db.findUser("d0m96", dom) 23 | 24 | let messages = db.findMessages(dom.following) 25 | echo(messages) 26 | doAssert(messages[0].msg == "99.9% off Nim in Action for everyone, for the next minute only!") 27 | doAssert(messages[1].msg == "Hello Nim in Action readers") 28 | echo("All tests finished successfully!") 29 | -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 2012 Dominik Picheta 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 19 | IN THE SOFTWARE. -------------------------------------------------------------------------------- /tests/issue247.nim: -------------------------------------------------------------------------------- 1 | from std/cgi import decodeUrl 2 | from std/strformat import fmt 3 | from std/strutils import join 4 | import jesterfork 5 | 6 | settings: 7 | port = Port(5454) 8 | bindAddr = "127.0.0.1" 9 | 10 | proc formatParams(params: Table[string, string]): string = 11 | result = "" 12 | for key, value in params.pairs: 13 | result.add fmt"{key}: {value}" 14 | 15 | proc formatSeqParams(params: Table[string, seq[string]]): string = 16 | result = "" 17 | for key, values in params.pairs: 18 | let value = values.join "," 19 | result.add fmt"{key}: {value}" 20 | 21 | routes: 22 | get "/": 23 | resp Http200 24 | get "/params": 25 | let params = params request 26 | resp formatParams params 27 | get "/params/@val%23ue": 28 | let params = params request 29 | resp formatParams params 30 | post "/params/@val%23ue": 31 | let params = params request 32 | resp formatParams params 33 | get "/multi": 34 | let params = paramValuesAsSeq request 35 | resp formatSeqParams(params) 36 | get "/@val%23ue": 37 | let params = paramValuesAsSeq request 38 | resp formatSeqParams(params) 39 | post "/@val%23ue": 40 | let params = paramValuesAsSeq request 41 | resp formatSeqParams(params) 42 | -------------------------------------------------------------------------------- /tests/nim-in-action-code/Chapter7/Tweeter/src/views/general.nim: -------------------------------------------------------------------------------- 1 | #? stdtmpl(subsChar = '$', metaChar = '#') 2 | #import "../database" 3 | #import user 4 | #import xmltree 5 | # 6 | #proc `$!`(text: string): string = escape(text) 7 | #end proc 8 | # 9 | #proc renderMain*(body: string): string = 10 | # result = "" 11 | 12 | 13 | 14 | Tweeter written in Nim 15 | 16 | 17 | 18 | 19 | ${body} 20 | 21 | 22 | 23 | #end proc 24 | # 25 | #proc renderLogin*(): string = 26 | # result = "" 27 |
    28 | Login 29 | Please type in your username... 30 |
    31 | 32 | 33 |
    34 |
    35 | #end proc 36 | # 37 | #proc renderTimeline*(username: string, messages: seq[Message]): string = 38 | # result = "" 39 |
    40 |

    ${$!username}'s timeline

    41 |
    42 |
    43 | New message 44 |
    45 | 46 | 47 | 48 |
    49 |
    50 | ${renderMessages(messages)} 51 | #end proc -------------------------------------------------------------------------------- /tests/nim-in-action-code copy/Chapter7/Tweeter/src/views/general.nim: -------------------------------------------------------------------------------- 1 | #? stdtmpl(subsChar = '$', metaChar = '#') 2 | #import "../database" 3 | #import user 4 | #import xmltree 5 | # 6 | #proc `$!`(text: string): string = escape(text) 7 | #end proc 8 | # 9 | #proc renderMain*(body: string): string = 10 | # result = "" 11 | 12 | 13 | 14 | Tweeter written in Nim 15 | 16 | 17 | 18 | 19 | ${body} 20 | 21 | 22 | 23 | #end proc 24 | # 25 | #proc renderLogin*(): string = 26 | # result = "" 27 |
    28 | Login 29 | Please type in your username... 30 |
    31 | 32 | 33 |
    34 |
    35 | #end proc 36 | # 37 | #proc renderTimeline*(username: string, messages: seq[Message]): string = 38 | # result = "" 39 |
    40 |

    ${$!username}'s timeline

    41 |
    42 |
    43 | New message 44 |
    45 | 46 | 47 | 48 |
    49 |
    50 | ${renderMessages(messages)} 51 | #end proc -------------------------------------------------------------------------------- /tests/nim-in-action-code/Chapter7/Tweeter/src/views/user.nim: -------------------------------------------------------------------------------- 1 | #? stdtmpl(subsChar = '$', metaChar = '#', toString = "xmltree.escape") 2 | #import "../database" 3 | #import xmltree 4 | #import times 5 | # 6 | #proc renderUser*(user: User): string = 7 | # result = "" 8 |
    9 |

    ${user.username}

    10 | Following: ${$user.following.len} 11 |
    12 | #end proc 13 | # 14 | #proc renderUser*(user: User, currentUser: User): string = 15 | # result = "" 16 |
    17 |

    ${user.username}

    18 | Following: ${$user.following.len} 19 | #if user.username notin currentUser.following: 20 |
    21 | 22 | 23 | 24 |
    25 | #end if 26 |
    27 | # 28 | #end proc 29 | # 30 | #proc renderMessages*(messages: seq[Message]): string = 31 | # result = "" 32 |
    33 | #for message in messages: 34 |
    35 | ${message.username} 36 | ${message.time.utc().format("HH:mm MMMM d',' yyyy")} 37 |

    ${message.msg}

    38 |
    39 | #end for 40 |
    41 | #end proc 42 | # 43 | #when isMainModule: 44 | # echo renderUser(User(username: "d0m96<>", following: @[])) 45 | # echo renderMessages(@[ 46 | # Message(username: "d0m96", time: getTime(), msg: "Hello World!"), 47 | # Message(username: "d0m96", time: getTime(), msg: "Testing") 48 | # ]) 49 | #end when 50 | -------------------------------------------------------------------------------- /tests/nim-in-action-code copy/Chapter7/Tweeter/src/views/user.nim: -------------------------------------------------------------------------------- 1 | #? stdtmpl(subsChar = '$', metaChar = '#', toString = "xmltree.escape") 2 | #import "../database" 3 | #import xmltree 4 | #import times 5 | # 6 | #proc renderUser*(user: User): string = 7 | # result = "" 8 |
    9 |

    ${user.username}

    10 | Following: ${$user.following.len} 11 |
    12 | #end proc 13 | # 14 | #proc renderUser*(user: User, currentUser: User): string = 15 | # result = "" 16 |
    17 |

    ${user.username}

    18 | Following: ${$user.following.len} 19 | #if user.username notin currentUser.following: 20 |
    21 | 22 | 23 | 24 |
    25 | #end if 26 |
    27 | # 28 | #end proc 29 | # 30 | #proc renderMessages*(messages: seq[Message]): string = 31 | # result = "" 32 |
    33 | #for message in messages: 34 |
    35 | ${message.username} 36 | ${message.time.utc().format("HH:mm MMMM d',' yyyy")} 37 |

    ${message.msg}

    38 |
    39 | #end for 40 |
    41 | #end proc 42 | # 43 | #when isMainModule: 44 | # echo renderUser(User(username: "d0m96<>", following: @[])) 45 | # echo renderMessages(@[ 46 | # Message(username: "d0m96", time: getTime(), msg: "Hello World!"), 47 | # Message(username: "d0m96", time: getTime(), msg: "Testing") 48 | # ]) 49 | #end when 50 | -------------------------------------------------------------------------------- /tests/techempower/techempower.nim: -------------------------------------------------------------------------------- 1 | import json, options 2 | 3 | import jesterfork, jesterfork/patterns 4 | 5 | settings: 6 | port = Port(8080) 7 | 8 | when true: 9 | routes: 10 | get "/json": 11 | const data = $(%*{"message": "Hello, World!"}) 12 | resp data, "application/json" 13 | 14 | get "/plaintext": 15 | const data = "Hello, World!" 16 | resp data, "text/plain" 17 | elif false: 18 | proc match(request: Request): ResponseData {.gcsafe.} = 19 | block allRoutes: 20 | setDefaultResp() 21 | var request = request 22 | block routesList: 23 | case request.reqMethod 24 | of HttpGet: 25 | block outerRoute: 26 | if request.pathInfo == "/json": 27 | block route: 28 | const 29 | data = $(%*{"message": "Hello, World!"}) 30 | resp data, "application/json" 31 | if checkAction(result): 32 | result.matched = true 33 | break routesList 34 | block outerRoute: 35 | if request.pathInfo == "/plaintext": 36 | block route: 37 | const 38 | data = "Hello, World!" 39 | resp data, "text/plain" 40 | if checkAction(result): 41 | result.matched = true 42 | break routesList 43 | else: discard 44 | 45 | var j = initJester(match, settings) 46 | j.serve() 47 | else: 48 | proc match(request: Request): ResponseData = 49 | if request.pathInfo == "/plaintext": 50 | result = (TCActionSend, Http200, some[RawHeaders](@{"Content-Type": "text/plain"}), "Hello, World!", true) 51 | else: 52 | result = (TCActionSend, Http404, none[RawHeaders](), "404", true) 53 | 54 | var j = initJester(match, settings) 55 | j.serve() -------------------------------------------------------------------------------- /tests/nim-in-action-code/Chapter7/Tweeter/src/tweeter.nim: -------------------------------------------------------------------------------- 1 | import asyncdispatch, times 2 | 3 | import jesterfork 4 | 5 | import database, views/user, views/general 6 | 7 | proc userLogin(db: Database, request: Request, user: var User): bool = 8 | if request.cookies.hasKey("username"): 9 | if not db.findUser(request.cookies["username"], user): 10 | user = User(username: request.cookies["username"], following: @[]) 11 | db.create(user) 12 | return true 13 | else: 14 | return false 15 | 16 | let db = newDatabase() 17 | routes: 18 | get "/": 19 | var user: User 20 | if db.userLogin(request, user): 21 | let messages = db.findMessages(user.following & user.username) 22 | resp renderMain(renderTimeline(user.username, messages)) 23 | else: 24 | resp renderMain(renderLogin()) 25 | 26 | get "/@name": 27 | cond '.' notin @"name" 28 | var user: User 29 | if not db.findUser(@"name", user): 30 | halt "User not found" 31 | let messages = db.findMessages(@[user.username]) 32 | 33 | var currentUser: User 34 | if db.userLogin(request, currentUser): 35 | resp renderMain(renderUser(user, currentUser) & renderMessages(messages)) 36 | else: 37 | resp renderMain(renderUser(user) & renderMessages(messages)) 38 | 39 | post "/follow": 40 | var follower: User 41 | var target: User 42 | if not db.findUser(@"follower", follower): 43 | halt "Follower not found" 44 | if not db.findUser(@"target", target): 45 | halt "Follow target not found" 46 | db.follow(follower, target) 47 | redirect(uri("/" & @"target")) 48 | 49 | post "/login": 50 | setCookie("username", @"username", getTime().utc() + 2.hours) 51 | redirect("/") 52 | 53 | post "/createMessage": 54 | let message = Message( 55 | username: @"username", 56 | time: getTime(), 57 | msg: @"message" 58 | ) 59 | db.post(message) 60 | redirect("/") 61 | 62 | runForever() 63 | -------------------------------------------------------------------------------- /tests/nim-in-action-code copy/Chapter7/Tweeter/src/tweeter.nim: -------------------------------------------------------------------------------- 1 | import asyncdispatch, times 2 | 3 | import jesterfork 4 | 5 | import database, views/user, views/general 6 | 7 | proc userLogin(db: Database, request: Request, user: var User): bool = 8 | if request.cookies.hasKey("username"): 9 | if not db.findUser(request.cookies["username"], user): 10 | user = User(username: request.cookies["username"], following: @[]) 11 | db.create(user) 12 | return true 13 | else: 14 | return false 15 | 16 | let db = newDatabase() 17 | routes: 18 | get "/": 19 | var user: User 20 | if db.userLogin(request, user): 21 | let messages = db.findMessages(user.following & user.username) 22 | resp renderMain(renderTimeline(user.username, messages)) 23 | else: 24 | resp renderMain(renderLogin()) 25 | 26 | get "/@name": 27 | cond '.' notin @"name" 28 | var user: User 29 | if not db.findUser(@"name", user): 30 | halt "User not found" 31 | let messages = db.findMessages(@[user.username]) 32 | 33 | var currentUser: User 34 | if db.userLogin(request, currentUser): 35 | resp renderMain(renderUser(user, currentUser) & renderMessages(messages)) 36 | else: 37 | resp renderMain(renderUser(user) & renderMessages(messages)) 38 | 39 | post "/follow": 40 | var follower: User 41 | var target: User 42 | if not db.findUser(@"follower", follower): 43 | halt "Follower not found" 44 | if not db.findUser(@"target", target): 45 | halt "Follow target not found" 46 | db.follow(follower, target) 47 | redirect(uri("/" & @"target")) 48 | 49 | post "/login": 50 | setCookie("username", @"username", getTime().utc() + 2.hours) 51 | redirect("/") 52 | 53 | post "/createMessage": 54 | let message = Message( 55 | username: @"username", 56 | time: getTime(), 57 | msg: @"message" 58 | ) 59 | db.post(message) 60 | redirect("/") 61 | 62 | runForever() 63 | -------------------------------------------------------------------------------- /tests/nim-in-action-code/Chapter7/Tweeter/public/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #f1f9ea; 3 | margin: 0; 4 | font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; 5 | } 6 | 7 | div#main { 8 | width: 80%; 9 | margin-left: auto; 10 | margin-right: auto; 11 | } 12 | 13 | div#user { 14 | background-color: #66ac32; 15 | width: 100%; 16 | color: #c7f0aa; 17 | padding: 5pt; 18 | } 19 | 20 | div#user > h1 { 21 | color: #ffffff; 22 | } 23 | 24 | h1 { 25 | margin: 0; 26 | display: inline; 27 | padding-left: 10pt; 28 | padding-right: 10pt; 29 | } 30 | 31 | div#user > form { 32 | float: right; 33 | margin-right: 10pt; 34 | } 35 | 36 | div#user > form > input[type="submit"] { 37 | border: 0px none; 38 | padding: 5pt; 39 | font-size: 108%; 40 | color: #ffffff; 41 | background-color: #515d47; 42 | border-radius: 5px; 43 | cursor: pointer; 44 | } 45 | 46 | div#user > form > input[type="submit"]:hover { 47 | background-color: #538c29; 48 | } 49 | 50 | 51 | div#messages { 52 | background-color: #a2dc78; 53 | width: 90%; 54 | margin-left: auto; 55 | margin-right: auto; 56 | color: #1a1a1a; 57 | } 58 | 59 | div#messages > div { 60 | border-left: 1px solid #869979; 61 | border-right: 1px solid #869979; 62 | border-bottom: 1px solid #869979; 63 | padding: 5pt; 64 | } 65 | 66 | div#messages > div > a, div#messages > div > span { 67 | color: #475340; 68 | } 69 | 70 | div#messages > div > a:hover { 71 | text-decoration: none; 72 | color: #c13746; 73 | } 74 | 75 | h3 { 76 | margin-bottom: 0; 77 | font-weight: normal; 78 | } 79 | 80 | div#login { 81 | width: 200px; 82 | margin-left: auto; 83 | margin-right: auto; 84 | margin-top: 20%; 85 | 86 | font-size: 130%; 87 | } 88 | 89 | div#login span.small { 90 | display: block; 91 | font-size: 56%; 92 | } 93 | 94 | div#newMessage { 95 | background-color: #538c29; 96 | width: 90%; 97 | margin-left: auto; 98 | margin-right: auto; 99 | color: #ffffff; 100 | padding: 5pt; 101 | } 102 | 103 | div#newMessage span { 104 | padding-right: 5pt; 105 | } 106 | 107 | div#newMessage form { 108 | display: inline; 109 | } 110 | 111 | div#newMessage > form > input[type="text"] { 112 | width: 80%; 113 | } 114 | 115 | div#newMessage > form > input[type="submit"] { 116 | font-size: 80%; 117 | } 118 | -------------------------------------------------------------------------------- /tests/nim-in-action-code copy/Chapter7/Tweeter/public/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #f1f9ea; 3 | margin: 0; 4 | font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; 5 | } 6 | 7 | div#main { 8 | width: 80%; 9 | margin-left: auto; 10 | margin-right: auto; 11 | } 12 | 13 | div#user { 14 | background-color: #66ac32; 15 | width: 100%; 16 | color: #c7f0aa; 17 | padding: 5pt; 18 | } 19 | 20 | div#user > h1 { 21 | color: #ffffff; 22 | } 23 | 24 | h1 { 25 | margin: 0; 26 | display: inline; 27 | padding-left: 10pt; 28 | padding-right: 10pt; 29 | } 30 | 31 | div#user > form { 32 | float: right; 33 | margin-right: 10pt; 34 | } 35 | 36 | div#user > form > input[type="submit"] { 37 | border: 0px none; 38 | padding: 5pt; 39 | font-size: 108%; 40 | color: #ffffff; 41 | background-color: #515d47; 42 | border-radius: 5px; 43 | cursor: pointer; 44 | } 45 | 46 | div#user > form > input[type="submit"]:hover { 47 | background-color: #538c29; 48 | } 49 | 50 | 51 | div#messages { 52 | background-color: #a2dc78; 53 | width: 90%; 54 | margin-left: auto; 55 | margin-right: auto; 56 | color: #1a1a1a; 57 | } 58 | 59 | div#messages > div { 60 | border-left: 1px solid #869979; 61 | border-right: 1px solid #869979; 62 | border-bottom: 1px solid #869979; 63 | padding: 5pt; 64 | } 65 | 66 | div#messages > div > a, div#messages > div > span { 67 | color: #475340; 68 | } 69 | 70 | div#messages > div > a:hover { 71 | text-decoration: none; 72 | color: #c13746; 73 | } 74 | 75 | h3 { 76 | margin-bottom: 0; 77 | font-weight: normal; 78 | } 79 | 80 | div#login { 81 | width: 200px; 82 | margin-left: auto; 83 | margin-right: auto; 84 | margin-top: 20%; 85 | 86 | font-size: 130%; 87 | } 88 | 89 | div#login span.small { 90 | display: block; 91 | font-size: 56%; 92 | } 93 | 94 | div#newMessage { 95 | background-color: #538c29; 96 | width: 90%; 97 | margin-left: auto; 98 | margin-right: auto; 99 | color: #ffffff; 100 | padding: 5pt; 101 | } 102 | 103 | div#newMessage span { 104 | padding-right: 5pt; 105 | } 106 | 107 | div#newMessage form { 108 | display: inline; 109 | } 110 | 111 | div#newMessage > form > input[type="text"] { 112 | width: 80%; 113 | } 114 | 115 | div#newMessage > form > input[type="submit"] { 116 | font-size: 80%; 117 | } 118 | -------------------------------------------------------------------------------- /tests/nim-in-action-code/Chapter7/Tweeter/src/database.nim: -------------------------------------------------------------------------------- 1 | when NimMajor >= 2: 2 | import times, strutils #<1> 3 | import db_connector/db_sqlite #<1> 4 | else: 5 | import times, db_sqlite, strutils #<1> 6 | 7 | type #<2> 8 | Database* = ref object 9 | db*: DbConn 10 | 11 | User* = object #<3> 12 | username*: string #<4> 13 | following*: seq[string] #<5> 14 | 15 | Message* = object #<6> 16 | username*: string #<7> 17 | time*: Time #<8> 18 | msg*: string #<9> 19 | 20 | proc newDatabase*(filename = "tweeter.db"): Database = 21 | new result 22 | result.db = open(filename, "", "", "") 23 | 24 | proc close*(database: Database) = 25 | database.db.close() 26 | 27 | proc setup*(database: Database) = 28 | database.db.exec(sql""" 29 | CREATE TABLE IF NOT EXISTS User( 30 | username text PRIMARY KEY 31 | ); 32 | """) 33 | 34 | database.db.exec(sql""" 35 | CREATE TABLE IF NOT EXISTS Following( 36 | follower text, 37 | followed_user text, 38 | PRIMARY KEY (follower, followed_user), 39 | FOREIGN KEY (follower) REFERENCES User(username), 40 | FOREIGN KEY (followed_user) REFERENCES User(username) 41 | ); 42 | """) 43 | 44 | database.db.exec(sql""" 45 | CREATE TABLE IF NOT EXISTS Message( 46 | username text, 47 | time integer, 48 | msg text NOT NULL, 49 | FOREIGN KEY (username) REFERENCES User(username) 50 | ); 51 | """) 52 | 53 | proc post*(database: Database, message: Message) = 54 | if message.msg.len > 140: #<1> 55 | raise newException(ValueError, "Message has to be less than 140 characters.") 56 | 57 | database.db.exec(sql"INSERT INTO Message VALUES (?, ?, ?);", #<2> 58 | message.username, $message.time.toUnix().int, message.msg) #<3> 59 | 60 | proc follow*(database: Database, follower: User, user: User) = 61 | database.db.exec(sql"INSERT INTO Following VALUES (?, ?);",#<2> 62 | follower.username, user.username) 63 | 64 | proc create*(database: Database, user: User) = 65 | database.db.exec(sql"INSERT INTO User VALUES (?);", user.username) #<2> 66 | 67 | proc findUser*(database: Database, username: string, user: var User): bool = 68 | let row = database.db.getRow( 69 | sql"SELECT username FROM User WHERE username = ?;", username) 70 | if row[0].len == 0: return false 71 | else: user.username = row[0] 72 | 73 | let following = database.db.getAllRows( 74 | sql"SELECT followed_user FROM Following WHERE follower = ?;", username) 75 | user.following = @[] 76 | for row in following: 77 | if row[0].len != 0: 78 | user.following.add(row[0]) 79 | 80 | return true 81 | 82 | proc findMessages*(database: Database, usernames: seq[string], 83 | limit = 10): seq[Message] = 84 | result = @[] 85 | if usernames.len == 0: return 86 | var whereClause = " WHERE " 87 | for i in 0..= 2: 2 | import times, strutils #<1> 3 | import db_connector/db_sqlite #<1> 4 | else: 5 | import times, db_sqlite, strutils #<1> 6 | 7 | type #<2> 8 | Database* = ref object 9 | db*: DbConn 10 | 11 | User* = object #<3> 12 | username*: string #<4> 13 | following*: seq[string] #<5> 14 | 15 | Message* = object #<6> 16 | username*: string #<7> 17 | time*: Time #<8> 18 | msg*: string #<9> 19 | 20 | proc newDatabase*(filename = "tweeter.db"): Database = 21 | new result 22 | result.db = open(filename, "", "", "") 23 | 24 | proc close*(database: Database) = 25 | database.db.close() 26 | 27 | proc setup*(database: Database) = 28 | database.db.exec(sql""" 29 | CREATE TABLE IF NOT EXISTS User( 30 | username text PRIMARY KEY 31 | ); 32 | """) 33 | 34 | database.db.exec(sql""" 35 | CREATE TABLE IF NOT EXISTS Following( 36 | follower text, 37 | followed_user text, 38 | PRIMARY KEY (follower, followed_user), 39 | FOREIGN KEY (follower) REFERENCES User(username), 40 | FOREIGN KEY (followed_user) REFERENCES User(username) 41 | ); 42 | """) 43 | 44 | database.db.exec(sql""" 45 | CREATE TABLE IF NOT EXISTS Message( 46 | username text, 47 | time integer, 48 | msg text NOT NULL, 49 | FOREIGN KEY (username) REFERENCES User(username) 50 | ); 51 | """) 52 | 53 | proc post*(database: Database, message: Message) = 54 | if message.msg.len > 140: #<1> 55 | raise newException(ValueError, "Message has to be less than 140 characters.") 56 | 57 | database.db.exec(sql"INSERT INTO Message VALUES (?, ?, ?);", #<2> 58 | message.username, $message.time.toUnix().int, message.msg) #<3> 59 | 60 | proc follow*(database: Database, follower: User, user: User) = 61 | database.db.exec(sql"INSERT INTO Following VALUES (?, ?);",#<2> 62 | follower.username, user.username) 63 | 64 | proc create*(database: Database, user: User) = 65 | database.db.exec(sql"INSERT INTO User VALUES (?);", user.username) #<2> 66 | 67 | proc findUser*(database: Database, username: string, user: var User): bool = 68 | let row = database.db.getRow( 69 | sql"SELECT username FROM User WHERE username = ?;", username) 70 | if row[0].len == 0: return false 71 | else: user.username = row[0] 72 | 73 | let following = database.db.getAllRows( 74 | sql"SELECT followed_user FROM Following WHERE follower = ?;", username) 75 | user.following = @[] 76 | for row in following: 77 | if row[0].len != 0: 78 | user.following.add(row[0]) 79 | 80 | return true 81 | 82 | proc findMessages*(database: Database, usernames: seq[string], 83 | limit = 10): seq[Message] = 84 | result = @[] 85 | if usernames.len == 0: return 86 | var whereClause = " WHERE " 87 | for i in 0..= len(s): 28 | '\0' 29 | else: 30 | s[i] 31 | 32 | var i = 0 33 | var text = "" 34 | while i < pattern.len(): 35 | case pattern[i] 36 | of '@': 37 | # Add the stored text. 38 | if text != "": 39 | result.addNode(NodeText, text, false) 40 | text = "" 41 | # Parse named parameter. 42 | inc(i) # Skip @ 43 | var nparam = "" 44 | i += pattern.parseUntil(nparam, {'/', '?'}, i) 45 | var optional = pattern{i} == '?' 46 | result.addNode(NodeField, nparam, optional) 47 | if pattern{i} == '?': inc(i) # Only skip ?. / should not be skipped. 48 | of '?': 49 | var optionalChar = text[^1] 50 | setLen(text, text.len-1) # Truncate ``text``. 51 | # Add the stored text. 52 | if text != "": 53 | result.addNode(NodeText, text, false) 54 | text = "" 55 | # Add optional char. 56 | inc(i) # Skip ? 57 | result.addNode(NodeText, $optionalChar, true) 58 | of '\\': 59 | inc i # Skip \ 60 | if pattern[i] notin {'?', '@', '\\'}: 61 | raise newException(ValueError, 62 | "This character does not require escaping: " & pattern[i]) 63 | text.add(pattern{i}) 64 | inc i # Skip ``pattern[i]`` 65 | else: 66 | text.add(pattern{i}) 67 | inc(i) 68 | 69 | if text != "": 70 | result.addNode(NodeText, text, false) 71 | 72 | proc findNextText(pattern: Pattern, i: int, toNode: var Node): bool = 73 | ## Finds the next NodeText in the pattern, starts looking from ``i``. 74 | result = false 75 | for n in i..pattern.len()-1: 76 | if pattern[n].typ == NodeText: 77 | toNode = pattern[n] 78 | return true 79 | 80 | proc check(n: Node, s: string, i: int): bool = 81 | let cutTo = (n.text.len-1)+i 82 | if cutTo > s.len-1: return false 83 | return s.substr(i, cutTo) == n.text 84 | 85 | proc match*(pattern: Pattern, s: string): 86 | tuple[matched: bool, params: Table[string, string]] = 87 | var i = 0 # Location in ``s``. 88 | 89 | result.matched = true 90 | result.params = initTable[string, string]() 91 | 92 | for ncount, node in pattern: 93 | case node.typ 94 | of NodeText: 95 | if node.optional: 96 | if check(node, s, i): 97 | inc(i, node.text.len) # Skip over this optional character. 98 | else: 99 | # If it's not there, we have nothing to do. It's optional after all. 100 | discard 101 | else: 102 | if check(node, s, i): 103 | inc(i, node.text.len) # Skip over this 104 | else: 105 | # No match. 106 | result.matched = false 107 | return 108 | of NodeField: 109 | var nextTxtNode: Node 110 | var stopChar = '/' 111 | if findNextText(pattern, ncount, nextTxtNode): 112 | stopChar = nextTxtNode.text[0] 113 | var matchNamed = "" 114 | i += s.parseUntil(matchNamed, stopChar, i) 115 | result.params[node.text] = matchNamed 116 | if matchNamed == "" and not node.optional: 117 | result.matched = false 118 | return 119 | 120 | if s.len != i: 121 | result.matched = false 122 | 123 | when isMainModule: 124 | let f = parsePattern("/show/@id/test/@show?/?") 125 | doAssert match(f, "/show/12/test/hallo/").matched 126 | doAssert match(f, "/show/2131726/test/jjjuuwąąss").matched 127 | doAssert(not match(f, "/").matched) 128 | doAssert(not match(f, "/show//test//").matched) 129 | doAssert(match(f, "/show/asd/test//").matched) 130 | doAssert(not match(f, "/show/asd/asd/test/jjj/").matched) 131 | doAssert(match(f, "/show/@łę¶ŧ←/test/asd/").params["id"] == "@łę¶ŧ←") 132 | 133 | let f2 = parsePattern("/test42/somefile.?@ext?/?") 134 | doAssert(match(f2, "/test42/somefile/").params["ext"] == "") 135 | doAssert(match(f2, "/test42/somefile.txt").params["ext"] == "txt") 136 | doAssert(match(f2, "/test42/somefile.txt/").params["ext"] == "txt") 137 | 138 | let f3 = parsePattern(r"/test32/\@\\\??") 139 | doAssert(match(f3, r"/test32/@\").matched) 140 | doAssert(not match(f3, r"/test32/@\\").matched) 141 | doAssert(match(f3, r"/test32/@\?").matched) 142 | -------------------------------------------------------------------------------- /changelog.markdown: -------------------------------------------------------------------------------- 1 | # Jester changelog 2 | 3 | ## 0.6.0 - 17/06/2023 4 | 5 | - **Breaking change:** All request parameters are automatically decoded using `decodeUrl`. Accessing the parameters with the `@` operator or directly through the raw `request.params` returns the value decoded. 6 | - Fix for [#211](https://github.com/dom96/jester/issues/211) - custom routers now have the same error handling as normal routes. 7 | - Fix for [#269](https://github.com/dom96/jester/issues/269) - a bug that prevented redirecting from within error handlers. 8 | 9 | For full list, see the commits since the last version: 10 | 11 | https://github.com/dom96/jester/compare/v0.5.0...v0.6.0 12 | 13 | ## 0.5.0 - 17/10/2020 14 | 15 | Major new release mainly due to some breaking changes. 16 | This release brings compatibility with Nim 1.4.0 as well. 17 | 18 | - **Breaking change:** By default `redirect` now skips future handlers, including when used in a `before` route. To retain the old behavior, set the parameter `halt=false` (e.g. `redirect("/somewhere", halt=false)`) 19 | 20 | For full list, see the commits since the last version: 21 | 22 | https://github.com/dom96/jester/compare/v0.4.3...v0.5.0 23 | 24 | ## 0.4.3 - 12/08/2019 25 | 26 | Minor release correcting a few packaging issues and includes some other 27 | fixes by the community. 28 | 29 | For full list, see the commits since the last version: 30 | 31 | https://github.com/dom96/jester/compare/v0.4.2...v0.4.3 32 | 33 | ## 0.4.2 - 18/04/2019 34 | 35 | This is a minor release containing a number of bug fixes. 36 | **In particular it fixes a 0-day vulnerability**, which allows an attacker to 37 | request static files from outside the static directory in certain circumastances. 38 | See [this commit](https://github.com/dom96/jester/commit/0bf4e344e3d95934780f2e7a39e7eed692b94f09) for a test which reproduces the bug. 39 | 40 | For other changes, see the commits since the last version: 41 | 42 | https://github.com/dom96/jester/compare/v0.4.1...v0.4.2 43 | 44 | ## 0.4.1 - 24/08/2018 45 | 46 | This is a minor release containing a number of bug fixes. The main purpose of 47 | this release is compatibility with the recent Nim seq/string changes. 48 | 49 | ## 0.4.0 - 18/07/2018 50 | 51 | This is a major new release focusing on optimizations. In one specific benchmark 52 | involving pipelined HTTP requests, the speed up was 650% in comparison to 53 | Jester v0.3.0. For another benchmark using the `wrk` tool, with no pipelining, 54 | the speed up was 178%. 55 | 56 | A list of changes follows: 57 | 58 | - **Breaking change:** The response headers are now stored in a more efficient 59 | data structure called ``RawHeaders``. This new data structure is also stored 60 | in an ``Option`` type, this makes some responses significantly more efficient. 61 | - ``sendFile`` has been implemented, so it's now possible to easily respond 62 | to a request with a file. 63 | 64 | ## 0.3.0 - 06/07/2018 65 | 66 | This is a major new release containing many changes and improvements. 67 | Primary new addition is support for the brand new HttpBeast server which offers 68 | unparalleled performance and scalability across CPU cores. 69 | 70 | This release also fixes a **security vulnerability**. which even got a 71 | CVE number: CVE-2018-13034. If you are exposing Jester directly to outside users, 72 | i.e. without a reverse proxy (such as nginx), then you are vulnerable and 73 | should upgrade ASAP. See below for details. 74 | 75 | ### Modular routes 76 | 77 | Routes can now be separated into multiple `router` blocks and each block 78 | can be placed inside a separate module. For example: 79 | 80 | ```nim 81 | import jester 82 | 83 | router api: 84 | get "/names": 85 | resp "Dom,George,Charles" 86 | 87 | get "/info/@name": 88 | resp @"name" 89 | 90 | routes: 91 | extend api, "/api" 92 | ``` 93 | 94 | The `api` routes are all prefixed with `/api`, for example 95 | http://localhost:5000/api/names. 96 | 97 | ### Error handlers 98 | 99 | Errors including exceptions and error HTTP codes can now be handled. 100 | For example: 101 | 102 | ```nim 103 | import jester 104 | 105 | routes: 106 | error Http404: 107 | resp Http404, "Looks you took a wrong turn somewhere." 108 | 109 | error Exception: 110 | resp Http500, "Something bad happened: " & exception.msg 111 | ``` 112 | 113 | ### Meta routes 114 | 115 | Jester now supports `before` and `after` routes. So you can easily perform 116 | actions before or after requests, you don't have to specify a pattern if you 117 | want the handler to run before/after all requests. For example: 118 | 119 | ```nim 120 | import jester 121 | 122 | routes: 123 | before: 124 | resp Http200, "", "text/xml" 125 | 126 | get "/test": 127 | result[3] = "foobar" 128 | ``` 129 | 130 | ### CVE-2018-13034 131 | 132 | **The fix for this vulnerability has been backported to Jester v0.2.1.** Use it 133 | if you do not wish to upgrade to Jester v0.3.0 or are stuck with Nim 0.18.0 134 | or earlier. 135 | 136 | This vulnerability makes it possible for an attacker to access files outside 137 | your designated `static` directory. This can be done by requesting URLs such as 138 | http://localhost:5000/../webapp.nim. An attacker could potentially access 139 | anything on your filesystem using this method, as long as the running application 140 | had the necessary permissions to read the file. 141 | 142 | **Note:** It is recommended to always run Jester applications behind a reverse 143 | proxy such as nginx. If your application is running behind such a proxy then you 144 | are not vulnerable. Services such as cloudflare also protect against this 145 | form of attack. 146 | 147 | ### Other changes 148 | 149 | * **Breaking change:** The `body`, `headers`, `status` templates have been 150 | removed. These may be brought back in the future. 151 | * Templates and macros now work in routes. 152 | * HttpBeast support. 153 | * SameSite support for cookies. 154 | * Multi-core support. 155 | 156 | ## 0.2.1 - 08/07/2018 157 | 158 | Fixes CVE-2018-13034. See above for details. 159 | 160 | ## 0.2.0 - 02/09/2017 161 | 162 | ## 0.1.1 - 01/10/2016 163 | 164 | This release contains small improvements and fixes to support Nim 0.15.0. 165 | 166 | * **Breaking change:** The ``ReqMeth`` type was removed in favour of Nim's 167 | ``HttpMethod`` type. 168 | * The ``CONNECT`` HTTP method is now supported. 169 | -------------------------------------------------------------------------------- /tests/alltest.nim: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2018 Dominik Picheta 2 | # MIT License - Look at license.txt for details. 3 | import jesterfork, asyncdispatch, strutils, random, os, asyncnet, re, typetraits 4 | import json 5 | 6 | import alltest_router2 7 | 8 | type 9 | MyCustomError = object of Exception 10 | RaiseAnotherError = object of Exception 11 | 12 | 13 | template return200(): untyped = 14 | resp Http200, "Templates now work!" 15 | 16 | settings: 17 | port = Port(5454) 18 | appName = "/foo" 19 | bindAddr = "127.0.0.1" 20 | staticDir = "tests/public" 21 | 22 | router internal: 23 | get "/simple": 24 | resp "Works!" 25 | 26 | get "/params/@foo": 27 | resp @"foo" 28 | 29 | routes: 30 | extend internal, "/internal" 31 | extend external, "/external" 32 | extend external, "/(regexEscaped.txt)" 33 | 34 | get "/": 35 | resp "Hello World" 36 | 37 | get "/resp": 38 | if true: 39 | resp "This should be the response" 40 | resp "This should NOT be the response" 41 | 42 | get "/halt": 43 | halt Http502, "I'm sorry, this page has been halted." 44 | resp "test" 45 | 46 | get "/halt": 47 | resp "

    Not halted!

    " 48 | 49 | before re"/halt-before/.*?": 50 | halt Http502, "Halted!" 51 | 52 | get "/halt-before/@something": 53 | resp "Should never reach this" 54 | 55 | get "/guess/@who": 56 | if @"who" != "Frank": pass() 57 | resp "You've found me!" 58 | 59 | get "/guess/@_": 60 | resp "Haha. You will never find me!" 61 | 62 | get "/redirect/@url/?": 63 | redirect(uri(@"url")) 64 | 65 | get "/redirect-halt/@url/?": 66 | redirect(uri(@"url")) 67 | resp "ok" 68 | 69 | before re"/redirect-before/.*?": 70 | redirect(uri("/nowhere")) 71 | 72 | get "/redirect-before/@url/?": 73 | resp "should not get here" 74 | 75 | get "/win": 76 | cond rand(5) < 3 77 | resp "You won!" 78 | 79 | get "/win": 80 | resp "Try your luck again, loser." 81 | 82 | get "/profile/@id/@value?/?": 83 | var html = "" 84 | html.add "Msg: " & @"id" & 85 | "
    Name: " & @"value" 86 | html.add "
    " 87 | html.add "Params: " & $request.params 88 | 89 | resp html 90 | 91 | get "/attachment": 92 | attachment "public/root/index.html" 93 | resp "blah" 94 | 95 | # get "/live": 96 | # await response.sendHeaders() 97 | # for i in 0 .. 10: 98 | # await response.send("The number is: " & $i & "
    ") 99 | # await sleepAsync(1000) 100 | # response.client.close() 101 | 102 | # curl -v -F file='blah' http://dom96.co.cc:5000 103 | # curl -X POST -d 'test=56' localhost:5000/post 104 | 105 | post "/post": 106 | var body = "" 107 | body.add "Received:
    " 108 | body.add($request.formData) 109 | body.add "
    \n" 110 | body.add($request.params) 111 | 112 | resp Http200, body 113 | 114 | get "/post": 115 | resp """ 116 |
    117 | First name:
    118 | Last name:
    119 | 120 |
    """ % [uri("/post", absolute = false)] 121 | 122 | get "/file": 123 | resp """ 124 |
    126 | 127 | 128 |
    129 | 130 |
    """ 131 | 132 | get re"^\/([0-9]{2})\.html$": 133 | resp request.matches[0] 134 | 135 | patch "/patch": 136 | var body = "" 137 | body.add "Received: " 138 | body.add($request.body) 139 | resp Http200, body 140 | 141 | get "/template": 142 | return200() 143 | resp Http404, "Template not working" 144 | 145 | get "/nil": 146 | resp "" 147 | 148 | get "/MyCustomError": 149 | raise newException(MyCustomError, "testing") 150 | 151 | get "/RaiseAnotherError": 152 | raise newException(RaiseAnotherError, "testing") 153 | 154 | error MyCustomError: 155 | resp "Something went wrong: " & $type(exception) 156 | 157 | error RaiseAnotherError: 158 | raise newException(RaiseAnotherError, "This shouldn't crash.") # TODO 159 | 160 | error Http404: 161 | resp Http404, "404 not found!!!" 162 | 163 | get "/401": 164 | resp Http401 165 | get "/403": 166 | resp Http403 167 | 168 | error {Http401 .. Http408}: 169 | if error.data.code == Http401: 170 | pass 171 | 172 | doAssert error.data.code != Http401 173 | resp error.data.code, "OK: " & $error.data.code 174 | 175 | error {Http401 .. Http408}: 176 | doAssert error.data.code == Http401 177 | resp error.data.code, "OK: " & $error.data.code 178 | 179 | # TODO: Add explicit test for `resp Http404, "With Body!"`. 180 | 181 | before: 182 | if request.pathInfo == "/before/global": 183 | resp "Before/Global: OK!" 184 | 185 | get "/before/global": 186 | resp result[3] & " After global `before`: OK!" 187 | 188 | before re"/before/.*": 189 | if request.pathInfo.startsWith("/before/restricted"): 190 | # Halt should stop all processing and reply with the specified content. 191 | halt "You cannot access this!" 192 | 193 | get "/before/restricted": 194 | resp "This should never be accessed!" 195 | 196 | get "/before/available": 197 | resp "This is accessible" 198 | 199 | get "/after/added": 200 | resp "Hello! " 201 | 202 | after "/after/added": 203 | result[3].add("Added by after!") 204 | 205 | get "/json": 206 | var j = %*{ 207 | "name": "Dominik" 208 | } 209 | 210 | resp j 211 | 212 | get "/path": 213 | resp request.path 214 | 215 | get "/sendFile": 216 | sendFile(getCurrentDir() / "tests/public/root/test_file.txt") 217 | 218 | get "/query": 219 | resp $request.params 220 | 221 | get "/querystring": 222 | resp $request.query 223 | 224 | get "/issue157": 225 | resp(Http200, [("Content-Type","text/css")] , "foo") 226 | 227 | get "/manyheaders": 228 | setHeader(responseHeaders, "foo", "foo") 229 | setHeader(responseHeaders, "bar", "bar") 230 | resp Http200, {"Content-Type": "text/plain"}, "result" 231 | 232 | get "/redirectDefault": 233 | redirect("/") 234 | 235 | get "/redirect301": 236 | redirect("/", httpStatusCode = Http301) 237 | 238 | get "/redirect302": 239 | redirect("/", httpStatusCode = Http302) 240 | -------------------------------------------------------------------------------- /jesterfork/private/utils.nim: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2012 Dominik Picheta 2 | # MIT License - Look at license.txt for details. 3 | import parseutils, strtabs, strutils, tables, net, mimetypes, asyncdispatch, os 4 | from cgi import decodeUrl 5 | 6 | const 7 | useHttpBeast* = not defined(windows) and not defined(useStdLib) 8 | 9 | type 10 | MultiData* = OrderedTable[string, tuple[fields: StringTableRef, body: string]] 11 | 12 | Settings* = ref object 13 | staticDir*: string # By default ./public 14 | appName*: string 15 | mimes*: MimeDb 16 | port*: Port 17 | bindAddr*: string 18 | reusePort*: bool 19 | maxBody*: int 20 | futureErrorHandler*: proc (fut: Future[void]) {.closure, gcsafe.} 21 | numThreads*: int # Only available with Httpbeast (`useHttpBeast = true`) 22 | startup*: proc () {.closure, gcsafe.} # Only available with Httpbeast (`useHttpBeast = true`) 23 | 24 | JesterError* = object of Exception 25 | 26 | proc parseUrlQuery*(query: string, result: var Table[string, string]) 27 | {.deprecated: "use stdlib cgi/decodeData".} = 28 | var i = 0 29 | i = query.skip("?") 30 | while i < query.len()-1: 31 | var key = "" 32 | var val = "" 33 | i += query.parseUntil(key, '=', i) 34 | if query[i] != '=': 35 | raise newException(ValueError, "Expected '=' at " & $i & 36 | " but got: " & $query[i]) 37 | inc(i) # Skip = 38 | i += query.parseUntil(val, '&', i) 39 | inc(i) # Skip & 40 | result[decodeUrl(key)] = decodeUrl(val) 41 | 42 | template parseContentDisposition(): typed = 43 | var hCount = 0 44 | while hCount < hValue.len()-1: 45 | var key = "" 46 | hCount += hValue.parseUntil(key, {';', '='}, hCount) 47 | if hValue[hCount] == '=': 48 | var value = hvalue.captureBetween('"', start = hCount) 49 | hCount += value.len+2 50 | inc(hCount) # Skip ; 51 | hCount += hValue.skipWhitespace(hCount) 52 | if key == "name": name = value 53 | newPart[0][key] = value 54 | else: 55 | inc(hCount) 56 | hCount += hValue.skipWhitespace(hCount) 57 | 58 | proc parseMultiPart*(body: string, boundary: string): MultiData = 59 | result = initOrderedTable[string, tuple[fields: StringTableRef, body: string]]() 60 | var mboundary = "--" & boundary 61 | 62 | var i = 0 63 | var partsLeft = true 64 | while partsLeft: 65 | var firstBoundary = body.skip(mboundary, i) 66 | if firstBoundary == 0: 67 | raise newException(ValueError, "Expected boundary. Got: " & body.substr(i, i+25)) 68 | i += firstBoundary 69 | i += body.skipWhitespace(i) 70 | 71 | # Headers 72 | var newPart: tuple[fields: StringTableRef, body: string] = ({:}.newStringTable, "") 73 | var name = "" 74 | while true: 75 | if body[i] == '\c': 76 | inc(i, 2) # Skip \c\L 77 | break 78 | var hName = "" 79 | i += body.parseUntil(hName, ':', i) 80 | if body[i] != ':': 81 | raise newException(ValueError, "Expected : in headers.") 82 | inc(i) # Skip : 83 | i += body.skipWhitespace(i) 84 | var hValue = "" 85 | i += body.parseUntil(hValue, {'\c', '\L'}, i) 86 | if toLowerAscii(hName) == "content-disposition": 87 | parseContentDisposition() 88 | newPart[0][hName] = hValue 89 | i += body.skip("\c\L", i) # Skip *one* \c\L 90 | 91 | # Parse body. 92 | while true: 93 | if body[i] == '\c' and body[i+1] == '\L' and 94 | body.skip(mboundary, i+2) != 0: 95 | if body.skip("--", i+2+mboundary.len) != 0: 96 | partsLeft = false 97 | break 98 | break 99 | else: 100 | newPart[1].add(body[i]) 101 | inc(i) 102 | i += body.skipWhitespace(i) 103 | 104 | result.add(name, newPart) 105 | 106 | proc parseMPFD*(contentType: string, body: string): MultiData = 107 | var boundaryEqIndex = contentType.find("boundary=")+9 108 | var boundary = contentType.substr(boundaryEqIndex, contentType.len()-1) 109 | return parseMultiPart(body, boundary) 110 | 111 | proc parseCookies*(s: string): Table[string, string] = 112 | ## parses cookies into a string table. 113 | ## 114 | ## The proc is meant to parse the Cookie header set by a client, not the 115 | ## "Set-Cookie" header set by servers. 116 | 117 | result = initTable[string, string]() 118 | var i = 0 119 | while true: 120 | i += skipWhile(s, {' ', '\t'}, i) 121 | var keystart = i 122 | i += skipUntil(s, {'='}, i) 123 | var keyend = i-1 124 | if i >= len(s): break 125 | inc(i) # skip '=' 126 | var valstart = i 127 | i += skipUntil(s, {';'}, i) 128 | result[substr(s, keystart, keyend)] = substr(s, valstart, i-1) 129 | if i >= len(s): break 130 | inc(i) # skip ';' 131 | 132 | type 133 | SameSite* = enum 134 | None, Lax, Strict 135 | 136 | proc makeCookie*(key, value, expires: string, domain = "", path = "", 137 | secure = false, httpOnly = false, 138 | sameSite = Lax): string = 139 | result = "" 140 | result.add key & "=" & value 141 | if domain != "": result.add("; Domain=" & domain) 142 | if path != "": result.add("; Path=" & path) 143 | if expires != "": result.add("; Expires=" & expires) 144 | if secure: result.add("; Secure") 145 | if httpOnly: result.add("; HttpOnly") 146 | result.add("; SameSite=" & $sameSite) 147 | 148 | when not declared(tables.getOrDefault): 149 | template getOrDefault*(tab, key): untyped = tab[key] 150 | 151 | when not declared(normalizePath) and not declared(normalizedPath): 152 | proc normalizePath*(path: var string) = 153 | ## Normalize a path. 154 | ## 155 | ## Consecutive directory separators are collapsed, including an initial double slash. 156 | ## 157 | ## On relative paths, double dot (..) sequences are collapsed if possible. 158 | ## On absolute paths they are always collapsed. 159 | ## 160 | ## Warning: URL-encoded and Unicode attempts at directory traversal are not detected. 161 | ## Triple dot is not handled. 162 | let isAbs = isAbsolute(path) 163 | var stack: seq[string] = @[] 164 | for p in split(path, {DirSep}): 165 | case p 166 | of "", ".": 167 | continue 168 | of "..": 169 | if stack.len == 0: 170 | if isAbs: 171 | discard # collapse all double dots on absoluta paths 172 | else: 173 | stack.add(p) 174 | elif stack[^1] == "..": 175 | stack.add(p) 176 | else: 177 | discard stack.pop() 178 | else: 179 | stack.add(p) 180 | 181 | if isAbs: 182 | path = DirSep & join(stack, $DirSep) 183 | elif stack.len > 0: 184 | path = join(stack, $DirSep) 185 | else: 186 | path = "." 187 | 188 | proc normalizedPath*(path: string): string = 189 | ## Returns a normalized path for the current OS. See `<#normalizePath>`_ 190 | result = path 191 | normalizePath(result) 192 | 193 | when isMainModule: 194 | var r = {:}.newStringTable 195 | parseUrlQuery("FirstName=Mickey", r) 196 | echo r 197 | 198 | -------------------------------------------------------------------------------- /jesterfork/request.nim: -------------------------------------------------------------------------------- 1 | import uri, cgi, tables, logging, strutils, re, options 2 | from sequtils import map 3 | 4 | import private/utils 5 | 6 | when useHttpBeast: 7 | import httpbeastfork except Settings 8 | import options, httpcore 9 | 10 | type 11 | NativeRequest* = httpbeastfork.Request 12 | else: 13 | import asynchttpserver 14 | 15 | type 16 | NativeRequest* = asynchttpserver.Request 17 | 18 | type 19 | Request* = object 20 | req: NativeRequest 21 | patternParams: Option[Table[string, string]] 22 | reMatches: array[MaxSubpatterns, string] 23 | settings*: Settings 24 | 25 | proc body*(req: Request): string = 26 | ## Body of the request, only for POST. 27 | ## 28 | ## You're probably looking for ``formData`` 29 | ## instead. 30 | when useHttpBeast: 31 | req.req.body.get("") 32 | else: 33 | req.req.body 34 | 35 | proc headers*(req: Request): HttpHeaders = 36 | ## Headers received with the request. 37 | ## Retrieving these is case insensitive. 38 | when useHttpBeast: 39 | if req.req.headers.isNone: 40 | newHttpHeaders() 41 | else: 42 | req.req.headers.get() 43 | else: 44 | req.req.headers 45 | 46 | proc path*(req: Request): string = 47 | ## Path of request without the query string. 48 | when useHttpBeast: 49 | let p = req.req.path.get("") 50 | let queryStart = p.find('?') 51 | if unlikely(queryStart != -1): 52 | return p[0 .. queryStart-1] 53 | else: 54 | return p 55 | else: 56 | let u = req.req.url 57 | return u.path 58 | 59 | proc query*(req: Request): string = 60 | ## Query string of request 61 | when useHttpBeast: 62 | let p = req.req.path.get("") 63 | let queryStart = p.find('?') 64 | if likely(queryStart != -1): 65 | return p[queryStart + 1 .. ^1] 66 | else: 67 | return "" 68 | else: 69 | let u = req.req.url 70 | return u.query 71 | 72 | proc reqMethod*(req: Request): HttpMethod = 73 | ## Request method, eg. HttpGet, HttpPost 74 | when useHttpBeast: 75 | req.req.httpMethod.get() 76 | else: 77 | req.req.reqMethod 78 | 79 | proc reqMeth*(req: Request): HttpMethod {.deprecated.} = 80 | req.reqMethod 81 | 82 | proc ip*(req: Request): string = 83 | ## IP address of the requesting client. 84 | when useHttpBeast: 85 | result = req.req.ip 86 | else: 87 | result = req.req.hostname 88 | 89 | let headers = req.headers 90 | if headers.hasKey("REMOTE_ADDR"): 91 | result = headers["REMOTE_ADDR"] 92 | if headers.hasKey("x-forwarded-for"): 93 | result = headers["x-forwarded-for"] 94 | 95 | proc params*(req: Request): Table[string, string] = 96 | ## Parameters from the pattern and the query string. 97 | ## 98 | ## Note that this doesn't allow for duplicated keys (it simply returns the last occuring value) 99 | ## Use `paramValuesAsSeq` if you need multiple values for a key 100 | if req.patternParams.isSome(): 101 | result = req.patternParams.get() 102 | else: 103 | result = initTable[string, string]() 104 | 105 | var queriesToDecode: seq[string] = @[] 106 | queriesToDecode.add query(req) 107 | 108 | let contentType = req.headers.getOrDefault("Content-Type") 109 | if contentType.startswith("application/x-www-form-urlencoded"): 110 | queriesToDecode.add req.body 111 | 112 | for query in queriesToDecode: 113 | try: 114 | for key, val in cgi.decodeData(query): 115 | result[key] = decodeUrl(val) 116 | except CgiError: 117 | logging.warn("Incorrect query. Got: $1" % [query]) 118 | 119 | proc paramValuesAsSeq*(req: Request): Table[string, seq[string]] = 120 | ## Parameters from the pattern and the query string. 121 | ## 122 | ## This allows for duplicated keys in the query (in contrast to `params`) 123 | if req.patternParams.isSome(): 124 | let patternParams: Table[string, string] = req.patternParams.get() 125 | var patternParamsSeq: seq[(string, string)] = @[] 126 | for key, val in pairs(patternParams): 127 | patternParamsSeq.add (key, val) 128 | 129 | # We are not url-decoding the key/value for the patternParams (matches implementation in `params` 130 | result = sequtils.map(patternParamsSeq, 131 | proc(entry: (string, string)): (string, seq[string]) = 132 | (entry[0], @[entry[1]]) 133 | ).toTable() 134 | else: 135 | result = initTable[string, seq[string]]() 136 | 137 | var queriesToDecode: seq[string] = @[] 138 | queriesToDecode.add query(req) 139 | 140 | let contentType = req.headers.getOrDefault("Content-Type") 141 | if contentType.startswith("application/x-www-form-urlencoded"): 142 | queriesToDecode.add req.body 143 | 144 | for query in queriesToDecode: 145 | try: 146 | for key, value in cgi.decodeData(query): 147 | if result.hasKey(key): 148 | result[key].add value 149 | else: 150 | result[key] = @[value] 151 | except CgiError: 152 | logging.warn("Incorrect query. Got: $1" % [query]) 153 | 154 | proc formData*(req: Request): MultiData = 155 | let contentType = req.headers.getOrDefault("Content-Type") 156 | if contentType.startsWith("multipart/form-data"): 157 | result = parseMPFD(contentType, req.body) 158 | 159 | proc matches*(req: Request): array[MaxSubpatterns, string] = 160 | req.reMatches 161 | 162 | proc secure*(req: Request): bool = 163 | if req.headers.hasKey("x-forwarded-proto"): 164 | let proto = req.headers["x-forwarded-proto"] 165 | case proto.toLowerAscii() 166 | of "https": 167 | result = true 168 | of "http": 169 | result = false 170 | else: 171 | logging.warn("Unknown x-forwarded-proto ", proto) 172 | 173 | proc port*(req: Request): int = 174 | if (let p = req.headers.getOrDefault("SERVER_PORT"); p != ""): 175 | result = p.parseInt 176 | else: 177 | result = if req.secure: 443 else: 80 178 | 179 | proc host*(req: Request): string = 180 | req.headers.getOrDefault("HOST") 181 | 182 | proc appName*(req: Request): string = 183 | ## This is set by the user in ``run``, it is 184 | ## overriden by the "SCRIPT_NAME" scgi 185 | ## parameter. 186 | req.settings.appName 187 | 188 | proc stripAppName(path, appName: string): string = 189 | result = path 190 | if appname.len > 0: 191 | var slashAppName = appName 192 | if slashAppName[0] != '/' and path[0] == '/': 193 | slashAppName = '/' & slashAppName 194 | 195 | if path.startsWith(slashAppName): 196 | if slashAppName.len() == path.len: 197 | return "/" 198 | else: 199 | return path[slashAppName.len .. path.len-1] 200 | else: 201 | raise newException(ValueError, 202 | "Expected script name at beginning of path. Got path: " & 203 | path & " script name: " & slashAppName) 204 | 205 | proc pathInfo*(req: Request): string = 206 | ## This is ``.path`` without ``.appName``. 207 | req.path.stripAppName(req.appName) 208 | 209 | # TODO: Can cookie keys be duplicated? 210 | proc cookies*(req: Request): Table[string, string] = 211 | ## Cookies from the browser. 212 | if (let cookie = req.headers.getOrDefault("Cookie"); cookie != ""): 213 | result = parseCookies(cookie) 214 | else: 215 | result = initTable[string, string]() 216 | 217 | #[ Protected procs ]# 218 | 219 | proc initRequest*(req: NativeRequest, settings: Settings): Request {.inline.} = 220 | Request( 221 | req: req, 222 | settings: settings 223 | ) 224 | 225 | proc getNativeReq*(req: Request): NativeRequest = 226 | req.req 227 | 228 | #[ Only to be used by our route macro. ]# 229 | proc setPatternParams*(req: var Request, p: Table[string, string]) = 230 | req.patternParams = some(p) 231 | 232 | proc setReMatches*(req: var Request, r: array[MaxSubpatterns, string]) = 233 | req.reMatches = r 234 | -------------------------------------------------------------------------------- /readme.markdown: -------------------------------------------------------------------------------- 1 | # FORK OF Jester 2 | 3 | A fork of `jester` updated to work with Nim version 2.0. 4 | Package has been renamed to `jesterfork`. 5 | 6 | **Reason:** 7 | * Pull requests not being merged (or closed) in main repo 8 | * Dependency to `httpbeast` does not support Nim 2.0 9 | * Full support for Nim 2.0 10 | 11 | **Jester & httpbeast:** 12 | * To use this package with `httpbeast`, you need the fork [`httpbeastfork`](https://github.com/ThomasTJdev/httpbeast_fork). 13 | 14 | **Breaking changes / Info:** 15 | * The minimum Nim version this fork supports is 1.6.18. Test are done with Nim 1.6.18 and stable (2.x.x) versions. 16 | * Support for `httpbeast` is dropped. Use [`httpbeastfork`](https://github.com/ThomasTJdev/httpbeast_fork). 17 | * Various pull requests from the main repo has been merged into this fork. 18 | 19 | 20 | # 🃏 Jester 🃏 21 | 22 | The sinatra-like web framework for Nim. Jester provides a DSL for quickly 23 | creating web applications in Nim. 24 | 25 | ```nim 26 | # example.nim 27 | import htmlgen 28 | import jesterfork 29 | 30 | routes: 31 | get "/": 32 | resp h1("Hello world") 33 | ``` 34 | 35 | Compile and run with: 36 | 37 | ``` 38 | cd tests/example 39 | nim c -r example.nim 40 | ``` 41 | 42 | View at: [localhost:5000](http://localhost:5000) 43 | 44 | Before deploying to production ensure you run your application behind a reverse proxy. This library is not yet hardened against HTTP security exploits so applications written in it should not be exposed to the public internet. 45 | 46 | ## Routes 47 | 48 | ```nim 49 | routes: 50 | get "/": 51 | # do something here. 52 | ``` 53 | 54 | All routes must be inside a ``routes`` block. 55 | 56 | Routes will be executed in the order that they are declared. So be careful when 57 | halting. 58 | 59 | The route path may contain a special pattern or just a static string. Special 60 | patterns are almost identical to Sinatra's, the only real difference is the 61 | use of ``@`` instead of the ``:``. 62 | 63 | ```nim 64 | get "/hello/@name": 65 | # This matches "/hello/fred" and "/hello/bob". 66 | # In the route ``@"name"`` will be either "fred" or "bob". 67 | # This can of course match any value which does not contain '/'. 68 | resp "Hello " & @"name" 69 | ``` 70 | 71 | The patterns in Jester are currently a bit more limited, there is no 72 | wildcard patterns. 73 | 74 | You can use the '?' character to signify optional path parts. 75 | 76 | ```nim 77 | get "/hello/@name?": 78 | # This will match what the previous code example matches but will also match 79 | # "/hello/". 80 | if @"name" == "": 81 | resp "No name received :(" 82 | else: 83 | resp "Hello " & @"name" 84 | ``` 85 | 86 | In this case you might want to make the leading '/' optional too, you can do this 87 | by changing the pattern to "/hello/?@name?". This is useful because Jester will 88 | not match "/hello" if the leading '/' is not made optional. 89 | 90 | ### Regex 91 | 92 | Regex can also be used as a route pattern. The subpattern captures will be 93 | placed in ``request.matches`` when a route is matched. For example: 94 | 95 | ```nim 96 | get re"^\/([0-9]{2})\.html$": 97 | resp request.matches[0] 98 | ``` 99 | 100 | This will match URLs of the form ``/15.html``. In this case 101 | ``request.matches[0]`` will be ``15``. 102 | 103 | ## Conditions 104 | 105 | Jester supports conditions, however they are limited to a simple ``cond`` template. 106 | 107 | ```nim 108 | routes: 109 | get "/@name": 110 | cond @"name" == "daniel" 111 | # ``cond`` will pass execution to the next matching route if @"name" is not 112 | # "daniel". 113 | resp "Correct, my name is daniel." 114 | 115 | get "/@name": 116 | # This will be the next route that is matched. 117 | resp "No, that's not my name." 118 | ``` 119 | 120 | ## Return values 121 | 122 | Route bodies all have an implicit ``request`` object. This object is documented 123 | in jesterfork.nim and documentation can be generated by executing ``nim doc jesterfork.nim``. 124 | 125 | Returning a response from a route should be done using one of the following 126 | functions: 127 | 128 | * One of the ``resp`` functions. 129 | * By setting ``body``, ``headers`` and/or ``status`` and calling ``return``. 130 | * ``redirect`` function 131 | * ``attachment`` function 132 | 133 | There might be more. Take a look at the documentation of jesterfork.nim for more info. 134 | 135 | ## Manual routing 136 | 137 | It is possible not to use the ``routes`` macro and to do the routing yourself. 138 | 139 | You can do this by writing your own ``match`` procedure. Take a look at 140 | [example2](tests/example2.nim) for an example on how to do this. 141 | 142 | ## Static files 143 | 144 | By default Jester looks for static files in ``./public``. This can be overriden 145 | using the ``setStaticDir`` function. Files will be served like so: 146 | 147 | ./public/css/style.css ``->`` http://example.com/css/style.css 148 | 149 | **Note**: Jester will only serve files, that are readable by ``others``. On 150 | Unix/Linux you can ensure this with ``chmod o+r ./public/css/style.css``. 151 | 152 | ## Cookies 153 | 154 | Cookies can be set using the ``setCookie`` function. 155 | 156 | ```nim 157 | get "/": 158 | # Set a cookie "test:value" and make it expire in 5 days. 159 | setCookie("test", @"value", daysForward(5)) 160 | ``` 161 | 162 | They can then be accessed using the ``request.cookies`` procedure which returns 163 | a ``Table[string, string]``. 164 | 165 | ## Request object 166 | 167 | The request object holds all the information about the current request. 168 | You can access it from a route using the ``request`` variable. It is defined as: 169 | 170 | ```nim 171 | Request* = ref object 172 | params*: StringTableRef ## Parameters from the pattern, but also the 173 | ## query string. 174 | matches*: array[MaxSubpatterns, string] ## Matches if this is a regex 175 | ## pattern. 176 | body*: string ## Body of the request, only for POST. 177 | ## You're probably looking for ``formData`` 178 | ## instead. 179 | headers*: StringTableRef ## Headers received with the request. 180 | ## Retrieving these is case insensitive. 181 | formData*: MultiData ## Form data; only present for 182 | ## multipart/form-data 183 | port*: int 184 | host*: string 185 | appName*: string ## This is set by the user in ``run``, it is 186 | ## overriden by the "SCRIPT_NAME" scgi 187 | ## parameter. 188 | pathInfo*: string ## This is ``.path`` without ``.appName``. 189 | secure*: bool 190 | path*: string ## Path of request. 191 | query*: string ## Query string of request. 192 | cookies*: StringTableRef ## Cookies from the browser. 193 | ip*: string ## IP address of the requesting client. 194 | reqMeth*: HttpMethod ## Request method, eg. HttpGet, HttpPost 195 | settings*: Settings 196 | ``` 197 | 198 | ## Examples 199 | 200 | ### Custom router 201 | 202 | A custom router allows running your own initialization code and pass dynamic settings to Jester before starting the async loop. 203 | 204 | ```nim 205 | import asyncdispatch, jesterfork, os, strutils 206 | 207 | router myrouter: 208 | get "/": 209 | resp "It's alive!" 210 | 211 | proc main() = 212 | let port = paramStr(1).parseInt().Port 213 | let settings = newSettings(port=port) 214 | var jesterfork = initJester(myrouter, settings=settings) 215 | jesterfork.serve() 216 | 217 | when isMainModule: 218 | main() 219 | ``` 220 | 221 | ### Github service hooks 222 | 223 | The code for this is pretty similar to the code for Sinatra given here: http://help.github.com/post-receive-hooks/ 224 | 225 | ```nim 226 | import jesterfork, json 227 | 228 | routes: 229 | post "/": 230 | var push = parseJson(@"payload") 231 | resp "I got some JSON: " & $push 232 | ``` 233 | -------------------------------------------------------------------------------- /tests/tester.nim: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2018 Dominik Picheta 2 | # MIT License - Look at license.txt for details. 3 | import unittest, httpclient, strutils, asyncdispatch, os, terminal 4 | from osproc import execCmd 5 | 6 | import asynctools 7 | 8 | const 9 | port = 5454 10 | address = "http://localhost:" & $port 11 | var serverProcess: AsyncProcess 12 | 13 | proc readLoop(process: AsyncProcess) {.async.} = 14 | var wholebuf: string 15 | while true: 16 | var buf = newString(256) 17 | let len = await readInto(process.outputHandle, addr buf[0], 256) 18 | if len == 0: 19 | break 20 | buf.setLen(len) 21 | wholebuf.add(buf) 22 | while "\l" in wholebuf: 23 | let parts = wholebuf.split("\l", 1) 24 | styledEcho(fgBlue, "Process: ", resetStyle, parts[0]) 25 | wholebuf = parts[1] 26 | if wholebuf != "": 27 | styledEcho(fgBlue, "Process: ", resetStyle, wholebuf) 28 | styledEcho(fgRed, "Process terminated") 29 | 30 | proc startServer(file: string, useStdLib: bool) {.async.} = 31 | var file = "tests" / file 32 | if not serverProcess.isNil and serverProcess.running: 33 | serverProcess.terminate() 34 | # TODO: https://github.com/cheatfate/asynctools/issues/9 35 | doAssert execCmd("kill -15 " & $serverProcess.processID()) == QuitSuccess 36 | serverProcess = nil 37 | 38 | # The nim process doesn't behave well when using `-r`, if we kill it, the 39 | # process continues running... 40 | let stdLibFlag = 41 | if useStdLib: 42 | " -d:useStdLib " 43 | else: 44 | "" 45 | doAssert execCmd("nimble c --hints:off -y " & stdLibFlag & file) == QuitSuccess 46 | 47 | serverProcess = startProcess(file.changeFileExt(ExeExt)) 48 | asyncCheck readLoop(serverProcess) 49 | 50 | # Wait until server responds: 51 | await sleepAsync(10) # give it a chance to start 52 | for i in 0..10: 53 | var client = newAsyncHttpClient() 54 | styledEcho(fgBlue, "Getting ", address, " - attempt " & $i) 55 | let fut = client.get(address) 56 | yield fut or sleepAsync(3000) 57 | if not fut.finished: 58 | styledEcho(fgYellow, "Timed out") 59 | elif not fut.failed: 60 | styledEcho(fgGreen, "Server started!") 61 | return 62 | else: echo fut.error.msg 63 | client.close() 64 | if not serverProcess.running: 65 | doAssert false, "Server died." 66 | await sleepAsync(1000) 67 | 68 | doAssert false, "Failed to start server." 69 | 70 | proc allTest(useStdLib: bool) = 71 | waitFor startServer("alltest.nim", useStdLib) 72 | var client = newAsyncHttpClient(maxRedirects = 0) 73 | 74 | test "doesn't crash on missing script name": 75 | # If this fails then alltest is likely not running. 76 | let resp = waitFor client.get(address) 77 | checkpoint (waitFor resp.body) 78 | checkpoint $resp.code 79 | check resp.code.is5xx 80 | 81 | test "can access root": 82 | # If this fails then alltest is likely not running. 83 | let resp = waitFor client.get(address & "/foo/") 84 | check resp.status.startsWith("200") 85 | check (waitFor resp.body) == "Hello World" 86 | 87 | test "/nil": 88 | # Issue #139 89 | let resp = waitFor client.get(address & "/foo/nil") 90 | check resp.status.startsWith("200") 91 | check (waitFor resp.body) == "" 92 | 93 | test "/halt": 94 | let resp = waitFor client.get(address & "/foo/halt") 95 | check resp.status.startsWith("502") 96 | check (waitFor resp.body) == "I'm sorry, this page has been halted." 97 | 98 | test "/halt-before": 99 | let resp = waitFor client.request(address & "/foo/halt-before/something", HttpGet) 100 | let body = waitFor resp.body 101 | check body == "Halted!" 102 | 103 | test "/guess": 104 | let resp = waitFor client.get(address & "/foo/guess/foo") 105 | check (waitFor resp.body) == "Haha. You will never find me!" 106 | let resp2 = waitFor client.get(address & "/foo/guess/Frank") 107 | check (waitFor resp2.body) == "You've found me!" 108 | 109 | test "/redirect": 110 | let resp = waitFor client.request(address & "/foo/redirect/halt", HttpGet) 111 | check resp.headers["location"] == "http://localhost:5454/foo/halt" 112 | 113 | test "/redirect-halt": 114 | let resp = waitFor client.request(address & "/foo/redirect-halt/halt", HttpGet) 115 | check resp.headers["location"] == "http://localhost:5454/foo/halt" 116 | check (waitFor resp.body) == "" 117 | 118 | test "/redirect-before": 119 | let resp = waitFor client.request(address & "/foo/redirect-before/anywhere", HttpGet) 120 | check resp.headers["location"] == "http://localhost:5454/foo/nowhere" 121 | let body = waitFor resp.body 122 | check body == "" 123 | 124 | test "regex": 125 | let resp = waitFor client.get(address & "/foo/02.html") 126 | check (waitFor resp.body) == "02" 127 | 128 | test "resp": 129 | let resp = waitFor client.get(address & "/foo/resp") 130 | check (waitFor resp.body) == "This should be the response" 131 | 132 | test "template": 133 | let resp = waitFor client.get(address & "/foo/template") 134 | check (waitFor resp.body) == "Templates now work!" 135 | 136 | test "json": 137 | let resp = waitFor client.get(address & "/foo/json") 138 | check resp.headers["Content-Type"] == "application/json" 139 | check (waitFor resp.body) == """{"name":"Dominik"}""" 140 | 141 | test "sendFile": 142 | let resp = waitFor client.get(address & "/foo/sendFile") 143 | check (waitFor resp.body) == "Hello World!" 144 | 145 | test "can access query": 146 | let resp = waitFor client.get(address & "/foo/query?q=test") 147 | check (waitFor resp.body) == """{"q": "test"}""" 148 | 149 | test "can access querystring": 150 | let resp = waitFor client.get(address & "/foo/querystring?q=test&field=5") 151 | check (waitFor resp.body) == "q=test&field=5" 152 | 153 | test "issue 157": 154 | let resp = waitFor client.get(address & "/foo/issue157") 155 | let headers = resp.headers 156 | check headers["Content-Type"] == "text/css" 157 | 158 | test "resp doesn't overwrite headers": 159 | let resp = waitFor client.get(address & "/foo/manyheaders") 160 | let headers = resp.headers 161 | check headers["foo"] == "foo" 162 | check headers["bar"] == "bar" 163 | check headers["Content-Type"] == "text/plain" 164 | 165 | test "redirect set http status code": 166 | let resp = waitFor client.get(address & "/foo/redirectDefault") 167 | check resp.code == Http303 168 | let resp301 = waitFor client.get(address & "/foo/redirect301") 169 | check resp301.code == Http301 170 | let resp302 = waitFor client.get(address & "/foo/redirect302") 171 | check resp302.code == Http302 172 | 173 | suite "static": 174 | test "index.html": 175 | let resp = waitFor client.get(address & "/foo/root") 176 | let body = waitFor resp.body 177 | check body.startsWith("This should be available at /root/.") 178 | 179 | test "test_file.txt": 180 | let resp = waitFor client.get(address & "/foo/root/test_file.txt") 181 | check (waitFor resp.body) == "Hello World!" 182 | 183 | test "detects attempts to read parent dirs": 184 | # curl -v --path-as-is http://127.0.0.1:5454/foo/../public2/should_be_inaccessible 185 | let resp = waitFor client.get(address & "/foo/root/../../tester.nim") 186 | check resp.code == Http400 187 | let resp2 = waitFor client.get(address & "/foo/root/..%2f../tester.nim") 188 | check resp2.code == Http400 189 | let resp3 = waitFor client.get(address & "/foo/../public2/should_be_inaccessible") 190 | check resp3.code == Http400 191 | 192 | suite "extends": 193 | test "simple": 194 | let resp = waitFor client.get(address & "/foo/internal/simple") 195 | check (waitFor resp.body) == "Works!" 196 | 197 | test "params": 198 | let resp = waitFor client.get(address & "/foo/internal/params/blah") 199 | check (waitFor resp.body) == "blah" 200 | 201 | test "separate module": 202 | let resp = waitFor client.get(address & "/foo/external/params/qwer") 203 | check (waitFor resp.body) == "qwer" 204 | 205 | test "external regex": 206 | let resp = waitFor client.get(address & "/foo/external/(foobar)/qwer/") 207 | check (waitFor resp.body) == "qwer" 208 | 209 | test "regex path prefix escaped": 210 | let resp = waitFor client.get(address & "/foo/(regexEscaped.txt)/(foobar)/1/") 211 | check (waitFor resp.body) == "1" 212 | 213 | suite "error": 214 | test "exception": 215 | let resp = waitFor client.get(address & "/foo/MyCustomError") 216 | check (waitFor resp.body) == "Something went wrong: ref MyCustomError" 217 | 218 | test "HttpCode handling": 219 | let resp = waitFor client.get(address & "/foo/403") 220 | check (waitFor resp.body) == "OK: 403 Forbidden" 221 | 222 | test "`pass` in error handler": 223 | let resp = waitFor client.get(address & "/foo/401") 224 | check (waitFor resp.body) == "OK: 401 Unauthorized" 225 | 226 | test "custom 404": 227 | let resp = waitFor client.get(address & "/foo/404") 228 | check (waitFor resp.body) == "404 not found!!!" 229 | 230 | suite "before/after": 231 | test "before - halt": 232 | let resp = waitFor client.get(address & "/foo/before/restricted") 233 | check (waitFor resp.body) == "You cannot access this!" 234 | 235 | test "before - unaffected": 236 | let resp = waitFor client.get(address & "/foo/before/available") 237 | check (waitFor resp.body) == "This is accessible" 238 | 239 | test "before - global": 240 | let resp = waitFor client.get(address & "/foo/before/global") 241 | check (waitFor resp.body) == "Before/Global: OK! After global `before`: OK!" 242 | 243 | test "before - 404": 244 | let resp = waitFor client.get(address & "/foo/before/blah") 245 | check resp.code == Http404 246 | 247 | test "after - added": 248 | let resp = waitFor client.get(address & "/foo/after/added") 249 | check (waitFor resp.body) == "Hello! Added by after!" 250 | 251 | proc issue150(useStdLib: bool) = 252 | waitFor startServer("issue150.nim", useStdLib) 253 | var client = newAsyncHttpClient(maxRedirects = 0) 254 | 255 | suite "issue150 useStdLib=" & $useStdLib: 256 | test "can get root": 257 | # If this fails then `issue150` is likely not running. 258 | let resp = waitFor client.get(address) 259 | check resp.code == Http200 260 | 261 | test "can use custom 404 handler": 262 | let resp = waitFor client.get(address & "/nonexistent") 263 | check resp.code == Http404 264 | check (waitFor resp.body) == "Looks you took a wrong turn somewhere." 265 | 266 | test "can use custom error handler": 267 | let resp = waitFor client.get(address & "/raise") 268 | check resp.code == Http500 269 | check (waitFor resp.body).startsWith("Something bad happened") 270 | 271 | proc customRouterTest(useStdLib: bool) = 272 | waitFor startServer("customRouter.nim", useStdLib) 273 | var client = newAsyncHttpClient(maxRedirects = 0) 274 | 275 | suite "customRouter useStdLib=" & $useStdLib: 276 | test "error handler": 277 | let resp = waitFor client.get(address & "/raise") 278 | check resp.code == Http500 279 | let body = (waitFor resp.body) 280 | checkpoint body 281 | check body.startsWith("Something bad happened: Foobar") 282 | 283 | test "redirect in error": 284 | let resp = waitFor client.get(address & "/definitely404route") 285 | check resp.code == Http303 286 | check resp.headers["location"] == address & "/404" 287 | check (waitFor resp.body) == "" 288 | 289 | proc issue247(useStdLib: bool) = 290 | waitFor startServer("issue247.nim", useStdLib) 291 | var client = newAsyncHttpClient(maxRedirects = 0) 292 | 293 | suite "issue247 useStdLib=" & $useStdLib: 294 | test "duplicate keys in query": 295 | let resp = waitFor client.get(address & "/multi?a=1&a=2") 296 | check (waitFor resp.body) == "a: 1,2" 297 | 298 | test "no duplicate keys in query": 299 | let resp = waitFor client.get(address & "/multi?a=1") 300 | check (waitFor resp.body) == "a: 1" 301 | 302 | test "assure that empty values are handled": 303 | let resp = waitFor client.get(address & "/multi?a=1&a=") 304 | check (waitFor resp.body) == "a: 1," 305 | 306 | test "assure that fragment is not parsed": 307 | let resp = waitFor client.get(address & "/multi?a=1&#a=2") 308 | check (waitFor resp.body) == "a: 1" 309 | 310 | test "ensure that values are url decoded per default": 311 | let resp = waitFor client.get(address & "/multi?a=1&a=1%232") 312 | check (waitFor resp.body) == "a: 1,1#2" 313 | 314 | test "ensure that keys are url decoded per default": 315 | let resp = waitFor client.get(address & "/multi?a%23b=1&a%23b=1%232") 316 | check (waitFor resp.body) == "a#b: 1,1#2" 317 | 318 | test "test different keys": 319 | let resp = waitFor client.get(address & "/multi?a=1&b=2") 320 | check (waitFor resp.body) == "b: 2a: 1" 321 | 322 | test "ensure that path params aren't escaped": 323 | let resp = waitFor client.get(address & "/hello%23world") 324 | check (waitFor resp.body) == "val%23ue: hello%23world" 325 | 326 | test "test path params and query": 327 | let resp = waitFor client.get(address & "/hello%23world?a%23+b=1%23+b") 328 | check (waitFor resp.body) == "a# b: 1# bval%23ue: hello%23world" 329 | 330 | test "test percent encoded path param and query param (same key)": 331 | let resp = waitFor client.get(address & "/hello%23world?val%23ue=1%23+b") 332 | check (waitFor resp.body) == "val%23ue: hello%23worldval#ue: 1# b" 333 | 334 | test "test path param, query param and x-www-form-urlencoded": 335 | client.headers = newHttpHeaders({"Content-Type": "application/x-www-form-urlencoded"}) 336 | let resp = waitFor client.post(address & "/hello%23world?val%23ue=1%23+b", "val%23ue=1%23+b&b=2") 337 | check (waitFor resp.body) == "val%23ue: hello%23worldb: 2val#ue: 1# b,1# b" 338 | 339 | test "params duplicate keys in query": 340 | let resp = waitFor client.get(address & "/params?a=1&a=2") 341 | check (waitFor resp.body) == "a: 2" 342 | 343 | test "params no duplicate keys in query": 344 | let resp = waitFor client.get(address & "/params?a=1") 345 | check (waitFor resp.body) == "a: 1" 346 | 347 | test "params assure that empty values are handled": 348 | let resp = waitFor client.get(address & "/params?a=1&a=") 349 | check (waitFor resp.body) == "a: " 350 | 351 | test "params assure that fragment is not parsed": 352 | let resp = waitFor client.get(address & "/params?a=1&#a=2") 353 | check (waitFor resp.body) == "a: 1" 354 | 355 | test "params ensure that values are url decoded per default": 356 | let resp = waitFor client.get(address & "/params?a=1&a=1%232") 357 | check (waitFor resp.body) == "a: 1#2" 358 | 359 | test "params ensure that keys are url decoded per default": 360 | let resp = waitFor client.get(address & "/params?a%23b=1&a%23b=1%232") 361 | check (waitFor resp.body) == "a#b: 1#2" 362 | 363 | test "params test different keys": 364 | let resp = waitFor client.get(address & "/params?a=1&b=2") 365 | check (waitFor resp.body) == "b: 2a: 1" 366 | 367 | test "params ensure that path params aren't escaped": 368 | let resp = waitFor client.get(address & "/params/hello%23world") 369 | check (waitFor resp.body) == "val%23ue: hello%23world" 370 | 371 | test "params test path params and query": 372 | let resp = waitFor client.get(address & "/params/hello%23world?a%23+b=1%23+b") 373 | check (waitFor resp.body) == "a# b: 1# bval%23ue: hello%23world" 374 | 375 | test "params test percent encoded path param and query param (same key)": 376 | let resp = waitFor client.get(address & "/params/hello%23world?val%23ue=1%23+b") 377 | check (waitFor resp.body) == "val#ue: 1# bval%23ue: hello%23world" 378 | 379 | test "params test path param, query param and x-www-form-urlencoded": 380 | client.headers = newHttpHeaders({"Content-Type": "application/x-www-form-urlencoded"}) 381 | let resp = waitFor client.post(address & "/params/hello%23world?val%23ue=1%23+b", "val%23ue=1%23+b&b=2") 382 | check (waitFor resp.body) == "b: 2val#ue: 1# bval%23ue: hello%23world" 383 | 384 | when isMainModule: 385 | try: 386 | allTest(useStdLib=false) # Test HttpBeast. 387 | allTest(useStdLib=true) # Test asynchttpserver. 388 | issue150(useStdLib=false) 389 | issue150(useStdLib=true) 390 | customRouterTest(useStdLib=false) 391 | customRouterTest(useStdLib=true) 392 | issue247(useStdLib=false) 393 | issue247(useStdLib=true) 394 | 395 | # Verify that Nim in Action Tweeter still compiles. 396 | test "Nim in Action - Tweeter": 397 | let path = "tests/nim-in-action-code/Chapter7/Tweeter/src/tweeter.nim" 398 | check execCmd("nim c --threads:off --path:. " & path) == QuitSuccess 399 | finally: 400 | doAssert execCmd("kill -15 " & $serverProcess.processID()) == QuitSuccess 401 | -------------------------------------------------------------------------------- /jesterfork.nim: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Dominik Picheta 2 | # MIT License - Look at license.txt for details. 3 | import net, strtabs, re, tables, os, strutils, uri, 4 | times, mimetypes, asyncnet, asyncdispatch, macros, md5, 5 | logging, httpcore, asyncfile, macrocache, json, options, 6 | strformat 7 | 8 | import jesterfork/private/[errorpages, utils] 9 | import jesterfork/[request, patterns] 10 | 11 | from cgi import decodeData, decodeUrl, CgiError 12 | 13 | export request 14 | export strtabs 15 | export tables 16 | export httpcore 17 | export options 18 | export MultiData 19 | export HttpMethod 20 | export asyncdispatch 21 | 22 | export SameSite 23 | 24 | when useHttpBeast: 25 | import httpbeastfork except Settings, Request 26 | import options 27 | from nativesockets import close 28 | else: 29 | import asynchttpserver except Request 30 | 31 | type 32 | MatchProc* = proc (request: Request): Future[ResponseData] {.gcsafe, closure.} 33 | MatchProcSync* = proc (request: Request): ResponseData{.gcsafe, closure.} 34 | 35 | Matcher = object 36 | case async: bool 37 | of false: 38 | syncProc: MatchProcSync 39 | of true: 40 | asyncProc: MatchProc 41 | 42 | ErrorProc* = proc ( 43 | request: Request, error: RouteError 44 | ): Future[ResponseData] {.gcsafe, closure.} 45 | 46 | MatchPair* = tuple 47 | matcher: MatchProc 48 | errorHandler: ErrorProc 49 | 50 | MatchPairSync* = tuple 51 | matcher: MatchProcSync 52 | errorHandler: ErrorProc 53 | 54 | Jester* = object 55 | when not useHttpBeast: 56 | httpServer*: AsyncHttpServer 57 | settings: Settings 58 | matchers: seq[Matcher] 59 | errorHandlers: seq[ErrorProc] 60 | 61 | MatchType* = enum 62 | MRegex, MSpecial, MStatic 63 | 64 | RawHeaders* = seq[tuple[key, val: string]] 65 | ResponseHeaders* = Option[RawHeaders] 66 | ResponseData* = tuple[ 67 | action: CallbackAction, 68 | code: HttpCode, 69 | headers: ResponseHeaders, 70 | content: string, 71 | matched: bool 72 | ] 73 | 74 | CallbackAction* = enum 75 | TCActionNothing, TCActionSend, TCActionRaw, TCActionPass 76 | 77 | RouteErrorKind* = enum 78 | RouteException, RouteCode 79 | RouteError* = object 80 | case kind*: RouteErrorKind 81 | of RouteException: 82 | exc: ref Exception 83 | of RouteCode: 84 | data: ResponseData 85 | 86 | Startup = proc () {.closure, gcsafe.} 87 | 88 | const jesterVer = "1.0.0" 89 | 90 | proc doNothing(): Startup {.gcsafe.} = 91 | result = proc () {.closure, gcsafe.} = 92 | discard 93 | 94 | proc toStr(headers: Option[RawHeaders]): string = 95 | return $newHttpHeaders(headers.get(@({:}))) 96 | 97 | proc createHeaders(headers: RawHeaders): string = 98 | result = "" 99 | if headers.len > 0: 100 | for header in headers: 101 | let (key, value) = header 102 | result.add(key & ": " & value & "\c\L") 103 | 104 | result = result[0 .. ^3] # Strip trailing \c\L 105 | 106 | proc createResponse(status: HttpCode, headers: RawHeaders): string = 107 | return "HTTP/1.1 " & $status & "\c\L" & createHeaders(headers) & "\c\L\c\L" 108 | 109 | proc unsafeSend(request: Request, content: string) = 110 | when useHttpBeast: 111 | request.getNativeReq.unsafeSend(content) 112 | else: 113 | # TODO: This may cause issues if we send too fast. 114 | asyncCheck request.getNativeReq.client.send(content) 115 | 116 | proc newCompletedFuture(): Future[void] = 117 | result = newFuture[void]() 118 | complete(result) 119 | 120 | proc send( 121 | request: Request, code: HttpCode, headers: Option[RawHeaders], body: string 122 | ): Future[void] = 123 | when useHttpBeast: 124 | let h = 125 | if headers.isNone: "" 126 | else: headers.get().createHeaders 127 | request.getNativeReq.send(code, body, h) 128 | return newCompletedFuture() 129 | else: 130 | return request.getNativeReq.respond( 131 | code, body, newHttpHeaders(headers.get(@({:}))) 132 | ) 133 | 134 | proc statusContent(request: Request, status: HttpCode, content: string, 135 | headers: Option[RawHeaders]): Future[void] = 136 | try: 137 | result = send(request, status, headers, content) 138 | when not defined(release): 139 | logging.debug(" $1 $2" % [$status, toStr(headers)]) 140 | except: 141 | result = newCompletedFuture() 142 | logging.error("Could not send response: $1" % osErrorMsg(osLastError())) 143 | 144 | # TODO: Add support for proper Future Streams instead of this weird raw mode. 145 | template enableRawMode* = 146 | # TODO: Use the effect system to make this implicit? 147 | result.action = TCActionRaw 148 | 149 | proc send*(request: Request, content: string) = 150 | ## Sends ``content`` immediately to the client socket. 151 | ## 152 | ## Routes using this procedure must enable raw mode. 153 | unsafeSend(request, content) 154 | 155 | proc sendHeaders*(request: Request, status: HttpCode, 156 | headers: RawHeaders) = 157 | ## Sends ``status`` and ``headers`` to the client socket immediately. 158 | ## The user is then able to send the content immediately to the client on 159 | ## the fly through the use of ``response.client``. 160 | let headerData = createResponse(status, headers) 161 | try: 162 | request.send(headerData) 163 | logging.debug(" $1 $2" % [$status, $headers]) 164 | except: 165 | logging.error("Could not send response: $1" % [osErrorMsg(osLastError())]) 166 | 167 | proc sendHeaders*(request: Request, status: HttpCode) = 168 | ## Sends ``status`` and ``Content-Type: text/html`` as the headers to the 169 | ## client socket immediately. 170 | let headers = @({"Content-Type": "text/html;charset=utf-8"}) 171 | request.sendHeaders(status, headers) 172 | 173 | proc sendHeaders*(request: Request) = 174 | ## Sends ``Http200`` and ``Content-Type: text/html`` as the headers to the 175 | ## client socket immediately. 176 | request.sendHeaders(Http200) 177 | 178 | proc send*(request: Request, status: HttpCode, headers: RawHeaders, 179 | content: string) = 180 | ## Sends out a HTTP response comprising of the ``status``, ``headers`` and 181 | ## ``content`` specified. 182 | var headers = headers & @({"Content-Length": $content.len}) 183 | request.sendHeaders(status, headers) 184 | request.send(content) 185 | 186 | # TODO: Cannot capture 'paths: varargs[string]' here. 187 | proc sendStaticIfExists( 188 | req: Request, paths: seq[string] 189 | ): Future[HttpCode] {.async.} = 190 | result = Http200 191 | for p in paths: 192 | if existsFile(p): 193 | 194 | var fp = getFilePermissions(p) 195 | if not fp.contains(fpOthersRead): 196 | return Http403 197 | 198 | let fileSize = getFileSize(p) 199 | let ext = p.splitFile.ext 200 | let mimetype = req.settings.mimes.getMimetype( 201 | if ext.len > 0: ext[1 .. ^1] 202 | else: "" 203 | ) 204 | if fileSize < 10_000_000: # 10 mb 205 | var file = readFile(p) 206 | 207 | var hashed = getMD5(file) 208 | 209 | # If the user has a cached version of this file and it matches our 210 | # version, let them use it 211 | if req.headers.hasKey("If-None-Match") and req.headers["If-None-Match"] == hashed: 212 | await req.statusContent(Http304, "", none[RawHeaders]()) 213 | else: 214 | await req.statusContent(Http200, file, some(@({ 215 | "Content-Type": mimetype, 216 | "ETag": hashed 217 | }))) 218 | else: 219 | let headers = @({ 220 | "Content-Type": mimetype, 221 | "Content-Length": $fileSize 222 | }) 223 | await req.statusContent(Http200, "", some(headers)) 224 | 225 | var fileStream = newFutureStream[string]("sendStaticIfExists") 226 | var file = openAsync(p, fmRead) 227 | # Let `readToStream` write file data into fileStream in the 228 | # background. 229 | asyncCheck file.readToStream(fileStream) 230 | # The `writeFromStream` proc will complete once all the data in the 231 | # `bodyStream` has been written to the file. 232 | while true: 233 | let (hasValue, value) = await fileStream.read() 234 | if hasValue: 235 | req.unsafeSend(value) 236 | else: 237 | break 238 | file.close() 239 | 240 | return 241 | 242 | # If we get to here then no match could be found. 243 | return Http404 244 | 245 | proc close*(request: Request) = 246 | ## Closes client socket connection. 247 | ## 248 | ## Routes using this procedure must enable raw mode. 249 | let nativeReq = request.getNativeReq() 250 | when useHttpBeast: 251 | nativeReq.forget() 252 | nativeReq.client.close() 253 | 254 | proc defaultErrorFilter(error: RouteError): ResponseData = 255 | case error.kind 256 | of RouteException: 257 | let e = error.exc 258 | let traceback = getStackTrace(e) 259 | var errorMsg = e.msg 260 | if errorMsg.len == 0: errorMsg = "(empty)" 261 | 262 | let error = traceback & errorMsg 263 | logging.error(error) 264 | result.headers = some(@({ 265 | "Content-Type": "text/html;charset=utf-8" 266 | })) 267 | result.content = routeException( 268 | error.replace("\n", "
    \n"), 269 | jesterVer 270 | ) 271 | result.code = Http502 272 | result.matched = true 273 | result.action = TCActionSend 274 | of RouteCode: 275 | result.headers = some(@({ 276 | "Content-Type": "text/html;charset=utf-8" 277 | })) 278 | result.content = error( 279 | $error.data.code, 280 | jesterVer 281 | ) 282 | result.code = error.data.code 283 | result.matched = true 284 | result.action = TCActionSend 285 | 286 | proc initRouteError(exc: ref Exception): RouteError = 287 | RouteError( 288 | kind: RouteException, 289 | exc: exc 290 | ) 291 | 292 | proc initRouteError(data: ResponseData): RouteError = 293 | RouteError( 294 | kind: RouteCode, 295 | data: data 296 | ) 297 | 298 | proc dispatchError( 299 | jes: Jester, 300 | request: Request, 301 | error: RouteError 302 | ): Future[ResponseData] {.async.} = 303 | for errorProc in jes.errorHandlers: 304 | let data = await errorProc(request, error) 305 | if data.matched: 306 | return data 307 | 308 | return defaultErrorFilter(error) 309 | 310 | proc dispatch( 311 | self: Jester, 312 | req: Request 313 | ): Future[ResponseData] {.async.} = 314 | for matcher in self.matchers: 315 | if matcher.async: 316 | let data = await matcher.asyncProc(req) 317 | if data.matched: 318 | return data 319 | else: 320 | let data = matcher.syncProc(req) 321 | if data.matched: 322 | return data 323 | 324 | proc handleFileRequest( 325 | jes: Jester, req: Request 326 | ): Future[ResponseData] {.async.} = 327 | # Find static file. 328 | # TODO: Caching. 329 | # no need to normalize staticDir since it is normalized in `newSettings` 330 | let path = jes.settings.staticDir / normalizedPath( 331 | cgi.decodeUrl(req.pathInfo) 332 | ) 333 | 334 | # Verify that this isn't outside our static dir. 335 | var status = Http400 336 | let pathDir = path.splitFile.dir & (if path.splitFile.dir[^1] == DirSep: "" else: $DirSep) 337 | let staticDir = jes.settings.staticDir & (if jes.settings.staticDir[^1] == DirSep: "" else: $DirSep) 338 | if pathDir.startsWith(staticDir): 339 | if existsDir(path): 340 | status = await sendStaticIfExists( 341 | req, 342 | @[path / "index.html", path / "index.htm"] 343 | ) 344 | else: 345 | status = await sendStaticIfExists(req, @[path]) 346 | 347 | # Http200 means that the data was sent so there is nothing else to do. 348 | if status == Http200: 349 | result[0] = TCActionRaw 350 | when not defined(release): 351 | logging.debug(" -> $1" % path) 352 | return 353 | 354 | return (TCActionSend, status, none[seq[(string, string)]](), "", true) 355 | 356 | proc handleRequestSlow( 357 | jes: Jester, 358 | req: Request, 359 | respDataFut: Future[ResponseData] | ResponseData, 360 | dispatchedError: bool 361 | ): Future[void] {.async.} = 362 | var dispatchedError = dispatchedError 363 | var respData: ResponseData 364 | 365 | # httpReq.send(Http200, "Hello, World!", "") 366 | when respDataFut is Future[ResponseData]: 367 | yield respDataFut 368 | if respDataFut.failed: 369 | # Handle any errors by showing them in the browser. 370 | # TODO: Improve the look of this. 371 | let exc = respDataFut.readError() 372 | respData = await dispatchError(jes, req, initRouteError(exc)) 373 | dispatchedError = true 374 | else: 375 | respData = respDataFut.read() 376 | else: 377 | respData = respDataFut 378 | 379 | # TODO: Put this in a custom matcher? 380 | if not respData.matched: 381 | respData = await handleFileRequest(jes, req) 382 | 383 | case respData.action 384 | of TCActionSend: 385 | if (respData.code.is4xx or respData.code.is5xx) and 386 | not dispatchedError and respData.content.len == 0: 387 | respData = await dispatchError(jes, req, initRouteError(respData)) 388 | 389 | await statusContent( 390 | req, 391 | respData.code, 392 | respData.content, 393 | respData.headers 394 | ) 395 | else: 396 | when not defined(release): 397 | logging.debug(" $1" % [$respData.action]) 398 | 399 | # Cannot close the client socket. AsyncHttpServer may be keeping it alive. 400 | 401 | proc handleRequest(jes: Jester, httpReq: NativeRequest): Future[void] = 402 | var req = initRequest(httpReq, jes.settings) 403 | try: 404 | when not defined(release): 405 | logging.debug("$1 $2" % [$req.reqMethod, req.pathInfo]) 406 | 407 | if likely(jes.matchers.len == 1 and not jes.matchers[0].async): 408 | let respData = jes.matchers[0].syncProc(req) 409 | if likely(respData.matched): 410 | return statusContent( 411 | req, 412 | respData.code, 413 | respData.content, 414 | respData.headers 415 | ) 416 | else: 417 | return handleRequestSlow(jes, req, respData, false) 418 | else: 419 | return handleRequestSlow(jes, req, dispatch(jes, req), false) 420 | except: 421 | let exc = getCurrentException() 422 | let respDataFut = dispatchError(jes, req, initRouteError(exc)) 423 | return handleRequestSlow(jes, req, respDataFut, true) 424 | 425 | assert(not result.isNil, "Expected handleRequest to return a valid future.") 426 | 427 | proc newSettings*( 428 | port = Port(5000), staticDir = getCurrentDir() / "public", 429 | appName = "", bindAddr = "", reusePort = false, maxBody = 8388608, numThreads = 0, 430 | futureErrorHandler: proc (fut: Future[void]) {.closure, gcsafe.} = nil, 431 | startup: Startup = doNothing() 432 | ): Settings = 433 | result = Settings( 434 | staticDir: normalizedPath(staticDir), 435 | appName: appName, 436 | port: port, 437 | bindAddr: bindAddr, 438 | reusePort: reusePort, 439 | maxBody: maxBody, 440 | numThreads: numThreads, 441 | futureErrorHandler: futureErrorHandler, 442 | startup: startup 443 | ) 444 | 445 | proc register*(self: var Jester, matcher: MatchProc) = 446 | ## Adds the specified matcher procedure to the specified Jester instance. 447 | self.matchers.add( 448 | Matcher( 449 | async: true, 450 | asyncProc: matcher 451 | ) 452 | ) 453 | 454 | proc register*(self: var Jester, matcher: MatchProcSync) = 455 | ## Adds the specified matcher procedure to the specified Jester instance. 456 | self.matchers.add( 457 | Matcher( 458 | async: false, 459 | syncProc: matcher 460 | ) 461 | ) 462 | 463 | proc register*(self: var Jester, errorHandler: ErrorProc) = 464 | ## Adds the specified error handler procedure to the specified Jester instance. 465 | self.errorHandlers.add(errorHandler) 466 | 467 | proc initJester*( 468 | settings: Settings = newSettings() 469 | ): Jester = 470 | result.settings = settings 471 | result.settings.mimes = newMimetypes() 472 | result.matchers = @[] 473 | result.errorHandlers = @[] 474 | 475 | proc initJester*( 476 | pair: MatchPair, 477 | settings: Settings = newSettings() 478 | ): Jester = 479 | result = initJester(settings) 480 | result.register(pair.matcher) 481 | result.register(pair.errorHandler) 482 | 483 | proc initJester*( 484 | pair: MatchPairSync, # TODO: Annoying nim bug: `MatchPair | MatchPairSync` doesn't work. 485 | settings: Settings = newSettings() 486 | ): Jester = 487 | result = initJester(settings) 488 | result.register(pair.matcher) 489 | result.register(pair.errorHandler) 490 | 491 | proc initJester*( 492 | matcher: MatchProc, 493 | settings: Settings = newSettings() 494 | ): Jester = 495 | result = initJester(settings) 496 | result.register(matcher) 497 | 498 | proc initJester*( 499 | matcher: MatchProcSync, 500 | settings: Settings = newSettings() 501 | ): Jester = 502 | result = initJester(settings) 503 | result.register(matcher) 504 | 505 | proc serve*( 506 | self: var Jester 507 | ) = 508 | ## Creates a new async http server instance and registers 509 | ## it with the dispatcher. 510 | ## 511 | ## The event loop is executed by this function, so it will block forever. 512 | 513 | # Ensure we have at least one logger enabled, defaulting to console. 514 | if logging.getHandlers().len == 0: 515 | addHandler(logging.newConsoleLogger()) 516 | setLogFilter(when defined(release): lvlInfo else: lvlDebug) 517 | 518 | assert self.settings.staticDir.len > 0, "Static dir cannot be an empty string." 519 | 520 | if self.settings.bindAddr.len > 0: 521 | logging.info("Jester is making jokes at http://$1:$2$3" % 522 | [ 523 | self.settings.bindAddr, $self.settings.port, self.settings.appName 524 | ] 525 | ) 526 | else: 527 | when defined(windows): 528 | logging.info("Jester is making jokes at http://127.0.0.1:$1$2 (all interfaces)" % 529 | [$self.settings.port, self.settings.appName]) 530 | else: 531 | logging.info("Jester is making jokes at http://0.0.0.0:$1$2" % 532 | [$self.settings.port, self.settings.appName]) 533 | 534 | var jes = self 535 | let domain = block: 536 | if self.settings.bindAddr != "": 537 | let ip = self.settings.bindAddr.parseIpAddress() 538 | if ip.family == IPv4: 539 | AF_INET 540 | else: 541 | AF_INET6 542 | else: 543 | AF_INET 544 | when useHttpBeast: 545 | run( 546 | proc (req: httpbeastfork.Request): Future[void] = 547 | {.gcsafe.}: 548 | result = handleRequest(jes, req), 549 | httpbeastfork.initSettings(self.settings.port, self.settings.bindAddr, self.settings.numThreads, startup = self.settings.startup, domain = domain) 550 | ) 551 | else: 552 | self.httpServer = newAsyncHttpServer(reusePort=self.settings.reusePort, maxBody=self.settings.maxBody) 553 | let serveFut = self.httpServer.serve( 554 | self.settings.port, 555 | proc (req: asynchttpserver.Request): Future[void] {.gcsafe, closure.} = 556 | result = handleRequest(jes, req), 557 | self.settings.bindAddr, domain = domain) 558 | if not self.settings.futureErrorHandler.isNil: 559 | serveFut.callback = self.settings.futureErrorHandler 560 | else: 561 | asyncCheck serveFut 562 | runForever() 563 | 564 | template setHeader*(headers: var ResponseHeaders, key, value: string): typed = 565 | ## Sets a response header using the given key and value. 566 | ## Overwrites if the header key already exists. 567 | bind isNone 568 | if isNone(headers): 569 | headers = some(@({key: value})) 570 | else: 571 | block outer: 572 | # Overwrite key if it exists. 573 | var h = headers.get() 574 | for i in 0 ..< h.len: 575 | if h[i][0] == key: 576 | h[i][1] = value 577 | headers = some(h) 578 | break outer 579 | 580 | # Add key if it doesn't exist. 581 | headers = some(h & @({key: value})) 582 | 583 | template resp*(code: HttpCode, 584 | headers: openarray[tuple[key, val: string]], 585 | content: string): typed = 586 | ## Sets ``(code, headers, content)`` as the response. 587 | bind TCActionSend 588 | result = (TCActionSend, code, result[2], content, true) 589 | for header in headers: 590 | setHeader(result[2], header[0], header[1]) 591 | break route 592 | 593 | 594 | template resp*(content: string, contentType = "text/html;charset=utf-8"): typed = 595 | ## Sets ``content`` as the response; ``Http200`` as the status code 596 | ## and ``contentType`` as the Content-Type. 597 | bind TCActionSend, newHttpHeaders, strtabs.`[]=` 598 | result[0] = TCActionSend 599 | result[1] = Http200 600 | setHeader(result[2], "Content-Type", contentType) 601 | result[3] = content 602 | # This will be set by our macro, so this is here for those not using it. 603 | result.matched = true 604 | break route 605 | 606 | template resp*(content: JsonNode): typed = 607 | ## Serializes ``content`` as the response, sets ``Http200`` as status code 608 | ## and "application/json" Content-Type. 609 | resp($content, contentType="application/json") 610 | 611 | template resp*(code: HttpCode, content: string, 612 | contentType = "text/html;charset=utf-8"): typed = 613 | ## Sets ``content`` as the response; ``code`` as the status code 614 | ## and ``contentType`` as the Content-Type. 615 | bind TCActionSend, newHttpHeaders 616 | result[0] = TCActionSend 617 | result[1] = code 618 | setHeader(result[2], "Content-Type", contentType) 619 | result[3] = content 620 | result.matched = true 621 | break route 622 | 623 | template resp*(code: HttpCode): typed = 624 | ## Responds with the specified ``HttpCode``. This ensures that error handlers 625 | ## are called. 626 | bind TCActionSend, newHttpHeaders 627 | result[0] = TCActionSend 628 | result[1] = code 629 | result.matched = true 630 | break route 631 | 632 | template redirect*(url: string, halt = true, httpStatusCode = Http303): typed = 633 | ## Redirects to ``url``. Returns from this request handler immediately. 634 | ## 635 | ## If ``halt`` is true, skips executing future handlers, too. 636 | ## 637 | ## Any set response headers are preserved for this request. 638 | bind TCActionSend, newHttpHeaders 639 | result[0] = TCActionSend 640 | result[1] = httpStatusCode 641 | setHeader(result[2], "Location", url) 642 | result[3] = "" 643 | result.matched = true 644 | if halt: 645 | break allRoutes 646 | else: 647 | break route 648 | 649 | template pass*(): typed = 650 | ## Skips this request handler. 651 | ## 652 | ## If you want to stop this request from going further use ``halt``. 653 | result.action = TCActionPass 654 | break outerRoute 655 | 656 | template cond*(condition: bool): typed = 657 | ## If ``condition`` is ``False`` then ``pass`` will be called, 658 | ## i.e. this request handler will be skipped. 659 | if not condition: break outerRoute 660 | 661 | template halt*(code: HttpCode, 662 | headers: openarray[tuple[key, val: string]], 663 | content: string): typed = 664 | ## Immediately replies with the specified request. This means any further 665 | ## code will not be executed after calling this template in the current 666 | ## route. 667 | bind TCActionSend, newHttpHeaders 668 | result[0] = TCActionSend 669 | result[1] = code 670 | result[2] = some(@headers) 671 | result[3] = content 672 | result.matched = true 673 | break allRoutes 674 | 675 | template halt*(): typed = 676 | ## Halts the execution of this request immediately. Returns a 404. 677 | ## All previously set values are **discarded**. 678 | halt(Http404, {"Content-Type": "text/html;charset=utf-8"}, error($Http404, jesterVer)) 679 | 680 | template halt*(code: HttpCode): typed = 681 | halt(code, {"Content-Type": "text/html;charset=utf-8"}, error($code, jesterVer)) 682 | 683 | template halt*(content: string): typed = 684 | halt(Http404, {"Content-Type": "text/html;charset=utf-8"}, content) 685 | 686 | template halt*(code: HttpCode, content: string): typed = 687 | halt(code, {"Content-Type": "text/html;charset=utf-8"}, content) 688 | 689 | template attachment*(filename = ""): typed = 690 | ## Instructs the browser that the response should be stored on disk 691 | ## rather than displayed in the browser. 692 | var disposition = "attachment" 693 | if filename != "": 694 | disposition.add("; filename=\"" & extractFilename(filename) & "\"") 695 | let ext = splitFile(filename).ext 696 | let contentTypeSet = 697 | isSome(result[2]) and result[2].get().toTable.hasKey("Content-Type") 698 | if not contentTypeSet and ext != "": 699 | setHeader(result[2], "Content-Type", getMimetype(request.settings.mimes, ext)) 700 | setHeader(result[2], "Content-Disposition", disposition) 701 | 702 | template sendFile*(filename: string): typed = 703 | ## Sends the file at the specified filename as the response. 704 | result[0] = TCActionRaw 705 | let sendFut = sendStaticIfExists(request, @[filename]) 706 | yield sendFut 707 | let status = sendFut.read() 708 | if status != Http200: 709 | raise newException(JesterError, "Couldn't send requested file: " & filename) 710 | # This will be set by our macro, so this is here for those not using it. 711 | result.matched = true 712 | break route 713 | 714 | template `@`*(s: string): untyped = 715 | ## Retrieves the parameter ``s`` from ``request.params``. ``""`` will be 716 | ## returned if parameter doesn't exist. 717 | if s in params(request): 718 | # TODO: Why does request.params not work? :( 719 | # TODO: This is some weird bug with macros/templates, I couldn't 720 | # TODO: reproduce it easily. 721 | params(request)[s] 722 | else: 723 | "" 724 | 725 | proc setStaticDir*(request: Request, dir: string) = 726 | ## Sets the directory in which Jester will look for static files. It is 727 | ## ``./public`` by default. 728 | ## 729 | ## The files will be served like so: 730 | ## 731 | ## ./public/css/style.css ``->`` http://example.com/css/style.css 732 | ## 733 | ## (``./public`` is not included in the final URL) 734 | request.settings.staticDir = dir 735 | 736 | proc getStaticDir*(request: Request): string = 737 | ## Gets the directory in which Jester will look for static files. 738 | ## 739 | ## ``./public`` by default. 740 | return request.settings.staticDir 741 | 742 | proc makeUri*(request: Request, address = "", absolute = true, 743 | addScriptName = true): string = 744 | ## Creates a URI based on the current request. If ``absolute`` is true it will 745 | ## add the scheme (Usually 'http://'), `request.host` and `request.port`. 746 | ## If ``addScriptName`` is true `request.appName` will be prepended before 747 | ## ``address``. 748 | 749 | # Check if address already starts with scheme:// 750 | var uri = parseUri(address) 751 | 752 | if uri.scheme != "": return address 753 | uri.path = "/" 754 | uri.query = "" 755 | uri.anchor = "" 756 | if absolute: 757 | uri.hostname = request.host 758 | uri.scheme = (if request.secure: "https" else: "http") 759 | if request.port != (if request.secure: 443 else: 80): 760 | uri.port = $request.port 761 | 762 | if addScriptName: uri = uri / request.appName 763 | if address != "": 764 | uri = uri / address 765 | else: 766 | uri = uri / request.pathInfo 767 | return $uri 768 | 769 | template uri*(address = "", absolute = true, addScriptName = true): untyped = 770 | ## Convenience template which can be used in a route. 771 | request.makeUri(address, absolute, addScriptName) 772 | 773 | template responseHeaders*(): var ResponseHeaders = 774 | ## Access the Option[RawHeaders] response headers 775 | if result[2].isNone: 776 | result[2] = some[RawHeaders](@[]) 777 | result[2] 778 | 779 | proc daysForward*(days: int): DateTime = 780 | ## Returns a DateTime object referring to the current time plus ``days``. 781 | return getTime().utc + initTimeInterval(days = days) 782 | 783 | template setCookie*(headersOpt: var ResponseHeaders, name, value: string, expires="", 784 | sameSite: SameSite=Lax, secure = false, 785 | httpOnly = false, domain = "", path = "") = 786 | let newCookie = makeCookie(name, value, expires, domain, path, secure, httpOnly, sameSite) 787 | if isSome(headersOpt) and 788 | (let headers = headersOpt.get(); headers.toTable.hasKey("Set-Cookie")): 789 | headersOpt = some(headers & @({"Set-Cookie": newCookie})) 790 | else: 791 | setHeader(headersOpt, "Set-Cookie", newCookie) 792 | 793 | template setCookie*(name, value: string, expires="", 794 | sameSite: SameSite=Lax, secure = false, 795 | httpOnly = false, domain = "", path = "") = 796 | ## Creates a cookie which stores ``value`` under ``name``. 797 | ## 798 | ## The SameSite argument determines the level of CSRF protection that 799 | ## you wish to adopt for this cookie. It's set to Lax by default which 800 | ## should protect you from most vulnerabilities. Note that this is only 801 | ## supported by some browsers: 802 | ## https://caniuse.com/#feat=same-site-cookie-attribute 803 | responseHeaders.setCookie(name, value, expires, sameSite, secure, httpOnly, domain, path) 804 | 805 | template setCookie*(name, value: string, expires: DateTime, 806 | sameSite: SameSite=Lax, secure = false, 807 | httpOnly = false, domain = "", path = "") = 808 | ## Creates a cookie which stores ``value`` under ``name``. 809 | setCookie(name, value, 810 | format(expires.utc, "ddd',' dd MMM yyyy HH:mm:ss 'GMT'"), 811 | sameSite, secure, httpOnly, domain, path) 812 | 813 | proc normalizeUri*(uri: string): string = 814 | ## Remove any trailing ``/``. 815 | if uri[uri.len-1] == '/': result = uri[0 .. uri.len-2] 816 | else: result = uri 817 | 818 | # -- Macro 819 | 820 | proc checkAction*(respData: var ResponseData): bool = 821 | case respData.action 822 | of TCActionSend, TCActionRaw: 823 | result = true 824 | of TCActionPass: 825 | result = false 826 | of TCActionNothing: 827 | raise newException( 828 | ValueError, 829 | "Missing route action, did you forget to use `resp` in your route?" 830 | ) 831 | 832 | proc skipDo(node: NimNode): NimNode {.compiletime.} = 833 | if node.kind == nnkDo: 834 | result = node[6] 835 | else: 836 | result = node 837 | 838 | proc ctParsePattern(pattern, pathPrefix: string): NimNode {.compiletime.} = 839 | result = newNimNode(nnkPrefix) 840 | result.add newIdentNode("@") 841 | result.add newNimNode(nnkBracket) 842 | 843 | proc addPattNode(res: var NimNode, typ, text, 844 | optional: NimNode) {.compiletime.} = 845 | var objConstr = newNimNode(nnkObjConstr) 846 | 847 | objConstr.add bindSym("Node") 848 | objConstr.add newNimNode(nnkExprColonExpr).add( 849 | newIdentNode("typ"), typ) 850 | objConstr.add newNimNode(nnkExprColonExpr).add( 851 | newIdentNode("text"), text) 852 | objConstr.add newNimNode(nnkExprColonExpr).add( 853 | newIdentNode("optional"), optional) 854 | 855 | res[1].add objConstr 856 | 857 | var patt = parsePattern(pattern) 858 | if pathPrefix.len > 0: 859 | result.addPattNode( 860 | bindSym("NodeText"), # Node kind 861 | newStrLitNode(pathPrefix), # Text 862 | newIdentNode("false") # Optional? 863 | ) 864 | 865 | for node in patt: 866 | result.addPattNode( 867 | case node.typ 868 | of NodeText: bindSym("NodeText") 869 | of NodeField: bindSym("NodeField"), 870 | newStrLitNode(node.text), 871 | newIdentNode(if node.optional: "true" else: "false")) 872 | 873 | template setDefaultResp*() = 874 | # TODO: bindSym this in the 'routes' macro and put it in each route 875 | bind TCActionNothing, newHttpHeaders 876 | result.action = TCActionNothing 877 | result.code = Http200 878 | result.content = "" 879 | 880 | template declareSettings() {.dirty.} = 881 | bind newSettings 882 | when not declaredInScope(settings): 883 | var settings = newSettings() 884 | 885 | proc createJesterPattern( 886 | routeNode, patternMatchSym: NimNode, 887 | pathPrefix: string 888 | ): NimNode {.compileTime.} = 889 | var ctPattern = ctParsePattern(routeNode[1].strVal, pathPrefix) 890 | # -> let = .match(request.path) 891 | return newLetStmt(patternMatchSym, 892 | newCall(bindSym"match", ctPattern, parseExpr("request.pathInfo"))) 893 | 894 | proc escapeRegex(s: string): string = 895 | result = "" 896 | for i in s: 897 | case i 898 | # https://stackoverflow.com/a/400316/492186 899 | of '.', '^', '$', '*', '+', '?', '(', ')', '[', '{', '\\', '|': 900 | result.add('\\') 901 | result.add(i) 902 | else: 903 | result.add(i) 904 | 905 | proc createRegexPattern( 906 | routeNode, reMatchesSym, patternMatchSym: NimNode, 907 | pathPrefix: string 908 | ): NimNode {.compileTime.} = 909 | # -> let = find(request.pathInfo, , ) 910 | var strNode = routeNode[1].copyNimTree() 911 | strNode[1].strVal = escapeRegex(pathPrefix) & strNode[1].strVal 912 | return newLetStmt( 913 | patternMatchSym, 914 | newCall( 915 | bindSym"find", 916 | parseExpr("request.pathInfo"), 917 | strNode, 918 | reMatchesSym 919 | ) 920 | ) 921 | 922 | proc determinePatternType(pattern: NimNode): MatchType {.compileTime.} = 923 | case pattern.kind 924 | of nnkStrLit: 925 | var patt = parsePattern(pattern.strVal) 926 | if patt.len == 1 and patt[0].typ == NodeText: 927 | return MStatic 928 | else: 929 | return MSpecial 930 | of nnkCallStrLit: 931 | expectKind(pattern[0], nnkIdent) 932 | case ($pattern[0]).normalize 933 | of "re": return MRegex 934 | else: 935 | macros.error("Invalid pattern type: " & $pattern[0]) 936 | else: 937 | macros.error("Unexpected node kind: " & $pattern.kind) 938 | 939 | proc createCheckActionIf(): NimNode = 940 | var checkActionIf = parseExpr( 941 | "if checkAction(result): result.matched = true; break routesList" 942 | ) 943 | checkActionIf[0][0][0] = bindSym"checkAction" 944 | return checkActionIf 945 | 946 | proc createGlobalMetaRoute(routeNode, dest: NimNode) {.compileTime.} = 947 | ## Creates a ``before`` or ``after`` route with no pattern, i.e. one which 948 | ## will be always executed. 949 | 950 | # -> block route: 951 | var innerBlockStmt = newStmtList( 952 | newNimNode(nnkBlockStmt).add(newIdentNode("route"), routeNode[1].skipDo()) 953 | ) 954 | 955 | # -> block outerRoute: 956 | var blockStmt = newNimNode(nnkBlockStmt).add( 957 | newIdentNode("outerRoute"), innerBlockStmt) 958 | dest.add blockStmt 959 | 960 | proc createRoute( 961 | routeNode, dest: NimNode, pathPrefix: string, isMetaRoute: bool = false 962 | ) {.compileTime.} = 963 | ## Creates code which checks whether the current request path 964 | ## matches a route. 965 | ## 966 | ## The `isMetaRoute` parameter determines whether the route to be created is 967 | ## one of either a ``before`` or an ``after`` route. 968 | 969 | var patternMatchSym = genSym(nskLet, "patternMatchRet") 970 | 971 | # Only used for Regex patterns. 972 | var reMatchesSym = genSym(nskVar, "reMatches") 973 | var reMatches = parseExpr("var reMatches: array[20, string]") 974 | reMatches[0][0] = reMatchesSym 975 | reMatches[0][1][1] = bindSym("MaxSubpatterns") 976 | 977 | let patternType = determinePatternType(routeNode[1]) 978 | case patternType 979 | of MStatic: 980 | discard 981 | of MSpecial: 982 | dest.add createJesterPattern(routeNode, patternMatchSym, pathPrefix) 983 | of MRegex: 984 | dest.add reMatches 985 | dest.add createRegexPattern( 986 | routeNode, reMatchesSym, patternMatchSym, pathPrefix 987 | ) 988 | 989 | var ifStmtBody = newStmtList() 990 | case patternType 991 | of MStatic: discard 992 | of MSpecial: 993 | # -> setPatternParams(request, ret.params) 994 | ifStmtBody.add newCall(bindSym"setPatternParams", newIdentNode"request", 995 | newDotExpr(patternMatchSym, newIdentNode"params")) 996 | of MRegex: 997 | # -> setReMatches(request, ) 998 | ifStmtBody.add newCall(bindSym"setReMatches", newIdentNode"request", 999 | reMatchesSym) 1000 | 1001 | ifStmtBody.add routeNode[2].skipDo() 1002 | 1003 | let checkActionIf = 1004 | if isMetaRoute: 1005 | parseExpr("break routesList") 1006 | else: 1007 | createCheckActionIf() 1008 | # -> block route: ; 1009 | var innerBlockStmt = newStmtList( 1010 | newNimNode(nnkBlockStmt).add(newIdentNode("route"), ifStmtBody), 1011 | checkActionIf 1012 | ) 1013 | 1014 | let ifCond = 1015 | case patternType 1016 | of MStatic: 1017 | infix( 1018 | parseExpr("request.pathInfo"), 1019 | "==", 1020 | newStrLitNode(pathPrefix & routeNode[1].strVal) 1021 | ) 1022 | of MSpecial: 1023 | newDotExpr(patternMatchSym, newIdentNode("matched")) 1024 | of MRegex: 1025 | infix(patternMatchSym, "!=", newIntLitNode(-1)) 1026 | 1027 | # -> if .matched: 1028 | var ifStmt = newIfStmt((ifCond, innerBlockStmt)) 1029 | 1030 | # -> block outerRoute: 1031 | var blockStmt = newNimNode(nnkBlockStmt).add( 1032 | newIdentNode("outerRoute"), ifStmt) 1033 | dest.add blockStmt 1034 | 1035 | proc createError( 1036 | errorNode: NimNode, 1037 | httpCodeBranches, 1038 | exceptionBranches: var seq[tuple[cond, body: NimNode]] 1039 | ) = 1040 | if errorNode.len != 3: 1041 | error("Missing error condition or body.", errorNode) 1042 | 1043 | let routeIdent = newIdentNode("route") 1044 | let outerRouteIdent = newIdentNode("outerRoute") 1045 | let checkActionIf = createCheckActionIf() 1046 | let exceptionIdent = newIdentNode("exception") 1047 | let errorIdent = newIdentNode("error") # TODO: Ugh. I shouldn't need these... 1048 | let errorCond = errorNode[1] 1049 | let errorBody = errorNode[2] 1050 | let body = quote do: 1051 | block `outerRouteIdent`: 1052 | block `routeIdent`: 1053 | `errorBody` 1054 | `checkActionIf` 1055 | 1056 | case errorCond.kind 1057 | of nnkIdent: 1058 | let name = errorCond.strVal 1059 | if name.len == 7 and name.startsWith("Http"): 1060 | # HttpCode. 1061 | httpCodeBranches.add( 1062 | ( 1063 | infix(parseExpr("error.data.code"), "==", errorCond), 1064 | body 1065 | ) 1066 | ) 1067 | else: 1068 | # Exception 1069 | exceptionBranches.add( 1070 | ( 1071 | infix(parseExpr("error.exc"), "of", errorCond), 1072 | quote do: 1073 | let `exceptionIdent` = (ref `errorCond`)(`errorIdent`.exc) 1074 | `body` 1075 | ) 1076 | ) 1077 | of nnkCurly: 1078 | expectKind(errorCond[0], nnkInfix) 1079 | httpCodeBranches.add( 1080 | ( 1081 | infix(parseExpr("error.data.code"), "in", errorCond), 1082 | body 1083 | ) 1084 | ) 1085 | else: 1086 | error("Expected exception type or set[HttpCode].", errorCond) 1087 | 1088 | const definedRoutes = CacheTable"jesterfork.routes" 1089 | 1090 | proc processRoutesBody( 1091 | body: NimNode, 1092 | # For HTTP methods. 1093 | caseStmtGetBody, 1094 | caseStmtPostBody, 1095 | caseStmtPutBody, 1096 | caseStmtDeleteBody, 1097 | caseStmtHeadBody, 1098 | caseStmtOptionsBody, 1099 | caseStmtTraceBody, 1100 | caseStmtConnectBody, 1101 | caseStmtPatchBody: var NimNode, 1102 | # For `error`. 1103 | httpCodeBranches, 1104 | exceptionBranches: var seq[tuple[cond, body: NimNode]], 1105 | # For before/after stmts. 1106 | beforeStmts, 1107 | afterStmts: var NimNode, 1108 | # For other statements. 1109 | outsideStmts: var NimNode, 1110 | pathPrefix: string 1111 | ) = 1112 | for i in 0.. 1: 1160 | extend[2].strVal 1161 | else: 1162 | "" 1163 | if prefix.len != 0 and prefix[0] != '/': 1164 | error("Path prefix for extended route must start with '/'", extend[2]) 1165 | 1166 | processRoutesBody( 1167 | definedRoutes[extend[1].strVal], 1168 | caseStmtGetBody, 1169 | caseStmtPostBody, 1170 | caseStmtPutBody, 1171 | caseStmtDeleteBody, 1172 | caseStmtHeadBody, 1173 | caseStmtOptionsBody, 1174 | caseStmtTraceBody, 1175 | caseStmtConnectBody, 1176 | caseStmtPatchBody, 1177 | httpCodeBranches, 1178 | exceptionBranches, 1179 | beforeStmts, 1180 | afterStmts, 1181 | outsideStmts, 1182 | pathPrefix & prefix 1183 | ) 1184 | else: 1185 | outsideStmts.add(body[i]) 1186 | of nnkCommentStmt: 1187 | discard 1188 | of nnkPragma: 1189 | if body[i][0].strVal.normalize notin ["async", "sync"]: 1190 | outsideStmts.add(body[i]) 1191 | else: 1192 | outsideStmts.add(body[i]) 1193 | 1194 | type 1195 | NeedsAsync = enum 1196 | ImplicitTrue, ImplicitFalse, ExplicitTrue, ExplicitFalse 1197 | proc needsAsync(node: NimNode): NeedsAsync = 1198 | result = ImplicitFalse 1199 | case node.kind 1200 | of nnkCommand, nnkCall: 1201 | if node[0].kind == nnkIdent: 1202 | case node[0].strVal.normalize 1203 | of "await", "sendfile": 1204 | return ImplicitTrue 1205 | of "resp", "halt", "attachment", "pass", "redirect", "cond", "get", 1206 | "post", "patch", "delete": 1207 | # This is just a simple heuristic. It's by no means meant to be 1208 | # exhaustive. 1209 | discard 1210 | else: 1211 | return ImplicitTrue 1212 | of nnkYieldStmt: 1213 | return ImplicitTrue 1214 | of nnkPragma: 1215 | if node[0].kind == nnkIdent: 1216 | case node[0].strVal.normalize 1217 | of "sync": 1218 | return ExplicitFalse 1219 | of "async": 1220 | return ExplicitTrue 1221 | else: discard 1222 | else: discard 1223 | 1224 | for c in node: 1225 | let r = needsAsync(c) 1226 | if r in {ImplicitTrue, ExplicitTrue, ExplicitFalse}: return r 1227 | 1228 | proc routesEx(name: string, body: NimNode): NimNode = 1229 | # echo(treeRepr(body)) 1230 | # echo(treeRepr(name)) 1231 | 1232 | # Save this route's body so that it can be incorporated into another route. 1233 | definedRoutes[name] = body.copyNimTree 1234 | 1235 | result = newStmtList() 1236 | 1237 | # -> declareSettings() 1238 | result.add newCall(bindSym"declareSettings") 1239 | 1240 | var outsideStmts = newStmtList() 1241 | 1242 | var matchBody = newNimNode(nnkStmtList) 1243 | let setDefaultRespIdent = bindSym"setDefaultResp" 1244 | matchBody.add newCall(setDefaultRespIdent) 1245 | # TODO: This diminishes the performance. Would be nice to only include it 1246 | # TODO: when setPatternParams or setReMatches is used. 1247 | matchBody.add parseExpr("var request = request") 1248 | 1249 | # HTTP router case statement nodes: 1250 | var caseStmt = newNimNode(nnkCaseStmt) 1251 | caseStmt.add parseExpr("request.reqMethod") 1252 | 1253 | var caseStmtGetBody = newNimNode(nnkStmtList) 1254 | var caseStmtPostBody = newNimNode(nnkStmtList) 1255 | var caseStmtPutBody = newNimNode(nnkStmtList) 1256 | var caseStmtDeleteBody = newNimNode(nnkStmtList) 1257 | var caseStmtHeadBody = newNimNode(nnkStmtList) 1258 | var caseStmtOptionsBody = newNimNode(nnkStmtList) 1259 | var caseStmtTraceBody = newNimNode(nnkStmtList) 1260 | var caseStmtConnectBody = newNimNode(nnkStmtList) 1261 | var caseStmtPatchBody = newNimNode(nnkStmtList) 1262 | 1263 | # Error handler nodes: 1264 | var httpCodeBranches: seq[tuple[cond, body: NimNode]] = @[] 1265 | var exceptionBranches: seq[tuple[cond, body: NimNode]] = @[] 1266 | 1267 | # Before/After nodes: 1268 | var beforeRoutes = newStmtList() 1269 | var afterRoutes = newStmtList() 1270 | 1271 | processRoutesBody( 1272 | body, 1273 | caseStmtGetBody, 1274 | caseStmtPostBody, 1275 | caseStmtPutBody, 1276 | caseStmtDeleteBody, 1277 | caseStmtHeadBody, 1278 | caseStmtOptionsBody, 1279 | caseStmtTraceBody, 1280 | caseStmtConnectBody, 1281 | caseStmtPatchBody, 1282 | httpCodeBranches, 1283 | exceptionBranches, 1284 | beforeRoutes, 1285 | afterRoutes, 1286 | outsideStmts, 1287 | "" 1288 | ) 1289 | 1290 | var ofBranchGet = newNimNode(nnkOfBranch) 1291 | ofBranchGet.add newIdentNode("HttpGet") 1292 | ofBranchGet.add caseStmtGetBody 1293 | caseStmt.add ofBranchGet 1294 | 1295 | var ofBranchPost = newNimNode(nnkOfBranch) 1296 | ofBranchPost.add newIdentNode("HttpPost") 1297 | ofBranchPost.add caseStmtPostBody 1298 | caseStmt.add ofBranchPost 1299 | 1300 | var ofBranchPut = newNimNode(nnkOfBranch) 1301 | ofBranchPut.add newIdentNode("HttpPut") 1302 | ofBranchPut.add caseStmtPutBody 1303 | caseStmt.add ofBranchPut 1304 | 1305 | var ofBranchDelete = newNimNode(nnkOfBranch) 1306 | ofBranchDelete.add newIdentNode("HttpDelete") 1307 | ofBranchDelete.add caseStmtDeleteBody 1308 | caseStmt.add ofBranchDelete 1309 | 1310 | var ofBranchHead = newNimNode(nnkOfBranch) 1311 | ofBranchHead.add newIdentNode("HttpHead") 1312 | ofBranchHead.add caseStmtHeadBody 1313 | caseStmt.add ofBranchHead 1314 | 1315 | var ofBranchOptions = newNimNode(nnkOfBranch) 1316 | ofBranchOptions.add newIdentNode("HttpOptions") 1317 | ofBranchOptions.add caseStmtOptionsBody 1318 | caseStmt.add ofBranchOptions 1319 | 1320 | var ofBranchTrace = newNimNode(nnkOfBranch) 1321 | ofBranchTrace.add newIdentNode("HttpTrace") 1322 | ofBranchTrace.add caseStmtTraceBody 1323 | caseStmt.add ofBranchTrace 1324 | 1325 | var ofBranchConnect = newNimNode(nnkOfBranch) 1326 | ofBranchConnect.add newIdentNode("HttpConnect") 1327 | ofBranchConnect.add caseStmtConnectBody 1328 | caseStmt.add ofBranchConnect 1329 | 1330 | var ofBranchPatch = newNimNode(nnkOfBranch) 1331 | ofBranchPatch.add newIdentNode("HttpPatch") 1332 | ofBranchPatch.add caseStmtPatchBody 1333 | caseStmt.add ofBranchPatch 1334 | 1335 | # Wrap the routes inside ``routesList`` blocks accordingly, and add them to 1336 | # the `match` procedure body. 1337 | let routesListIdent = newIdentNode("routesList") 1338 | matchBody.add( 1339 | quote do: 1340 | block `routesListIdent`: 1341 | `beforeRoutes` 1342 | ) 1343 | 1344 | matchBody.add( 1345 | quote do: 1346 | block `routesListIdent`: 1347 | `caseStmt` 1348 | ) 1349 | 1350 | matchBody.add( 1351 | quote do: 1352 | block `routesListIdent`: 1353 | `afterRoutes` 1354 | ) 1355 | 1356 | let matchIdent = newIdentNode(name & "Matcher") 1357 | let reqIdent = newIdentNode("request") 1358 | let needsAsync = needsAsync(body) 1359 | case needsAsync 1360 | of ImplicitFalse, ExplicitFalse: 1361 | hint(fmt"Synchronous route `{name}` has been optimised. Use `{{.async.}}` to change.") 1362 | of ImplicitTrue, ExplicitTrue: 1363 | hint(fmt"Asynchronous route: {name}.") 1364 | var matchProc = 1365 | if needsAsync in {ImplicitTrue, ExplicitTrue}: 1366 | quote do: 1367 | proc `matchIdent`( 1368 | `reqIdent`: Request 1369 | ): Future[ResponseData] {.async, gcsafe.} = 1370 | discard 1371 | else: 1372 | quote do: 1373 | proc `matchIdent`( 1374 | `reqIdent`: Request 1375 | ): ResponseData {.gcsafe.} = 1376 | discard 1377 | 1378 | # The following `block` is for `halt`. (`return` didn't work :/) 1379 | let allRoutesBlock = newTree( 1380 | nnkBlockStmt, 1381 | newIdentNode("allRoutes"), 1382 | matchBody 1383 | ) 1384 | matchProc[6] = newTree(nnkStmtList, allRoutesBlock) 1385 | result.add(outsideStmts) 1386 | result.add(matchProc) 1387 | 1388 | # Error handler proc 1389 | let errorHandlerIdent = newIdentNode(name & "ErrorHandler") 1390 | let errorIdent = newIdentNode("error") 1391 | let allRoutesIdent = ident("allRoutes") 1392 | var exceptionStmts = newStmtList() 1393 | if exceptionBranches.len != 0: 1394 | for branch in exceptionBranches: 1395 | exceptionStmts.add(newIfStmt(branch)) 1396 | var codeStmts = newStmtList() 1397 | if httpCodeBranches.len != 0: 1398 | for branch in httpCodeBranches: 1399 | codeStmts.add(newIfStmt(branch)) 1400 | var errorHandlerProc = quote do: 1401 | proc `errorHandlerIdent`( 1402 | `reqIdent`: Request, `errorIdent`: RouteError 1403 | ): Future[ResponseData] {.gcsafe, async.} = 1404 | block `allRoutesIdent`: 1405 | block `routesListIdent`: 1406 | `setDefaultRespIdent`() 1407 | case `errorIdent`.kind 1408 | of RouteException: 1409 | `exceptionStmts` 1410 | of RouteCode: 1411 | `codeStmts` 1412 | result.add(errorHandlerProc) 1413 | 1414 | # Pair the matcher and error matcher 1415 | let pairIdent = newIdentNode(name) 1416 | let matchProcVarIdent = newIdentNode(name & "MatchProc") 1417 | let errorProcVarIdent = newIdentNode(name & "ErrorProc") 1418 | if needsAsync in {ImplicitTrue, ExplicitTrue}: 1419 | # TODO: I don't understand why I have to assign these procs to intermediate 1420 | # variables in order to get them into the tuple. It would be nice if it could 1421 | # just be: 1422 | # let `pairIdent`: MatchPair = (`matchIdent`, `errorHandlerIdent`) 1423 | result.add quote do: 1424 | let `matchProcVarIdent`: MatchProc = `matchIdent` 1425 | let `errorProcVarIdent`: ErrorProc = `errorHandlerIdent` 1426 | let `pairIdent`: MatchPair = (`matchProcVarIdent`, `errorProcVarIdent`) 1427 | else: 1428 | result.add quote do: 1429 | let `matchProcVarIdent`: MatchProcSync = `matchIdent` 1430 | let `errorProcVarIdent`: ErrorProc = `errorHandlerIdent` 1431 | let `pairIdent`: MatchPairSync = (`matchProcVarIdent`, `errorProcVarIdent`) 1432 | 1433 | 1434 | # TODO: Replace `body`, `headers`, `code` in routes with `result[i]` to 1435 | # get these shortcuts back without sacrificing usability. 1436 | # TODO2: Make sure you replace what `guessAction` used to do for this. 1437 | 1438 | # echo toStrLit(result) 1439 | # echo treeRepr(result) 1440 | 1441 | macro routes*(body: untyped) = 1442 | result = routesEx("match", body) 1443 | let jesIdent = genSym(nskVar, "jes") 1444 | let pairIdent = newIdentNode("match") 1445 | let settingsIdent = newIdentNode("settings") 1446 | result.add( 1447 | quote do: 1448 | var `jesIdent` = initJester(`pairIdent`, `settingsIdent`) 1449 | ) 1450 | result.add( 1451 | quote do: 1452 | serve(`jesIdent`) 1453 | ) 1454 | 1455 | macro router*(name: untyped, body: untyped) = 1456 | if name.kind != nnkIdent: 1457 | error("Need an ident.", name) 1458 | 1459 | routesEx(strVal(name), body) 1460 | 1461 | macro settings*(body: untyped) = 1462 | #echo(treeRepr(body)) 1463 | expectKind(body, nnkStmtList) 1464 | 1465 | result = newStmtList() 1466 | 1467 | # var settings = newSettings() 1468 | let settingsIdent = newIdentNode("settings") 1469 | result.add newVarStmt(settingsIdent, newCall("newSettings")) 1470 | 1471 | for asgn in body.children: 1472 | expectKind(asgn, nnkAsgn) 1473 | result.add newAssignment(newDotExpr(settingsIdent, asgn[0]), asgn[1]) 1474 | --------------------------------------------------------------------------------