chickadee » schematra

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

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:

(cond-expand
  (schematra-prod
   (logger/format 'json))  ;; use JSON logs in production
  (else
   (logger/level 'debug))) ;; verbose logging in development

App Management

schematra/make-app #!optional configprocedure

Creates a new isolated Schematra application instance. Each app has its own routes and middleware.

(define app (schematra/make-app))
(with-schematra-app app thunk)syntax

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)))
current-app #!optional appparameter

Get or set the current application instance. Used internally by route macros and middleware functions.

Route Definition

(get route body ...)syntax
(post route body ...)syntax
(put route body ...)syntax
(delete route body ...)syntax

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)])))))
current-paramsparameter

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"
(halt status [body] [headers])procedure

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)))
redirect location #!optional statusprocedure

Redirect the client to a different URL. Default status is found (302).

(redirect "/login")
(redirect "/new-location" 'moved-permanently)
send-json-response datum #!optional statusprocedure

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)
static path directoryprocedure

Serve static files from directory at URL path prefix.

schematra-installprocedure

Install the schematra route handlers.

(schematra-start #!key (port 8080) (bind-address #f) (log-output (current-output-port)) (log-level 'info) (log-format 'text))procedure

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

cookie-set! key val #!key (path /) (max-age #f) (secure #f) (http-only #f) (domain #f)procedure

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")
cookie-ref key #!optional defaultprocedure

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"))
cookie-delete! key #!key (path /)procedure

Delete a cookie by setting its max-age to 0.

(cookie-delete! "user_id")

Server-Sent Events

sse path handlerprocedure

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.

write-sse-data data #!key id eventprocedure

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.

(websocket path clause ...)syntax

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)))
(websocket* path on-open on-text on-binary on-close on-error)syntax

Procedural form behind the websocket macro. All five handler thunks are required. Use this if you want to build a WebSocket route programmatically.

send-text messageprocedure

Send a UTF-8 text frame on (current-websocket). message is a string. Raises an error if there is no current WebSocket connection.

send-binary bytesprocedure

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.

current-websocketparameter

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.

close-websocket! #!optional code reason wsprocedure

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:

websocket-max-frame-size #!optional bytesparameter

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

websocket-max-message-size #!optional bytesparameter

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.

websocket-max-fragment-count #!optional nparameter

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

ccup->html s-htmlprocedure
ccup->sxml s-htmlprocedure

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="{&quot;theme&quot;:&quot;dark&quot;}">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

use-middleware! middleware-functionprocedure

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...
             ))))
current-request-bodyparameter

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.

request-body-port bodyprocedure

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.

request-body-string bodyprocedure

Returns the captured request body as a string. This may load the full body into memory when the body was spooled to disk.

request-body-spool-threshold #!optional bytesparameter

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.

read-current-multipart-form-data #!optional max-lengthprocedure

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.

oauthtoothy-middleware providers #!key success-redirect save-proc load-procprocedure

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)
auth-base-url #!optional urlparameter

Base URL for OAuth2 callback URLs.

current-authparameter

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

test-route app method path #!key headers body cookiesprocedure

Test a route and return the response tuple (status body headers) or #f when no route matches.

test-route-status app method path #!key headers body cookiesprocedure

Return only the response status symbol (e.g., 'ok, 'not-found) or #f.

test-route-body app method path #!key headers body cookiesprocedure

Return only the response body string or #f.

test-route-headers app method path #!key headers body cookiesprocedure

Return only the response headers alist or #f.

test-route-full app method path #!key headers body cookiesprocedure

Return (status body headers cookies) including Set-Cookie response headers, or #f.

test-route-cookies app method path #!key headers body cookiesprocedure

Return only the response cookies alist or #f.

Extract a specific cookie value from a test-route-full result. Returns the cookie value string or #f.

test-route-redirect-location app method path #!key headers body cookiesprocedure

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.

Repository & full docs

GitHub Repository

Full docs

Contents »