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
   (include-relative "aws-logging.scm"))
  (else
   (print "[DEV] normal logging for schematra")))

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:

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) access-port-or-file error-port-or-file)

Start the web server.

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.

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

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="{&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

[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

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

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:

[parameter] (auth-base-url [url])

Base URL for OAuth2 callback URLs.

[parameter] (current-auth)

Current user's authentication state. Returns association list with:

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:

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