Safsaf

Next:   [Contents][Index]

Overview

Safsaf is a web framework for Guile Scheme, built on Guile Fibers using the Guile Knots web server.

Table of Contents


Next: , Previous: , Up: Overview   [Contents][Index]

1 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 API.


Next: , Up: Guidance   [Contents][Index]

1.1 Getting Started

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.


1.2 Routing

Patterns

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 groups

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.

Named routes and path-for

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.


Next: , Previous: , Up: Guidance   [Contents][Index]

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

Per-group wrappers

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

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.

Max body size

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.


1.4 Responses

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: , Previous: , Up: Guidance   [Contents][Index]

1.5 Request Parsing

Form bodies

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))))

Query strings

parse-query-string extracts query parameters from the request URL:

(let ((qs (parse-query-string request)))
  (assoc-ref qs "page"))  ;; => "2" or #f

Multipart

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

Cookies

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: , Previous: , Up: Guidance   [Contents][Index]

1.6 Parameter Parsing

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.

Form params with CSRF

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: , Previous: , Up: Guidance   [Contents][Index]

1.7 Sessions

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: , Previous: , Up: Guidance   [Contents][Index]

1.8 Templating

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: , Up: Guidance   [Contents][Index]

1.9 Static Files

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: , Previous: , Up: Overview   [Contents][Index]

2 API

The following is the list of modules provided by this library.


2.1 (safsaf)

2.1.1 Procedures

Procedure: default-method-not-allowed-handler request allowed-methods

Return a 405 Method Not Allowed response with an Allow header listing ALLOWED-METHODS.

Procedure: run-safsaf routes KEY: #:host #:port #:method-not-allowed? #:method-not-allowed-handler #:connection-buffer-size

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.


2.2 (safsaf handler-wrappers cors)

2.2.1 Procedures

Procedure: cors-handler-wrapper handler KEY: #:origins #:methods #:headers #:max-age #:allow-credentials? #:expose-headers

Handler wrapper that adds CORS (Cross-Origin Resource Sharing) headers to responses.

Browsers enforce the Same-Origin Policy: scripts on one origin (scheme + host + port) cannot read responses from a different origin. CORS relaxes this by letting the server declare which origins, methods, and headers are permitted.

For “simple” requests the browser sends the request and checks the response headers. For non-simple requests (e.g. PUT/DELETE, custom headers, or JSON Content-Type) the browser sends a preflight OPTIONS request first. This wrapper handles both cases.

ORIGINS is a list of allowed origin strings, or ’("*") for any. METHODS is a list of allowed method symbols. HEADERS is a list of allowed request header name strings. MAX-AGE is the preflight cache duration in seconds. ALLOW-CREDENTIALS? controls whether credentials (cookies, auth) are allowed cross-origin. Note: cannot be #t when origins is ’("*"). EXPOSE-HEADERS is a list of response header name strings the browser may read from JavaScript.


2.3 (safsaf handler-wrappers csrf)

2.3.1 Parameters

Parameter: current-csrf-token

Default value:

#f

2.3.2 Procedures

Procedure: csrf-handler-wrapper handler KEY: #:cookie-name

CSRF token handler wrapper.

Ensures a CSRF token cookie is present on every response (generates one if the request has none). The token is bound to current-csrf-token so handlers and templates can read it via (current-csrf-token).

Token validation is NOT done here — it belongs in the form processing layer. Use parse-form-params from (safsaf params), which automatically checks the submitted token against the cookie token.

Procedure: csrf-token-field

Return an SXML hidden input element for the CSRF token. Use in forms: (csrf-token-field)(input (@ (type "hidden") ...)).


2.4 (safsaf handler-wrappers exceptions)

2.4.1 Procedures

Procedure: default-render-error render-html render-json

Return a render-error procedure that content-negotiates between RENDER-HTML and RENDER-JSON based on the request’s Accept header.

Procedure: default-render-html request code message backtrace-string dev?

Default HTML error renderer. In dev mode, shows a rich backtrace page. In production, returns a minimal HTML page.

Procedure: default-render-json _request code message backtrace-string dev?

Default JSON error renderer. In dev mode, includes the backtrace. In production, returns only the error message.

Procedure: exceptions-handler-wrapper handler KEY: #:dev? #:logger #:render-html #:render-json #:render-error

Handler wrapper that catches exceptions from HANDLER and returns an error response.

The response format is content-negotiated from the request’s Accept header, choosing between HTML and JSON.

When LOGGER is provided, exceptions are logged through it. Otherwise, the backtrace is written to the current error port. In dev mode (DEV? is #t), the response includes the backtrace and exception details. In production mode, a generic error is returned.

Rendering can be customised at three levels:

#:render-error — full override. A procedure (request code message backtrace-string dev?) -> (values response body) that bypasses content negotiation entirely.

#:render-html — custom HTML rendering. A procedure with the same signature, called when content negotiation selects HTML.

#:render-json — custom JSON rendering. A procedure with the same signature, called when content negotiation selects JSON.

The default RENDER-ERROR content-negotiates between RENDER-HTML and RENDER-JSON. Providing #:render-html or #:render-json replaces just that format; providing #:render-error replaces the entire rendering.

Procedure: make-exceptions-handler-wrapper KEY: #:dev? #:logger #:render-html #:render-json #:render-error

Return a handler wrapper that catches exceptions and returns an error response. See exceptions-handler-wrapper for details.


2.5 (safsaf handler-wrappers logging)

2.5.1 Procedures

Procedure: logging-handler-wrapper handler KEY: #:logger #:level

Handler wrapper that logs each request and response.

Logs at LEVEL (default ’INFO) with method, path, status code, and duration in milliseconds. If LOGGER is given, logs to that logger; otherwise uses the default logger set via set-default-logger!.


2.6 (safsaf handler-wrappers max-body-size)

2.6.1 Procedures

Procedure: make-max-body-size-handler-wrapper max-bytes KEY: #:handler-413

Return a handler wrapper that rejects requests whose Content-Length exceeds MAX-BYTES with a 413 Payload Too Large response.

HANDLER-413 is a handler (request body-port) -> (values response body) called when the limit is exceeded; the default returns plain text.

Note: this checks the Content-Length header only. Chunked transfers without Content-Length are not limited by this wrapper.


2.7 (safsaf handler-wrappers security-headers)

2.7.1 Procedures

Procedure: security-headers-handler-wrapper handler KEY: #:content-type-options #:frame-options #:strict-transport-security #:referrer-policy #:cross-origin-opener-policy #:permissions-policy #:content-security-policy #:content-security-policy-report-only

Handler wrapper that adds security headers to every response.

All headers are optional and configurable. Pass #f to disable a header. Defaults: X-Content-Type-Options: nosniff X-Frame-Options: DENY Referrer-Policy: strict-origin-when-cross-origin

Not set by default (enable explicitly): Strict-Transport-Security (e.g. "max-age=63072000; includeSubDomains") Cross-Origin-Opener-Policy (e.g. "same-origin") Permissions-Policy (e.g. "camera=(), microphone=()") Content-Security-Policy (e.g. "default-src ’self’; script-src ’self’") Content-Security-Policy-Report-Only — same syntax, for testing policies without enforcing them


2.8 (safsaf handler-wrappers sessions)

2.8.1 Parameters

Parameter: current-session

Default value:

#f

2.8.2 Procedures

Procedure: make-session-config secret-key KEY: #:cookie-name #:expire-delta #:algorithm

Create a session manager for use with session-handler-wrapper.

SECRET-KEY is the HMAC signing key (a string). EXPIRE-DELTA is (days hours minutes), default 30 days. ALGORITHM is the HMAC algorithm, default sha512.

Procedure: make-session-handler-wrapper session-manager

Return a handler wrapper that binds session data from SESSION-MANAGER. See session-handler-wrapper for details.

Procedure: session-delete session-manager

Return a Set-Cookie header that expires the session cookie. Include in a response headers list: (redirect-response "/" #:headers (list (session-delete manager)))

Procedure: session-handler-wrapper handler session-manager

Session handler wrapper using signed cookies via (webutils sessions).

Reads the session cookie from the request, verifies the HMAC signature, and binds current-session for the duration of the handler. If no valid session cookie is present, current-session is #f.

Handlers read session data via: (current-session) → session data or #f

To set or delete the session, handlers include the appropriate header in their response using session-set and session-delete:

(redirect-response "/" #:headers (list (session-set manager data))) (redirect-response "/" #:headers (list (session-delete manager)))

Procedure: session-set session-manager data

Return a Set-Cookie header that stores signed DATA in the session cookie. DATA can be any Scheme value that can be written and read back. Include in a response headers list: (redirect-response "/" #:headers (list (session-set manager ’((user-id . 42)))))


2.9 (safsaf handler-wrappers trailing-slash)

2.9.1 Procedures

Procedure: make-trailing-slash-handler-wrapper KEY: #:mode #:code

Return a handler wrapper that normalizes trailing slashes.

MODE is either ’strip (default) or ’append: ’strip — redirect /foo/ to /foo ’append — redirect /foo to /foo/

The root path / is always left alone.

CODE is the HTTP status code for the redirect (default 301).

Use with wrap-routes: (wrap-routes routes (make-trailing-slash-handler-wrapper #:mode ’append))

Procedure: trailing-slash-handler-wrapper handler KEY: #:mode #:code

Handler wrapper that normalizes trailing slashes in request paths.

MODE is either ’strip (default) or ’append: ’strip — redirect /foo/ to /foo ’append — redirect /foo to /foo/

The root path / is always left alone.

CODE is the HTTP status code for the redirect (default 301).


2.10 (safsaf params)

2.10.1 Macros

Macro: invalid-param-message x

Undocumented macro.

Macro: invalid-param-value x

Undocumented macro.

Macro: invalid-param? x

Undocumented macro.

Macro: make-invalid-param x

Undocumented macro.

2.10.2 Procedures

Procedure: any-invalid-params? parsed-params

Return #t if any values in PARSED-PARAMS are invalid.

Procedure: as-checkbox s

Undocumented procedure.

Procedure: as-integer s

Undocumented procedure.

Procedure: as-matching regex KEY: #:message

Return a processor that accepts values matching REGEX.

Procedure: as-number s

Undocumented procedure.

Procedure: as-one-of choices KEY: #:message

Return a processor that accepts only values in CHOICES (a list of strings).

Procedure: as-predicate pred KEY: #:message

Return a processor that accepts values for which PRED returns true.

Procedure: as-string x

Undocumented procedure.

Procedure: field-errors parsed-params name

Return a list of error message strings for NAME, or ’(). Convenient for rendering form fields with per-field errors.

Procedure: guard-against-mutually-exclusive-params parsed-params groups

Check PARSED-PARAMS for mutually exclusive parameter groups. GROUPS is a list of lists of symbols, e.g. ’((limit_results all_results)). If parameters from the same group co-occur, the later ones are replaced with <invalid-param> records.

Procedure: invalid-param-ref parsed-params name

Return the <invalid-param> record for NAME, or #f if valid or absent.

Procedure: params->query-string parsed-params

Serialize PARSED-PARAMS back to a URI query string. Skips invalid params. Handles multi-value (list) entries. Useful for building pagination links that preserve current filters.

Procedure: parse-form-params param-specs raw-params KEY: #:csrf-field

Like parse-params but prepends a CSRF token check. Uses current-csrf-token from (safsaf handler-wrappers csrf).

Procedure: parse-params param-specs raw-params

Parse and transform parameters from RAW-PARAMS according to PARAM-SPECS.

RAW-PARAMS is an alist of (string . string) pairs, as returned by parse-query-string or parse-form-body.

PARAM-SPECS is a list of specifications. Each spec is a list whose first element is the parameter name (a symbol), second is a processor procedure (string -> value | <invalid-param>), and the rest are keyword options:

(name processor) ; optional (name processor #:required) ; must be present (name processor #:default value) ; fallback (name processor #:multi-value) ; collect all occurrences (name processor #:multi-value #:default value) ; multi-value with fallback (name processor #:no-default-when (fields) #:default value) ; conditional default

Returns an alist of (symbol . value) pairs. Values that fail validation appear as <invalid-param> records inline. Missing optional params without defaults are omitted.

2.10.3 Record Types

Record type: <invalid-param>

This record type has the following fields:

  • value
  • message

Next: , Previous: , Up: API   [Contents][Index]

2.11 (safsaf response-helpers)

2.11.1 Procedures

Procedure: bad-request-response OPT: body KEY: #:headers

Return a 400 Bad Request response.

Procedure: build-response/inherit response KEY: #:headers

Build a new response based on RESPONSE, preserving its version, status code, and reason phrase. HEADERS defaults to the existing headers; override it to modify them.

Use this in handler wrappers that need to adjust headers on an inner handler’s response without losing any response fields.

Procedure: delete-cookie-header name

Return a Set-Cookie header pair that expires cookie NAME. Wraps (webutils cookie) delete-cookie.

Procedure: forbidden-response OPT: body KEY: #:headers

Return a 403 Forbidden response.

Procedure: html-response shtml KEY: #:code #:headers #:charset

Return an HTML response by streaming SHTML to the client. SHTML is an SXML/SHTML tree as accepted by write-shtml-as-html. CHARSET defaults to "utf-8".

Procedure: internal-server-error-response OPT: body KEY: #:headers

Return a 500 Internal Server Error response.

Procedure: json-response str KEY: #:code #:headers

Return a JSON response. STR is the JSON string to send.

Procedure: list->streaming-json-array proc lst port KEY: #:unicode

Write LST as a JSON array to PORT, applying PROC to each element to produce a JSON-serializable value. Each element is written individually via scm->json so the entire array need not be held in memory.

Procedure: make-static-handler root-dir KEY: #:cache-control

Return a handler that serves static files from ROOT-DIR.

The handler expects route params to contain a wildcard capture (the file path segments). Use with a wildcard route:

(route ’GET ’(. path) (make-static-handler "/path/to/public"))

Supports If-Modified-Since for 304 responses. CACHE-CONTROL, if given, is a Cache-Control value in Guile’s header format — an alist, e.g. ’((max-age . 3600)) or ’((no-cache)).

Works with /gnu/store paths: files with a very low mtime (as produced by the store’s timestamp normalization) use the process start time as Last-Modified instead, so that conditional requests behave sensibly.

Procedure: negotiate-content-type request OPT: supported KEY: #:extensions

Return the most appropriate MIME type symbol for REQUEST from SUPPORTED.

Checks the URL path extension first (.json, .html, .txt) — if present and the implied type is in SUPPORTED, it wins. Otherwise, walks the Accept header and returns the first type that appears in SUPPORTED. Falls back to the first element of SUPPORTED if nothing matches.

EXTENSIONS is an alist mapping file extension strings to MIME type symbols, used for path-based negotiation. Defaults to %negotiation-extensions.

Procedure: not-found-response OPT: body KEY: #:headers

Return a 404 Not Found response.

Procedure: payload-too-large-response OPT: body KEY: #:headers

Return a 413 Payload Too Large response.

Procedure: redirect-response path KEY: #:code #:headers

Return a redirect response to PATH (a string).

Procedure: scm-alist->streaming-json alist port KEY: #:unicode

Write ALIST as a JSON object to PORT, streaming each value as it is produced. If a value in the alist is a procedure, it is called with PORT so it can write its own JSON representation directly. Otherwise the value is serialized via scm->json.

Procedure: set-cookie-header name value KEY: #:path #:domain #:max-age #:secure #:http-only #:expires

Return a Set-Cookie header pair suitable for inclusion in a response headers alist. Wraps (webutils cookie) set-cookie.

Example: (values (build-response #:headers (list (set-cookie-header "session" token #:path "/" #:http-only #t #:secure #t))) "ok")

Procedure: streaming-json-response thunk KEY: #:code #:headers

Return a JSON response whose body is written incrementally by THUNK. THUNK is a procedure of one argument (the output port). Use scm-alist->streaming-json and list->streaming-json-array inside THUNK to write JSON without materializing the entire response in memory.

Procedure: text-response str KEY: #:code #:headers

Return a plain text response. STR is the text string to send.


2.12 (safsaf router)

2.12.1 Macros

Macro: compiled-route-handler x

Undocumented macro.

Macro: route-group-children x

Return the list of child routes and groups of ROUTE-GROUP.

Macro: route-group-name x

Return the name of ROUTE-GROUP, or #f if unnamed.

Macro: route-group-prefix x

Return the prefix pattern of ROUTE-GROUP.

Macro: route-group? x

Return #t if OBJ is a <route-group>.

Macro: route-handler x

Return the handler procedure of ROUTE.

Macro: route-method x

Return the HTTP method of ROUTE.

Macro: route-name x

Return the name of ROUTE, or #f if unnamed.

Macro: route-pattern x

Return the URL pattern of ROUTE.

Macro: route? x

Return #t if OBJ is a <route>.

2.12.2 Parameters

Parameter: current-reverse-routes

Default value:

#f
Parameter: current-route-params

Default value:

()

2.12.3 Procedures

Procedure: compile-routes routes

Compile a route tree (route, route-group, or list) into two values: 1. An ordered list of <compiled-route> records ready for matching. 2. A <reverse-routes> record for use with path-for.

The last route must be a catch-all (’* pattern with a rest parameter) so that every request is handled.

Procedure: find-allowed-methods compiled-routes path-segments

Scan COMPILED-ROUTES for routes whose path matches PATH-SEGMENTS, collecting their HTTP methods. The last route (the catch-all) is excluded. Returns a deduplicated list of method symbols, or ’() if no route’s path matches.

Procedure: make-route-group prefix KEY: #:name

Create an empty route group with PREFIX. Children can be added later with route-group-add-children!.

Procedure: match-route compiled-routes method path-segments

Find the first matching route for METHOD and PATH-SEGMENTS. Returns (values handler bindings) on match, or (values #f #f) on no match.

Procedure: path-for group name OPT: params KEY: #:query #:fragment #:relative?

Generate a URL path for a named route within GROUP.

GROUP is a route-group value. NAME is either a symbol naming a route within GROUP, or a list of symbols for nested lookup where the last element is the route name and preceding elements are child group names.

(path-for routes ’users) (path-for routes ’user ’((id . "42"))) (path-for routes ’(api items) ’((id . "7")))

PARAMS is an alist mapping capture symbols to string values, or to a list of strings for rest parameters.

Optional keyword arguments: #:query — alist of query parameters ((key . value) ...) #:fragment — fragment string (without the leading #) #:relative? — if #t, omit the leading /

Procedure: route method pattern handler KEY: #:name

Create a route. METHOD is a symbol, list of symbols, or ’* for any. PATTERN is a list of segments: strings (literal), symbols (capture), two-element lists (predicate capture: (proc name)), with optional dotted tail (wildcard capture). HANDLER is a procedure (request body-port) -> (values response body). NAME is an optional symbol used for reverse routing with path-for.

Procedure: route-group prefix KEY: #:name . children

Create a route group. PREFIX is a pattern list (same syntax as route patterns). CHILDREN is an ordered list of routes and route-groups. NAME is an optional symbol for nested path-for lookups.

Procedure: route-group-add-children! group new-children

Append NEW-CHILDREN to GROUP’s child list.

Procedure: wrap-routes routes . wrappers

Apply WRAPPERS to every handler in ROUTES, which may be a route, route-group, or list of either. Returns a new structure with wrapped handlers. When multiple wrappers are given, the first wrapper in the list wraps outermost (runs first on the request, last on the response).


Next: , Previous: , Up: API   [Contents][Index]

2.13 (safsaf templating)

2.13.1 Procedures

Procedure: streaming-html-response shtml KEY: #:code #:headers #:charset

Return an HTML response that streams SHTML to the client.

SHTML is an SHTML tree that may contain procedures. Each procedure is called as (proc port) during output and should write HTML to the port. Static parts are rendered via htmlprag.

  (streaming-response
   `(*TOP*
     (*DECL* DOCTYPE html)
     (html (head (title "My Page"))
           (body (h1 "Hello")
                 ,(lambda (port)
                    (write-shtml-as-html '(p "dynamic") port))))))
Procedure: write-shtml-as-html/streaming shtml port

Write SHTML to PORT, like write-shtml-as-html from htmlprag, but any procedure encountered in the tree is called as (proc port) and may write directly to PORT.

This allows mixing static SHTML with dynamic streaming sections:

  (write-shtml-as-html/streaming
   `(html (body (h1 "Title")
                ,(lambda (port) (display "dynamic" port))
                (footer "bye")))
   port)

Static parts are rendered via htmlprag’s shtml->html, then interleaved with procedure calls at output time.


Previous: , Up: API   [Contents][Index]

2.14 (safsaf utils)

2.14.1 Procedures

Procedure: multipart-text-fields parts

Extract text fields from multipart PARTS as an alist of (name . value). File upload parts (those with a filename parameter) are excluded.

Procedure: parse-form-body request body-port

Read and parse a URL-encoded form body from REQUEST. Returns an alist of string key-value pairs.

Procedure: parse-multipart-body request body-port

Read and parse a multipart/form-data body from REQUEST. Returns a list of <part> records from (webutils multipart). Use parts-ref, parts-ref-string, part-body, etc. to access parts.

Procedure: parse-query-string request

Parse the query string from REQUEST. Returns an alist of string key-value pairs, or ’() if no query string.

Procedure: request-cookie-ref request name OPT: default

Return the value of cookie NAME from REQUEST, or DEFAULT if not found.

Procedure: request-cookies request

Return the cookies from REQUEST as an alist of (name . value) pairs. Returns ’() if no Cookie header is present. Importing (webutils cookie) registers the Cookie header parser with (web http).


Next: , Previous: , Up: Overview   [Contents][Index]

Appendix A Version History

Version 0.1
  • Initial release.
  • Built on the code of the Guix Data Serivce, plus other web services like the Guix Build Coordinator and Nar Herder.
  • Written using Claude Opus 4.6 using Claude Code.

Next: , Previous: , Up: Overview   [Contents][Index]

Appendix B Copying Information

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.


Concept Index


Next: , Previous: , Up: Overview   [Contents][Index]

Data Type Index

Jump to:   <
Index Entry  Section

<
<invalid-param>: safsaf_params

Jump to:   <

Next: , Previous: , Up: Overview   [Contents][Index]

Procedure Index

Jump to:   A   B   C   D   E   F   G   H   I   J   L   M   N   P   R   S   T   W  
Index Entry  Section

A
any-invalid-params?: safsaf_params
as-checkbox: safsaf_params
as-integer: safsaf_params
as-matching: safsaf_params
as-number: safsaf_params
as-one-of: safsaf_params
as-predicate: safsaf_params
as-string: safsaf_params

B
bad-request-response: safsaf_response-helpers
build-response/inherit: safsaf_response-helpers

C
compile-routes: safsaf_router
compiled-route-handler: safsaf_router
cors-handler-wrapper: safsaf_handler-wrappers_cors
csrf-handler-wrapper: safsaf_handler-wrappers_csrf
csrf-token-field: safsaf_handler-wrappers_csrf

D
default-method-not-allowed-handler: safsaf
default-render-error: safsaf_handler-wrappers_exceptions
default-render-html: safsaf_handler-wrappers_exceptions
default-render-json: safsaf_handler-wrappers_exceptions
delete-cookie-header: safsaf_response-helpers

E
exceptions-handler-wrapper: safsaf_handler-wrappers_exceptions

F
field-errors: safsaf_params
find-allowed-methods: safsaf_router
forbidden-response: safsaf_response-helpers

G
guard-against-mutually-exclusive-params: safsaf_params

H
html-response: safsaf_response-helpers

I
internal-server-error-response: safsaf_response-helpers
invalid-param-message: safsaf_params
invalid-param-ref: safsaf_params
invalid-param-value: safsaf_params
invalid-param?: safsaf_params

J
json-response: safsaf_response-helpers

L
list->streaming-json-array: safsaf_response-helpers
logging-handler-wrapper: safsaf_handler-wrappers_logging

M
make-exceptions-handler-wrapper: safsaf_handler-wrappers_exceptions
make-invalid-param: safsaf_params
make-max-body-size-handler-wrapper: safsaf_handler-wrappers_max-body-size
make-route-group: safsaf_router
make-session-config: safsaf_handler-wrappers_sessions
make-session-handler-wrapper: safsaf_handler-wrappers_sessions
make-static-handler: safsaf_response-helpers
make-trailing-slash-handler-wrapper: safsaf_handler-wrappers_trailing-slash
match-route: safsaf_router
multipart-text-fields: safsaf_utils

N
negotiate-content-type: safsaf_response-helpers
not-found-response: safsaf_response-helpers

P
params->query-string: safsaf_params
parse-form-body: safsaf_utils
parse-form-params: safsaf_params
parse-multipart-body: safsaf_utils
parse-params: safsaf_params
parse-query-string: safsaf_utils
path-for: safsaf_router
payload-too-large-response: safsaf_response-helpers

R
redirect-response: safsaf_response-helpers
request-cookie-ref: safsaf_utils
request-cookies: safsaf_utils
route: safsaf_router
route-group: safsaf_router
route-group-add-children!: safsaf_router
route-group-children: safsaf_router
route-group-name: safsaf_router
route-group-prefix: safsaf_router
route-group?: safsaf_router
route-handler: safsaf_router
route-method: safsaf_router
route-name: safsaf_router
route-pattern: safsaf_router
route?: safsaf_router
run-safsaf: safsaf

S
scm-alist->streaming-json: safsaf_response-helpers
security-headers-handler-wrapper: safsaf_handler-wrappers_security-headers
session-delete: safsaf_handler-wrappers_sessions
session-handler-wrapper: safsaf_handler-wrappers_sessions
session-set: safsaf_handler-wrappers_sessions
set-cookie-header: safsaf_response-helpers
streaming-html-response: safsaf_templating
streaming-json-response: safsaf_response-helpers

T
text-response: safsaf_response-helpers
trailing-slash-handler-wrapper: safsaf_handler-wrappers_trailing-slash

W
wrap-routes: safsaf_router
write-shtml-as-html/streaming: safsaf_templating

Jump to:   A   B   C   D   E   F   G   H   I   J   L   M   N   P   R   S   T   W  

Previous: , Up: Overview   [Contents][Index]

Variable Index

Jump to:   C  
Index Entry  Section

C
current-csrf-token: safsaf_handler-wrappers_csrf
current-reverse-routes: safsaf_router
current-route-params: safsaf_router
current-session: safsaf_handler-wrappers_sessions

Jump to:   C