diff --git a/index.html b/index.html new file mode 100644 index 0000000..4c8e568 --- /dev/null +++ b/index.html @@ -0,0 +1,780 @@ + + + + + +Safsaf + + + + + + + + + + + + + + + + + + + +

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

+ +
+
+
+
+

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

+
+

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

+ +
+
+
+
+

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

+
+

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

+ + + +
+
+
+
+

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

+
+

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

+ + + +
+
+ + + + +