Day 18: From a socket to a Webframework
Today we will be focusing on building a webframework starting from a socket :)
What to expect
proc main() =
var router = newRouter()
let loggingMiddleware = proc(request: var Request): (ref Response, bool) =
let path = request.path
let headers = request.headers
echo "==============================="
echo "from logger handler"
echo "path: " & path
echo "headers: " & $headers
echo "==============================="
return (newResponse(), true)
let trimTrailingSlash = proc(request: var Request): (ref Response, bool) =
let path = request.path
if path.endswith("/"):
request.path = path[0..^2]
echo "==============================="
echo "from slash trimmer "
echo "path was : " & path
echo "path: " & request.path
echo "==============================="
return (newResponse(), true)
proc handleHello(req:var Request): ref Response =
result = newResponse()
result.code = Http200
result.content = "hello world from handler /hello" & $req
router.addRoute("/hello", handleHello)
let assertJwtFieldExists = proc(request: var Request): (ref Response, bool) =
echo $request.headers
let jwtHeaderVals = request.headers.getOrDefault("jwt", @[""])
let jwt = jwtHeaderVals[0]
echo "================\n\njwt middleware"
if jwt.len != 0:
echo fmt"bye bye {jwt} "
else:
echo fmt"sure bye but i didn't get ur name"
echo "===================\n\n"
return (newResponse(), true)
router.addRoute("/bye", handleHello, HttpGet, @[assertJwtFieldExists])
proc handleGreet(req:var Request): ref Response =
result = newResponse()
result.code = Http200
result.content = "generic greet" & $req
router.addRoute("/greet", handleGreet, HttpGet, @[])
router.addRoute("/greet/:username", handleGreet, HttpGet, @[])
router.addRoute("/greet/:first/:second/:lang", handleGreet, HttpGet, @[])
let opts = ServerOptions(address:"127.0.0.1", port:9000.Port)
var s = newServy(opts, router, @[loggingMiddleware, trimTrailingSlash])
asyncCheck s.serve()
echo "servy started..."
runForever()
main()
defining a handler and wiring to to a pattern or more
proc handleHello(req:var Request): ref Response =
result = newResponse()
result.code = Http200
result.content = "hello world from handler /hello" & $req
router.addRoute("/hello", handleHello)
proc handleGreet(req:var Request): ref Response =
result = newResponse()
result.code = Http200
result.content = "generic greet" & $req
router.addRoute("/greet", handleGreet, HttpGet, @[])
router.addRoute("/greet/:username", handleGreet, HttpGet, @[])
router.addRoute("/greet/:first/:second/:lang", handleGreet, HttpGet, @[])
defining/registering middlewares on the server globally
let loggingMiddleware = proc(request: var Request): (ref Response, bool) =
let path = request.path
let headers = request.headers
echo "==============================="
echo "from logger handler"
echo "path: " & path
echo "headers: " & $headers
echo "==============================="
return (newResponse(), true)
let trimTrailingSlash = proc(request: var Request): (ref Response, bool) =
let path = request.path
if path.endswith("/"):
request.path = path[0..^2]
echo "==============================="
echo "from slash trimmer "
echo "path was : " & path
echo "path: " & request.path
echo "==============================="
return (newResponse(), true)
var s = newServy(opts, router, @[loggingMiddleware, trimTrailingSlash])
defining middlewares (request filters on certain routes)
router.addRoute("/bye", handleHello, HttpGet, @[assertJwtFieldExists])
Sounds like a lot. Let's get to it.
Implementation
The big picture
proc newServy(options: ServerOptions, router:ref Router, middlewares:seq[MiddlewareFunc]): ref Servy =
result = new Servy
result.options = options
result.router = router
result.middlewares = middlewares
result.sock = newAsyncSocket()
result.sock.setSockOpt(OptReuseAddr, true)
we have a server listening on a socket/address (should be configurable) and has a router that knows which pattern should be handled by which handler and a set of middlewares to be used.
proc serve(s: ref Servy) {.async.} =
s.sock.bindAddr(s.options.port)
s.sock.listen()
while true:
let client = await s.sock.accept()
asyncCheck s.handleClient(client)
runForever()
we receive a connection and pass it to handleClient
proc
proc handleClient(s: ref Servy, client: AsyncSocket) {.async.} =
## code to read request from the user
var req = await s.parseRequestFromConnection(client)
...
echo "received request from client: " & $req
## code to get the route handler
let (routeHandler, params) = s.router.getByPath(req.path)
req.urlParams = params
let handler = routeHandler.handlerFunc
..
## call the handler and return response in valid http protocol format
let resp = handler(req)
echo "reached the handler safely.. and executing now."
await client.send(resp.format())
echo $req.formData
handleClient reads the data from the wire in HTTP protocol and finds the route or requested path handler and then formats a valid http response and write it on the wire. Cool? Awesome!
Example HTTP requests and responses
when you execute curl httpbin.org/get -v
the following (http formatted request) is sent to httpbin.org
webserver
GET /get HTTP/1.1
Host: httpbin.org
User-Agent: curl/7.62.0-DEV
That is called a Request
that has a request line METHOD PATH HTTPVERSION
e.g GET /get HTTP/1.1
. Followed by a list of headers lines with colon in it
representing key values
e.g
Host: httpbin.org
a header is a line ofKey: value
User-Agent: curl/7.62.0-DEV
a header indicating the client type
As soon as the server receives that request it'll handle it as it was told to
HTTP/1.1 200 OK
Content-Type: application/json
Date: Mon, 21 Oct 2019 18:28:13 GMT
Server: nginx
Content-Length: 206
{
"args": {},
"headers": {
"Accept": "*/*",
"Host": "httpbin.org",
"User-Agent": "curl/7.62.0-DEV"
},
"origin": "197.52.178.58, 197.52.178.58",
"url": "https://httpbin.org/get"
}
This is called a Response, response consists of
- status line:
HTTPVER STATUS_CODE STATUS_MESSAGE
e.gHTTP/1.1 200 OK
- list of headers
Content-Type
:application/json
type of contentDate
:Mon, 21 Oct 2019 18:28:13 GMT
date of the responseServer
: nginxserver name
Content-Length
: 206 length of the upcoming body
Now let's go over the abstractions needed
Http Version
There're multiple http specifications 0.9
, 1.0
, 1.1
, ..
so let's start with that. a Simple enum should be enough
type
HttpVersion* = enum
HttpVer11,
HttpVer10
proc `$`(ver:HttpVersion): string =
case ver
of HttpVer10: result="HTTP/1.0"
of HttpVer11: result="HTTP/1.1"
HttpMethods
We all know GET
, POST
, HEAD
, .. methods, again can be represented by a Simple enum
type
HttpMethod* = enum ## the requested HttpMethod
HttpHead, ## Asks for the response identical to the one that would
## correspond to a GET request, but without the response
## body.
HttpGet, ## Retrieves the specified resource.
HttpPost, ## Submits data to be processed to the identified
## resource. The data is included in the body of the
## request.
HttpPut, ## Uploads a representation of the specified resource.
HttpDelete, ## Deletes the specified resource.
HttpTrace, ## Echoes back the received request, so that a client
## can see what intermediate servers are adding or
## changing in the request.
HttpOptions, ## Returns the HTTP methods that the server supports
## for specified address.
HttpConnect, ## Converts the request connection to a transparent
## TCP/IP tunnel, usually used for proxies.
HttpPatch ## Applies partial modifications to a resource.
proc httpMethodFromString(txt: string): Option[HttpMethod] =
let s2m = {"GET": HttpGet, "POST": HttpPost, "PUT":HttpPut, "PATCH": HttpPatch, "DELETE": HttpDelete, "HEAD":HttpHead}.toTable
if txt in s2m:
result = some(s2m[txt.toUpper])
else:
result = none(HttpMethod)
Also we add httpMethodFromString
that takes a string and returns option[HttpMethod] value.
Http Code
HTTP specifications specifies certain code responses (status codes) to indicate the state for the request
- 20X -> it's fine
- 30X -> redirections
- 40X -> client messed up
- 50X -> server messed up
HttpCode* = distinct range[0 .. 599]
const
Http200* = HttpCode(200)
Http201* = HttpCode(201)
Http202* = HttpCode(202)
Http203* = HttpCode(203)
...
Http300* = HttpCode(300)
Http301* = HttpCode(301)
Http302* = HttpCode(302)
Http303* = HttpCode(303)
..
Http400* = HttpCode(400)
Http401* = HttpCode(401)
Http403* = HttpCode(403)
Http404* = HttpCode(404)
Http405* = HttpCode(405)
Http406* = HttpCode(406)
...
Http451* = HttpCode(451)
Http500* = HttpCode(500)
...
proc `$`*(code: HttpCode): string =
## Converts the specified ``HttpCode`` into a HTTP status.
##
## For example:
##
## .. code-block:: nim
## doAssert($Http404 == "404 Not Found")
case code.int
..
of 200: "200 OK"
of 201: "201 Created"
of 202: "202 Accepted"
of 204: "204 No Content"
of 205: "205 Reset Content"
...
of 301: "301 Moved Permanently"
of 302: "302 Found"
of 303: "303 See Other"
..
of 400: "400 Bad Request"
of 401: "401 Unauthorized"
of 403: "403 Forbidden"
of 404: "404 Not Found"
of 405: "405 Method Not Allowed"
of 406: "406 Not Acceptable"
of 408: "408 Request Timeout"
of 409: "409 Conflict"
of 410: "410 Gone"
of 411: "411 Length Required"
of 413: "413 Request Entity Too Large"
of 414: "414 Request-URI Too Long"
of 415: "415 Unsupported Media Type"
of 416: "416 Requested Range Not Satisfiable"
of 429: "429 Too Many Requests"
...
of 500: "500 Internal Server Error"
of 501: "501 Not Implemented"
of 502: "502 Bad Gateway"
of 503: "503 Service Unavailable"
of 504: "504 Gateway Timeout"
...
else: $(int(code))
the code above is taken from pure/http
in nim stdlib
headers
another abstraction we need is the headers list. Headers in http aren't just key=value, but key=[value] so key can has a list of values.
type HttpHeaders* = ref object
table*: TableRef[string, seq[string]]
type HttpHeaderValues* = seq[string]
proc newHttpHeaders*(): HttpHeaders =
new result
result.table = newTable[string, seq[string]]()
proc newHttpHeaders*(keyValuePairs:
seq[tuple[key: string, val: string]]): HttpHeaders =
var pairs: seq[tuple[key: string, val: seq[string]]] = @[]
for pair in keyValuePairs:
pairs.add((pair.key.toLowerAscii(), @[pair.val]))
new result
result.table = newTable[string, seq[string]](pairs)
proc `$`*(headers: HttpHeaders): string =
return $headers.table
proc clear*(headers: HttpHeaders) =
headers.table.clear()
proc `[]`*(headers: HttpHeaders, key: string): HttpHeaderValues =
## Returns the values associated with the given ``key``. If the returned
## values are passed to a procedure expecting a ``string``, the first
## value is automatically picked. If there are
## no values associated with the key, an exception is raised.
##
## To access multiple values of a key, use the overloaded ``[]`` below or
## to get all of them access the ``table`` field directly.
return headers.table[key.toLowerAscii].HttpHeaderValues
# converter toString*(values: HttpHeaderValues): string =
# return seq[string](values)[0]
proc `[]`*(headers: HttpHeaders, key: string, i: int): string =
## Returns the ``i``'th value associated with the given key. If there are
## no values associated with the key or the ``i``'th value doesn't exist,
## an exception is raised.
return headers.table[key.toLowerAscii][i]
proc `[]=`*(headers: HttpHeaders, key, value: string) =
## Sets the header entries associated with ``key`` to the specified value.
## Replaces any existing values.
headers.table[key.toLowerAscii] = @[value]
proc `[]=`*(headers: HttpHeaders, key: string, value: seq[string]) =
## Sets the header entries associated with ``key`` to the specified list of
## values.
## Replaces any existing values.
headers.table[key.toLowerAscii] = value
proc add*(headers: HttpHeaders, key, value: string) =
## Adds the specified value to the specified key. Appends to any existing
## values associated with the key.
if not headers.table.hasKey(key.toLowerAscii):
headers.table[key.toLowerAscii] = @[value]
else:
headers.table[key.toLowerAscii].add(value)
proc del*(headers: HttpHeaders, key: string) =
## Delete the header entries associated with ``key``
headers.table.del(key.toLowerAscii)
iterator pairs*(headers: HttpHeaders): tuple[key, value: string] =
## Yields each key, value pair.
for k, v in headers.table:
for value in v:
yield (k, value)
proc contains*(values: HttpHeaderValues, value: string): bool =
## Determines if ``value`` is one of the values inside ``values``. Comparison
## is performed without case sensitivity.
for val in seq[string](values):
if val.toLowerAscii == value.toLowerAscii: return true
proc hasKey*(headers: HttpHeaders, key: string): bool =
return headers.table.hasKey(key.toLowerAscii())
proc getOrDefault*(headers: HttpHeaders, key: string,
default = @[""].HttpHeaderValues): HttpHeaderValues =
## Returns the values associated with the given ``key``. If there are no
## values associated with the key, then ``default`` is returned.
if headers.hasKey(key):
return headers[key]
else:
return default
proc len*(headers: HttpHeaders): int = return headers.table.len
proc parseList(line: string, list: var seq[string], start: int): int =
var i = 0
var current = ""
while start+i < line.len and line[start + i] notin {'\c', '\l'}:
i += line.skipWhitespace(start + i)
i += line.parseUntil(current, {'\c', '\l', ','}, start + i)
list.add(current)
if start+i < line.len and line[start + i] == ',':
i.inc # Skip ,
current.setLen(0)
proc parseHeader*(line: string): tuple[key: string, value: seq[string]] =
## Parses a single raw header HTTP line into key value pairs.
##
## Used by ``asynchttpserver`` and ``httpclient`` internally and should not
## be used by you.
result.value = @[]
var i = 0
i = line.parseUntil(result.key, ':')
inc(i) # skip :
if i < len(line):
i += parseList(line, result.value, i)
elif result.key.len > 0:
result.value = @[""]
else:
result.value = @[]
So we have the abstraction now over the headers. very nice.
Request
type Request = object
httpMethod*: HTTPMethod
httpVersion*: HttpVersion
headers*: HTTPHeaders
path*: string
body*: string
queryParams*: TableRef[string, string]
formData*: TableRef[string, string]
urlParams*: TableRef[string, string]
request is a type that keeps track of
- http version: from the client request
- request method: get, post, .. etc
- requested path: if the url is
localhost:9000/users/myfile
the requested path would be/users/myfile
- headers: request headers
- body: body
- formData: submitted form data
- queryParams: if the url is
/users/search?name=xmon&age=50
the queryParams will be Table {"name":"xmon", "age":50} - urlParams: are the captured variables by the router
if we have a route to handle
/users/:username/:language
and we received request with path/users/xmon/ar
it will bindusername
toxmon
andlanguage
toar
and make that available on the request object to be used later on by the handler.
Building the request
remember the handleClient
that we mentioned in the big picture section?
proc handleClient(s: ref Servy, client: AsyncSocket) {.async.} =
var req = await s.parseRequestFromConnection(client)
...
So let's implement parseRequestFromConnection
proc parseRequestFromConnection(s: ref Servy, conn:AsyncSocket): Future[Request] {.async.} =
result.queryParams = newTable[string, string]()
result.formData = newTable[string, string]()
result.urlParams = newTable[string, string]()
let requestline = $await conn.recvLine(maxLength=maxLine)
var meth, path, httpver: string
var parts = requestLine.splitWhitespace()
meth = parts[0]
path = parts[1]
httpver = parts[2]
var contentLength = 0
echo meth, path, httpver
let m = httpMethodFromString(meth)
if m.isSome:
result.httpMethod = m.get()
else:
echo meth
raise newException(OSError, "invalid httpmethod")
if "1.1" in httpver:
result.httpVersion = HttpVer11
elif "1.0" in httpver:
result.httpVersion = HttpVer10
result.path = path
if "?" in path:
# has query params
result.queryParams = parseQueryParams(path)
First we parse the request line METHOD PATH HTTPVER
e.g GET /users HTTP/1.1
so if we split on spaces we get the method, path, and http version
Also if there's ?
like in /users?username=xmon
in the request path, we should parse the Query Parameters
proc parseQueryParams(content: string): TableRef[string, string] =
result = newTable[string, string]()
var consumed = 0
if "?" notin content and "=" notin content:
return
if "?" in content:
consumed += content.skipUntil({'?'}, consumed)
inc consumed # skip ? now.
while consumed < content.len:
if "=" notin content[consumed..^1]:
break
var key = ""
var val = ""
consumed += content.parseUntil(key, "=", consumed)
inc consumed # =
consumed += content.parseUntil(val, "&", consumed)
inc consumed
# result[decodeUrl(key)] = result[decodeUrl(val)]
result.add(decodeUrl(key), decodeUrl(val))
echo "consumed:" & $consumed
echo "contentlen:" & $content.len
Next should be the headers
result.headers = newHttpHeaders()
# parse headers
var line = ""
line = $(await conn.recvLine(maxLength=maxLine))
echo fmt"line: >{line}< "
while line != "\r\n":
# a header line
let kv = parseHeader(line)
result.headers[kv.key] = kv.value
if kv.key.toLowerAscii == "content-length":
contentLength = parseInt(kv.value[0])
line = $(await conn.recvLine(maxLength=maxLine))
# echo fmt"line: >{line}< "
We receive the headers and figure out the body length from content-length
header to know how much to consume from the socket after we're done with the headers.
if contentLength > 0:
result.body = await conn.recv(contentLength)
discard result.parseFormData()
Now that we know how much to consume (contentLength
) from socket we can capture the request's body.
Notice that parseFormData
handles the form submitted in the request, let's take a look at that next.
Submitting data.
In HTTP there are different Content-Type(s)
to submit (post) data: application/x-www-form-urlencoded
and multipart/form-data
.
Quoting stackoverflow answer
The purpose of both of those types of requests is to send a list of name/value pairs to the server. Depending on the type and amount of data being transmitted, one of the methods will be more efficient than the other. To understand why, you have to look at what each is doing under the covers.
For application/x-www-form-urlencoded, the body of the HTTP message sent to the server is essentially one giant query string -- name/value pairs are separated by the ampersand (&), and names are separated from values by the equals symbol (=). An example of this would be:
MyVariableOne=ValueOne&MyVariableTwo=ValueTwo
That means that for each non-alphanumeric byte that exists in one of our values, it's going to take three bytes to represent it. For large binary files, tripling the payload is going to be highly inefficient.
That's where multipart/form-data comes in. With this method of transmitting name/value pairs, each pair is represented as a "part" in a MIME message (as described by other answers). Parts are separated by a particular string boundary (chosen specifically so that this boundary string does not occur in any of the "value" payloads). Each part has its own set of MIME headers like Content-Type, and particularly Content-Disposition, which can give each part its "name." The value piece of each name/value pair is the payload of each part of the MIME message. The MIME spec gives us more options when representing the value payload -- we can choose a more efficient encoding of binary data to save bandwidth (e.g. base 64 or even raw binary).
e.g:
If you want to send the following data to the web server:
name = John
age = 12
using application/x-www-form-urlencoded
would be like this:
name=John&age=12
As you can see, the server knows that parameters are separated by an ampersand &. If & is required for a parameter value then it must be encoded.
So how does the server know where a parameter value starts and ends when it receives an HTTP request using multipart/form-data?
Using the boundary, similar to &.
For example:
--XXX
Content-Disposition: form-data; name="name"
John
--XXX
Content-Disposition: form-data; name="age"
12
--XXX--
reference of the above explanation
type FormPart = object
name*: string
headers*: HttpHeaders
body*: string
proc newFormPart(): ref FormPart =
new result
result.headers = newHttpHeaders()
proc `$`(this:ref FormPart): string =
result = fmt"partname: {this.name} partheaders: {this.headers} partbody: {this.body}"
type FormMultiPart = object
parts*: TableRef[string, ref FormPart]
proc newFormMultiPart(): ref FormMultiPart =
new result
result.parts = newTable[string, ref FormPart]()
proc `$`(this: ref FormMultiPart): string =
return fmt"parts: {this.parts}"
So that's our abstraction for multipart form.
proc parseFormData(r: Request): ref FormMultiPart =
discard """
received request from client: (httpMethod: HttpPost, requestURI: "", httpVersion: HTTP/1.1, headers: {"accept": @["*/*"], "content-length": @["241"], "content-type": @["multipart/form-data; boundary=------------------------95909933ebe184f2"], "host": @["127.0.0.1:9000"], "user-agent": @["curl/7.62.0-DEV"]}, path: "/post", body: "--------------------------95909933ebe184f2\c\nContent-Disposition: form-data; name=\"who\"\c\n\c\nhamada\c\n--------------------------95909933ebe184f2\c\nContent-Disposition: form-data; name=\"next\"\c\n\c\nhome\c\n--------------------------95909933ebe184f2--\c\n", raw_body: "", queryParams: {:})
"""
result = newFormMultiPart()
let contenttype = r.headers.getOrDefault("content-type")[0]
let body = r.body
if "form-urlencoded" in contenttype.toLowerAscii():
# query params are the post body
let postBodyAsParams = parseQueryParams(body)
for k, v in postBodyAsParams.pairs:
r.queryParams.add(k, v)
if the content-type has the word form-urlencoded
we parse he body as if it was queryParams
elif contenttype.startsWith("multipart/") and "boundary" in contenttype:
var boundaryName = contenttype[contenttype.find("boundary=")+"boundary=".len..^1]
echo "boundayName: " & boundaryName
for partString in body.split(boundaryName & "\c\L"):
var part = newFormPart()
var partName = ""
var totalParsedLines = 1
let bodyLines = body.split("\c\L")[1..^1] # at the boundary line
for line in bodyLines:
if line.strip().len != 0:
let splitted = line.split(": ")
if len(splitted) == 2:
part.headers.add(splitted[0], splitted[1])
elif len(splitted) == 1:
part.headers.add(splitted[0], "")
if "content-disposition" in line.toLowerAscii and "name" in line.toLowerAscii:
# Content-Disposition: form-data; name="next"
var consumed = line.find("name=")+"name=".len
discard line.skip("\"", consumed)
inc consumed
consumed += line.parseUntil(partName, "\"", consumed)
else:
break # done with headers now for the body.
inc totalParsedLines
let content = join(bodyLines[totalParsedLines..^1], "\c\L")
part.body = content
part.name = partName
result.parts.add(partName, part)
echo $result.parts
if it's not form-urlencoded
then it's a multipart then we need to figure out the boundary and split the body on that boundary text
Response
Now that we can parse the client request we need to be able to build a correctly formatted response. Response keeps track of
- http version
- response status code
- response content
- response headers
type Response = object
headers: HttpHeaders
httpver: HttpVersion
code: HttpCode
content: string
Formatting response
proc formatStatusLine(code: HttpCode, httpver: HttpVersion) : string =
return fmt"{httpver} {code}" & "\r\n"
Here we build status line which is HTTPVERSION STATUS_CODE STATUS_MSG\r\n
e.g HTTP/1.1 200 OK
proc formatResponse(code:HttpCode, httpver:HttpVersion, content:string, headers:HttpHeaders): string =
result &= formatStatusLine(code, httpver)
if headers.len > 0:
for k,v in headers.pairs:
result &= fmt"{k}: {v}" & "\r\n"
result &= fmt"Content-Length: {content.len}" & "\r\n\r\n"
result &= content
echo "will send"
echo result
proc format(resp: ref Response) : string =
result = formatResponse(resp.code, resp.httpver, resp.content, resp.headers)
To format a complete response we need
- building status line
- headers to string
- content length to be the length for the body
- the body itself
Handling client request
so every handler function should take a Request
object and return a Response to be sent on the wire. Right?
proc handleClient(s: ref Servy, client: AsyncSocket) {.async.} =
var req = await s.parseRequestFromConnection(client)
...
let (routeHandler, params) = s.router.getByPath(req.path)
req.urlParams = params
let handler = routeHandler.handlerFunc
...
let resp = handler(req)
await client.send(resp.format())
Very cool the router will magically return to us a suitable route handler or 404 handler if not found using its getByPath
proc
- We get the handler
- apply it to the request to get a valid http response
- send the response to the client on the wire.
Let's get to the Handler Function example definition again
proc handleHello(req:var Request): ref Response =
result = newResponse()
result.code = Http200
result.content = "hello world from handler /hello" & $req
so it takes a request and returns a response, how about we create an alias for that?
type HandlerFunc = proc(req: var Request):ref Response {.nimcall.}
Middlewares
It's typical in many frameworks to apply certain set of checks or functions on the incoming request before sending it to any handler, like logging the request first, or trimming the trailing slashes, or checking for a certain header
How can we implement that? Remember our handleClient
? they need to be applied before the request reach the handler so should be above handler(req)
proc handleClient(s: ref Servy, client: AsyncSocket) {.async.} =
var req = await s.parseRequestFromConnection(client)
### HERE SHOULD BE MIDDLEWARE Code
###
###
let (routeHandler, params) = s.router.getByPath(req.path)
req.urlParams = params
let handler = routeHandler.handlerFunc
...
let resp = handler(req)
await client.send(resp.format())
So let's get to the implementation
proc handleClient(s: ref Servy, client: AsyncSocket) {.async.} =
var req = await s.parseRequestFromConnection(client)
for m in s.middlewares:
let (resp, usenextmiddleware) = m(req)
if not usenextmiddleware:
echo "early return from middleware..."
await client.send(resp.format())
return
...
let handler = routeHandler.handlerFunc
...
let resp = handler(req)
await client.send(resp.format())
here we loop over all registered middlewares
- middleware should return a response to be sent if it needs to terminate the handling immediately
- should tell us if we should continue applying middlewares or terminate immediately
That's why the definition of a middleware is like that
let loggingMiddleware = proc(request: var Request): (ref Response, bool) =
let path = request.path
let headers = request.headers
echo "==============================="
echo "from logger handler"
echo "path: " & path
echo "headers: " & $headers
echo "==============================="
return (newResponse(), true)
Let's create an alias for middleware function so we can use it easily in the rest of our code
type MiddlewareFunc = proc(req: var Request): (ref Response, bool) {.nimcall.}
Route specific middlewares
above we talked about global application middlewares, but maybe we want to apply some middleware or filter
to a certain route
proc handleClient(s: ref Servy, client: AsyncSocket) {.async.} =
var req = await s.parseRequestFromConnection(client)
for m in s.middlewares:
let (resp, usenextmiddleware) = m(req)
if not usenextmiddleware:
echo "early return from middleware..."
await client.send(resp.format())
return
echo "received request from client: " & $req
let (routeHandler, params) = s.router.getByPath(req.path)
req.urlParams = params
let handler = routeHandler.handlerFunc
let middlewares = routeHandler.middlewares
for m in middlewares:
let (resp, usenextmiddleware) = m(req)
if not usenextmiddleware:
echo "early return from route middleware..."
await client.send(resp.format())
return
let resp = handler(req)
echo "reached the handler safely.. and executing now."
await client.send(resp.format())
echo $req.formData
notice now we have a route specific middlewares to apply as well before calling handler(req)
maybe to check for a header before allowing access on that route.
Router
Router is one of the essential components in our code it's responsible to keep track of what the registered pattern and their handlers so we can actually do something with incoming request and the filters middlewares
to apply on the request
type RouterValue = object
handlerFunc: HandlerFunc
middlewares:seq[MiddlewareFunc]
type Router = object
table: TableRef[string, RouterValue]
Basic definition of the router as it's a map from a url pattern
to RouterValue
that basically has a reference to the handler proc and a sequence of middlewares/filters
proc newRouter(): ref Router =
result = new Router
result.table = newTable[string, RouterValue]()
Initializing the router
proc handle404(req: var Request): ref Response =
var resp = newResponse()
resp.code = Http404
resp.content = fmt"nothing at {req.path}"
return resp
Simple 404 handler in case that we don't find a handler for the requested path
proc getByPath(r: ref Router, path: string, notFoundHandler:HandlerFunc=handle404) : (RouterValue, TableRef[string, string]) =
var found = false
if path in r.table: # exact match
return (r.table[path], newTable[string, string]())
for handlerPath, routerValue in r.table.pairs:
echo fmt"checking handler: {handlerPath} if it matches {path}"
let pathParts = path.split({'/'})
let handlerPathParts = handlerPath.split({'/'})
echo fmt"pathParts {pathParts} and handlerPathParts {handlerPathParts}"
if len(pathParts) != len(handlerPathParts):
echo "length isn't ok"
continue
else:
var idx = 0
var capturedParams = newTable[string, string]()
while idx<len(pathParts):
let pathPart = pathParts[idx]
let handlerPathPart = handlerPathParts[idx]
echo fmt"current pathPart {pathPart} current handlerPathPart: {handlerPathPart}"
if handlerPathPart.startsWith(":") or handlerPathPart.startsWith("@"):
echo fmt"found var in path {handlerPathPart} matches {pathPart}"
capturedParams[handlerPathPart[1..^1]] = pathPart
inc idx
else:
if pathPart == handlerPathPart:
inc idx
else:
break
if idx == len(pathParts):
found = true
return (routerValue, capturedParams)
if not found:
return (RouterValue(handlerFunc:notFoundHandler, middlewares: @[]), newTable[string, string]())
Here we search for pattern registered in the router for exact match or if it has varialbes we and capture their values
e.g: /users/:name/:lang
pattern matches the request /users/xmon/ar
and creates env Table
with {"name":"xmon", "lang":"ar"}
/mywebsite/homepage
pattern matches /mywebsite/homepage/blogs/:username
patternmatches the path
/blogs/xmonand
/blogs/ahmedso it capture the env with variable name
usernameand variable value
xmonor
ahmed` and returns- when we found the suitable handler and its env we set the env on the request on
urlParams
field and call the handler on the updated request. Remember ourhandleClient
proc?
proc handleClient(s: ref Servy, client: AsyncSocket) {.async.} =
var req = await s.parseRequestFromConnection(client)
## Global middlewares
## ..
## ..
let (routeHandler, params) = s.router.getByPath(req.path)
req.urlParams = params
let handler = routeHandler.handlerFunc
## Route middlewares.
## ..
## ..
let resp = handler(req)
await client.send(resp.format())
proc addHandler(router: ref Router, route: string, handler: HandlerFunc, httpMethod:HttpMethod=HttpGet, middlewares:seq[MiddlewareFunc]= @[]) =
router.table.add(route, RouterValue(handlerFunc:handler, middlewares:middlewares))
we provide a simple function to add a handler to a route setting the method type and the middlewares as well on a Router
object.
What's next?
We didn't talk about templates, cookies, sessions, dates, sending files and for sure that's not a complete HTTP ref implementation by any means. Jester is a great option to check. Thank you for going through this day and please feel free to send PR or open issue on nim-servy repository