All checks were successful
/ test (push) Successful in 9s
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.
424 lines
12 KiB
Text
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.
|