├── 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 ""
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 |
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 |
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 |
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 |
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 |
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 |
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 | """ % [uri("/post", absolute = false)]
121 |
122 | get "/file":
123 | resp """
124 | """
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=1a=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=1a=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 |
--------------------------------------------------------------------------------