safsaf/doc/guidance.texi
Christopher Baines 5b0e6397dc
All checks were successful
/ test (push) Successful in 9s
Initial commit
Safsaf is a Guile web framework, written using Claude Code running
Claude Opus 4.6, based off of the Guix Data Service, Nar Herder and
Guix Build Coordinator codebases.
2026-04-13 14:24:19 +03:00

424 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.