425 lines
12 KiB
Text
425 lines
12 KiB
Text
|
|
@node Guidance
|
||
|
|
@chapter Guidance
|
||
|
|
|
||
|
|
This chapter explains how the pieces of Safsaf fit together. Each
|
||
|
|
section covers one concept with a short code example. For the full
|
||
|
|
list of parameters and options, see @ref{API}.
|
||
|
|
|
||
|
|
@menu
|
||
|
|
* Getting Started:: A minimal runnable server.
|
||
|
|
* Routing:: Route patterns, groups, and reverse routing.
|
||
|
|
* Handler Wrappers:: Composing middleware via wrap-routes.
|
||
|
|
* Responses:: HTML, JSON, text, redirects, and errors.
|
||
|
|
* Request Parsing:: Forms, query strings, multipart, cookies.
|
||
|
|
* Parameter Parsing:: Declarative param specs with validation.
|
||
|
|
* Sessions:: Signed cookie sessions.
|
||
|
|
* Templating:: Streaming HTML with dynamic slots.
|
||
|
|
* Static Files:: Serving files from disk.
|
||
|
|
@end menu
|
||
|
|
|
||
|
|
|
||
|
|
@node Getting Started
|
||
|
|
@section Getting Started
|
||
|
|
|
||
|
|
A Safsaf application is a list of routes passed to @code{run-safsaf}.
|
||
|
|
Each route binds an HTTP method and URL pattern to a handler procedure.
|
||
|
|
The handler receives a Guile @code{<request>} and a body port, and
|
||
|
|
returns two values: a response and a body.
|
||
|
|
|
||
|
|
@lisp
|
||
|
|
(use-modules (safsaf)
|
||
|
|
(safsaf router)
|
||
|
|
(safsaf response-helpers))
|
||
|
|
|
||
|
|
(define routes
|
||
|
|
(list
|
||
|
|
(route 'GET '() index-page
|
||
|
|
#:name 'index)
|
||
|
|
(route 'GET '("hello" name) hello-page)
|
||
|
|
(route '* '* (lambda (request body-port)
|
||
|
|
(not-found-response)))))
|
||
|
|
|
||
|
|
(define (index-page request body-port)
|
||
|
|
(html-response '(h1 "Welcome")))
|
||
|
|
|
||
|
|
(define (hello-page request body-port)
|
||
|
|
(let ((name (assoc-ref (current-route-params) 'name)))
|
||
|
|
(text-response (string-append "Hello, " name "!"))))
|
||
|
|
|
||
|
|
(run-safsaf routes #:port 8080)
|
||
|
|
@end lisp
|
||
|
|
|
||
|
|
The last route should be a catch-all (@code{'*} method, @code{'*}
|
||
|
|
pattern) so that every request is handled. @code{run-safsaf} sets up
|
||
|
|
a Fibers scheduler, starts the HTTP server, and blocks until Ctrl-C.
|
||
|
|
|
||
|
|
|
||
|
|
@node Routing
|
||
|
|
@section Routing
|
||
|
|
|
||
|
|
@subheading Patterns
|
||
|
|
|
||
|
|
Route patterns are lists of segments. A string matches literally, a
|
||
|
|
symbol captures that segment into @code{current-route-params}, and a
|
||
|
|
two-element list @code{(predicate name)} captures only when
|
||
|
|
@var{predicate} returns true. A dotted tail captures the remaining
|
||
|
|
path.
|
||
|
|
|
||
|
|
@lisp
|
||
|
|
;; Literal: /about
|
||
|
|
(route 'GET '("about") about-handler)
|
||
|
|
|
||
|
|
;; Capture: /users/:id
|
||
|
|
(route 'GET '("users" id) show-user)
|
||
|
|
|
||
|
|
;; Predicate: /posts/:id where id is numeric
|
||
|
|
(route 'GET '("posts" (,string->number id)) show-post)
|
||
|
|
|
||
|
|
;; Wildcard (rest): /files/* — captures remaining segments
|
||
|
|
(route 'GET '("files" . path) serve-file)
|
||
|
|
@end lisp
|
||
|
|
|
||
|
|
@subheading Route groups
|
||
|
|
|
||
|
|
@code{route-group} nests routes under a shared prefix:
|
||
|
|
|
||
|
|
@lisp
|
||
|
|
(route-group '("api" "v1")
|
||
|
|
(route 'GET '("users") api-list-users)
|
||
|
|
(route 'GET '("users" id) api-show-user))
|
||
|
|
@end lisp
|
||
|
|
|
||
|
|
This matches @code{/api/v1/users} and @code{/api/v1/users/:id}.
|
||
|
|
|
||
|
|
@subheading Named routes and path-for
|
||
|
|
|
||
|
|
Give a route a @code{#:name} and use @code{path-for} to generate its
|
||
|
|
URL, so paths are never hard-coded. The first argument is always a
|
||
|
|
route group:
|
||
|
|
|
||
|
|
@lisp
|
||
|
|
(define my-routes
|
||
|
|
(route-group '()
|
||
|
|
(route 'GET '("posts" id) show-post #:name 'show-post)))
|
||
|
|
|
||
|
|
(define all-routes
|
||
|
|
(list my-routes
|
||
|
|
(route '* '* (lambda (r b) (not-found-response)))))
|
||
|
|
|
||
|
|
;; In a handler or template:
|
||
|
|
(path-for my-routes 'show-post '((id . "42")))
|
||
|
|
;; => "/posts/42"
|
||
|
|
@end lisp
|
||
|
|
|
||
|
|
@code{path-for} also accepts @code{#:query} and @code{#:fragment}
|
||
|
|
keyword arguments.
|
||
|
|
|
||
|
|
|
||
|
|
@node Handler Wrappers
|
||
|
|
@section Handler Wrappers
|
||
|
|
|
||
|
|
A handler wrapper is a procedure that takes a handler and returns a
|
||
|
|
new handler. It can transform the request on the way in and the
|
||
|
|
response on the way out. Apply wrappers to a route tree with
|
||
|
|
@code{wrap-routes}.
|
||
|
|
|
||
|
|
@lisp
|
||
|
|
(wrap-routes routes
|
||
|
|
(make-exceptions-handler-wrapper #:dev? #t)
|
||
|
|
logging-handler-wrapper)
|
||
|
|
@end lisp
|
||
|
|
|
||
|
|
When multiple wrappers are given, the first wraps outermost — it runs
|
||
|
|
first on the request and last on the response. In the example above,
|
||
|
|
exceptions catches errors from the logging wrapper and the inner
|
||
|
|
handler.
|
||
|
|
|
||
|
|
@subheading Per-group wrappers
|
||
|
|
|
||
|
|
Apply wrappers to part of the route tree by wrapping a group
|
||
|
|
separately:
|
||
|
|
|
||
|
|
@lisp
|
||
|
|
(define api-routes
|
||
|
|
(wrap-routes
|
||
|
|
(route-group '("api")
|
||
|
|
(route 'GET '("items") api-list-items))
|
||
|
|
cors-handler-wrapper))
|
||
|
|
|
||
|
|
(define all-routes
|
||
|
|
(wrap-routes
|
||
|
|
(list api-routes
|
||
|
|
(route 'GET '() index-page)
|
||
|
|
(route '* '* (lambda (r b) (not-found-response))))
|
||
|
|
logging-handler-wrapper))
|
||
|
|
@end lisp
|
||
|
|
|
||
|
|
Here CORS headers are added only to @code{/api/*} routes, while
|
||
|
|
logging applies to everything.
|
||
|
|
|
||
|
|
@subheading Security headers
|
||
|
|
|
||
|
|
@code{security-headers-handler-wrapper} @emph{appends} its headers to
|
||
|
|
the response rather than replacing existing ones. If a handler sets
|
||
|
|
@code{X-Frame-Options} itself, both values will appear in the response.
|
||
|
|
To avoid duplication, either omit the header from the wrapper (pass
|
||
|
|
@code{#:frame-options #f}) or do not set it in the handler.
|
||
|
|
|
||
|
|
@subheading Max body size
|
||
|
|
|
||
|
|
@code{make-max-body-size-handler-wrapper} checks the
|
||
|
|
@code{Content-Length} header and rejects requests that exceed the
|
||
|
|
limit with a 413 response. However, it does @emph{not} limit chunked
|
||
|
|
transfer-encoded requests that lack @code{Content-Length}. For
|
||
|
|
untrusted networks, use a reverse proxy (e.g.@: Nginx's
|
||
|
|
@code{client_max_body_size}) to enforce size limits at the transport
|
||
|
|
level.
|
||
|
|
|
||
|
|
|
||
|
|
@node Responses
|
||
|
|
@section Responses
|
||
|
|
|
||
|
|
Safsaf provides helpers that return @code{(values response body)}
|
||
|
|
directly:
|
||
|
|
|
||
|
|
@lisp
|
||
|
|
;; HTML — streams an SXML tree
|
||
|
|
(html-response '(div (h1 "Hello") (p "world")))
|
||
|
|
|
||
|
|
;; JSON — takes a JSON string
|
||
|
|
(json-response (scm->json-string '(("ok" . #t))))
|
||
|
|
|
||
|
|
;; Plain text
|
||
|
|
(text-response "pong")
|
||
|
|
|
||
|
|
;; Redirect (default 303 See Other)
|
||
|
|
(redirect-response "/login")
|
||
|
|
(redirect-response "/new-item" #:code 302)
|
||
|
|
|
||
|
|
;; Error responses
|
||
|
|
(not-found-response)
|
||
|
|
(bad-request-response "Missing field")
|
||
|
|
(forbidden-response)
|
||
|
|
@end lisp
|
||
|
|
|
||
|
|
@code{html-response}, @code{json-response}, @code{text-response}, and
|
||
|
|
@code{redirect-response} accept @code{#:code} and @code{#:headers} for
|
||
|
|
overrides. The error helpers (@code{not-found-response}, etc.)@: accept
|
||
|
|
@code{#:headers} but have a fixed status code.
|
||
|
|
|
||
|
|
For content negotiation, use @code{negotiate-content-type}:
|
||
|
|
|
||
|
|
@lisp
|
||
|
|
(define (show-item request body-port)
|
||
|
|
(let ((item (fetch-item (assoc-ref (current-route-params) 'id))))
|
||
|
|
(case (negotiate-content-type request
|
||
|
|
'(text/html application/json))
|
||
|
|
((application/json)
|
||
|
|
(json-response (scm->json-string (item->alist item))))
|
||
|
|
(else
|
||
|
|
(html-response `(div (h1 ,(item-title item))))))))
|
||
|
|
@end lisp
|
||
|
|
|
||
|
|
|
||
|
|
@node Request Parsing
|
||
|
|
@section Request Parsing
|
||
|
|
|
||
|
|
@subheading Form bodies
|
||
|
|
|
||
|
|
@code{parse-form-body} reads a URL-encoded POST body and returns an
|
||
|
|
alist of string pairs:
|
||
|
|
|
||
|
|
@lisp
|
||
|
|
(define (handle-login request body-port)
|
||
|
|
(let* ((form (parse-form-body request body-port))
|
||
|
|
(username (assoc-ref form "username"))
|
||
|
|
(password (assoc-ref form "password")))
|
||
|
|
(if (valid-credentials? username password)
|
||
|
|
(redirect-response "/dashboard")
|
||
|
|
(text-response "Invalid login" #:code 401))))
|
||
|
|
@end lisp
|
||
|
|
|
||
|
|
@subheading Query strings
|
||
|
|
|
||
|
|
@code{parse-query-string} extracts query parameters from the request
|
||
|
|
URL:
|
||
|
|
|
||
|
|
@lisp
|
||
|
|
(let ((qs (parse-query-string request)))
|
||
|
|
(assoc-ref qs "page")) ;; => "2" or #f
|
||
|
|
@end lisp
|
||
|
|
|
||
|
|
@subheading Multipart
|
||
|
|
|
||
|
|
For file uploads, use @code{parse-multipart-body}:
|
||
|
|
|
||
|
|
@lisp
|
||
|
|
(let* ((parts (parse-multipart-body request body-port))
|
||
|
|
(form (multipart-text-fields parts))
|
||
|
|
(file (parts-ref parts "avatar")))
|
||
|
|
;; form is an alist of text fields
|
||
|
|
;; file is a <part> record — read its body with (part-body file)
|
||
|
|
...)
|
||
|
|
@end lisp
|
||
|
|
|
||
|
|
@subheading Cookies
|
||
|
|
|
||
|
|
Read cookies with @code{request-cookie-ref} or
|
||
|
|
@code{request-cookies}. Set them via response headers with
|
||
|
|
@code{set-cookie-header} and @code{delete-cookie-header}:
|
||
|
|
|
||
|
|
@lisp
|
||
|
|
(request-cookie-ref request "theme") ;; => "dark" or #f
|
||
|
|
|
||
|
|
(text-response "ok"
|
||
|
|
#:headers (list (set-cookie-header "theme" "dark"
|
||
|
|
#:path "/"
|
||
|
|
#:http-only #t)))
|
||
|
|
@end lisp
|
||
|
|
|
||
|
|
|
||
|
|
@node Parameter Parsing
|
||
|
|
@section Parameter Parsing
|
||
|
|
|
||
|
|
@code{parse-params} validates and transforms raw form or query data
|
||
|
|
according to a declarative spec. Each spec entry names a parameter,
|
||
|
|
a processor (a procedure that converts a string or returns an
|
||
|
|
@code{<invalid-param>}), and options like @code{#:required} or
|
||
|
|
@code{#:default}.
|
||
|
|
|
||
|
|
@lisp
|
||
|
|
(let ((params (parse-params
|
||
|
|
`((page ,as-integer #:default 1)
|
||
|
|
(per-page ,as-integer #:default 20)
|
||
|
|
(q ,as-string))
|
||
|
|
(parse-query-string request))))
|
||
|
|
(assq-ref params 'page)) ;; => 1 (integer, not string)
|
||
|
|
@end lisp
|
||
|
|
|
||
|
|
Built-in processors: @code{as-string}, @code{as-integer},
|
||
|
|
@code{as-number}, @code{as-checkbox}, @code{as-one-of},
|
||
|
|
@code{as-matching}, @code{as-predicate}.
|
||
|
|
|
||
|
|
@subheading Form params with CSRF
|
||
|
|
|
||
|
|
For POST forms, use @code{parse-form-params} instead — it
|
||
|
|
automatically checks the CSRF token (from
|
||
|
|
@code{csrf-handler-wrapper}) before parsing:
|
||
|
|
|
||
|
|
@lisp
|
||
|
|
(let* ((form (parse-form-body request body-port))
|
||
|
|
(params (parse-form-params
|
||
|
|
`((title ,as-string #:required)
|
||
|
|
(body ,as-string #:required))
|
||
|
|
form)))
|
||
|
|
(if (any-invalid-params? params)
|
||
|
|
;; Re-render the form with errors
|
||
|
|
(render-form (field-errors params 'title)
|
||
|
|
(field-errors params 'body))
|
||
|
|
;; Proceed
|
||
|
|
(create-item! (assq-ref params 'title)
|
||
|
|
(assq-ref params 'body))))
|
||
|
|
@end lisp
|
||
|
|
|
||
|
|
@code{any-invalid-params?} returns @code{#t} if any value failed
|
||
|
|
validation. @code{field-errors} returns a list of error message
|
||
|
|
strings for a given field, suitable for rendering next to form inputs.
|
||
|
|
|
||
|
|
|
||
|
|
@node Sessions
|
||
|
|
@section Sessions
|
||
|
|
|
||
|
|
Sessions use HMAC-signed cookies via @code{(webutils sessions)}.
|
||
|
|
Set up a session config and apply the wrapper:
|
||
|
|
|
||
|
|
@lisp
|
||
|
|
(define session-mgr
|
||
|
|
(make-session-config "my-secret-key"
|
||
|
|
#:cookie-name "my-session"))
|
||
|
|
|
||
|
|
(define routes
|
||
|
|
(wrap-routes my-routes
|
||
|
|
(make-session-handler-wrapper session-mgr)))
|
||
|
|
@end lisp
|
||
|
|
|
||
|
|
Inside a handler, @code{(current-session)} returns the session data
|
||
|
|
(an alist) or @code{#f} if no valid session exists.
|
||
|
|
|
||
|
|
To set session data, include a @code{session-set} header in the
|
||
|
|
response. To delete, use @code{session-delete}:
|
||
|
|
|
||
|
|
@lisp
|
||
|
|
;; Set session
|
||
|
|
(redirect-response "/"
|
||
|
|
#:headers (list (session-set session-mgr
|
||
|
|
'((user-id . 42)))))
|
||
|
|
|
||
|
|
;; Read session
|
||
|
|
(let ((user-id (and (current-session)
|
||
|
|
(assoc-ref (current-session) 'user-id))))
|
||
|
|
...)
|
||
|
|
|
||
|
|
;; Delete session
|
||
|
|
(redirect-response "/"
|
||
|
|
#:headers (list (session-delete session-mgr)))
|
||
|
|
@end lisp
|
||
|
|
|
||
|
|
|
||
|
|
@node Templating
|
||
|
|
@section Templating
|
||
|
|
|
||
|
|
@code{write-shtml-as-html/streaming} works like htmlprag's
|
||
|
|
@code{write-shtml-as-html}, but any procedure in the SHTML tree is
|
||
|
|
called as @code{(proc port)} and can write dynamic content directly.
|
||
|
|
|
||
|
|
@code{streaming-html-response} wraps this into a response: give it an
|
||
|
|
SHTML tree (with optional procedure slots) and it returns
|
||
|
|
@code{(values response body)} ready for a handler.
|
||
|
|
|
||
|
|
@lisp
|
||
|
|
(define (base-layout title content-proc)
|
||
|
|
`(*TOP*
|
||
|
|
(*DECL* DOCTYPE html)
|
||
|
|
(html
|
||
|
|
(head (title ,title))
|
||
|
|
(body
|
||
|
|
(nav (a (@@ (href "/")) "Home"))
|
||
|
|
(main ,content-proc)
|
||
|
|
(footer (p "Footer"))))))
|
||
|
|
@end lisp
|
||
|
|
|
||
|
|
The layout is plain SHTML with a procedure in the @var{content-proc}
|
||
|
|
position. Use @code{streaming-html-response} to send it:
|
||
|
|
|
||
|
|
@lisp
|
||
|
|
(define (index-page request body-port)
|
||
|
|
(streaming-html-response
|
||
|
|
(base-layout "Home"
|
||
|
|
(lambda (port)
|
||
|
|
(write-shtml-as-html
|
||
|
|
`(div (h1 "Welcome")
|
||
|
|
(p "Content goes here."))
|
||
|
|
port)))))
|
||
|
|
@end lisp
|
||
|
|
|
||
|
|
You can also call @code{write-shtml-as-html/streaming} directly when
|
||
|
|
you need to write SHTML with procedure slots to an arbitrary port.
|
||
|
|
|
||
|
|
|
||
|
|
@node Static Files
|
||
|
|
@section Static Files
|
||
|
|
|
||
|
|
@code{make-static-handler} returns a handler that serves files from a
|
||
|
|
directory. Pair it with a wildcard route:
|
||
|
|
|
||
|
|
@lisp
|
||
|
|
(route-group '("static")
|
||
|
|
(route 'GET '(. path)
|
||
|
|
(make-static-handler "./public"
|
||
|
|
#:cache-control '((max-age . 3600)))))
|
||
|
|
@end lisp
|
||
|
|
|
||
|
|
This serves @code{/static/css/style.css} from
|
||
|
|
@code{./public/css/style.css}. The handler supports
|
||
|
|
@code{If-Modified-Since} for 304 responses.
|