Safsaf

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.


Up: API   [Contents][Index]

2.1 (safsaf)

2.1.1 Procedures

Procedure: default-method-not-allowed-handler a b

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

Procedure: run-safsaf _ 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.


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


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

Procedure Index

Jump to:   D   R  
Index Entry  Section

D
default-method-not-allowed-handler: safsaf

R
run-safsaf: safsaf

Jump to:   D   R  

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

Variable Index