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.
This commit is contained in:
commit
5b0e6397dc
53 changed files with 7427 additions and 0 deletions
424
doc/guidance.texi
Normal file
424
doc/guidance.texi
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
@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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue