Safsaf is a web framework for Guile Scheme, built on Guile Fibers using the Guile Knots web server.
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 API.
A Safsaf application is a list of routes passed to run-safsaf.
Each route binds an HTTP method and URL pattern to a handler procedure.
The handler receives a Guile <request> and a body port, and
returns two values: a response and a body.
(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)
The last route should be a catch-all ('* method, '*
pattern) so that every request is handled. run-safsaf sets up
a Fibers scheduler, starts the HTTP server, and blocks until Ctrl-C.
Next: Handler Wrappers, Previous: Getting Started, Up: Guidance [Contents][Index]
Route patterns are lists of segments. A string matches literally, a
symbol captures that segment into current-route-params, and a
two-element list (predicate name) captures only when
predicate returns true. A dotted tail captures the remaining
path.
;; 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)
route-group nests routes under a shared prefix:
(route-group '("api" "v1")
(route 'GET '("users") api-list-users)
(route 'GET '("users" id) api-show-user))
This matches /api/v1/users and /api/v1/users/:id.
Give a route a #:name and use path-for to generate its
URL, so paths are never hard-coded. The first argument is always a
route group:
(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"
path-for also accepts #:query and #:fragment
keyword arguments.
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
wrap-routes.
(wrap-routes routes (make-exceptions-handler-wrapper #:dev? #t) logging-handler-wrapper)
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.
Apply wrappers to part of the route tree by wrapping a group separately:
(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))
Here CORS headers are added only to /api/* routes, while
logging applies to everything.
security-headers-handler-wrapper appends its headers to
the response rather than replacing existing ones. If a handler sets
X-Frame-Options itself, both values will appear in the response.
To avoid duplication, either omit the header from the wrapper (pass
#:frame-options #f) or do not set it in the handler.
make-max-body-size-handler-wrapper checks the
Content-Length header and rejects requests that exceed the
limit with a 413 response. However, it does not limit chunked
transfer-encoded requests that lack Content-Length. For
untrusted networks, use a reverse proxy (e.g. Nginx’s
client_max_body_size) to enforce size limits at the transport
level.
Next: Request Parsing, Previous: Handler Wrappers, Up: Guidance [Contents][Index]
Safsaf provides helpers that return (values response body)
directly:
;; 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)
html-response, json-response, text-response, and
redirect-response accept #:code and #:headers for
overrides. The error helpers (not-found-response, etc.) accept
#:headers but have a fixed status code.
For content negotiation, use negotiate-content-type:
(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))))))))
Next: Parameter Parsing, Previous: Responses, Up: Guidance [Contents][Index]
parse-form-body reads a URL-encoded POST body and returns an
alist of string pairs:
(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))))
parse-query-string extracts query parameters from the request
URL:
(let ((qs (parse-query-string request))) (assoc-ref qs "page")) ;; => "2" or #f
For file uploads, use parse-multipart-body:
(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)
...)
Read cookies with request-cookie-ref or
request-cookies. Set them via response headers with
set-cookie-header and delete-cookie-header:
(request-cookie-ref request "theme") ;; => "dark" or #f
(text-response "ok"
#:headers (list (set-cookie-header "theme" "dark"
#:path "/"
#:http-only #t)))
Next: Sessions, Previous: Request Parsing, Up: Guidance [Contents][Index]
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
<invalid-param>), and options like #:required or
#:default.
(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)
Built-in processors: as-string, as-integer,
as-number, as-checkbox, as-one-of,
as-matching, as-predicate.
For POST forms, use parse-form-params instead — it
automatically checks the CSRF token (from
csrf-handler-wrapper) before parsing:
(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))))
any-invalid-params? returns #t if any value failed
validation. field-errors returns a list of error message
strings for a given field, suitable for rendering next to form inputs.
Next: Templating, Previous: Parameter Parsing, Up: Guidance [Contents][Index]
Sessions use HMAC-signed cookies via (webutils sessions).
Set up a session config and apply the wrapper:
(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)))
Inside a handler, (current-session) returns the session data
(an alist) or #f if no valid session exists.
To set session data, include a session-set header in the
response. To delete, use session-delete:
;; 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)))
Next: Static Files, Previous: Sessions, Up: Guidance [Contents][Index]
write-shtml-as-html/streaming works like htmlprag’s
write-shtml-as-html, but any procedure in the SHTML tree is
called as (proc port) and can write dynamic content directly.
streaming-html-response wraps this into a response: give it an
SHTML tree (with optional procedure slots) and it returns
(values response body) ready for a handler.
(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"))))))
The layout is plain SHTML with a procedure in the content-proc
position. Use streaming-html-response to send it:
(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)))))
You can also call write-shtml-as-html/streaming directly when
you need to write SHTML with procedure slots to an arbitrary port.
Previous: Templating, Up: Guidance [Contents][Index]
make-static-handler returns a handler that serves files from a
directory. Pair it with a wildcard route:
(route-group '("static")
(route 'GET '(. path)
(make-static-handler "./public"
#:cache-control '((max-age . 3600)))))
This serves /static/css/style.css from
./public/css/style.css. The handler supports
If-Modified-Since for 304 responses.
Next: Version History, Previous: Guidance, Up: Overview [Contents][Index]
The following is the list of modules provided by this library.
Return a 405 Method Not Allowed response with an Allow header listing ALLOWED-METHODS.
Start a Safsaf web server.
ROUTES is a list of routes and route-groups (as returned by component constructors). The last route must be a catch-all so that every request is handled.
HEAD requests are handled automatically: when no explicit HEAD route matches, the matching GET handler runs and its response body is discarded. Explicit HEAD routes always take precedence.
When METHOD-NOT-ALLOWED? is #t (the default), requests that match a route’s path but not its method receive a 405 response with an Allow header. METHOD-NOT-ALLOWED-HANDLER is a procedure (request allowed-methods) -> (values response body) that produces the 405 response; the default returns plain text.
When called outside a Fibers scheduler, sets up a scheduler, starts the HTTP server, and blocks until Ctrl-C. When called inside an existing scheduler (e.g. within run-fibers), just starts the HTTP server and returns immediately — the caller manages the lifecycle.
Next: Copying Information, Previous: API, Up: Overview [Contents][Index]
Next: Concept Index, Previous: Version History, Up: Overview [Contents][Index]
Copyright © 2026 Christopher Baines <mail@cbaines.net>
This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
Next: Variable Index, Previous: Data Type Index, Up: Overview [Contents][Index]
| Jump to: | D R |
|---|
| Index Entry | Section | ||
|---|---|---|---|
| | |||
| D | |||
default-method-not-allowed-handler: | safsaf | ||
| | |||
| R | |||
run-safsaf: | safsaf | ||
| | |||
| Jump to: | D R |
|---|
Previous: Procedure Index, Up: Overview [Contents][Index]