You are looking at historical revision 45749 of this page. It may differ significantly from its current revision.
Schematra
A modern web framework for CHICKEN Scheme inspired by Sinatra that combines simplicity with power.
Description
Schematra is a lightweight, expressive web framework for CHICKEN Scheme built on top of Spiffy that brings the elegance of Lisp to web development. It features minimal boilerplate, Chiccup HTML rendering, flexible routing, built-in sessions, and extensible middleware support.
Requirements
- CHICKEN Scheme 5.0 or later
- Dependencies: spiffy, format, openssl, message-digest, hmac, sha2, http-client, medea, nrepl, logger
- WebSocket support (schematra.ws) additionally uses base64 and simple-sha1
Installation
chicken-install schematra
SRFI-1 Compatibility: If you're using SRFI-1 and need its delete function alongside Schematra's HTTP DELETE verb, rename one on import:
;; Rename SRFI-1's delete (recommended) (import (rename srfi-1 (delete srfi1:delete)) schematra chiccup) (delete "/users/:id" ...) ; HTTP DELETE route (srfi1:delete 'x my-list) ; Remove items from list
Basic Usage
(import schematra chiccup) ;; Create app instance (define app (schematra/make-app)) ;; Define routes within app context (with-schematra-app app (lambda () (get "/" (ccup->html `[h1 "Hello, Schematra!"])) (post "/submit" "Form submitted!") ;; Install and start server (schematra-install) (schematra-start)))
API
Features
Based on the value of SCHEMATRA_ENV, one of two features is registered:
- schematra-dev: if the value of SCHEMATRA_ENV is equal to the string "development".
- schematra-prod: if the value of SCHEMATRA_ENV is anything else.
(cond-expand (schematra-prod (logger/format 'json)) ;; use JSON logs in production (else (logger/level 'debug))) ;; verbose logging in development
App Management
[procedure] (schematra/make-app [config])Creates a new isolated Schematra application instance. Each app has its own routes and middleware.
(define app (schematra/make-app))[syntax] (with-schematra-app app thunk)
Sets the current app context for route and middleware definitions. The thunk must be a zero-argument procedure.
(with-schematra-app app
(lambda ()
(get "/" "Hello!")
(use-middleware! my-middleware)
(schematra-install)
(schematra-start)))[parameter] (current-app [app])
Get or set the current application instance. Used internally by route macros and middleware functions.
Route Definition
[syntax] (get route body ...)[syntax] (post route body ...)
[syntax] (put route body ...)
[syntax] (delete route body ...)
Define HTTP route handlers for the given path. Routes must be defined within a with-schematra-app context. Route can contain URL parameters using :param syntax.
(with-schematra-app app
(lambda ()
;; Simple routes
(get "/" "Home page")
(get "/about" (ccup->html `[h1 "About Us"]))
;; URL parameters
(get "/user/:id"
(let ((id (alist-ref "id" (current-params) equal?)))
(ccup->html `[h1 ,(string-append "User " id)])))))[parameter] (current-params)
Returns an association list containing the current request parameters:
- Path parameters (string keys): URL segments starting with ':'
- Query parameters (symbol keys): URL query string parameters
- Form data (symbol keys): POST form fields (requires body-parser-middleware)
This parameter only contains meaningful data inside a route. Outside it will default to #f.
;; For route /users/:id and request /users/123?format=json (current-params) ; => '(("id" . "123") (format . "json")) ;; Access path parameter (alist-ref "id" (current-params) equal?) ; => "123" ;; Access query parameter (alist-ref 'format (current-params)) ; => "json"[procedure] (halt status [body] [headers])
Immediately stop request processing and send an HTTP response.
(halt 'not-found "Page not found") (halt 'bad-request "{\"error\": \"Invalid input\"}" '((content-type application/json)))[procedure] (redirect location [status])
Redirect the client to a different URL. Default status is found (302).
(redirect "/login") (redirect "/new-location" 'moved-permanently)[procedure] (send-json-response datum [status])
Send a JSON response with proper content-type headers. Default status is ok (200).
(send-json-response '((status . "healthy") (version . "1.0"))) (send-json-response '((error . "Not found")) 'not-found)[procedure] (static path directory)
Serve static files from directory at URL path prefix.
[procedure] (schematra-install)Install the schematra route handlers.
[procedure] (schematra-start #!key (port 8080) (bind-address #f) (log-output (current-output-port)) (log-level 'info) (log-format 'text))Start the web server. Logging is handled by the logger egg.
- port: the port to listen to, defaults to 8080
- bind-address: whether or not to bind to a specific address. If #f then it binds to 0.0.0.0
- log-output: output port for logs. Defaults to (current-output-port).
- log-level: minimum log level — 'debug, 'info, 'warn, or 'error. Defaults to 'info.
- log-format: output format — 'text or 'json. Defaults to 'text.
When log-format is 'json, access logs are emitted as structured logger entries with message "request" and fields such as remote_addr, method, uri, response_code, referer, and user_agent. Text logs keep the default Spiffy-style access log line.
When SCHEMATRA_ENV is set to "development", the server starts on a separate thread.
Cookies
[procedure] (cookie-set! key val #!key (path "/") (max-age #f) (secure #f) (http-only #f) (domain #f))Set a cookie in the HTTP response. Must be called within a route handler.
;; Simple session cookie (cookie-set! "user_id" "12345") ;; Persistent cookie with 1 day expiration (cookie-set! "preferences" "theme=dark" max-age: "86400") ;; Secure authentication cookie (cookie-set! "auth_token" token-value secure: #t http-only: #t max-age: "3600")[procedure] (cookie-ref key [default])
Read a cookie value from the current request. Returns default (or #f) if the cookie is not present.
(let ((user-id (cookie-ref "user_id")))
(if user-id
(format "Welcome, user ~A" user-id)
"Please log in"))[procedure] (cookie-delete! key #!key (path "/"))
Delete a cookie by setting its max-age to 0.
(cookie-delete! "user_id")
Server-Sent Events
[procedure] (sse path handler)Register a Server-Sent Events endpoint. The handler receives the request and should loop to send events using write-sse-data. SSE headers (Content-Type, Cache-Control, Connection) are set automatically.
[procedure] (write-sse-data data #!key id event)Send an SSE message to the connected client.
- data: string - The message data
- id: string or #f - Optional event ID for client-side tracking
- event: string or #f - Optional event type name
;; Simple time server (sse ("/time" req) (let loop () (write-sse-data (current-time-string) event: "time-update") (thread-sleep! 1) (loop)))
Client-side usage:
const es = new EventSource('/time');
es.addEventListener('time-update', function(e) {
console.log('Time:', e.data);
});
WebSockets
WebSockets give you a bidirectional, message-oriented channel between the server and the browser. Schematra implements RFC 6455 in the schematra.ws module — the WebSocket primitives are not part of the core schematra module, so you add a second import. If you don't use WebSockets, you don't pay for the extra dependencies (base64, simple-sha1, etc.).
(import schematra schematra.ws) (websocket "/echo" (on-open (send-text "welcome")) (on-text message (send-text (string-append "you said: " message))) (on-close code reason (void)))
The framework validates the upgrade request, performs the handshake, decodes each incoming frame, and dispatches to the matching clause. Your code only sees decoded messages.
[syntax] (websocket path clause ...)Register a WebSocket route at path. The route syntax is the same family as get/post/sse. Each clause matches an event; all are optional except on-text:
- (on-open body ...) — runs once when the handshake completes.
- (on-text message body ...) — runs for each text frame. message is bound to the decoded string payload.
- (on-binary bytes body ...) — runs for each binary frame. bytes is the raw byte string.
- (on-close code reason body ...) — runs when the connection closes, regardless of who initiated it. code is the WebSocket close code (1000 for normal, 1006 for an abrupt disconnect, etc.) and reason is the human-readable string.
- (on-error exn body ...) — runs if a handler raises an exception, before the connection is closed. Use it for logging, metrics, or notifying other clients.
You can omit on-binary, on-error, or both, and there's a single-clause form for echo-style routes:
(websocket "/echo"
(on-text message (send-text message)))[syntax] (websocket* path on-open on-text on-binary on-close on-error)
Procedural form behind the websocket macro. All five handler thunks are required. Use this if you want to build a WebSocket route programmatically.
[procedure] (send-text message)Send a UTF-8 text frame on (current-websocket). message is a string. Raises an error if there is no current WebSocket connection.
[procedure] (send-binary bytes)Send a binary frame on (current-websocket). bytes is a byte string.
Both implicitly target (current-websocket), the connection bound by the current handler. Sends are mutex-guarded per connection, so it's safe to call them from multiple threads as long as you parameterize current-websocket to the connection you want to write to.
[parameter] (current-websocket)Inside a WebSocket handler, holds the current connection as a websocket-connection record. You can store it, share it across threads, and reach for it later — that's how broadcasting works. Parameterize it to redirect send-text/send-binary at another connection.
[procedure] (close-websocket! [code [reason [ws]]])Initiate a graceful close.
- code: integer close code (default 1000 for normal closure).
- reason: human-readable string (default "").
- ws: the connection to close (default (current-websocket)).
Sends the close frame to the peer (best effort) and tears down the underlying input port so the connection's read loop unblocks immediately — even when called from another thread. The connection's on-close clause still runs for cleanup.
;; From inside a handler — close the current connection (code 1000): (on-text message (when (string=? message "/bye") (close-websocket!))) ;; From another thread — force-close a specific stored connection: (close-websocket! 1008 "policy violation" some-other-ws)
Broadcasting: because current-websocket is a first-class value, you can keep a registry of connections and write to each by parameterizing the connection. Always snapshot the registry under a lock before iterating, then release the lock — send-text can block on slow clients, and you don't want to hold the lock during I/O.
(define clients-mutex (make-mutex)) (define clients '()) (define (broadcast! message) (let ((snapshot (dynamic-wind (lambda () (mutex-lock! clients-mutex)) (lambda () clients) (lambda () (mutex-unlock! clients-mutex))))) (for-each (lambda (ws) (parameterize ((current-websocket ws)) (condition-case (send-text message) (exn () (set! clients (delete ws clients)))))) snapshot))) (websocket "/chat" (on-open (set! clients (cons (current-websocket) clients)) (broadcast! "a new client joined")) (on-text message (broadcast! message)) (on-close code reason (set! clients (delete (current-websocket) clients)) (broadcast! "a client left")))
Error handling: if a handler raises an exception, Schematra runs your on-error clause first, then sends a 1011 close frame, runs on-close, and lets the framework log the original exception. The connection is always closed cleanly, so you don't need to wrap send-text in condition-case for normal usage.
Configuration: three parameters bound incoming traffic, all in schematra.ws:
[parameter] (websocket-max-frame-size [bytes])Maximum size of a single incoming frame. Default 1 MB. Larger frames are rejected before the payload is read (a defense against buffer-exhaustion attacks) and the connection is closed with code 1009 (message too big).
[parameter] (websocket-max-message-size [bytes])Maximum total bytes for a fragmented message (sum of all fragments). Default 16 MB. Must be >= websocket-max-frame-size. Exceeding it closes the connection with code 1009.
[parameter] (websocket-max-fragment-count [n])Maximum number of fragments per message. Default 1024. Prevents a peer from sending millions of tiny fragments that stay under the message-size cap but exhaust memory. Exceeding it closes with code 1009.
(websocket-max-frame-size (* 4 1024 1024)) ;; 4 MB
RFC 6455 conformance: the implementation is run against the Autobahn TestSuite on every release, with a strict pass of 296/301 (98.3%) across framing, fragmentation, control frames, UTF-8, close handling, and limits. The remaining cases are RFC-undefined or INFORMATIONAL behavior; permessage-deflate compression is not implemented. A complete multi-client chat lives in examples/websocket-demo.scm.
Response Format
Route handlers can return:
String Response: Returns 200 OK with the string as body
(get "/hello" "Hello, World!")
Response Tuple: Format (status body [headers]) for full control
'(created "User created successfully") '(ok "{\"message\": \"success\"}" ((content-type application/json)))
chiccup lists: Return a list that begins with the symbol 'ccup. The Schematra router will automatically pass the cdr of that list to ccup->html. It's useful to return a chiccup list for testing and composition at the middleware level.
'(ccup [h1 "hello" ...])
Chiccup Rendering
[procedure] (ccup->html s-html)[procedure] (ccup->sxml s-html)
Convert Chiccup list to HTML string or SXML. Chiccup allows writing HTML using Lisp syntax with CSS class integration. Behind the scenes, chiccup converts the simplified chiccup-style lists to SXML and then to HTML using sxml-transforms. You can use ccup->sxml to get the intermediate SXML representation and leverage the full power of the sxml-transforms ecosystem for advanced transformations.
;; Basic elements `[h1 "Hello World"] ;; CSS classes and IDs - default element is "div" ;; Note: ID (#id) must come after classes `[.container.mx-auto#main "Content"] ;; => <div class="container mx-auto" id="main">Content</div> ;; Attributes with @ syntax `[a (@ (href "/page")) "Link"] `[input (@ (type "text") (name "username"))] ;; Attribute values are automatically HTML-escaped (like React/Vue) `[div (@ (data-config "{\"theme\":\"dark\"}")) "Content"] ;; => <div data-config="{"theme":"dark"}">Content</div> ;; Boolean attributes (no values) `[input (@ (type "checkbox") (checked) (disabled))] ;; => <input type="checkbox" checked disabled> ;; Conditional attributes (let ((is-disabled #t)) `[button (@ (type "submit") ,@(if is-disabled '((disabled)) '())) "Submit"]) ;; => <button type="submit" disabled>Submit</button> ;; Dynamic content `[ul ,@(map (lambda (item) `[li ,item]) items)] ;; Raw HTML (unescaped) `[div (raw "<em>emphasized</em>")]
See chiccup for more details.
Middleware System
[procedure] (use-middleware! middleware-function)Install middleware functions that process requests before route handlers. Must be called within a with-schematra-app context.
Middleware signature:
(define (my-middleware next)
;; Process request before handler
(let ((response (next))) ; Call next middleware or handler
;; Process response after handler
response))
Middleware can access and modify request parameters using (current-params) and (current-params new-params).
Session Middleware
(import schematra-session) (with-schematra-app app (lambda () (use-middleware! (session-middleware "secret-key")) ;; Session functions available in route handlers: ;; (session-get "key" [default]) ;; (session-set! "key" value) ;; (session-delete! "key") ;; (session-destroy!) (get "/profile" (let ((user (session-get "username"))) (if user (format "Welcome, ~A!" user) (redirect "/login"))))))
See schematra-session for full documentation.
Body Parser Middleware
The body parser captures request bodies into (current-request-body). For application/x-www-form-urlencoded requests, it also parses fields into (current-params) using symbol keys. Bodies larger than (request-body-spool-threshold) are spooled to a temporary file, while callers still use the same replayable body API.
(import schematra.body-parser) (with-schematra-app app (lambda () (use-middleware! (body-parser-middleware)) ;; Automatically parses form data and adds to (current-params) ;; Form fields become symbol keys (post "/login" (let ((username (alist-ref 'username (current-params)))) ;; Handle login... ))))[parameter] (current-request-body)
Returns a replayable request body object, or #f if there was no body. Use (request-body-port body) to get a fresh input port, or (request-body-string body) when you explicitly need the entire body as a string.
[procedure] (request-body-port body)Returns a fresh input port for the captured request body, regardless of whether the body is backed by an in-memory string or a temporary file.
[procedure] (request-body-string body)Returns the captured request body as a string. This may load the full body into memory when the body was spooled to disk.
[parameter] (request-body-spool-threshold [bytes])Controls when captured request bodies are spooled to a temporary file. The default is 5MB.
Multipart Forms
body-parser-middleware captures multipart/form-data request bodies, but it does not automatically merge multipart fields into (current-params). Decode multipart bodies explicitly with read-current-multipart-form-data.
[procedure] (read-current-multipart-form-data [max-length])Decodes the current multipart request body using the body captured by body-parser-middleware. It opens a fresh request-body-port and calls multipart-form-data-decode, so it does not try to re-read the original request port.
Do not call read-multipart-form-data from the multipart-form-data egg after installing body-parser-middleware; the original request port has already been consumed.
(import schematra.body-parser multipart-form-data) (with-schematra-app app (lambda () (use-middleware! (body-parser-middleware)) (post "/upload" (let* ((parts (read-current-multipart-form-data)) (title (alist-ref 'title parts)) (upload (alist-ref 'file parts))) (when (multipart-file? upload) (let ((filename (multipart-file-filename upload)) (port (multipart-file-port upload))) (save-upload! filename port))) (string-append "Uploaded: " title)))))
OAuth2 Authentication (Oauthtoothy)
Oauthtoothy provides complete OAuth2 authentication integration.
[procedure] (oauthtoothy-middleware providers #!key success-redirect save-proc load-proc)Creates OAuth2 authentication middleware.
Parameters:
- providers: List of OAuth2 provider configurations
- success-redirect: URL to redirect after successful auth (default: "/")
- save-proc: Function to save user data (optional)
- load-proc: Function to load user data (optional)
Base URL for OAuth2 callback URLs.
[parameter] (current-auth)Current user's authentication state. Returns association list with:
- authenticated?: Boolean indicating if user is logged in
- user-id: Unique identifier for the user
- Additional user data from provider
Provider Configuration
Each provider is an association list with required keys:
(define (google-provider #!key client-id client-secret)
`((name . "google")
(client-id . ,client-id)
(client-secret . ,client-secret)
(auth-url . "https://accounts.google.com/o/oauth2/auth")
(token-url . "https://oauth2.googleapis.com/token")
(user-info-url . "https://www.googleapis.com/oauth2/v2/userinfo")
(scopes . "profile email")
(user-info-parser . ,parse-google-user)))
Complete OAuth2 Example
(import schematra schematra-session oauthtoothy chiccup) ;; User data parser (define (parse-google-user json-response) `((id . ,(alist-ref 'id json-response)) (name . ,(alist-ref 'name json-response)) (email . ,(alist-ref 'email json-response)))) ;; Create app (define app (schematra/make-app)) (with-schematra-app app (lambda () ;; Install middleware (use-middleware! (session-middleware "secret-key")) (use-middleware! (oauthtoothy-middleware (list (google-provider client-id: (get-environment-variable "GOOGLE_CLIENT_ID") client-secret: (get-environment-variable "GOOGLE_CLIENT_SECRET"))) success-redirect: "/profile")) ;; Protected route (get "/profile" (let ((auth (current-auth))) (if (alist-ref 'authenticated? auth) (ccup->html `[h1 ,(string-append "Welcome, " (alist-ref 'name auth))]) (redirect "/auth/google")))) ;; Logout (get "/logout" (session-destroy!) (redirect "/")) (schematra-install) (schematra-start)))
See oauthtoothy for full documentation.
Testing
Schematra includes a built-in testing module (schematra.test) for testing routes, middleware, cookies, and sessions without starting a server.
Setup
chicken-install test
(import schematra schematra.test test)
Creating a Test App
Use with-test-app to create an app with routes:
(define app
(with-test-app a
(get "/" "Hello World")
(get "/users/:id"
(let ((id (alist-ref "id" (current-params) equal?)))
(string-append "User " id)))))
The variable name (a above) is bound to the app inside the body, so you can use it for middleware setup:
(define app
(with-test-app a
(use-middleware! (session-middleware "secret"))
(get "/profile" (session-get "user" "anonymous"))))
Route Testing Helpers
[procedure] (test-route app method path #!key headers body cookies)Test a route and return the response tuple (status body headers) or #f when no route matches.
[procedure] (test-route-status app method path #!key headers body cookies)Return only the response status symbol (e.g., 'ok, 'not-found) or #f.
[procedure] (test-route-body app method path #!key headers body cookies)Return only the response body string or #f.
[procedure] (test-route-headers app method path #!key headers body cookies)Return only the response headers alist or #f.
[procedure] (test-route-full app method path #!key headers body cookies)Return (status body headers cookies) including Set-Cookie response headers, or #f.
[procedure] (test-route-cookies app method path #!key headers body cookies)Return only the response cookies alist or #f.
[procedure] (response-cookie-value full-result cookie-name)Extract a specific cookie value from a test-route-full result. Returns the cookie value string or #f.
[procedure] (test-route-redirect-location app method path #!key headers body cookies)Return the Location header URI as a string or #f.
All route-testing functions accept these keyword arguments:
- headers: request headers alist (e.g., '((content-type application/json)))
- body: request body string
- cookies: request cookies alist (e.g., '(("name" . "value")))
Test Examples
(import schematra schematra.test schematra-session test) ;; Basic routing tests (test-group "My routes" (let ((app (with-test-app a (get "/hello" "Hello World") (post "/echo" (alist-ref 'msg (current-params)))))) (test "GET /hello" 'ok (test-route-status app 'GET "/hello")) (test "GET /hello body" "Hello World" (test-route-body app 'GET "/hello")) (test "Unknown route" #f (test-route app 'GET "/unknown")))) ;; Cookie tests (test-group "Cookies" (let ((app (with-test-app a (get "/set" (begin (cookie-set! "k" "v") "done"))))) (test "Cookie is set" "v" (response-cookie-value (test-route-full app 'GET "/set") "k")))) ;; Session round-trip test (test-group "Sessions" (let* ((app (with-test-app a (use-middleware! (session-middleware "secret")) (get "/login" (session-set! "user" "alice") "ok") (get "/whoami" (session-get "user" "nobody")))) (full (test-route-full app 'GET "/login")) (cookie (response-cookie-value full (session-key)))) (test "Session persists" "alice" (test-route-body app 'GET "/whoami" cookies: (list (cons (session-key) cookie)))))) (test-exit)
Run tests with:
csi -s tests/my_tests.scm
Examples
Simple Web App with Sessions
(import schematra chiccup schematra-session) (define app (schematra/make-app)) (with-schematra-app app (lambda () (use-middleware! (session-middleware "secret-key")) (get "/" (let ((user (session-get "username"))) (if user (ccup->html `[h1 ,(format "Welcome back, ~a!" user)]) (redirect "/login")))) (get "/login" (ccup->html `[form (@ (method "POST") (action "/login")) [input (@ (type "text") (name "username") (placeholder "Username"))] [button "Login"]])) (post "/login" (let ((username (alist-ref 'username (current-params)))) (session-set! "username" username) (redirect "/"))) (schematra-install) (schematra-start)))
JSON API
(define app (schematra/make-app)) (with-schematra-app app (lambda () (get "/api/users" (send-json-response `((users . ,(map user->alist (get-all-users))) (count . ,(length (get-all-users)))))) (post "/api/users" (let ((name (alist-ref 'name (current-params))) (email (alist-ref 'email (current-params)))) (if (and name email) (let ((user-id (create-user! name email))) (send-json-response `((id . ,user-id) (created . #t)) 'created)) (send-json-response '((error . "Invalid input")) 'bad-request)))) (schematra-install) (schematra-start)))
License
Copyright (c) 2025 Rolando Abarca. Released under the BSD-3-Clause License.