Safsaf is a Guile web framework, written using Claude Code running Claude Opus 4.6, based off of the Guix Data Service, Nar Herder and Guix Build Coordinator codebases.
This commit is contained in:
commit
5b0e6397dc
53 changed files with 7427 additions and 0 deletions
23
.dir-locals.el
Normal file
23
.dir-locals.el
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
((scheme-mode
|
||||
;; Safsaf forms
|
||||
(eval . (put 'route-group 'scheme-indent-function 1))
|
||||
(eval . (put 'wrap-routes 'scheme-indent-function 1))
|
||||
|
||||
;; Safsaf test framework
|
||||
(eval . (put 'test 'scheme-indent-function 1))
|
||||
(eval . (put 'suite 'scheme-indent-function 1))
|
||||
(eval . (put 'define-suite 'scheme-indent-function 1))
|
||||
|
||||
;; Guile / SRFI
|
||||
(eval . (put 'parameterize 'scheme-indent-function 1))
|
||||
(eval . (put 'with-exception-handler 'scheme-indent-function 2))
|
||||
(eval . (put 'call-with-output-string 'scheme-indent-function 0))
|
||||
(eval . (put 'call-with-port 'scheme-indent-function 1))
|
||||
(eval . (put 'syntax-case 'scheme-indent-function 2))
|
||||
|
||||
;; Knots / Fibers
|
||||
(eval . (put 'run-fibers 'scheme-indent-function 0))
|
||||
(eval . (put 'call-with-sigint 'scheme-indent-function 0))
|
||||
(eval . (put 'with-resource-from-pool 'scheme-indent-function 2))
|
||||
(eval . (put 'fibers-let 'scheme-indent-function 1))
|
||||
(eval . (put 'with-fibers-timeout 'scheme-indent-function 1))))
|
||||
28
.forgejo/workflows/build-website.yaml
Normal file
28
.forgejo/workflows/build-website.yaml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
on:
|
||||
push:
|
||||
branches:
|
||||
- trunk
|
||||
jobs:
|
||||
test:
|
||||
runs-on: host
|
||||
steps:
|
||||
- run: git clone --depth=1 https://$FORGEJO_TOKEN@forge.cbaines.net/cbaines/safsaf.git safsaf-trunk
|
||||
- run: git clone --depth=1 https://$FORGEJO_TOKEN@forge.cbaines.net/cbaines/safsaf.git --branch=pages safsaf-pages
|
||||
- run: |
|
||||
cd safsaf-trunk
|
||||
guix shell -D -f guix-dev.scm -- ./bootstrap.sh
|
||||
guix shell -D -f guix-dev.scm -- ./configure
|
||||
guix shell -D -f guix-dev.scm -- make -C doc index.html
|
||||
|
||||
- run: |
|
||||
cd safsaf-pages
|
||||
cp ../safsaf-trunk/doc/index.html .
|
||||
git add .
|
||||
if [[ -z "$(git status -s)" ]]; then
|
||||
echo "Nothing to push"
|
||||
else
|
||||
git config user.email ""
|
||||
git config user.name "Automatic website updater"
|
||||
git commit -m "Automatic website update"
|
||||
git push
|
||||
fi
|
||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Autotools generated files
|
||||
/aclocal.m4
|
||||
/autom4te.cache/
|
||||
/build-aux/*
|
||||
!/build-aux/news-to-texi.scm
|
||||
/configure
|
||||
/Makefile
|
||||
/Makefile.in
|
||||
/config.log
|
||||
/config.status
|
||||
/pre-inst-env
|
||||
/doc/Makefile
|
||||
/doc/Makefile.in
|
||||
|
||||
# Compiled Guile files
|
||||
*.go
|
||||
|
||||
# Test output
|
||||
*.log
|
||||
*.trs
|
||||
|
||||
# Generated documentation
|
||||
/doc/api/
|
||||
/doc/*.html
|
||||
/doc/*.info
|
||||
/doc/stamp-vti
|
||||
/doc/version.texi
|
||||
/doc/version-history.texi
|
||||
3
AUTHORS
Normal file
3
AUTHORS
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Safsaf was written by:
|
||||
|
||||
Christopher Baines <mail@cbaines.net>
|
||||
88
CLAUDE.md
Normal file
88
CLAUDE.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Safsaf is a web framework for Guile Scheme, built on Guile Fibers
|
||||
using the Guile Knots web server.
|
||||
|
||||
## Environment Setup
|
||||
|
||||
The project uses `direnv` with Guix. The `.envrc` runs `use guix -D -f guix-dev.scm`, which pulls in all dependencies: `guile-knots`, `guile-webutils`, `guile-lib`, `guile-json-4`, `guile-squee`, `guile-sqlite3`, `guile-gcrypt`.
|
||||
|
||||
Run `direnv allow` to activate the environment. All Guile dependencies are on `GUILE_LOAD_PATH` via the Guix profile.
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
### Guile Knots
|
||||
- `(knots web-server)` — `run-knots-web-server`: the HTTP server. Handler signature is `(request body-port) → (values response body)`. `body-port` is the port for reading the request body lazily.
|
||||
- `(knots resource-pool)` — `with-resource-from-pool`: DB/resource connection pooling.
|
||||
- `(knots parallelism)` — `fibers-let`, `fibers-parallel`, `fibers-map`: concurrent work in handlers.
|
||||
- `(knots thread-pool)` — `call-with-thread`: offload blocking/CPU-bound work.
|
||||
- `(knots timeout)` — `with-fibers-timeout`, `with-port-timeouts`: request and I/O timeouts.
|
||||
- `(knots web)` — `call-with-connection-cache`: outbound HTTP with connection pooling.
|
||||
- `(knots)` — `call-with-sigint`, `format/knots`, `spawn-fiber/knots`.
|
||||
- `(knots web-server)` also exports `make-chunked-output-port/knots`, `sanitize-response`, `request-body-port/knots`, `read-request-body/knots`.
|
||||
|
||||
### Guile Webutils
|
||||
- `(webutils multipart)` — `parse-request-body`, `<part>` record, `parts-ref`, `parts-ref-string`.
|
||||
- `(webutils cookie)` — `set-cookie`, `delete-cookie`. Registers `Cookie`/`Set-Cookie` header parsers with `(web http)`.
|
||||
- `(webutils sessions)` — `<session-manager>`, HMAC-signed cookie sessions. Format: `signature$expires$base64-data`.
|
||||
- `(webutils date)` — RFC3339 and HTTP date conversions.
|
||||
|
||||
### Guile JSON (v4.7.3)
|
||||
- `(json)` — Re-exports everything from parser, builder, and record modules.
|
||||
- `(json parser)` — `json->scm` (from port), `json-string->scm` (from string). Options: `#:null` (default `'null`), `#:ordered` (preserve key order).
|
||||
- `(json builder)` — `scm->json` (to port), `scm->json-string` (to string). Options: `#:pretty`, `#:unicode`, `#:validate`.
|
||||
- `(json record)` — `define-json-mapping`: bidirectional SRFI-9 record ↔ JSON conversion.
|
||||
- Data mapping: objects ↔ alists, arrays ↔ vectors, strings ↔ strings, numbers ↔ numbers, `true`/`false` ↔ `#t`/`#f`, `null` ↔ `'null`.
|
||||
|
||||
### Guile Gcrypt
|
||||
- `(gcrypt random)` — cryptographic random bytes (used for CSRF token generation).
|
||||
|
||||
### Guile Lib
|
||||
- `(htmlprag)` — HTML/SHTML parsing and generation. `html->shtml`: parse HTML to SXML. `shtml->html`: render SXML to HTML string. `write-shtml-as-html`: write SXML to port.
|
||||
- `(logging logger)` — Logging framework. `(logging port-log)` — log to ports. `(logging rotating-log)` — rotating file logs.
|
||||
- `(md5)` — MD5 hashing.
|
||||
- `(container async-queue)` — `make-async-queue`, `async-enqueue!`, `async-dequeue!`.
|
||||
- `(string transform)`, `(string wrap)`, `(string completion)` — String utilities.
|
||||
|
||||
### Guile Standard Library
|
||||
- `(web request)`, `(web response)`, `(web uri)`, `(web http)` — Guile's built-in HTTP types.
|
||||
- `(srfi srfi-9)` — Record types. `(srfi srfi-64)` — Test framework. `(srfi srfi-71)` — Extended `let` with multiple values; prefer over `(srfi srfi-11)` `let-values`.
|
||||
|
||||
## Architecture
|
||||
|
||||
Handler signature throughout is `(request body-port) → (values response body)`, using Guile's `<request>` directly. `body-port` is the port for reading the request body lazily. Context is threaded via Guile parameters, not a wrapper record.
|
||||
|
||||
Safsaf wraps `run-knots-web-server` with:
|
||||
|
||||
1. **Parameters for context** — `current-route-params` (alist of matched route bindings), `current-reverse-routes` (for `path-for`). Handler wrappers add their own parameters (e.g. `current-csrf-token`, `current-session`).
|
||||
2. **Router** — data-driven route table using `(route method pattern handler)`. Patterns are lists of segments: strings (literal match), symbols (capture), or `(predicate name)` pairs. Dotted-tail patterns (e.g. `'("api" . rest)`) capture remaining segments. Routes can be organized with `(route-group prefix ...)`. Named routes support reverse routing via `path-for`.
|
||||
3. **Handler wrappers** — convention: `(foo-handler-wrapper handler) → handler'`. A handler wrapper transforms the request on the way in and the response/body on the way out. Wrappers that need configuration provide a `(make-foo-handler-wrapper ...)` constructor. Applied to route trees via `wrap-routes`, which accepts one or more wrappers.
|
||||
4. **Entry point** — `(run-safsaf routes #:key host port method-not-allowed? method-not-allowed-handler connection-buffer-size)` compiles the route table, builds the dispatch handler, and starts the HTTP server via `run-knots-web-server`. When called outside a Fibers scheduler, it wraps everything in `run-fibers` and blocks until Ctrl-C. When called inside an existing scheduler (e.g. within `run-fibers`), it just starts the server and returns immediately. `method-not-allowed?` defaults to `#t`, enabling automatic 405 responses. Handler wrappers are applied to routes via `wrap-routes` before passing to `run-safsaf`.
|
||||
|
||||
## Finding Guile Library Sources
|
||||
|
||||
To read source code for Guile dependencies, look them up via `GUILE_LOAD_PATH`. The first entry is the project's Guix profile directory containing all dependencies. Do **not** search `/gnu/store` directly — it is slow and noisy.
|
||||
|
||||
A Guile module path like `(knots web-server)` maps to the file `knots/web-server.scm` under a load path directory. To find it:
|
||||
|
||||
```
|
||||
ls "$GUILE_LOAD_PATH" | head # see what's available
|
||||
cat "$(echo $GUILE_LOAD_PATH | cut -d: -f1)/knots/web-server.scm" # read a specific module
|
||||
```
|
||||
|
||||
Or use the Read/Glob tools directly against the first `GUILE_LOAD_PATH` entry (e.g. `/gnu/store/...-profile/share/guile/site/3.0/`). Module path segments map to directories, with the final segment as `<name>.scm`. For example:
|
||||
|
||||
- `(json parser)` → `json/parser.scm`
|
||||
- `(webutils multipart)` → `webutils/multipart.scm`
|
||||
- `(srfi srfi-9)` → `srfi/srfi-9.scm`
|
||||
|
||||
## Guile Conventions
|
||||
|
||||
- Predicates end with `?`. Setters use `set-` prefix. Constructors use `make-`.
|
||||
- Records defined with `define-record-type` from `(srfi srfi-9)`.
|
||||
- Modules use `define-module` with `#:use-module` and `#:export`.
|
||||
- Use `values` for multiple return values, `call-with-values` or `receive` to consume them.
|
||||
674
COPYING
Normal file
674
COPYING
Normal file
|
|
@ -0,0 +1,674 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
165
COPYING.LESSER
Normal file
165
COPYING.LESSER
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version
|
||||
of the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
||||
81
Makefile.am
Normal file
81
Makefile.am
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# Safsaf, a Guile web framework
|
||||
# Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
#
|
||||
# This program 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.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public
|
||||
# License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
|
||||
include guile.am
|
||||
|
||||
SOURCES = \
|
||||
safsaf.scm \
|
||||
safsaf/utils.scm \
|
||||
safsaf/templating.scm \
|
||||
safsaf/response-helpers.scm \
|
||||
safsaf/params.scm \
|
||||
safsaf/handler-wrappers/logging.scm \
|
||||
safsaf/handler-wrappers/security-headers.scm \
|
||||
safsaf/handler-wrappers/cors.scm \
|
||||
safsaf/handler-wrappers/csrf.scm \
|
||||
safsaf/handler-wrappers/exceptions.scm \
|
||||
safsaf/handler-wrappers/sessions.scm \
|
||||
safsaf/handler-wrappers/trailing-slash.scm \
|
||||
safsaf/handler-wrappers/max-body-size.scm \
|
||||
safsaf/router.scm
|
||||
|
||||
SUBDIRS = doc
|
||||
|
||||
TEST_EXTENSIONS = .scm
|
||||
SCM_LOG_COMPILER = $(top_builddir)/pre-inst-env $(GUILE)
|
||||
TESTS = \
|
||||
tests/test-router.scm \
|
||||
tests/test-csrf-validation.scm \
|
||||
tests/test-handler-wrappers.scm \
|
||||
tests/test-params.scm \
|
||||
tests/test-response-helpers.scm \
|
||||
tests/test-templating.scm \
|
||||
tests/test-utils.scm \
|
||||
tests/test-exceptions.scm \
|
||||
tests/test-integration.scm
|
||||
|
||||
EXTRA_DIST += \
|
||||
AUTHORS \
|
||||
NEWS \
|
||||
README.md \
|
||||
VERSION \
|
||||
COPYING.LESSER \
|
||||
bootstrap.sh \
|
||||
build-aux/news-to-texi.scm \
|
||||
pre-inst-env.in \
|
||||
tests/support.scm \
|
||||
$(TESTS) \
|
||||
examples/blog-site/README \
|
||||
examples/blog-site/blog-site.scm \
|
||||
examples/blog-site/model.scm \
|
||||
examples/blog-site/views.scm \
|
||||
examples/blog-site/static/style.css \
|
||||
examples/paste-bin/paste-bin.scm
|
||||
|
||||
format:
|
||||
@for f in $(SOURCES) $(TESTS) tests/support.scm; do \
|
||||
echo "Formatting $$f"; \
|
||||
emacs --batch \
|
||||
--eval '(setq enable-local-variables :all)' \
|
||||
--eval '(setq backup-inhibited t)' \
|
||||
--visit "$$f" \
|
||||
--eval '(indent-region (point-min) (point-max))' \
|
||||
--eval '(delete-trailing-whitespace)' \
|
||||
--eval '(save-buffer)'; \
|
||||
done
|
||||
|
||||
.PHONY: format
|
||||
8
NEWS
Normal file
8
NEWS
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
Safsaf NEWS
|
||||
|
||||
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.
|
||||
80
README.md
Normal file
80
README.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# Safsaf
|
||||
|
||||
<p align="center">
|
||||
<img src="doc/logo.svg" alt="Safsaf" width="150">
|
||||
</p>
|
||||
|
||||
A web framework for [GNU Guile](https://www.gnu.org/software/guile/),
|
||||
using [Guile Fibers](https://codeberg.org/guile/fibers) and the [Guile
|
||||
Knots](https://cbaines.codeberg.page/guile-knots/) web server.
|
||||
|
||||
Developed based off of the Guix Data Service and Nar Herder codebases,
|
||||
written using Claude Code running Claude Opus 4.6.
|
||||
|
||||
## Quick Example
|
||||
|
||||
```scheme
|
||||
(use-modules (safsaf)
|
||||
(safsaf router)
|
||||
(safsaf response-helpers))
|
||||
|
||||
(define routes
|
||||
(list
|
||||
(route 'GET '() (lambda (request body-port)
|
||||
(text-response "Hello, world!")))
|
||||
(route '* '* (lambda (request body-port)
|
||||
(not-found-response)))))
|
||||
|
||||
(run-safsaf routes #:port 8080)
|
||||
```
|
||||
|
||||
## Design
|
||||
|
||||
Safsaf aims for functional over imperitive configuration and to be
|
||||
minimal but very extensible.
|
||||
|
||||
Suspendable ports in Guile plus Guile Fibers is used both for Safsaf
|
||||
internals, but should also be used for user applications where
|
||||
possible and suitable.
|
||||
|
||||
## Features
|
||||
|
||||
- **Request router** with parameterised segments, wildcards, nested
|
||||
groups, and reverse routing (`path-for`)
|
||||
- **Handler wrappers** (middleware) applied per-route or per-group via
|
||||
`wrap-routes`
|
||||
- **Included handler wrappers**: logging, CORS, CSRF protection,
|
||||
sessions (signed cookies), security headers, trailing-slash
|
||||
normalization, exception handling
|
||||
- **Request helpers**: form body parsing, multipart parsing, query
|
||||
strings, cookies
|
||||
- **Response helpers**: HTML/SXML, JSON, redirects, plain text, static
|
||||
files with `Last-Modified` / `Cache-Control`
|
||||
- **Parameter parsing**: declarative param specs with built-in
|
||||
processors, CSRF integration, and error inspection
|
||||
|
||||
### Wishlist
|
||||
|
||||
- Internationalization support
|
||||
- Server sent events (SSE) support
|
||||
- WebSockets support
|
||||
|
||||
## Other Guile web frameworks
|
||||
|
||||
- **GNU Artanis** https://artanis.dev/
|
||||
- **schingle** https://github.com/petelliott/schingle
|
||||
|
||||
## Dependencies
|
||||
|
||||
- GNU Guile 3.0+
|
||||
- [Guile Knots](https://forge.cbaines.net/cbaines/guile-knots) (web server, resource pools)
|
||||
- Guile Webutils (multipart, cookies, sessions)
|
||||
- [Guile JSON](https://github.com/aconchillo/guile-json) 4.x
|
||||
- [Guile Lib](https://www.nongnu.org/guile-lib/) (htmlprag, logging)
|
||||
|
||||
A Guix development environment is provided via `guix-dev.scm` and
|
||||
`.envrc` (direnv).
|
||||
|
||||
## License
|
||||
|
||||
LGPL-3.0-or-later
|
||||
187
STYLE.md
Normal file
187
STYLE.md
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
# Guile Style Guide for Safsaf
|
||||
|
||||
This guide draws on
|
||||
[Riastradh's Lisp Style Rules](https://mumble.net/~campbell/scheme/style.txt),
|
||||
the [Guix Coding Style](https://guix.gnu.org/manual/1.5.0/en/html_node/Coding-Style.html).
|
||||
|
||||
---
|
||||
|
||||
## Formatting
|
||||
|
||||
### Indentation
|
||||
|
||||
Use **2-space indentation**, no tabs. When a subform follows the
|
||||
operator on the same line, align subsequent subforms to that column.
|
||||
When the first subform is on the next line, align it with the operator.
|
||||
Special forms (`define`, `let`, `lambda`, `if`, `cond`, `match`, etc.)
|
||||
follow standard Scheme indentation.
|
||||
|
||||
### Line Length
|
||||
|
||||
Do not exceed **80 columns**.
|
||||
|
||||
### Parentheses
|
||||
|
||||
Never place closing parentheses on their own line. Do not put spaces
|
||||
after opening parentheses or before closing ones.
|
||||
|
||||
### Blank Lines
|
||||
|
||||
Separate top-level forms with a single blank line. Do not place blank
|
||||
lines in procedure bodies except to separate internal definitions from
|
||||
the body.
|
||||
|
||||
### Square Brackets
|
||||
|
||||
Do not use square brackets. They are non-standard and non-portable.
|
||||
|
||||
|
||||
## Naming
|
||||
|
||||
Write names with **English words separated by hyphens**. No underscores,
|
||||
camelCase, or abbreviations.
|
||||
|
||||
- **`?` (predicates):** Boolean-returning questions. E.g., `route?`,
|
||||
`logged-in?`.
|
||||
- **`!` (mutation):** Procedures whose primary purpose is destructive
|
||||
update. E.g., `set-route-handler!`. Do not append to every procedure
|
||||
with side effects.
|
||||
- **`%` (private):** Module-private bindings. E.g., `%make-route`,
|
||||
`%email-rx`.
|
||||
- **`*` (variants):** Variations on a theme (`let*`, `define*`).
|
||||
|
||||
### Records
|
||||
|
||||
```scheme
|
||||
(define-record-type <route>
|
||||
(%make-route method pattern handler name) ; internal constructor
|
||||
route? ; predicate
|
||||
(method route-method) ; accessor
|
||||
(handler route-handler set-route-handler!)) ; accessor + setter
|
||||
```
|
||||
|
||||
- `make-foo` for public constructors, `%make-foo` for raw constructors.
|
||||
- `foo?` for predicates, `foo-field` for accessors, `set-foo-field!`
|
||||
for setters.
|
||||
|
||||
### Parameters and Dynamic State
|
||||
|
||||
- Guile parameters use the **`current-`** prefix: `current-session`,
|
||||
`current-csrf-token`.
|
||||
- **`with-`** establishes dynamic state and calls a thunk.
|
||||
- **`call-with-`** calls a procedure with arguments, managing resources
|
||||
or continuations.
|
||||
|
||||
### Local Variables
|
||||
|
||||
Use meaningful names. Single-letter names only for unambiguous index
|
||||
variables in tight loops.
|
||||
|
||||
|
||||
## Comments
|
||||
|
||||
```scheme
|
||||
;;;; file-heading.scm — File Heading
|
||||
|
||||
;;;
|
||||
;;; Section Heading
|
||||
;;;
|
||||
|
||||
;;; Top-level explanatory comment.
|
||||
|
||||
(define (fnord zarquon)
|
||||
;; Fragment comment before code.
|
||||
(quux zot mumble ;margin note
|
||||
frotz))
|
||||
```
|
||||
|
||||
- **`;;;;`** — File headings.
|
||||
- **`;;;`** — Section headings (use the sandwich: `;;;` / `;;; Title` /
|
||||
`;;;`) and top-level explanations.
|
||||
- **`;;`** — Fragment comments, before the code they describe.
|
||||
- **`;`** — Margin comments, after code on the same line.
|
||||
|
||||
Write comments only where the code cannot explain itself.
|
||||
|
||||
|
||||
## Docstrings
|
||||
|
||||
All public procedures must carry a docstring. Place it as the first
|
||||
expression after the parameter list. Describe what the procedure does,
|
||||
its parameters, and return values:
|
||||
|
||||
```scheme
|
||||
(define* (route method pattern handler #:key (name #f))
|
||||
"Create a route. METHOD is a symbol, list of symbols, or '* for any.
|
||||
HANDLER is a procedure (request body-port) -> (values response body).
|
||||
NAME is an optional symbol used for reverse routing with path-for."
|
||||
...)
|
||||
```
|
||||
|
||||
For parameters, use `set-procedure-property!` with `'documentation`.
|
||||
|
||||
|
||||
## Module Definitions
|
||||
|
||||
Use `define-module` with `#:use-module` and `#:export`. Group imports:
|
||||
|
||||
1. **Standard library** — `(ice-9 ...)`, `(web ...)`
|
||||
2. **SRFIs** — `(srfi srfi-1)`, `(srfi srfi-9)`, etc.
|
||||
3. **External dependencies** — `(knots ...)`, `(webutils ...)`, `(json ...)`
|
||||
4. **Internal modules** — `(safsaf ...)`
|
||||
|
||||
Prefer `#:use-module`; use `#:autoload` for heavy or circular deps.
|
||||
Standalone scripts may use `use-modules` instead of `define-module`.
|
||||
|
||||
|
||||
## Procedures
|
||||
|
||||
- No more than **four positional parameters**. Use keyword arguments
|
||||
(via `define*`) beyond that.
|
||||
- Keep procedures under roughly **21 lines** (excluding docstring).
|
||||
Break long procedures into meaningfully named helpers.
|
||||
- Prefer **purely functional** code. Use mutation only for I/O,
|
||||
performance, and low-level utilities.
|
||||
- Avoid point-free style and functional combinators. Use explicit
|
||||
`lambda`. Reserve `compose` for cases where composition is genuinely
|
||||
the idea being expressed.
|
||||
|
||||
|
||||
## Data Types
|
||||
|
||||
- Prefer **records** (`define-record-type` from `(srfi srfi-9)`) over
|
||||
ad-hoc lists. Do not browse data with `car`/`cdr`/`cadr`.
|
||||
- Do not export record type descriptors (e.g., `<route>`). Export only
|
||||
predicates, constructors, and accessors.
|
||||
- Use `(ice-9 match)` for pattern matching.
|
||||
- Use **alists** for lightweight key-value data (route params, headers,
|
||||
config).
|
||||
|
||||
|
||||
## Multiple Return Values
|
||||
|
||||
Use `values` for multiple return values. Prefer `(srfi srfi-71)` extended
|
||||
`let` over `(srfi srfi-11)` `let-values`:
|
||||
|
||||
```scheme
|
||||
(let ((response body (handler request body-port)))
|
||||
(values response body))
|
||||
```
|
||||
|
||||
|
||||
## Error Handling
|
||||
|
||||
Use `(ice-9 exceptions)` functionality.
|
||||
|
||||
|
||||
## File Organization
|
||||
|
||||
- Keep files under **512 lines**. Do not exceed 1024.
|
||||
- Minimize module dependencies.
|
||||
- Structure files with `;;;` section headings.
|
||||
|
||||
|
||||
## Testing
|
||||
|
||||
Tests use `define-suite`, `suite`, `test`, and `is`. Keep test files
|
||||
under `tests/`.
|
||||
1
VERSION
Normal file
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
0.1
|
||||
19
bootstrap.sh
Executable file
19
bootstrap.sh
Executable file
|
|
@ -0,0 +1,19 @@
|
|||
#! /bin/sh
|
||||
# Safsaf, a Guile web framework
|
||||
# Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
#
|
||||
# This program 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.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public
|
||||
# License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
|
||||
autoreconf --verbose --install --force
|
||||
73
build-aux/news-to-texi.scm
Normal file
73
build-aux/news-to-texi.scm
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
;;; news-to-texi.scm — Convert a GNU-style NEWS file to Texinfo
|
||||
;;;
|
||||
;;; Usage: guile news-to-texi.scm NEWS > version-history.texi
|
||||
;;;
|
||||
;;; The NEWS format is:
|
||||
;;;
|
||||
;;; Version X.Y (date)
|
||||
;;;
|
||||
;;; * First item.
|
||||
;;; * Second item, possibly
|
||||
;;; continued on the next line.
|
||||
;;;
|
||||
;;; Lines before the first "Version" heading (title, copyright notice)
|
||||
;;; are skipped.
|
||||
|
||||
(use-modules (ice-9 rdelim)
|
||||
(ice-9 regex)
|
||||
(ice-9 match))
|
||||
|
||||
(define (news->texi port out)
|
||||
(let loop ((line (read-line port))
|
||||
(in-version? #f)
|
||||
(in-item? #f))
|
||||
(cond
|
||||
((eof-object? line)
|
||||
;; Close any open item/version.
|
||||
(when in-item?
|
||||
(display "\n" out))
|
||||
(when in-version?
|
||||
(format out "@end itemize~%~%")))
|
||||
|
||||
;; Version heading: "Version X.Y ..."
|
||||
((string-match "^Version " line)
|
||||
;; Close previous version if open.
|
||||
(when in-item?
|
||||
(display "\n" out))
|
||||
(when in-version?
|
||||
(format out "@end itemize~%~%"))
|
||||
(format out "@item ~a~%" line)
|
||||
(format out "@itemize~%" )
|
||||
(loop (read-line port) #t #f))
|
||||
|
||||
;; Bullet item: " * text"
|
||||
((string-match "^ \\* (.+)" line)
|
||||
=>
|
||||
(lambda (m)
|
||||
;; Close previous item if open.
|
||||
(when in-item?
|
||||
(display "\n" out))
|
||||
(format out "@item~%")
|
||||
(display (match:substring m 1) out)
|
||||
(loop (read-line port) in-version? #t)))
|
||||
|
||||
;; Continuation line: " text" (indented, no bullet)
|
||||
((and in-item? (string-match "^ (.+)" line))
|
||||
=>
|
||||
(lambda (m)
|
||||
(display "\n" out)
|
||||
(display (match:substring m 1) out)
|
||||
(loop (read-line port) in-version? #t)))
|
||||
|
||||
;; Blank or preamble line — skip.
|
||||
(else
|
||||
(loop (read-line port) in-version? in-item?)))))
|
||||
|
||||
(match (command-line)
|
||||
((_ news-file)
|
||||
(call-with-input-file news-file
|
||||
(lambda (in)
|
||||
(news->texi in (current-output-port)))))
|
||||
((_ . _)
|
||||
(format (current-error-port) "Usage: guile news-to-texi.scm NEWS~%")
|
||||
(exit 1)))
|
||||
58
configure.ac
Normal file
58
configure.ac
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
dnl Safsaf, a Guile web framework
|
||||
dnl Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
dnl
|
||||
dnl This program is free software: you can redistribute it and/or
|
||||
dnl modify it under the terms of the GNU Lesser General Public License
|
||||
dnl as published by the Free Software Foundation, either version 3 of
|
||||
dnl the License, or (at your option) any later version.
|
||||
dnl
|
||||
dnl This program is distributed in the hope that it will be useful, but
|
||||
dnl WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
dnl MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
dnl Lesser General Public License for more details.
|
||||
dnl
|
||||
dnl You should have received a copy of the GNU Lesser General Public
|
||||
dnl License along with this program. If not, see
|
||||
dnl <https://www.gnu.org/licenses/>.
|
||||
|
||||
AC_INIT([safsaf], [m4_translit(m4_esyscmd([cat VERSION]),m4_newline)])
|
||||
AC_CONFIG_AUX_DIR([build-aux])
|
||||
AM_INIT_AUTOMAKE([gnu color-tests -Wall -Wno-portability foreign])
|
||||
|
||||
GUILE_PKG([3.0])
|
||||
GUILE_PROGS
|
||||
if test "x$GUILD" = "x"; then
|
||||
AC_MSG_ERROR(['guild' binary not found; please check your guile-3.x installation.])
|
||||
fi
|
||||
|
||||
if test "$cross_compiling" != no; then
|
||||
GUILE_TARGET="--target=$host_alias"
|
||||
AC_SUBST([GUILE_TARGET])
|
||||
fi
|
||||
|
||||
dnl Check for Guile Knots.
|
||||
GUILE_MODULE_AVAILABLE([have_guile_knots], [(knots web-server)])
|
||||
if test "x$have_guile_knots" != "xyes"; then
|
||||
AC_MSG_ERROR([Guile Knots is missing; please install it.])
|
||||
fi
|
||||
|
||||
dnl Check for Guile Webutils.
|
||||
GUILE_MODULE_AVAILABLE([have_guile_webutils], [(webutils sessions)])
|
||||
if test "x$have_guile_webutils" != "xyes"; then
|
||||
AC_MSG_ERROR([Guile Webutils is missing; please install it.])
|
||||
fi
|
||||
|
||||
dnl Check for Guile-lib.
|
||||
GUILE_MODULE_AVAILABLE([have_guile_lib], [(logging logger)])
|
||||
if test "x$have_guile_lib" != "xyes"; then
|
||||
AC_MSG_ERROR([Guile-lib is missing; please install it.])
|
||||
fi
|
||||
|
||||
dnl Check for Guile Documentá (optional, for API doc generation).
|
||||
AC_PATH_PROG([DOCUMENTA], [documenta])
|
||||
AM_CONDITIONAL([HAVE_DOCUMENTA], [test "x$DOCUMENTA" != "x"])
|
||||
|
||||
AC_CONFIG_FILES([Makefile doc/Makefile])
|
||||
AC_CONFIG_FILES([pre-inst-env], [chmod +x pre-inst-env])
|
||||
|
||||
AC_OUTPUT
|
||||
55
doc/Makefile.am
Normal file
55
doc/Makefile.am
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Safsaf, a Guile web framework
|
||||
# Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
#
|
||||
# This program 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.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public
|
||||
# License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
|
||||
info_TEXINFOS = index.texi
|
||||
index_TEXINFOS = guidance.texi api/index.texi version-history.texi
|
||||
|
||||
version-history.texi: $(top_srcdir)/NEWS
|
||||
$(AM_V_GEN)$(GUILE) $(top_srcdir)/build-aux/news-to-texi.scm $< > $@
|
||||
|
||||
API_SOURCES = \
|
||||
$(top_srcdir)/safsaf.scm \
|
||||
$(top_srcdir)/safsaf/utils.scm \
|
||||
$(top_srcdir)/safsaf/templating.scm \
|
||||
$(top_srcdir)/safsaf/response-helpers.scm \
|
||||
$(top_srcdir)/safsaf/params.scm \
|
||||
$(top_srcdir)/safsaf/handler-wrappers/logging.scm \
|
||||
$(top_srcdir)/safsaf/handler-wrappers/security-headers.scm \
|
||||
$(top_srcdir)/safsaf/handler-wrappers/cors.scm \
|
||||
$(top_srcdir)/safsaf/handler-wrappers/csrf.scm \
|
||||
$(top_srcdir)/safsaf/handler-wrappers/exceptions.scm \
|
||||
$(top_srcdir)/safsaf/handler-wrappers/sessions.scm \
|
||||
$(top_srcdir)/safsaf/handler-wrappers/trailing-slash.scm \
|
||||
$(top_srcdir)/safsaf/handler-wrappers/max-body-size.scm \
|
||||
$(top_srcdir)/safsaf/router.scm
|
||||
|
||||
html-local: index.html
|
||||
|
||||
index.html: index.texi $(index_TEXINFOS)
|
||||
$(AM_V_GEN)$(MAKEINFO) --css-ref=https://luis-felipe.gitlab.io/texinfo-css/static/css/texinfo-7.css \
|
||||
--no-split --html -c SHOW_TITLE=true -o $@ $(srcdir)/index.texi
|
||||
|
||||
EXTRA_DIST = logo.svg
|
||||
|
||||
CLEANFILES = index.html
|
||||
|
||||
if HAVE_DOCUMENTA
|
||||
api/index.texi: $(API_SOURCES)
|
||||
$(AM_V_GEN)$(top_builddir)/pre-inst-env \
|
||||
$(DOCUMENTA) api -d $(srcdir)/api \
|
||||
$(top_srcdir)/safsaf.scm $(top_srcdir)/safsaf/
|
||||
endif
|
||||
424
doc/guidance.texi
Normal file
424
doc/guidance.texi
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
@node Guidance
|
||||
@chapter 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 @ref{API}.
|
||||
|
||||
@menu
|
||||
* Getting Started:: A minimal runnable server.
|
||||
* Routing:: Route patterns, groups, and reverse routing.
|
||||
* Handler Wrappers:: Composing middleware via wrap-routes.
|
||||
* Responses:: HTML, JSON, text, redirects, and errors.
|
||||
* Request Parsing:: Forms, query strings, multipart, cookies.
|
||||
* Parameter Parsing:: Declarative param specs with validation.
|
||||
* Sessions:: Signed cookie sessions.
|
||||
* Templating:: Streaming HTML with dynamic slots.
|
||||
* Static Files:: Serving files from disk.
|
||||
@end menu
|
||||
|
||||
|
||||
@node Getting Started
|
||||
@section Getting Started
|
||||
|
||||
A Safsaf application is a list of routes passed to @code{run-safsaf}.
|
||||
Each route binds an HTTP method and URL pattern to a handler procedure.
|
||||
The handler receives a Guile @code{<request>} and a body port, and
|
||||
returns two values: a response and a body.
|
||||
|
||||
@lisp
|
||||
(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)
|
||||
@end lisp
|
||||
|
||||
The last route should be a catch-all (@code{'*} method, @code{'*}
|
||||
pattern) so that every request is handled. @code{run-safsaf} sets up
|
||||
a Fibers scheduler, starts the HTTP server, and blocks until Ctrl-C.
|
||||
|
||||
|
||||
@node Routing
|
||||
@section Routing
|
||||
|
||||
@subheading Patterns
|
||||
|
||||
Route patterns are lists of segments. A string matches literally, a
|
||||
symbol captures that segment into @code{current-route-params}, and a
|
||||
two-element list @code{(predicate name)} captures only when
|
||||
@var{predicate} returns true. A dotted tail captures the remaining
|
||||
path.
|
||||
|
||||
@lisp
|
||||
;; 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)
|
||||
@end lisp
|
||||
|
||||
@subheading Route groups
|
||||
|
||||
@code{route-group} nests routes under a shared prefix:
|
||||
|
||||
@lisp
|
||||
(route-group '("api" "v1")
|
||||
(route 'GET '("users") api-list-users)
|
||||
(route 'GET '("users" id) api-show-user))
|
||||
@end lisp
|
||||
|
||||
This matches @code{/api/v1/users} and @code{/api/v1/users/:id}.
|
||||
|
||||
@subheading Named routes and path-for
|
||||
|
||||
Give a route a @code{#:name} and use @code{path-for} to generate its
|
||||
URL, so paths are never hard-coded. The first argument is always a
|
||||
route group:
|
||||
|
||||
@lisp
|
||||
(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"
|
||||
@end lisp
|
||||
|
||||
@code{path-for} also accepts @code{#:query} and @code{#:fragment}
|
||||
keyword arguments.
|
||||
|
||||
|
||||
@node Handler Wrappers
|
||||
@section 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
|
||||
@code{wrap-routes}.
|
||||
|
||||
@lisp
|
||||
(wrap-routes routes
|
||||
(make-exceptions-handler-wrapper #:dev? #t)
|
||||
logging-handler-wrapper)
|
||||
@end lisp
|
||||
|
||||
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.
|
||||
|
||||
@subheading Per-group wrappers
|
||||
|
||||
Apply wrappers to part of the route tree by wrapping a group
|
||||
separately:
|
||||
|
||||
@lisp
|
||||
(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))
|
||||
@end lisp
|
||||
|
||||
Here CORS headers are added only to @code{/api/*} routes, while
|
||||
logging applies to everything.
|
||||
|
||||
@subheading Security headers
|
||||
|
||||
@code{security-headers-handler-wrapper} @emph{appends} its headers to
|
||||
the response rather than replacing existing ones. If a handler sets
|
||||
@code{X-Frame-Options} itself, both values will appear in the response.
|
||||
To avoid duplication, either omit the header from the wrapper (pass
|
||||
@code{#:frame-options #f}) or do not set it in the handler.
|
||||
|
||||
@subheading Max body size
|
||||
|
||||
@code{make-max-body-size-handler-wrapper} checks the
|
||||
@code{Content-Length} header and rejects requests that exceed the
|
||||
limit with a 413 response. However, it does @emph{not} limit chunked
|
||||
transfer-encoded requests that lack @code{Content-Length}. For
|
||||
untrusted networks, use a reverse proxy (e.g.@: Nginx's
|
||||
@code{client_max_body_size}) to enforce size limits at the transport
|
||||
level.
|
||||
|
||||
|
||||
@node Responses
|
||||
@section Responses
|
||||
|
||||
Safsaf provides helpers that return @code{(values response body)}
|
||||
directly:
|
||||
|
||||
@lisp
|
||||
;; 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)
|
||||
@end lisp
|
||||
|
||||
@code{html-response}, @code{json-response}, @code{text-response}, and
|
||||
@code{redirect-response} accept @code{#:code} and @code{#:headers} for
|
||||
overrides. The error helpers (@code{not-found-response}, etc.)@: accept
|
||||
@code{#:headers} but have a fixed status code.
|
||||
|
||||
For content negotiation, use @code{negotiate-content-type}:
|
||||
|
||||
@lisp
|
||||
(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))))))))
|
||||
@end lisp
|
||||
|
||||
|
||||
@node Request Parsing
|
||||
@section Request Parsing
|
||||
|
||||
@subheading Form bodies
|
||||
|
||||
@code{parse-form-body} reads a URL-encoded POST body and returns an
|
||||
alist of string pairs:
|
||||
|
||||
@lisp
|
||||
(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))))
|
||||
@end lisp
|
||||
|
||||
@subheading Query strings
|
||||
|
||||
@code{parse-query-string} extracts query parameters from the request
|
||||
URL:
|
||||
|
||||
@lisp
|
||||
(let ((qs (parse-query-string request)))
|
||||
(assoc-ref qs "page")) ;; => "2" or #f
|
||||
@end lisp
|
||||
|
||||
@subheading Multipart
|
||||
|
||||
For file uploads, use @code{parse-multipart-body}:
|
||||
|
||||
@lisp
|
||||
(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)
|
||||
...)
|
||||
@end lisp
|
||||
|
||||
@subheading Cookies
|
||||
|
||||
Read cookies with @code{request-cookie-ref} or
|
||||
@code{request-cookies}. Set them via response headers with
|
||||
@code{set-cookie-header} and @code{delete-cookie-header}:
|
||||
|
||||
@lisp
|
||||
(request-cookie-ref request "theme") ;; => "dark" or #f
|
||||
|
||||
(text-response "ok"
|
||||
#:headers (list (set-cookie-header "theme" "dark"
|
||||
#:path "/"
|
||||
#:http-only #t)))
|
||||
@end lisp
|
||||
|
||||
|
||||
@node Parameter Parsing
|
||||
@section Parameter Parsing
|
||||
|
||||
@code{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
|
||||
@code{<invalid-param>}), and options like @code{#:required} or
|
||||
@code{#:default}.
|
||||
|
||||
@lisp
|
||||
(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)
|
||||
@end lisp
|
||||
|
||||
Built-in processors: @code{as-string}, @code{as-integer},
|
||||
@code{as-number}, @code{as-checkbox}, @code{as-one-of},
|
||||
@code{as-matching}, @code{as-predicate}.
|
||||
|
||||
@subheading Form params with CSRF
|
||||
|
||||
For POST forms, use @code{parse-form-params} instead — it
|
||||
automatically checks the CSRF token (from
|
||||
@code{csrf-handler-wrapper}) before parsing:
|
||||
|
||||
@lisp
|
||||
(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))))
|
||||
@end lisp
|
||||
|
||||
@code{any-invalid-params?} returns @code{#t} if any value failed
|
||||
validation. @code{field-errors} returns a list of error message
|
||||
strings for a given field, suitable for rendering next to form inputs.
|
||||
|
||||
|
||||
@node Sessions
|
||||
@section Sessions
|
||||
|
||||
Sessions use HMAC-signed cookies via @code{(webutils sessions)}.
|
||||
Set up a session config and apply the wrapper:
|
||||
|
||||
@lisp
|
||||
(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)))
|
||||
@end lisp
|
||||
|
||||
Inside a handler, @code{(current-session)} returns the session data
|
||||
(an alist) or @code{#f} if no valid session exists.
|
||||
|
||||
To set session data, include a @code{session-set} header in the
|
||||
response. To delete, use @code{session-delete}:
|
||||
|
||||
@lisp
|
||||
;; 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)))
|
||||
@end lisp
|
||||
|
||||
|
||||
@node Templating
|
||||
@section Templating
|
||||
|
||||
@code{write-shtml-as-html/streaming} works like htmlprag's
|
||||
@code{write-shtml-as-html}, but any procedure in the SHTML tree is
|
||||
called as @code{(proc port)} and can write dynamic content directly.
|
||||
|
||||
@code{streaming-html-response} wraps this into a response: give it an
|
||||
SHTML tree (with optional procedure slots) and it returns
|
||||
@code{(values response body)} ready for a handler.
|
||||
|
||||
@lisp
|
||||
(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"))))))
|
||||
@end lisp
|
||||
|
||||
The layout is plain SHTML with a procedure in the @var{content-proc}
|
||||
position. Use @code{streaming-html-response} to send it:
|
||||
|
||||
@lisp
|
||||
(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)))))
|
||||
@end lisp
|
||||
|
||||
You can also call @code{write-shtml-as-html/streaming} directly when
|
||||
you need to write SHTML with procedure slots to an arbitrary port.
|
||||
|
||||
|
||||
@node Static Files
|
||||
@section Static Files
|
||||
|
||||
@code{make-static-handler} returns a handler that serves files from a
|
||||
directory. Pair it with a wildcard route:
|
||||
|
||||
@lisp
|
||||
(route-group '("static")
|
||||
(route 'GET '(. path)
|
||||
(make-static-handler "./public"
|
||||
#:cache-control '((max-age . 3600)))))
|
||||
@end lisp
|
||||
|
||||
This serves @code{/static/css/style.css} from
|
||||
@code{./public/css/style.css}. The handler supports
|
||||
@code{If-Modified-Since} for 304 responses.
|
||||
102
doc/index.texi
Normal file
102
doc/index.texi
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
\input texinfo
|
||||
@setfilename safsaf.info
|
||||
|
||||
@dircategory The Algorithmic Language Scheme
|
||||
@direntry
|
||||
* Safsaf: (safsaf). A web framework for Guile Scheme.
|
||||
@end direntry
|
||||
|
||||
@html
|
||||
<div style="text-align: center;">
|
||||
<img src="logo.svg" alt="Safsaf" width="200" height="200">
|
||||
</div>
|
||||
@end html
|
||||
|
||||
@c HEADER
|
||||
@settitle Safsaf
|
||||
@documentlanguage en
|
||||
@documentencoding UTF-8
|
||||
@afourpaper
|
||||
@c END HEADER
|
||||
|
||||
@c MASTER MENU
|
||||
@node Top
|
||||
@top Overview
|
||||
|
||||
Safsaf is a web framework for Guile Scheme, built on
|
||||
@url{https://codeberg.org/guile/fibers, Guile Fibers} using the
|
||||
@url{https://cbaines.codeberg.page/guile-knots/, Guile Knots} web
|
||||
server.
|
||||
|
||||
@c END MASTER MENU
|
||||
|
||||
|
||||
@c TABLE OF CONTENTS
|
||||
@contents
|
||||
@c END TABLE OF CONTENTS
|
||||
|
||||
|
||||
@c CHAPTER: GUIDANCE
|
||||
@include guidance.texi
|
||||
@c END CHAPTER: GUIDANCE
|
||||
|
||||
|
||||
@c CHAPTER: API
|
||||
@include api/index.texi
|
||||
@c END CHAPTER: API
|
||||
|
||||
|
||||
|
||||
@c APPENDICES
|
||||
@node Version History
|
||||
@appendix Version History
|
||||
|
||||
@table @dfn
|
||||
|
||||
@include version-history.texi
|
||||
|
||||
@end table
|
||||
|
||||
|
||||
|
||||
@node Copying Information
|
||||
@appendix Copying Information
|
||||
|
||||
Copyright @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.
|
||||
|
||||
@c END APPENDICES
|
||||
|
||||
|
||||
|
||||
@c INDICES
|
||||
@node Concept Index
|
||||
@unnumbered Concept Index
|
||||
|
||||
@printindex cp
|
||||
|
||||
|
||||
@node Data Type Index
|
||||
@unnumbered Data Type Index
|
||||
|
||||
@printindex tp
|
||||
|
||||
|
||||
@node Procedure Index
|
||||
@unnumbered Procedure Index
|
||||
|
||||
@printindex fn
|
||||
|
||||
|
||||
@node Variable Index
|
||||
@unnumbered Variable Index
|
||||
|
||||
@printindex vr
|
||||
@c END INDICES
|
||||
|
||||
|
||||
@bye
|
||||
42
doc/logo.svg
Normal file
42
doc/logo.svg
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
|
||||
<!-- Safsaf logo -->
|
||||
|
||||
<!-- Trunk outline + fill -->
|
||||
<path d="M100 190 Q97 145 96 110 Q94 80 97 58"
|
||||
stroke="#444444" stroke-width="14" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M100 190 Q97 145 96 110 Q94 80 97 58"
|
||||
stroke="#B89B78" stroke-width="9" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
<!-- Branch outlines -->
|
||||
<path d="M97 58 Q82 35 65 20" stroke="#444444" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
<path d="M97 58 Q100 30 100 12" stroke="#444444" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
<path d="M97 58 Q115 33 132 18" stroke="#444444" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
<!-- Branch fills -->
|
||||
<path d="M97 58 Q82 35 65 20" stroke="#B89B78" stroke-width="6" fill="none" stroke-linecap="round"/>
|
||||
<path d="M97 58 Q100 30 100 12" stroke="#B89B78" stroke-width="6" fill="none" stroke-linecap="round"/>
|
||||
<path d="M97 58 Q115 33 132 18" stroke="#B89B78" stroke-width="6" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Frond outlines - left -->
|
||||
<path d="M65 20 Q42 40 30 78 Q24 105 22 140" stroke="#444444" stroke-width="8" fill="none" stroke-linecap="round"/>
|
||||
<path d="M65 20 Q52 35 44 68 Q40 90 38 120" stroke="#444444" stroke-width="7" fill="none" stroke-linecap="round"/>
|
||||
<path d="M65 20 Q35 30 20 62 Q12 85 14 115" stroke="#444444" stroke-width="7" fill="none" stroke-linecap="round"/>
|
||||
<!-- Frond outlines - center -->
|
||||
<path d="M100 12 Q91 38 84 75 Q80 105 78 148" stroke="#444444" stroke-width="8" fill="none" stroke-linecap="round"/>
|
||||
<path d="M100 12 Q109 38 116 75 Q120 105 122 148" stroke="#444444" stroke-width="8" fill="none" stroke-linecap="round"/>
|
||||
<!-- Frond outlines - right -->
|
||||
<path d="M132 18 Q155 38 168 78 Q174 105 176 140" stroke="#444444" stroke-width="8" fill="none" stroke-linecap="round"/>
|
||||
<path d="M132 18 Q145 33 154 68 Q158 90 160 120" stroke="#444444" stroke-width="7" fill="none" stroke-linecap="round"/>
|
||||
<path d="M132 18 Q162 28 178 60 Q186 83 184 115" stroke="#444444" stroke-width="7" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Frond fills - left -->
|
||||
<path d="M65 20 Q42 40 30 78 Q24 105 22 140" stroke="#66DD66" stroke-width="5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M65 20 Q52 35 44 68 Q40 90 38 120" stroke="#88EE88" stroke-width="4.5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M65 20 Q35 30 20 62 Q12 85 14 115" stroke="#99FF99" stroke-width="4.5" fill="none" stroke-linecap="round"/>
|
||||
<!-- Frond fills - center -->
|
||||
<path d="M100 12 Q91 38 84 75 Q80 105 78 148" stroke="#66DD66" stroke-width="5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M100 12 Q109 38 116 75 Q120 105 122 148" stroke="#88EE88" stroke-width="5" fill="none" stroke-linecap="round"/>
|
||||
<!-- Frond fills - right -->
|
||||
<path d="M132 18 Q155 38 168 78 Q174 105 176 140" stroke="#66DD66" stroke-width="5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M132 18 Q145 33 154 68 Q158 90 160 120" stroke="#88EE88" stroke-width="4.5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M132 18 Q162 28 178 60 Q186 83 184 115" stroke="#99FF99" stroke-width="4.5" fill="none" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
23
examples/blog-site/README
Normal file
23
examples/blog-site/README
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
Blog Site Example
|
||||
=================
|
||||
|
||||
A small blog application demonstrating Safsaf's routing, handler
|
||||
wrappers, sessions, CSRF protection, and content negotiation. Uses
|
||||
SQLite for storage.
|
||||
|
||||
Running
|
||||
-------
|
||||
|
||||
From the repository root:
|
||||
|
||||
./pre-inst-env guile -L examples/blog-site examples/blog-site/blog-site.scm
|
||||
|
||||
Or from the example directory:
|
||||
|
||||
cd examples/blog-site
|
||||
../../pre-inst-env guile -L . blog-site.scm
|
||||
|
||||
The server listens on http://localhost:8082.
|
||||
|
||||
The session secret and database path are hard-coded for demonstration
|
||||
purposes — do not use these values in production.
|
||||
54
examples/blog-site/blog-site.scm
Normal file
54
examples/blog-site/blog-site.scm
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
(use-modules (model)
|
||||
(views)
|
||||
(safsaf)
|
||||
(safsaf handler-wrappers csrf)
|
||||
(safsaf handler-wrappers exceptions)
|
||||
(safsaf handler-wrappers logging)
|
||||
(safsaf handler-wrappers security-headers)
|
||||
(safsaf handler-wrappers sessions)
|
||||
(safsaf response-helpers)
|
||||
(safsaf router))
|
||||
|
||||
(unless (file-exists? "static/style.css")
|
||||
(format (current-error-port)
|
||||
"error: run this from the examples/blog-site/ directory~%")
|
||||
(exit 1))
|
||||
|
||||
;; Create a shared database thread pool.
|
||||
(define pool (make-db "/tmp/blog-site.db"))
|
||||
|
||||
;; Initialise the schema.
|
||||
(db-init! pool)
|
||||
|
||||
;; Session manager — in production, use a proper secret.
|
||||
(define session-manager
|
||||
(make-session-config "change-me-in-production"
|
||||
#:cookie-name "blog-session"))
|
||||
|
||||
;; Build the blog component — handles both HTML and JSON via content negotiation.
|
||||
(define blog-routes (make-blog-component pool session-manager))
|
||||
|
||||
;; Static file serving.
|
||||
(define static-routes
|
||||
(route-group '("static")
|
||||
(route 'GET '(. path)
|
||||
(make-static-handler "./static"
|
||||
#:cache-control '((max-age . 3600))))))
|
||||
|
||||
;; Apply handler wrappers and add a catch-all 404 route.
|
||||
(define all-routes
|
||||
(wrap-routes (list blog-routes
|
||||
static-routes
|
||||
(route '* '* (lambda (request body-port)
|
||||
(not-found-response))))
|
||||
(make-exceptions-handler-wrapper #:dev? #t)
|
||||
logging-handler-wrapper
|
||||
security-headers-handler-wrapper
|
||||
(make-session-handler-wrapper session-manager)
|
||||
csrf-handler-wrapper))
|
||||
|
||||
;; Start the server.
|
||||
(let ((port 8082))
|
||||
(format #t "Listening on http://localhost:~a~%" port)
|
||||
(force-output)
|
||||
(run-safsaf all-routes #:port port))
|
||||
144
examples/blog-site/model.scm
Normal file
144
examples/blog-site/model.scm
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
(define-module (model)
|
||||
#:use-module (knots thread-pool)
|
||||
#:use-module (sqlite3)
|
||||
#:export (make-db
|
||||
call-with-db
|
||||
db-init!
|
||||
db-list-posts
|
||||
db-get-post
|
||||
db-create-post!
|
||||
db-update-post!
|
||||
db-delete-post!))
|
||||
|
||||
;;;
|
||||
;;; Connection pool
|
||||
;;;
|
||||
|
||||
(define* (make-db database-file #:key (pool-size 4))
|
||||
"Create a thread pool where each thread holds an open SQLite
|
||||
connection to DATABASE-FILE."
|
||||
(make-fixed-size-thread-pool
|
||||
pool-size
|
||||
#:name "sqlite"
|
||||
#:thread-initializer
|
||||
(lambda ()
|
||||
(let ((db (sqlite-open database-file
|
||||
(logior SQLITE_OPEN_READWRITE
|
||||
SQLITE_OPEN_CREATE))))
|
||||
(sqlite-busy-timeout db 5000)
|
||||
(sqlite-exec db "PRAGMA journal_mode=WAL")
|
||||
(sqlite-exec db "PRAGMA foreign_keys=ON")
|
||||
(list db)))
|
||||
#:thread-destructor
|
||||
(lambda (db)
|
||||
(sqlite-close db))))
|
||||
|
||||
(define (call-with-db pool proc)
|
||||
"Run (PROC db) on a thread from POOL, where DB is the thread's
|
||||
SQLite connection. Returns whatever PROC returns."
|
||||
(call-with-thread pool
|
||||
(lambda (db)
|
||||
(proc db))))
|
||||
|
||||
;;;
|
||||
;;; Schema
|
||||
;;;
|
||||
|
||||
(define (db-init! pool)
|
||||
"Create the schema if it doesn't exist."
|
||||
(call-with-db pool
|
||||
(lambda (db)
|
||||
(sqlite-exec db "
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
image_url TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)"))))
|
||||
|
||||
;;;
|
||||
;;; Row conversions
|
||||
;;;
|
||||
|
||||
(define (row->post-summary row)
|
||||
"Convert a list-view row (vector) to an alist."
|
||||
`((id . ,(vector-ref row 0))
|
||||
(title . ,(vector-ref row 1))
|
||||
(created-at . ,(vector-ref row 2))))
|
||||
|
||||
(define (row->post row)
|
||||
"Convert a detail-view row (vector) to an alist."
|
||||
`((id . ,(vector-ref row 0))
|
||||
(title . ,(vector-ref row 1))
|
||||
(body . ,(vector-ref row 2))
|
||||
(image-url . ,(vector-ref row 3))
|
||||
(created-at . ,(vector-ref row 4))))
|
||||
|
||||
;;;
|
||||
;;; Queries
|
||||
;;;
|
||||
|
||||
(define (db-list-posts pool)
|
||||
"Return all posts as a list of alists (id, title, created-at),
|
||||
newest first."
|
||||
(call-with-db pool
|
||||
(lambda (db)
|
||||
(let ((stmt (sqlite-prepare db
|
||||
"SELECT id, title, created_at FROM posts ORDER BY id DESC")))
|
||||
(let ((rows (sqlite-map row->post-summary stmt)))
|
||||
(sqlite-finalize stmt)
|
||||
rows)))))
|
||||
|
||||
(define (db-get-post pool id)
|
||||
"Return the post with ID as an alist, or #f if not found."
|
||||
(call-with-db pool
|
||||
(lambda (db)
|
||||
(let ((stmt (sqlite-prepare db
|
||||
"SELECT id, title, body, image_url, created_at
|
||||
FROM posts WHERE id = ?")))
|
||||
(sqlite-bind stmt 1 id)
|
||||
(let ((row (sqlite-step stmt)))
|
||||
(sqlite-finalize stmt)
|
||||
(and row (row->post row)))))))
|
||||
|
||||
(define (db-create-post! pool title body image-url)
|
||||
"Insert a new post and return its ID."
|
||||
(call-with-db pool
|
||||
(lambda (db)
|
||||
(let ((stmt (sqlite-prepare db
|
||||
"INSERT INTO posts (title, body, image_url) VALUES (?, ?, ?)")))
|
||||
(sqlite-bind stmt 1 title)
|
||||
(sqlite-bind stmt 2 body)
|
||||
(sqlite-bind stmt 3 image-url)
|
||||
(sqlite-step stmt)
|
||||
(sqlite-finalize stmt)
|
||||
(let ((stmt (sqlite-prepare db "SELECT last_insert_rowid()")))
|
||||
(let ((row (sqlite-step stmt)))
|
||||
(sqlite-finalize stmt)
|
||||
(vector-ref row 0)))))))
|
||||
|
||||
(define (db-update-post! pool id title body image-url)
|
||||
"Update the post with ID. Returns #t if a row was changed, #f otherwise."
|
||||
(call-with-db pool
|
||||
(lambda (db)
|
||||
(let ((stmt (sqlite-prepare db
|
||||
"UPDATE posts SET title = ?, body = ?, image_url = ?
|
||||
WHERE id = ?")))
|
||||
(sqlite-bind stmt 1 title)
|
||||
(sqlite-bind stmt 2 body)
|
||||
(sqlite-bind stmt 3 image-url)
|
||||
(sqlite-bind stmt 4 id)
|
||||
(sqlite-step stmt)
|
||||
(sqlite-finalize stmt)
|
||||
(> (sqlite-changes db) 0)))))
|
||||
|
||||
(define (db-delete-post! pool id)
|
||||
"Delete the post with ID. Returns #t if a row was deleted, #f otherwise."
|
||||
(call-with-db pool
|
||||
(lambda (db)
|
||||
(let ((stmt (sqlite-prepare db "DELETE FROM posts WHERE id = ?")))
|
||||
(sqlite-bind stmt 1 id)
|
||||
(sqlite-step stmt)
|
||||
(sqlite-finalize stmt)
|
||||
(> (sqlite-changes db) 0)))))
|
||||
30
examples/blog-site/static/style.css
Normal file
30
examples/blog-site/static/style.css
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: system-ui, sans-serif; line-height: 1.6; max-width: 48rem; margin: 0 auto; padding: 1rem; color: #222; }
|
||||
nav { border-bottom: 1px solid #ddd; padding: 0.75rem 0; margin-bottom: 1.5rem; }
|
||||
nav a { text-decoration: none; color: #444; margin-right: 1rem; }
|
||||
nav a:hover { color: #000; }
|
||||
h1 { margin-bottom: 0.75rem; }
|
||||
h1 a { text-decoration: none; color: inherit; }
|
||||
p, ul, pre { margin-bottom: 1rem; }
|
||||
ul { padding-left: 1.5rem; }
|
||||
a { color: #1a6; }
|
||||
pre { background: #f5f5f5; padding: 1rem; overflow-x: auto; white-space: pre-wrap; }
|
||||
form label { display: block; margin-bottom: 0.25rem; font-weight: 600; }
|
||||
form input[type="text"], form textarea { width: 100%; padding: 0.4rem; border: 1px solid #ccc; border-radius: 3px; font-family: inherit; font-size: inherit; }
|
||||
form textarea { resize: vertical; }
|
||||
form button { margin-top: 0.5rem; padding: 0.4rem 1.2rem; background: #1a6; color: #fff; border: none; border-radius: 3px; cursor: pointer; }
|
||||
form button:hover { background: #158; }
|
||||
.field { margin-bottom: 1rem; }
|
||||
.error { color: #c33; font-size: 0.9rem; margin-top: 0.2rem; }
|
||||
.post-meta { color: #888; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
.header-image { width: 100%; max-height: 20rem; object-fit: cover; border-radius: 4px; margin-bottom: 1rem; }
|
||||
.nav-form { display: inline; }
|
||||
.nav-link { background: none; border: none; color: #444; cursor: pointer; font: inherit; padding: 0; margin-right: 1rem; }
|
||||
.nav-link:hover { color: #000; }
|
||||
form input[type="file"] { margin-top: 0.25rem; }
|
||||
.post-actions { margin-bottom: 1rem; }
|
||||
.post-actions a { margin-right: 0.5rem; }
|
||||
.inline-form { display: inline; }
|
||||
.danger { background: #c33; color: #fff; border: none; border-radius: 3px; padding: 0.2rem 0.6rem; cursor: pointer; font-size: 0.9rem; }
|
||||
.danger:hover { background: #a22; }
|
||||
footer { border-top: 1px solid #ddd; padding-top: 0.75rem; margin-top: 2rem; color: #888; font-size: 0.9rem; }
|
||||
466
examples/blog-site/views.scm
Normal file
466
examples/blog-site/views.scm
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
(define-module (views)
|
||||
#:use-module (htmlprag)
|
||||
#:use-module (ice-9 binary-ports)
|
||||
#:use-module (ice-9 match)
|
||||
#:use-module (json)
|
||||
#:use-module (model)
|
||||
#:use-module (safsaf handler-wrappers csrf)
|
||||
#:use-module (safsaf handler-wrappers sessions)
|
||||
#:use-module (safsaf response-helpers)
|
||||
#:use-module (safsaf router)
|
||||
#:use-module (safsaf templating)
|
||||
#:use-module (safsaf utils)
|
||||
#:use-module (safsaf params)
|
||||
#:use-module (web request)
|
||||
#:use-module (webutils multipart)
|
||||
#:export (make-blog-component))
|
||||
|
||||
(define %demo-username "admin")
|
||||
(define %demo-password "password")
|
||||
|
||||
(define %pool #f)
|
||||
(define %session-manager #f)
|
||||
(define %routes #f)
|
||||
|
||||
;;;
|
||||
;;; JSON helpers
|
||||
;;;
|
||||
|
||||
(define (post->json-alist post)
|
||||
(match post
|
||||
((('id . id) ('title . title) ('body . body)
|
||||
('image-url . image-url) ('created-at . created-at))
|
||||
`(("id" . ,id)
|
||||
("title" . ,title)
|
||||
("body" . ,body)
|
||||
("image_url" . ,(or image-url 'null))
|
||||
("created_at" . ,created-at)))))
|
||||
|
||||
(define (post-summary->json-alist post)
|
||||
(match post
|
||||
((('id . id) ('title . title) ('created-at . created-at))
|
||||
`(("id" . ,id)
|
||||
("title" . ,title)
|
||||
("created_at" . ,created-at)))))
|
||||
|
||||
;;;
|
||||
;;; View helpers
|
||||
;;;
|
||||
|
||||
(define (render-field label name type value errors)
|
||||
`(div (@ (class "field"))
|
||||
(label ,label
|
||||
,(if (string=? type "textarea")
|
||||
`(textarea (@ (name ,name) (rows "15") (cols "60"))
|
||||
,value)
|
||||
`(input (@ (name ,name) (type ,type) (value ,value)))))
|
||||
,@(map (lambda (err) `(p (@ (class "error")) ,err))
|
||||
errors)))
|
||||
|
||||
(define (form-errors params field-name)
|
||||
(if params
|
||||
(field-errors params field-name)
|
||||
'()))
|
||||
|
||||
(define (wants-json? request)
|
||||
(eq? 'application/json
|
||||
(negotiate-content-type request '(text/html application/json))))
|
||||
|
||||
;;;
|
||||
;;; File upload helpers
|
||||
;;;
|
||||
|
||||
(define upload-dir "./static/uploads")
|
||||
|
||||
(define (ensure-upload-dir!)
|
||||
(unless (file-exists? upload-dir)
|
||||
(mkdir upload-dir)))
|
||||
|
||||
(define (save-upload part)
|
||||
(let* ((params (part-content-disposition-params part))
|
||||
(filename (assoc-ref params 'filename)))
|
||||
(if (or (not filename) (string-null? filename))
|
||||
#f
|
||||
(let* ((ext (let ((dot (string-rindex filename #\.)))
|
||||
(if dot (substring filename dot) "")))
|
||||
(unique-name (string-append
|
||||
(number->string (current-time))
|
||||
"-"
|
||||
(number->string (random 1000000000))
|
||||
ext))
|
||||
(file-path (string-append upload-dir "/" unique-name))
|
||||
(url-path (string-append "/static/uploads/" unique-name)))
|
||||
(ensure-upload-dir!)
|
||||
(call-with-output-file file-path
|
||||
(lambda (out)
|
||||
(let ((body (part-body part)))
|
||||
(let loop ()
|
||||
(let ((bv (get-bytevector-some body)))
|
||||
(unless (eof-object? bv)
|
||||
(put-bytevector out bv)
|
||||
(loop)))))))
|
||||
url-path))))
|
||||
|
||||
;;;
|
||||
;;; Layout
|
||||
;;;
|
||||
|
||||
(define (logged-in?)
|
||||
(and (current-session) #t))
|
||||
|
||||
(define (base-layout title content-proc)
|
||||
(let ((signed-in? (logged-in?)))
|
||||
`(*TOP*
|
||||
(*DECL* DOCTYPE html)
|
||||
(html
|
||||
(head
|
||||
(meta (@ (charset "utf-8")))
|
||||
(meta (@ (name "viewport")
|
||||
(content "width=device-width, initial-scale=1")))
|
||||
(title ,title " — Blog")
|
||||
(link (@ (rel "stylesheet") (href "/static/style.css"))))
|
||||
(body
|
||||
(nav
|
||||
(a (@ (href ,(path-for %routes 'index))) "Blog")
|
||||
,@(if signed-in?
|
||||
`((a (@ (href ,(path-for %routes 'new-post))) "New Post"))
|
||||
'())
|
||||
,@(if signed-in?
|
||||
`((form (@ (method "POST") (action ,(path-for %routes 'logout))
|
||||
(class "nav-form"))
|
||||
,(csrf-token-field)
|
||||
(button (@ (type "submit") (class "nav-link")) "Log out")))
|
||||
`((a (@ (href ,(path-for %routes 'login))) "Log in"))))
|
||||
(main ,content-proc)
|
||||
(footer
|
||||
(p "Safsaf blog example")))))))
|
||||
|
||||
(define (page title shtml)
|
||||
(streaming-html-response
|
||||
(base-layout title
|
||||
(lambda (port) (write-shtml-as-html shtml port)))))
|
||||
|
||||
;;;
|
||||
;;; Auth
|
||||
;;;
|
||||
|
||||
(define (require-login handler)
|
||||
(lambda (request body-port)
|
||||
(if (logged-in?)
|
||||
(handler request body-port)
|
||||
(if (wants-json? request)
|
||||
(json-response (scm->json-string '(("error" . "unauthorized")))
|
||||
#:code 401)
|
||||
(redirect-response (path-for %routes 'login))))))
|
||||
|
||||
(define (login-page request body-port)
|
||||
(if (logged-in?)
|
||||
(redirect-response (path-for %routes 'index))
|
||||
(login-form-page #f)))
|
||||
|
||||
(define (login-form-page error)
|
||||
(page "Log in"
|
||||
`(div
|
||||
(h1 "Log in")
|
||||
,@(if error
|
||||
`((p (@ (class "error")) ,error))
|
||||
'())
|
||||
(form (@ (method "POST") (action ,(path-for %routes 'login-submit)))
|
||||
,(csrf-token-field)
|
||||
,(render-field "Username" "username" "text" "" '())
|
||||
,(render-field "Password" "password" "password" "" '())
|
||||
(button (@ (type "submit")) "Log in"))
|
||||
(p (@ (class "post-meta"))
|
||||
"Demo credentials: admin / password"))))
|
||||
|
||||
(define (handle-login request body-port)
|
||||
(let* ((form (parse-form-body request body-port))
|
||||
(params (parse-form-params
|
||||
`((username ,as-string #:required)
|
||||
(password ,as-string #:required))
|
||||
form)))
|
||||
(if (any-invalid-params? params)
|
||||
(login-form-page "Please fill in all fields")
|
||||
(let ((username (assq-ref params 'username))
|
||||
(password (assq-ref params 'password)))
|
||||
(if (and (string=? username %demo-username)
|
||||
(string=? password %demo-password))
|
||||
(redirect-response (path-for %routes 'index)
|
||||
#:headers (list (session-set %session-manager
|
||||
`((username . ,username)))))
|
||||
(login-form-page "Invalid username or password"))))))
|
||||
|
||||
(define (handle-logout request body-port)
|
||||
(redirect-response (path-for %routes 'index)
|
||||
#:headers (list (session-delete %session-manager))))
|
||||
|
||||
;;;
|
||||
;;; Blog views — content-negotiated
|
||||
;;;
|
||||
|
||||
(define (post-list-item post)
|
||||
(match post
|
||||
((('id . id) ('title . title) ('created-at . date))
|
||||
`(li (a (@ (href ,(path-for %routes 'show-post
|
||||
`((id . ,(number->string id))))))
|
||||
,title)
|
||||
" "
|
||||
(span (@ (class "post-meta")) ,date)))))
|
||||
|
||||
(define (list-posts request body-port)
|
||||
(let ((posts (db-list-posts %pool)))
|
||||
(if (wants-json? request)
|
||||
(json-response
|
||||
(scm->json-string (list->vector (map post-summary->json-alist posts))))
|
||||
(page "Posts"
|
||||
`(div
|
||||
(h1 "Blog")
|
||||
,(if (null? posts)
|
||||
'(p "No posts yet.")
|
||||
`(ul ,@(map post-list-item posts))))))))
|
||||
|
||||
(define (show-post request body-port)
|
||||
(let* ((id-str (assoc-ref (current-route-params) 'id))
|
||||
(id (and id-str (string->number id-str)))
|
||||
(post (and id (db-get-post %pool id))))
|
||||
(if (not post)
|
||||
(if (wants-json? request)
|
||||
(json-response (scm->json-string '(("error" . "not found")))
|
||||
#:code 404)
|
||||
(not-found-response "Post not found"))
|
||||
(if (wants-json? request)
|
||||
(json-response (scm->json-string (post->json-alist post)))
|
||||
(match post
|
||||
((('id . _) ('title . title) ('body . body)
|
||||
('image-url . image-url) ('created-at . date))
|
||||
(page title
|
||||
`(div
|
||||
(h1 ,title)
|
||||
(p (@ (class "post-meta")) ,date)
|
||||
,@(if (and image-url (string? image-url))
|
||||
`((img (@ (src ,image-url)
|
||||
(alt ,title)
|
||||
(class "header-image"))))
|
||||
'())
|
||||
(pre ,body)
|
||||
,@(if (logged-in?)
|
||||
`((div (@ (class "post-actions"))
|
||||
(a (@ (href ,(path-for %routes 'edit-post
|
||||
`((id . ,id-str)))))
|
||||
"Edit")
|
||||
" "
|
||||
(form (@ (method "POST")
|
||||
(action ,(path-for %routes 'post-actions
|
||||
`((id . ,id-str))))
|
||||
(class "inline-form"))
|
||||
,(csrf-token-field)
|
||||
(input (@ (type "hidden")
|
||||
(name "_method") (value "DELETE")))
|
||||
(button (@ (type "submit") (class "danger"))
|
||||
"Delete"))))
|
||||
'())
|
||||
(p (a (@ (href ,(path-for %routes 'index)))
|
||||
"Back to posts"))))))))))
|
||||
|
||||
;;;
|
||||
;;; Create
|
||||
;;;
|
||||
|
||||
(define (new-post-form-page result title-val body-val)
|
||||
(page "New Post"
|
||||
`(div
|
||||
(h1 "New Post")
|
||||
(form (@ (method "POST") (action ,(path-for %routes 'create-post))
|
||||
(enctype "multipart/form-data"))
|
||||
,(csrf-token-field)
|
||||
,(render-field "Title" "title" "text"
|
||||
title-val (form-errors result 'title))
|
||||
,(render-field "Body" "body" "textarea"
|
||||
body-val (form-errors result 'body))
|
||||
(div (@ (class "field"))
|
||||
(label "Header Image"
|
||||
(input (@ (name "image") (type "file")
|
||||
(accept "image/*")))))
|
||||
(button (@ (type "submit")) "Create"))
|
||||
(p (a (@ (href ,(path-for %routes 'index))) "Back")))))
|
||||
|
||||
(define (new-post-form request body-port)
|
||||
(new-post-form-page #f "" ""))
|
||||
|
||||
(define (create-post request body-port)
|
||||
(let* ((parts (parse-multipart-body request body-port))
|
||||
(form (multipart-text-fields parts))
|
||||
(params (parse-form-params
|
||||
`((title ,as-string #:required)
|
||||
(body ,as-string #:required))
|
||||
form)))
|
||||
(if (any-invalid-params? params)
|
||||
(new-post-form-page params
|
||||
(or (assoc-ref form "title") "")
|
||||
(or (assoc-ref form "body") ""))
|
||||
(let* ((title (assq-ref params 'title))
|
||||
(body (assq-ref params 'body))
|
||||
(image-part (parts-ref parts "image"))
|
||||
(image-url (and image-part (save-upload image-part)))
|
||||
(id (db-create-post! %pool title body image-url)))
|
||||
(redirect-response
|
||||
(path-for %routes 'show-post
|
||||
`((id . ,(number->string id)))))))))
|
||||
|
||||
;;;
|
||||
;;; Edit / Update
|
||||
;;;
|
||||
|
||||
(define (edit-post-form request body-port)
|
||||
(let* ((id-str (assoc-ref (current-route-params) 'id))
|
||||
(id (and id-str (string->number id-str)))
|
||||
(post (and id (db-get-post %pool id))))
|
||||
(if (not post)
|
||||
(not-found-response "Post not found")
|
||||
(match post
|
||||
((('id . _) ('title . title) ('body . body)
|
||||
('image-url . image-url) ('created-at . _))
|
||||
(edit-post-form-page id-str #f title body))))))
|
||||
|
||||
(define (edit-post-form-page id-str result title-val body-val)
|
||||
(page "Edit Post"
|
||||
`(div
|
||||
(h1 "Edit Post")
|
||||
(form (@ (method "POST")
|
||||
(action ,(path-for %routes 'post-actions
|
||||
`((id . ,id-str))))
|
||||
(enctype "multipart/form-data"))
|
||||
,(csrf-token-field)
|
||||
(input (@ (type "hidden") (name "_method") (value "PUT")))
|
||||
,(render-field "Title" "title" "text"
|
||||
title-val (form-errors result 'title))
|
||||
,(render-field "Body" "body" "textarea"
|
||||
body-val (form-errors result 'body))
|
||||
(div (@ (class "field"))
|
||||
(label "Header Image"
|
||||
(input (@ (name "image") (type "file")
|
||||
(accept "image/*")))))
|
||||
(button (@ (type "submit")) "Update"))
|
||||
(p (a (@ (href ,(path-for %routes 'show-post
|
||||
`((id . ,id-str)))))
|
||||
"Back")))))
|
||||
|
||||
;;;
|
||||
;;; Update / Delete — core operations
|
||||
;;;
|
||||
;;; These take parsed data, not body-port. Both the direct route handlers
|
||||
;;; (PUT, DELETE) and the _method dispatcher call these, so body reading
|
||||
;;; happens in exactly one place per request path.
|
||||
;;;
|
||||
|
||||
(define (do-update-post request parts form)
|
||||
"Validate and update the post identified by current-route-params.
|
||||
PARTS and FORM are already-parsed multipart data."
|
||||
(let* ((id-str (assoc-ref (current-route-params) 'id))
|
||||
(id (and id-str (string->number id-str)))
|
||||
(post (and id (db-get-post %pool id))))
|
||||
(if (not post)
|
||||
(if (wants-json? request)
|
||||
(json-response (scm->json-string '(("error" . "not found")))
|
||||
#:code 404)
|
||||
(not-found-response "Post not found"))
|
||||
(let ((params (parse-form-params
|
||||
`((title ,as-string #:required)
|
||||
(body ,as-string #:required))
|
||||
form)))
|
||||
(if (any-invalid-params? params)
|
||||
(edit-post-form-page id-str params
|
||||
(or (assoc-ref form "title") "")
|
||||
(or (assoc-ref form "body") ""))
|
||||
(let* ((title (assq-ref params 'title))
|
||||
(body (assq-ref params 'body))
|
||||
(image-part (and parts (parts-ref parts "image")))
|
||||
(new-image-url (and image-part (save-upload image-part)))
|
||||
(image-url (or new-image-url
|
||||
(assoc-ref post 'image-url))))
|
||||
(db-update-post! %pool id title body image-url)
|
||||
(if (wants-json? request)
|
||||
(json-response
|
||||
(scm->json-string (post->json-alist
|
||||
(db-get-post %pool id))))
|
||||
(redirect-response
|
||||
(path-for %routes 'show-post
|
||||
`((id . ,id-str)))))))))))
|
||||
|
||||
(define (do-delete-post request)
|
||||
"Delete the post identified by current-route-params."
|
||||
(let* ((id-str (assoc-ref (current-route-params) 'id))
|
||||
(id (and id-str (string->number id-str)))
|
||||
(deleted? (and id (db-delete-post! %pool id))))
|
||||
(if (wants-json? request)
|
||||
(if deleted?
|
||||
(json-response (scm->json-string '(("deleted" . #t))))
|
||||
(json-response (scm->json-string '(("error" . "not found")))
|
||||
#:code 404))
|
||||
(if deleted?
|
||||
(redirect-response (path-for %routes 'index))
|
||||
(not-found-response "Post not found")))))
|
||||
|
||||
;;;
|
||||
;;; Route handlers — each reads the body (if needed) then calls the core operation.
|
||||
;;;
|
||||
|
||||
(define (update-post request body-port)
|
||||
"PUT /posts/{id} — direct update for API clients and form submissions."
|
||||
(let* ((parts (parse-multipart-body request body-port))
|
||||
(form (multipart-text-fields parts)))
|
||||
(do-update-post request parts form)))
|
||||
|
||||
(define (delete-post request body-port)
|
||||
"DELETE /posts/{id} — direct delete for API clients."
|
||||
(do-delete-post request))
|
||||
|
||||
;;;
|
||||
;;; _method dispatch — HTML forms POST here with a hidden _method field
|
||||
;;; to simulate PUT and DELETE.
|
||||
;;;
|
||||
|
||||
(define (post-actions request body-port)
|
||||
"Handle POST /posts/{id} — dispatches on _method form field.
|
||||
HTML forms cannot send PUT or DELETE directly, so they POST with a
|
||||
hidden _method field. This handler reads the body once, then delegates
|
||||
to the appropriate core operation. API clients should use PUT/DELETE
|
||||
directly instead."
|
||||
(let* ((parts (parse-multipart-body request body-port))
|
||||
(form (multipart-text-fields parts))
|
||||
(method (assoc-ref form "_method")))
|
||||
(cond
|
||||
((equal? method "PUT") (do-update-post request parts form))
|
||||
((equal? method "DELETE") (do-delete-post request))
|
||||
(else (bad-request-response)))))
|
||||
|
||||
;;;
|
||||
;;; Component constructor
|
||||
;;;
|
||||
|
||||
(define (make-blog-component pool session-manager)
|
||||
(set! %pool pool)
|
||||
(set! %session-manager session-manager)
|
||||
(set! %routes (make-route-group '()))
|
||||
|
||||
(route-group-add-children! %routes
|
||||
(list (route 'GET '() list-posts #:name 'index)
|
||||
(route 'GET '("login") login-page #:name 'login)
|
||||
(route 'POST '("login") handle-login #:name 'login-submit)
|
||||
(route 'POST '("logout") handle-logout #:name 'logout)
|
||||
(route 'GET '("posts" "new") (require-login new-post-form)
|
||||
#:name 'new-post)
|
||||
(route 'POST '("posts") (require-login create-post)
|
||||
#:name 'create-post)
|
||||
(route 'GET '("posts" id) show-post #:name 'show-post)
|
||||
(route 'GET '("posts" id "edit") (require-login edit-post-form)
|
||||
#:name 'edit-post)
|
||||
;; _method dispatch: HTML forms POST here with _method=PUT or DELETE.
|
||||
(route 'POST '("posts" id) (require-login post-actions)
|
||||
#:name 'post-actions)
|
||||
;; Direct HTTP methods for API clients.
|
||||
(route 'PUT '("posts" id) (require-login update-post)
|
||||
#:name 'update-post)
|
||||
(route 'DELETE '("posts" id) (require-login delete-post)
|
||||
#:name 'delete-post)))
|
||||
%routes)
|
||||
107
examples/paste-bin/paste-bin.scm
Normal file
107
examples/paste-bin/paste-bin.scm
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
(use-modules (htmlprag)
|
||||
(logging logger)
|
||||
(logging port-log)
|
||||
((oop goops) #:select (make))
|
||||
(safsaf)
|
||||
(safsaf handler-wrappers csrf)
|
||||
(safsaf handler-wrappers logging)
|
||||
(safsaf response-helpers)
|
||||
(safsaf router)
|
||||
(safsaf utils)
|
||||
(safsaf params))
|
||||
|
||||
;;;
|
||||
;;; Paste storage (in-memory)
|
||||
;;;
|
||||
|
||||
(define %pastes (make-hash-table))
|
||||
(define %next-id 0)
|
||||
|
||||
(define (store-paste! content)
|
||||
"Store CONTENT and return its integer ID."
|
||||
(let ((id %next-id))
|
||||
(set! %next-id (1+ id))
|
||||
(hashv-set! %pastes id content)
|
||||
id))
|
||||
|
||||
(define (fetch-paste id)
|
||||
"Return the paste content for integer ID, or #f."
|
||||
(hashv-ref %pastes id))
|
||||
|
||||
;;;
|
||||
;;; Handlers
|
||||
;;;
|
||||
|
||||
(define (paste-form-page errors content-val)
|
||||
"Render the paste form, optionally with validation errors and prior input."
|
||||
(html-response
|
||||
`(div
|
||||
(h1 "Paste Bin")
|
||||
(form (@ (method "POST") (action "/pastes"))
|
||||
,(csrf-token-field)
|
||||
,@(map (lambda (err) `(p (@ (style "color: #c33")) ,err))
|
||||
errors)
|
||||
(textarea (@ (name "content") (rows "20") (cols "80"))
|
||||
,content-val)
|
||||
(br)
|
||||
(button (@ (type "submit")) "Create Paste")))))
|
||||
|
||||
(define (index-page request body-port)
|
||||
(paste-form-page '() ""))
|
||||
|
||||
(define (create-paste request body-port)
|
||||
(let* ((form (parse-form-body request body-port))
|
||||
(params (parse-form-params
|
||||
`((content ,as-string #:required))
|
||||
form)))
|
||||
(if (any-invalid-params? params)
|
||||
(paste-form-page
|
||||
(field-errors params 'content)
|
||||
(or (assoc-ref form "content") ""))
|
||||
(let ((id (store-paste! (assq-ref params 'content))))
|
||||
(redirect-response (string-append "/pastes/" (number->string id)))))))
|
||||
|
||||
(define (show-paste request body-port)
|
||||
(let* ((params (current-route-params))
|
||||
(id-str (assoc-ref params 'id))
|
||||
(id (string->number id-str))
|
||||
(content (and id (fetch-paste id))))
|
||||
(if content
|
||||
(html-response
|
||||
`(div
|
||||
(h1 "Paste " ,id-str)
|
||||
(pre ,content)
|
||||
(p (a (@ (href "/")) "New paste"))))
|
||||
(not-found-response "Paste not found"))))
|
||||
|
||||
;;;
|
||||
;;; Logging setup
|
||||
;;;
|
||||
|
||||
(define (setup-logging)
|
||||
(let ((lgr (make <logger>))
|
||||
(handler (make <port-log> #:port (current-error-port))))
|
||||
(add-handler! lgr handler)
|
||||
(set-default-logger! lgr)
|
||||
(open-log! lgr)))
|
||||
|
||||
(setup-logging)
|
||||
|
||||
;;;
|
||||
;;; Routes and entry point
|
||||
;;;
|
||||
|
||||
(define %port 8081)
|
||||
|
||||
(define paste-routes
|
||||
(wrap-routes
|
||||
(list
|
||||
(route 'GET '() index-page)
|
||||
(route 'POST '("pastes") create-paste)
|
||||
(route 'GET '("pastes" id) show-paste)
|
||||
(route '* '* (lambda (request body-port) (not-found-response))))
|
||||
logging-handler-wrapper
|
||||
csrf-handler-wrapper))
|
||||
|
||||
(format #t "Paste Bin listening on port ~a~%" %port)
|
||||
(run-safsaf paste-routes #:port %port)
|
||||
39
guile.am
Normal file
39
guile.am
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Safsaf, a Guile web framework
|
||||
# Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
#
|
||||
# This program 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.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public
|
||||
# License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
|
||||
moddir=$(datadir)/guile/site/$(GUILE_EFFECTIVE_VERSION)
|
||||
godir=$(libdir)/guile/$(GUILE_EFFECTIVE_VERSION)/site-ccache
|
||||
|
||||
GOBJECTS = $(SOURCES:%.scm=%.go)
|
||||
|
||||
nobase_mod_DATA = $(SOURCES) $(NOCOMP_SOURCES)
|
||||
nobase_go_DATA = $(GOBJECTS)
|
||||
|
||||
# Make sure source files are installed first, so that the mtime of
|
||||
# installed compiled files is greater than that of installed source
|
||||
# files. See
|
||||
# <http://lists.gnu.org/archive/html/guile-devel/2010-07/msg00125.html>
|
||||
# for details.
|
||||
guile_install_go_files = install-nobase_goDATA
|
||||
$(guile_install_go_files): install-nobase_modDATA
|
||||
|
||||
CLEANFILES = $(GOBJECTS)
|
||||
EXTRA_DIST = $(SOURCES) $(NOCOMP_SOURCES)
|
||||
GUILE_WARNINGS = -Wunbound-variable -Warity-mismatch -Wformat
|
||||
SUFFIXES = .scm .go
|
||||
.scm.go:
|
||||
$(AM_V_GEN)$(top_builddir)/pre-inst-env $(GUILE_TOOLS) compile -W3 $(GUILE_TARGET) $(GUILE_WARNINGS) -o "$@" "$<"
|
||||
57
guix-dev.scm
Normal file
57
guix-dev.scm
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
(use-modules ((guix licenses) #:prefix license:)
|
||||
(guix packages)
|
||||
(guix git-download)
|
||||
(guix build-system gnu)
|
||||
(gnu packages autotools)
|
||||
(gnu packages guile)
|
||||
(gnu packages gnupg)
|
||||
(gnu packages guile-xyz)
|
||||
(gnu packages databases)
|
||||
(gnu packages emacs)
|
||||
(gnu packages pkg-config)
|
||||
(gnu packages texinfo)
|
||||
(gnu packages version-control))
|
||||
|
||||
(package
|
||||
(name "safsaf")
|
||||
(version "0")
|
||||
(source #f)
|
||||
(build-system gnu-build-system)
|
||||
(inputs
|
||||
(list guile-next
|
||||
guile-knots
|
||||
guile-webutils
|
||||
guile-lib
|
||||
guile-json-4
|
||||
guile-sqlite3
|
||||
guile-squee
|
||||
guile-gcrypt))
|
||||
(native-inputs
|
||||
(list autoconf
|
||||
automake
|
||||
pkg-config
|
||||
emacs-minimal
|
||||
guile-documenta
|
||||
texinfo
|
||||
git))
|
||||
(synopsis "Web framework for Guile Scheme")
|
||||
(description "Web framework for Guile Scheme built on Guile Knots.")
|
||||
(home-page "")
|
||||
(license license:lgpl3+))
|
||||
29
pre-inst-env.in
Normal file
29
pre-inst-env.in
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
#!/bin/sh
|
||||
# Safsaf, a Guile web framework
|
||||
# Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
#
|
||||
# This program 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.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public
|
||||
# License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
|
||||
abs_top_srcdir="`cd "@abs_top_srcdir@" > /dev/null; pwd`"
|
||||
abs_top_builddir="`cd "@abs_top_builddir@" > /dev/null; pwd`"
|
||||
|
||||
GUILE_LOAD_COMPILED_PATH="$abs_top_builddir${GUILE_LOAD_COMPILED_PATH:+:}$GUILE_LOAD_COMPILED_PATH"
|
||||
GUILE_LOAD_PATH="$abs_top_builddir:$abs_top_srcdir${GUILE_LOAD_PATH:+:}:$GUILE_LOAD_PATH"
|
||||
export GUILE_LOAD_COMPILED_PATH GUILE_LOAD_PATH
|
||||
|
||||
PATH="$abs_top_builddir:$PATH"
|
||||
export PATH
|
||||
|
||||
exec "$@"
|
||||
161
safsaf.scm
Normal file
161
safsaf.scm
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
(define-module (safsaf)
|
||||
#:use-module (web request)
|
||||
#:use-module (web response)
|
||||
#:use-module (web uri)
|
||||
#:use-module (srfi srfi-1)
|
||||
#:use-module (srfi srfi-71)
|
||||
#:use-module (fibers)
|
||||
#:use-module (fibers conditions)
|
||||
#:use-module (fibers scheduler)
|
||||
#:use-module (knots)
|
||||
#:use-module (knots web-server)
|
||||
#:use-module (safsaf router)
|
||||
#:export (run-safsaf
|
||||
default-method-not-allowed-handler))
|
||||
|
||||
(define (default-method-not-allowed-handler request allowed-methods)
|
||||
"Return a 405 Method Not Allowed response with an Allow header listing
|
||||
ALLOWED-METHODS."
|
||||
(values (build-response
|
||||
#:code 405
|
||||
#:headers `((allow . ,allowed-methods)
|
||||
(content-type text/plain)))
|
||||
"Method Not Allowed"))
|
||||
|
||||
(define* (make-handler compiled-routes reverse-routes
|
||||
#:key
|
||||
method-not-allowed?
|
||||
method-not-allowed-handler)
|
||||
"Build a handler that dispatches to the matching route.
|
||||
Handler signature: (request body-port) -> (values response body).
|
||||
|
||||
HEAD requests that have no explicit route are automatically handled by
|
||||
dispatching to the matching GET handler and discarding the response body.
|
||||
|
||||
When METHOD-NOT-ALLOWED? is true, requests whose path matches a route but
|
||||
whose method does not receive a 405 response via METHOD-NOT-ALLOWED-HANDLER."
|
||||
(let ((catch-all-handler (compiled-route-handler
|
||||
(last compiled-routes))))
|
||||
|
||||
(define (run-handler handler bindings request body-port)
|
||||
(parameterize ((current-route-params bindings)
|
||||
(current-reverse-routes reverse-routes))
|
||||
(handler request body-port)))
|
||||
|
||||
(define (check-405-or-catch-all handler bindings
|
||||
path-segments request body-port)
|
||||
(if method-not-allowed?
|
||||
(let* ((allowed (find-allowed-methods compiled-routes
|
||||
path-segments))
|
||||
;; GET implies HEAD via auto-HEAD handling.
|
||||
(allowed (if (and (memq 'GET allowed)
|
||||
(not (memq 'HEAD allowed)))
|
||||
(cons 'HEAD allowed)
|
||||
allowed)))
|
||||
(if (null? allowed)
|
||||
(run-handler handler bindings request body-port)
|
||||
(method-not-allowed-handler request allowed)))
|
||||
(run-handler handler bindings request body-port)))
|
||||
|
||||
(lambda (request body-port)
|
||||
(let* ((method (request-method request))
|
||||
(path-segments (split-and-decode-uri-path
|
||||
(uri-path (request-uri request))))
|
||||
(handler bindings (match-route compiled-routes
|
||||
method path-segments)))
|
||||
(cond
|
||||
;; Direct match — dispatch normally.
|
||||
((not (eq? handler catch-all-handler))
|
||||
(run-handler handler bindings request body-port))
|
||||
|
||||
;; HEAD with no explicit route — try GET, discard body.
|
||||
((eq? method 'HEAD)
|
||||
(let ((get-handler get-bindings
|
||||
(match-route compiled-routes
|
||||
'GET path-segments)))
|
||||
(if (eq? get-handler catch-all-handler)
|
||||
;; No GET route either — 405 or catch-all.
|
||||
(check-405-or-catch-all handler bindings
|
||||
path-segments request body-port)
|
||||
;; Run GET handler, keep response headers, discard body.
|
||||
(let ((response _body
|
||||
(run-handler get-handler get-bindings
|
||||
request body-port)))
|
||||
(values response "")))))
|
||||
|
||||
;; Catch-all matched — check for 405.
|
||||
(else
|
||||
(check-405-or-catch-all handler bindings
|
||||
path-segments request body-port)))))))
|
||||
|
||||
(define* (run-safsaf routes
|
||||
#:key
|
||||
(host #f)
|
||||
(port 8080)
|
||||
(method-not-allowed? #t)
|
||||
(method-not-allowed-handler
|
||||
default-method-not-allowed-handler)
|
||||
(connection-buffer-size #f))
|
||||
"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."
|
||||
(let* ((compiled reverse-routes (compile-routes routes))
|
||||
(handler (make-handler compiled reverse-routes
|
||||
#:method-not-allowed? method-not-allowed?
|
||||
#:method-not-allowed-handler
|
||||
method-not-allowed-handler)))
|
||||
(define (start-server)
|
||||
(apply run-knots-web-server
|
||||
handler
|
||||
#:host host
|
||||
#:port port
|
||||
#:call-handler-with-body-port? #t
|
||||
(if connection-buffer-size
|
||||
(list #:connection-buffer-size connection-buffer-size)
|
||||
'())))
|
||||
|
||||
(if (current-scheduler)
|
||||
;; Already inside run-fibers — just start the server.
|
||||
(start-server)
|
||||
;; Standalone — manage the full lifecycle.
|
||||
(run-fibers
|
||||
(lambda ()
|
||||
(start-server)
|
||||
(let ((quit-cvar (make-condition)))
|
||||
(call-with-sigint
|
||||
(lambda () (wait quit-cvar))
|
||||
quit-cvar)))))))
|
||||
107
safsaf/handler-wrappers/cors.scm
Normal file
107
safsaf/handler-wrappers/cors.scm
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
(define-module (safsaf handler-wrappers cors)
|
||||
#:use-module (web request)
|
||||
#:use-module (web response)
|
||||
#:use-module (srfi srfi-71)
|
||||
#:use-module (safsaf response-helpers)
|
||||
#:export (cors-handler-wrapper))
|
||||
|
||||
(define* (cors-handler-wrapper handler
|
||||
#:key
|
||||
(origins '("*"))
|
||||
(methods '(GET POST PUT DELETE PATCH))
|
||||
(headers '("Content-Type" "Authorization"))
|
||||
(max-age 86400)
|
||||
(allow-credentials? #f)
|
||||
(expose-headers '()))
|
||||
"Handler wrapper that adds CORS (Cross-Origin Resource Sharing)
|
||||
headers to responses.
|
||||
|
||||
Browsers enforce the Same-Origin Policy: scripts on one origin
|
||||
(scheme + host + port) cannot read responses from a different origin.
|
||||
CORS relaxes this by letting the server declare which origins, methods,
|
||||
and headers are permitted.
|
||||
|
||||
For ``simple'' requests the browser sends the request and checks the
|
||||
response headers. For non-simple requests (e.g. PUT/DELETE, custom
|
||||
headers, or JSON Content-Type) the browser sends a preflight OPTIONS
|
||||
request first. This wrapper handles both cases.
|
||||
|
||||
ORIGINS is a list of allowed origin strings, or '(\"*\") for any.
|
||||
METHODS is a list of allowed method symbols.
|
||||
HEADERS is a list of allowed request header name strings.
|
||||
MAX-AGE is the preflight cache duration in seconds.
|
||||
ALLOW-CREDENTIALS? controls whether credentials (cookies, auth) are
|
||||
allowed cross-origin. Note: cannot be #t when origins is '(\"*\").
|
||||
EXPOSE-HEADERS is a list of response header name strings the browser
|
||||
may read from JavaScript."
|
||||
(let ((methods-str (string-join (map symbol->string methods) ", "))
|
||||
(headers-str (string-join headers ", "))
|
||||
(max-age-str (number->string max-age))
|
||||
(expose-str (string-join expose-headers ", "))
|
||||
(any-origin? (member "*" origins)))
|
||||
(when (and allow-credentials? any-origin?)
|
||||
(raise-exception
|
||||
(make-exception-with-message
|
||||
"cors-handler-wrapper: allow-credentials? cannot be #t when origins includes \"*\"")))
|
||||
(define (allowed-origin? request-origin)
|
||||
(or any-origin?
|
||||
(member request-origin origins)))
|
||||
|
||||
(define (cors-headers request-origin)
|
||||
(let* ((origin-val (if any-origin? "*" request-origin))
|
||||
(hdrs `((access-control-allow-origin . ,origin-val)
|
||||
(access-control-allow-methods . ,methods-str)
|
||||
(access-control-allow-headers . ,headers-str)
|
||||
(access-control-max-age . ,max-age-str)))
|
||||
(hdrs (if allow-credentials?
|
||||
(cons '(access-control-allow-credentials . "true")
|
||||
hdrs)
|
||||
hdrs))
|
||||
(hdrs (if (string-null? expose-str)
|
||||
hdrs
|
||||
(cons `(access-control-expose-headers . ,expose-str)
|
||||
hdrs))))
|
||||
hdrs))
|
||||
|
||||
(lambda (request body-port)
|
||||
(let ((request-origin (assoc-ref (request-headers request) 'origin)))
|
||||
(cond
|
||||
;; No Origin header — not a cross-origin request, pass through.
|
||||
((not request-origin)
|
||||
(handler request body-port))
|
||||
|
||||
;; Origin not allowed — pass through without CORS headers.
|
||||
((not (allowed-origin? request-origin))
|
||||
(handler request body-port))
|
||||
|
||||
;; Preflight OPTIONS request — respond immediately.
|
||||
((eq? (request-method request) 'OPTIONS)
|
||||
(values (build-response
|
||||
#:code 204
|
||||
#:headers (cors-headers request-origin))
|
||||
#f))
|
||||
|
||||
;; Normal request from allowed origin — call handler, add headers.
|
||||
(else
|
||||
(let ((response body (handler request body-port)))
|
||||
(values (build-response/inherit response
|
||||
#:headers (append (cors-headers request-origin)
|
||||
(response-headers response)))
|
||||
body))))))))
|
||||
78
safsaf/handler-wrappers/csrf.scm
Normal file
78
safsaf/handler-wrappers/csrf.scm
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
(define-module (safsaf handler-wrappers csrf)
|
||||
#:use-module (ice-9 format)
|
||||
#:use-module (rnrs bytevectors)
|
||||
#:use-module (srfi srfi-71)
|
||||
#:use-module (gcrypt random)
|
||||
#:use-module (webutils cookie)
|
||||
#:use-module (safsaf response-helpers)
|
||||
#:use-module (safsaf utils)
|
||||
#:export (csrf-handler-wrapper
|
||||
current-csrf-token
|
||||
csrf-token-field))
|
||||
|
||||
(define (generate-csrf-token)
|
||||
"Generate a 32-byte hex-encoded CSRF token."
|
||||
(let* ((bv (gen-random-bv 32))
|
||||
(len (bytevector-length bv)))
|
||||
(string-concatenate
|
||||
(map (lambda (i)
|
||||
(format #f "~2,'0x" (bytevector-u8-ref bv i)))
|
||||
(iota len)))))
|
||||
|
||||
(define current-csrf-token
|
||||
(make-parameter #f))
|
||||
|
||||
(define* (csrf-handler-wrapper handler
|
||||
#:key
|
||||
(cookie-name "csrf-token"))
|
||||
"CSRF token handler wrapper.
|
||||
|
||||
Ensures a CSRF token cookie is present on every response (generates one
|
||||
if the request has none). The token is bound to current-csrf-token so
|
||||
handlers and templates can read it via (current-csrf-token).
|
||||
|
||||
Token validation is NOT done here — it belongs in the form processing
|
||||
layer. Use parse-form-params from (safsaf params), which automatically
|
||||
checks the submitted token against the cookie token."
|
||||
(lambda (request body-port)
|
||||
(let* ((existing-token (request-cookie-ref request cookie-name))
|
||||
(token (or existing-token (generate-csrf-token))))
|
||||
(let ((response body (parameterize ((current-csrf-token token))
|
||||
(handler request body-port))))
|
||||
(if existing-token
|
||||
(values response body)
|
||||
(values (add-csrf-cookie response token cookie-name)
|
||||
body))))))
|
||||
|
||||
(define (add-csrf-cookie response token cookie-name)
|
||||
"Add a Set-Cookie header for the CSRF token to RESPONSE."
|
||||
(let ((cookie (set-cookie cookie-name token
|
||||
#:path "/"
|
||||
#:extensions '(("SameSite" . "Strict")))))
|
||||
(build-response/inherit response
|
||||
#:headers (append (response-headers response) (list cookie)))))
|
||||
|
||||
(define (csrf-token-field)
|
||||
"Return an SXML hidden input element for the CSRF token.
|
||||
Use in forms: @code{(csrf-token-field)} @result{} @code{(input (@@
|
||||
(type \"hidden\") ...))}."
|
||||
`(input (@ (type "hidden")
|
||||
(name "csrf-token")
|
||||
(value ,(or (current-csrf-token) "")))))
|
||||
201
safsaf/handler-wrappers/exceptions.scm
Normal file
201
safsaf/handler-wrappers/exceptions.scm
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
(define-module (safsaf handler-wrappers exceptions)
|
||||
#:use-module (ice-9 match)
|
||||
#:use-module (web request)
|
||||
#:use-module (web response)
|
||||
#:use-module (web uri)
|
||||
#:use-module (knots)
|
||||
#:use-module (knots backtraces)
|
||||
#:use-module (logging logger)
|
||||
#:autoload (json builder) (scm->json-string)
|
||||
#:use-module (safsaf response-helpers)
|
||||
#:export (make-exceptions-handler-wrapper
|
||||
exceptions-handler-wrapper
|
||||
default-render-error
|
||||
default-render-html
|
||||
default-render-json))
|
||||
|
||||
;;;
|
||||
;;; HTML pages
|
||||
;;;
|
||||
|
||||
(define (dev-error-page method path backtrace-string)
|
||||
"Return an SHTML tree for a development-mode error page."
|
||||
`(*TOP*
|
||||
(*DECL* DOCTYPE html)
|
||||
(html
|
||||
(head
|
||||
(title "500 - Internal Server Error")
|
||||
(style "
|
||||
body { font-family: monospace; margin: 2em; background: #1a1a2e; color: #e0e0e0; }
|
||||
h1 { color: #e74c3c; }
|
||||
.request { color: #a0a0a0; margin-bottom: 1em; }
|
||||
pre { background: #16213e; padding: 1em; overflow-x: auto;
|
||||
border-left: 3px solid #e74c3c; white-space: pre-wrap; }
|
||||
@media (prefers-color-scheme: light) {
|
||||
body { background: #f8f8f8; color: #1a1a1a; }
|
||||
.request { color: #555; }
|
||||
pre { background: #fff; border-left-color: #e74c3c; }
|
||||
}
|
||||
"))
|
||||
(body
|
||||
(h1 "Internal Server Error")
|
||||
(p (@ (class "request"))
|
||||
,(symbol->string method) " " ,path)
|
||||
(pre ,backtrace-string)))))
|
||||
|
||||
(define (prod-error-page code message)
|
||||
"Return an SHTML tree for a production error page."
|
||||
`(*TOP*
|
||||
(*DECL* DOCTYPE html)
|
||||
(html
|
||||
(head (title ,(string-append (number->string code) " - " message)))
|
||||
(body
|
||||
(h1 ,(number->string code))
|
||||
(p ,message)))))
|
||||
|
||||
;;;
|
||||
;;; Default renderers
|
||||
;;;
|
||||
|
||||
(define (default-render-html request code message backtrace-string dev?)
|
||||
"Default HTML error renderer. In dev mode, shows a rich backtrace page.
|
||||
In production, returns a minimal HTML page."
|
||||
(let ((method (request-method request))
|
||||
(path (uri-path (request-uri request))))
|
||||
(if dev?
|
||||
(html-response (dev-error-page method path backtrace-string)
|
||||
#:code code)
|
||||
(html-response (prod-error-page code message)
|
||||
#:code code))))
|
||||
|
||||
(define (default-render-json _request code message backtrace-string dev?)
|
||||
"Default JSON error renderer. In dev mode, includes the backtrace.
|
||||
In production, returns only the error message."
|
||||
(let ((body (if dev?
|
||||
(scm->json-string `((error . ,message)
|
||||
(backtrace . ,backtrace-string)))
|
||||
(scm->json-string `((error . ,message))))))
|
||||
(json-response body #:code code)))
|
||||
|
||||
;;;
|
||||
;;; Default render-error
|
||||
;;;
|
||||
|
||||
(define (default-render-error render-html render-json)
|
||||
"Return a render-error procedure that content-negotiates between
|
||||
RENDER-HTML and RENDER-JSON based on the request's Accept header."
|
||||
(lambda (request code message backtrace-string dev?)
|
||||
(case (negotiate-content-type request '(text/html application/json))
|
||||
((text/html) (render-html request code message backtrace-string dev?))
|
||||
(else (render-json request code message backtrace-string dev?)))))
|
||||
|
||||
;;;
|
||||
;;; Public API
|
||||
;;;
|
||||
|
||||
(define* (make-exceptions-handler-wrapper #:key
|
||||
(dev? #f)
|
||||
(logger #f)
|
||||
(render-html default-render-html)
|
||||
(render-json default-render-json)
|
||||
(render-error
|
||||
(default-render-error
|
||||
render-html render-json)))
|
||||
"Return a handler wrapper that catches exceptions and returns an error
|
||||
response. See exceptions-handler-wrapper for details."
|
||||
(lambda (handler)
|
||||
(exceptions-handler-wrapper handler
|
||||
#:dev? dev?
|
||||
#:logger logger
|
||||
#:render-error render-error)))
|
||||
|
||||
(define* (exceptions-handler-wrapper handler
|
||||
#:key
|
||||
(dev? #f)
|
||||
(logger #f)
|
||||
(render-html default-render-html)
|
||||
(render-json default-render-json)
|
||||
(render-error
|
||||
(default-render-error
|
||||
render-html render-json)))
|
||||
"Handler wrapper that catches exceptions from HANDLER and returns an
|
||||
error response.
|
||||
|
||||
The response format is content-negotiated from the request's Accept header,
|
||||
choosing between HTML and JSON.
|
||||
|
||||
When LOGGER is provided, exceptions are logged through it. Otherwise,
|
||||
the backtrace is written to the current error port.
|
||||
In dev mode (DEV? is #t), the response includes the backtrace and
|
||||
exception details. In production mode, a generic error is returned.
|
||||
|
||||
Rendering can be customised at three levels:
|
||||
|
||||
#:render-error — full override. A procedure
|
||||
(request code message backtrace-string dev?) -> (values response body)
|
||||
that bypasses content negotiation entirely.
|
||||
|
||||
#:render-html — custom HTML rendering. A procedure with the same
|
||||
signature, called when content negotiation selects HTML.
|
||||
|
||||
#:render-json — custom JSON rendering. A procedure with the same
|
||||
signature, called when content negotiation selects JSON.
|
||||
|
||||
The default RENDER-ERROR content-negotiates between RENDER-HTML and
|
||||
RENDER-JSON. Providing #:render-html or #:render-json replaces just
|
||||
that format; providing #:render-error replaces the entire rendering."
|
||||
(lambda (request body-port)
|
||||
(let ((method (request-method request))
|
||||
(path (uri-path (request-uri request))))
|
||||
(with-exception-handler
|
||||
(lambda (exn)
|
||||
(let ((backtrace-string
|
||||
(call-with-output-string
|
||||
(lambda (port)
|
||||
(print-backtrace-and-exception/knots
|
||||
exn
|
||||
#:port port)))))
|
||||
(if logger
|
||||
(log-msg logger 'ERROR
|
||||
method " " path " — unhandled exception:\n"
|
||||
backtrace-string)
|
||||
(format/knots (current-error-port)
|
||||
"~a ~a — unhandled exception:\n~a\n"
|
||||
method path backtrace-string))
|
||||
(render-error request 500 "Internal Server Error"
|
||||
backtrace-string dev?)))
|
||||
(lambda ()
|
||||
(with-exception-handler
|
||||
(lambda (exn)
|
||||
(let ((stack
|
||||
(match (fluid-ref %stacks)
|
||||
((_ . prompt-tag)
|
||||
(make-stack #t
|
||||
0 prompt-tag
|
||||
0 (and prompt-tag 1)))
|
||||
(_
|
||||
(make-stack #t)))))
|
||||
(raise-exception
|
||||
(make-exception
|
||||
exn
|
||||
(make-knots-exception stack)))))
|
||||
(lambda ()
|
||||
(start-stack #t (handler request body-port)))))
|
||||
#:unwind? #t))))
|
||||
48
safsaf/handler-wrappers/logging.scm
Normal file
48
safsaf/handler-wrappers/logging.scm
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
(define-module (safsaf handler-wrappers logging)
|
||||
#:use-module (web request)
|
||||
#:use-module (web response)
|
||||
#:use-module (web uri)
|
||||
#:use-module (srfi srfi-71)
|
||||
#:use-module (logging logger)
|
||||
#:export (logging-handler-wrapper))
|
||||
|
||||
(define* (logging-handler-wrapper handler #:key (logger #f) (level 'INFO))
|
||||
"Handler wrapper that logs each request and response.
|
||||
|
||||
Logs at LEVEL (default 'INFO) with method, path, status code, and
|
||||
duration in milliseconds. If LOGGER is given, logs to that logger;
|
||||
otherwise uses the default logger set via set-default-logger!."
|
||||
(lambda (request body-port)
|
||||
(let* ((start (get-internal-real-time))
|
||||
(method (request-method request))
|
||||
(path (uri-path (request-uri request)))
|
||||
(response body (handler request body-port))
|
||||
(duration-ms (inexact->exact
|
||||
(round
|
||||
(* 1000
|
||||
(/ (- (get-internal-real-time) start)
|
||||
internal-time-units-per-second)))))
|
||||
(code (response-code response)))
|
||||
(if logger
|
||||
(log-msg logger level
|
||||
method " " path " " code " " duration-ms "ms")
|
||||
(log-msg level
|
||||
method " " path " " code " " duration-ms "ms"))
|
||||
(values response body))))
|
||||
41
safsaf/handler-wrappers/max-body-size.scm
Normal file
41
safsaf/handler-wrappers/max-body-size.scm
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
(define-module (safsaf handler-wrappers max-body-size)
|
||||
#:use-module (safsaf response-helpers)
|
||||
#:use-module (web request)
|
||||
#:export (make-max-body-size-handler-wrapper))
|
||||
|
||||
(define* (make-max-body-size-handler-wrapper max-bytes
|
||||
#:key
|
||||
(handler-413
|
||||
(lambda (request body-port)
|
||||
(payload-too-large-response))))
|
||||
"Return a handler wrapper that rejects requests whose Content-Length
|
||||
exceeds MAX-BYTES with a 413 Payload Too Large response.
|
||||
|
||||
HANDLER-413 is a handler (request body-port) -> (values response body)
|
||||
called when the limit is exceeded; the default returns plain text.
|
||||
|
||||
Note: this checks the Content-Length header only. Chunked transfers
|
||||
without Content-Length are not limited by this wrapper."
|
||||
(lambda (handler)
|
||||
(lambda (request body-port)
|
||||
(let ((content-length (request-content-length request)))
|
||||
(if (and content-length (> content-length max-bytes))
|
||||
(handler-413 request body-port)
|
||||
(handler request body-port))))))
|
||||
66
safsaf/handler-wrappers/security-headers.scm
Normal file
66
safsaf/handler-wrappers/security-headers.scm
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
(define-module (safsaf handler-wrappers security-headers)
|
||||
#:use-module (srfi srfi-71)
|
||||
#:use-module (web response)
|
||||
#:use-module (safsaf response-helpers)
|
||||
#:export (security-headers-handler-wrapper))
|
||||
|
||||
(define* (security-headers-handler-wrapper handler
|
||||
#:key
|
||||
(content-type-options "nosniff")
|
||||
(frame-options "DENY")
|
||||
(strict-transport-security #f)
|
||||
(referrer-policy
|
||||
"strict-origin-when-cross-origin")
|
||||
(cross-origin-opener-policy #f)
|
||||
(permissions-policy #f)
|
||||
(content-security-policy #f)
|
||||
(content-security-policy-report-only #f))
|
||||
"Handler wrapper that adds security headers to every response.
|
||||
|
||||
All headers are optional and configurable. Pass #f to disable a header.
|
||||
Defaults:
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Frame-Options: DENY
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
|
||||
Not set by default (enable explicitly):
|
||||
Strict-Transport-Security (e.g. \"max-age=63072000; includeSubDomains\")
|
||||
Cross-Origin-Opener-Policy (e.g. \"same-origin\")
|
||||
Permissions-Policy (e.g. \"camera=(), microphone=()\")
|
||||
Content-Security-Policy (e.g. \"default-src 'self'; script-src 'self'\")
|
||||
Content-Security-Policy-Report-Only — same syntax, for testing policies
|
||||
without enforcing them"
|
||||
(let ((security-headers
|
||||
(filter cdr
|
||||
`((x-content-type-options . ,content-type-options)
|
||||
(x-frame-options . ,frame-options)
|
||||
(strict-transport-security . ,strict-transport-security)
|
||||
(referrer-policy . ,referrer-policy)
|
||||
(cross-origin-opener-policy . ,cross-origin-opener-policy)
|
||||
(permissions-policy . ,permissions-policy)
|
||||
(content-security-policy . ,content-security-policy)
|
||||
(content-security-policy-report-only
|
||||
. ,content-security-policy-report-only)))))
|
||||
(lambda (request body-port)
|
||||
(let ((response body (handler request body-port)))
|
||||
(values (build-response/inherit response
|
||||
#:headers (append (response-headers response)
|
||||
security-headers))
|
||||
body)))))
|
||||
81
safsaf/handler-wrappers/sessions.scm
Normal file
81
safsaf/handler-wrappers/sessions.scm
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
(define-module (safsaf handler-wrappers sessions)
|
||||
#:use-module (webutils sessions)
|
||||
#:export (make-session-config
|
||||
make-session-handler-wrapper
|
||||
session-handler-wrapper
|
||||
current-session
|
||||
session-set
|
||||
session-delete))
|
||||
|
||||
(define* (make-session-config secret-key
|
||||
#:key
|
||||
(cookie-name "session")
|
||||
(expire-delta '(30 0 0))
|
||||
(algorithm 'sha512))
|
||||
"Create a session manager for use with session-handler-wrapper.
|
||||
|
||||
SECRET-KEY is the HMAC signing key (a string).
|
||||
EXPIRE-DELTA is (days hours minutes), default 30 days.
|
||||
ALGORITHM is the HMAC algorithm, default sha512."
|
||||
(make-session-manager secret-key
|
||||
#:cookie-name cookie-name
|
||||
#:expire-delta expire-delta
|
||||
#:algorithm algorithm))
|
||||
|
||||
(define current-session
|
||||
(make-parameter #f))
|
||||
|
||||
(define (make-session-handler-wrapper session-manager)
|
||||
"Return a handler wrapper that binds session data from SESSION-MANAGER.
|
||||
See session-handler-wrapper for details."
|
||||
(lambda (handler) (session-handler-wrapper handler session-manager)))
|
||||
|
||||
(define* (session-handler-wrapper handler session-manager)
|
||||
"Session handler wrapper using signed cookies via (webutils sessions).
|
||||
|
||||
Reads the session cookie from the request, verifies the HMAC signature,
|
||||
and binds current-session for the duration of the handler. If no
|
||||
valid session cookie is present, current-session is #f.
|
||||
|
||||
Handlers read session data via:
|
||||
(current-session) → session data or #f
|
||||
|
||||
To set or delete the session, handlers include the appropriate header
|
||||
in their response using session-set and session-delete:
|
||||
|
||||
(redirect-response \"/\" #:headers (list (session-set manager data)))
|
||||
(redirect-response \"/\" #:headers (list (session-delete manager)))"
|
||||
(lambda (request body-port)
|
||||
(let ((data (session-data session-manager request)))
|
||||
(parameterize ((current-session data))
|
||||
(handler request body-port)))))
|
||||
|
||||
(define (session-set session-manager data)
|
||||
"Return a Set-Cookie header that stores signed DATA in the session cookie.
|
||||
DATA can be any Scheme value that can be written and read back.
|
||||
Include in a response headers list:
|
||||
(redirect-response \"/\" #:headers (list (session-set manager '((user-id . 42)))))"
|
||||
(set-session session-manager data))
|
||||
|
||||
(define (session-delete session-manager)
|
||||
"Return a Set-Cookie header that expires the session cookie.
|
||||
Include in a response headers list:
|
||||
(redirect-response \"/\" #:headers (list (session-delete manager)))"
|
||||
(delete-session session-manager))
|
||||
88
safsaf/handler-wrappers/trailing-slash.scm
Normal file
88
safsaf/handler-wrappers/trailing-slash.scm
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
(define-module (safsaf handler-wrappers trailing-slash)
|
||||
#:use-module (web request)
|
||||
#:use-module (web response)
|
||||
#:use-module (web uri)
|
||||
#:export (make-trailing-slash-handler-wrapper
|
||||
trailing-slash-handler-wrapper))
|
||||
|
||||
(define* (make-trailing-slash-handler-wrapper #:key (mode 'strip) (code 301))
|
||||
"Return a handler wrapper that normalizes trailing slashes.
|
||||
|
||||
MODE is either 'strip (default) or 'append:
|
||||
'strip — redirect /foo/ to /foo
|
||||
'append — redirect /foo to /foo/
|
||||
|
||||
The root path / is always left alone.
|
||||
|
||||
CODE is the HTTP status code for the redirect (default 301).
|
||||
|
||||
Use with wrap-routes:
|
||||
(wrap-routes routes (make-trailing-slash-handler-wrapper #:mode 'append))"
|
||||
(lambda (handler)
|
||||
(trailing-slash-handler-wrapper handler #:mode mode #:code code)))
|
||||
|
||||
(define* (trailing-slash-handler-wrapper handler
|
||||
#:key (mode 'strip) (code 301))
|
||||
"Handler wrapper that normalizes trailing slashes in request paths.
|
||||
|
||||
MODE is either 'strip (default) or 'append:
|
||||
'strip — redirect /foo/ to /foo
|
||||
'append — redirect /foo to /foo/
|
||||
|
||||
The root path / is always left alone.
|
||||
|
||||
CODE is the HTTP status code for the redirect (default 301)."
|
||||
(lambda (request body-port)
|
||||
(let* ((uri (request-uri request))
|
||||
(path (uri-path uri)))
|
||||
(cond
|
||||
;; Root path — always pass through
|
||||
((string=? path "/")
|
||||
(handler request body-port))
|
||||
;; Strip mode: redirect if path ends with /
|
||||
((and (eq? mode 'strip)
|
||||
(string-suffix? "/" path))
|
||||
(let ((new-path (string-trim-right path #\/)))
|
||||
(redirect request uri
|
||||
(if (string-null? new-path) "/" new-path)
|
||||
code)))
|
||||
;; Append mode: redirect if path does not end with /
|
||||
((and (eq? mode 'append)
|
||||
(not (string-suffix? "/" path)))
|
||||
(redirect request uri (string-append path "/") code))
|
||||
;; No normalization needed
|
||||
(else
|
||||
(handler request body-port))))))
|
||||
|
||||
(define (redirect request uri new-path code)
|
||||
"Build a redirect response to NEW-PATH, preserving query and fragment."
|
||||
(let* ((query (uri-query uri))
|
||||
(fragment (uri-fragment uri))
|
||||
(target (string-append new-path
|
||||
(if query
|
||||
(string-append "?" query)
|
||||
"")
|
||||
(if fragment
|
||||
(string-append "#" fragment)
|
||||
""))))
|
||||
(values (build-response
|
||||
#:code code
|
||||
#:headers `((location . ,(string->uri-reference target))))
|
||||
"")))
|
||||
318
safsaf/params.scm
Normal file
318
safsaf/params.scm
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
(define-module (safsaf params)
|
||||
#:use-module (ice-9 match)
|
||||
#:use-module (ice-9 regex)
|
||||
#:use-module (srfi srfi-1)
|
||||
#:use-module (srfi srfi-9 gnu)
|
||||
#:use-module (web uri)
|
||||
#:autoload (safsaf handler-wrappers csrf) (current-csrf-token)
|
||||
#:export (<invalid-param>
|
||||
make-invalid-param
|
||||
invalid-param?
|
||||
invalid-param-value
|
||||
invalid-param-message
|
||||
|
||||
parse-params
|
||||
parse-form-params
|
||||
any-invalid-params?
|
||||
invalid-param-ref
|
||||
field-errors
|
||||
params->query-string
|
||||
guard-against-mutually-exclusive-params
|
||||
|
||||
;; Built-in processors
|
||||
as-string
|
||||
as-integer
|
||||
as-number
|
||||
as-checkbox
|
||||
as-one-of
|
||||
as-matching
|
||||
as-predicate))
|
||||
|
||||
;;;
|
||||
;;; Error record — appears inline as a value in the result alist
|
||||
;;;
|
||||
|
||||
(define-immutable-record-type <invalid-param>
|
||||
(make-invalid-param value message)
|
||||
invalid-param?
|
||||
(value invalid-param-value)
|
||||
(message invalid-param-message))
|
||||
|
||||
;;;
|
||||
;;; Built-in processors
|
||||
;;;
|
||||
;;; A processor is a procedure: string → value | <invalid-param>.
|
||||
;;; It both validates and transforms the input string.
|
||||
;;;
|
||||
|
||||
(define as-string identity)
|
||||
|
||||
(define (as-integer s)
|
||||
(let ((n (string->number s)))
|
||||
(if (and n (exact-integer? n))
|
||||
n
|
||||
(make-invalid-param s "Must be a whole number"))))
|
||||
|
||||
(define (as-number s)
|
||||
(let ((n (string->number s)))
|
||||
(if n
|
||||
n
|
||||
(make-invalid-param s "Must be a number"))))
|
||||
|
||||
(define (as-checkbox s)
|
||||
(string=? s "on"))
|
||||
|
||||
(define* (as-one-of choices #:key (message #f))
|
||||
"Return a processor that accepts only values in CHOICES (a list of strings)."
|
||||
(lambda (s)
|
||||
(if (member s choices)
|
||||
s
|
||||
(make-invalid-param
|
||||
s (or message
|
||||
(string-append "Must be one of: "
|
||||
(string-join choices ", ")))))))
|
||||
|
||||
(define* (as-matching regex #:key (message "Invalid format"))
|
||||
"Return a processor that accepts values matching REGEX."
|
||||
(let ((rx (if (string? regex) (make-regexp regex) regex)))
|
||||
(lambda (s)
|
||||
(if (regexp-exec rx s)
|
||||
s
|
||||
(make-invalid-param s message)))))
|
||||
|
||||
(define* (as-predicate pred #:key (message "Invalid value"))
|
||||
"Return a processor that accepts values for which PRED returns true."
|
||||
(lambda (s)
|
||||
(if (pred s)
|
||||
s
|
||||
(make-invalid-param s message))))
|
||||
|
||||
;;;
|
||||
;;; Core parsing
|
||||
;;;
|
||||
|
||||
(define (parse-params param-specs raw-params)
|
||||
"Parse and transform parameters from RAW-PARAMS according to PARAM-SPECS.
|
||||
|
||||
RAW-PARAMS is an alist of (string . string) pairs, as returned by
|
||||
parse-query-string or parse-form-body.
|
||||
|
||||
PARAM-SPECS is a list of specifications. Each spec is a list whose first
|
||||
element is the parameter name (a symbol), second is a processor procedure
|
||||
(string -> value | <invalid-param>), and the rest are keyword options:
|
||||
|
||||
(name processor) ; optional
|
||||
(name processor #:required) ; must be present
|
||||
(name processor #:default value) ; fallback
|
||||
(name processor #:multi-value) ; collect all occurrences
|
||||
(name processor #:multi-value #:default value) ; multi-value with fallback
|
||||
(name processor #:no-default-when (fields) #:default value) ; conditional default
|
||||
|
||||
Returns an alist of (symbol . value) pairs. Values that fail validation
|
||||
appear as <invalid-param> records inline. Missing optional params without
|
||||
defaults are omitted."
|
||||
(let ((sym-params (map (match-lambda
|
||||
((name . value)
|
||||
(cons (if (symbol? name) name (string->symbol name))
|
||||
value)))
|
||||
raw-params)))
|
||||
(filter-map
|
||||
(match-lambda
|
||||
((name processor)
|
||||
(match (assq name sym-params)
|
||||
(#f #f)
|
||||
((_ . "") #f)
|
||||
((_ . value) (cons name (processor value)))))
|
||||
|
||||
((name processor #:required)
|
||||
(match (assq name sym-params)
|
||||
(#f (cons name (make-invalid-param #f "This field is required")))
|
||||
((_ . "") (cons name (make-invalid-param "" "This field is required")))
|
||||
((_ . value) (cons name (processor value)))))
|
||||
|
||||
((name processor #:multi-value)
|
||||
(match (filter-map
|
||||
(match-lambda
|
||||
((k . value)
|
||||
(and (eq? k name)
|
||||
(match value
|
||||
(#f #f)
|
||||
("" #f)
|
||||
(v (processor v))))))
|
||||
sym-params)
|
||||
(() #f)
|
||||
(x (cons name x))))
|
||||
|
||||
((name processor #:multi-value #:default default)
|
||||
(match (filter-map
|
||||
(match-lambda
|
||||
((k . value)
|
||||
(and (eq? k name)
|
||||
(match value
|
||||
(#f #f)
|
||||
("" #f)
|
||||
(v (processor v))))))
|
||||
sym-params)
|
||||
(() (cons name default))
|
||||
(x (cons name x))))
|
||||
|
||||
((name processor #:no-default-when fields #:default default)
|
||||
(let ((use-default?
|
||||
(every (lambda (field)
|
||||
(not (assq field sym-params)))
|
||||
fields)))
|
||||
(match (assq name sym-params)
|
||||
(#f (if use-default?
|
||||
(cons name default)
|
||||
#f))
|
||||
((_ . "") (if use-default?
|
||||
(cons name default)
|
||||
#f))
|
||||
((_ . value) (cons name (processor value))))))
|
||||
|
||||
((name processor #:default default)
|
||||
(match (assq name sym-params)
|
||||
(#f (cons name default))
|
||||
((_ . "") (cons name default))
|
||||
((_ . value) (cons name (processor value))))))
|
||||
param-specs)))
|
||||
|
||||
;;;
|
||||
;;; CSRF integration
|
||||
;;;
|
||||
|
||||
(define (csrf-processor s)
|
||||
(let ((expected (current-csrf-token)))
|
||||
(if (and expected s (string? s) (string=? s expected))
|
||||
#t
|
||||
(make-invalid-param s "Invalid CSRF token"))))
|
||||
|
||||
(define* (parse-form-params param-specs raw-params
|
||||
#:key (csrf-field 'csrf-token))
|
||||
"Like parse-params but prepends a CSRF token check.
|
||||
Uses current-csrf-token from (safsaf handler-wrappers csrf)."
|
||||
(parse-params (cons (list csrf-field csrf-processor #:required)
|
||||
param-specs)
|
||||
raw-params))
|
||||
|
||||
;;;
|
||||
;;; Result inspection
|
||||
;;;
|
||||
|
||||
(define (any-invalid-params? parsed-params)
|
||||
"Return #t if any values in PARSED-PARAMS are invalid."
|
||||
(any (match-lambda
|
||||
((_ . value)
|
||||
(if (list? value)
|
||||
(any invalid-param? value)
|
||||
(invalid-param? value))))
|
||||
parsed-params))
|
||||
|
||||
(define (invalid-param-ref parsed-params name)
|
||||
"Return the <invalid-param> record for NAME, or #f if valid or absent."
|
||||
(let ((v (assq-ref parsed-params name)))
|
||||
(and (invalid-param? v) v)))
|
||||
|
||||
(define (field-errors parsed-params name)
|
||||
"Return a list of error message strings for NAME, or '().
|
||||
Convenient for rendering form fields with per-field errors."
|
||||
(let ((v (assq-ref parsed-params name)))
|
||||
(cond
|
||||
((invalid-param? v)
|
||||
(let ((msg (invalid-param-message v)))
|
||||
(if msg (list msg) '())))
|
||||
((and (list? v) (any invalid-param? v))
|
||||
(filter-map (lambda (x)
|
||||
(and (invalid-param? x) (invalid-param-message x)))
|
||||
v))
|
||||
(else '()))))
|
||||
|
||||
;;;
|
||||
;;; Mutual exclusion
|
||||
;;;
|
||||
|
||||
(define (guard-against-mutually-exclusive-params parsed-params groups)
|
||||
"Check PARSED-PARAMS for mutually exclusive parameter groups.
|
||||
GROUPS is a list of lists of symbols, e.g. '((limit_results all_results)).
|
||||
If parameters from the same group co-occur, the later ones are replaced
|
||||
with <invalid-param> records."
|
||||
(map (match-lambda
|
||||
((name . value)
|
||||
(if (invalid-param? value)
|
||||
(cons name value)
|
||||
(or
|
||||
(any (lambda (group)
|
||||
(if (memq name group)
|
||||
(let ((other-names
|
||||
(filter (lambda (other-name)
|
||||
(and (not (eq? name other-name))
|
||||
(assq other-name parsed-params)))
|
||||
group)))
|
||||
(if (not (null? other-names))
|
||||
(cons
|
||||
name
|
||||
(make-invalid-param
|
||||
value
|
||||
(string-append
|
||||
"cannot be specified along with "
|
||||
(string-join (map symbol->string
|
||||
other-names)
|
||||
", "))))
|
||||
#f))
|
||||
#f))
|
||||
groups)
|
||||
(cons name value)))))
|
||||
parsed-params))
|
||||
|
||||
;;;
|
||||
;;; Serialization
|
||||
;;;
|
||||
|
||||
(define (params->query-string parsed-params)
|
||||
"Serialize PARSED-PARAMS back to a URI query string.
|
||||
Skips invalid params. Handles multi-value (list) entries.
|
||||
Useful for building pagination links that preserve current filters."
|
||||
(define (value->text value)
|
||||
(cond
|
||||
((eq? value #f) "")
|
||||
((eq? value #t) "on")
|
||||
((number? value) (number->string value))
|
||||
((string? value) value)
|
||||
(else (object->string value))))
|
||||
|
||||
(string-join
|
||||
(append-map
|
||||
(match-lambda
|
||||
((_ . (? invalid-param?)) '())
|
||||
((key . value)
|
||||
(if (list? value)
|
||||
(filter-map
|
||||
(lambda (v)
|
||||
(if (invalid-param? v)
|
||||
#f
|
||||
(string-append (uri-encode (symbol->string key))
|
||||
"="
|
||||
(uri-encode (value->text v)))))
|
||||
value)
|
||||
(list (string-append (uri-encode (symbol->string key))
|
||||
"="
|
||||
(uri-encode (value->text value)))))))
|
||||
parsed-params)
|
||||
"&"))
|
||||
370
safsaf/response-helpers.scm
Normal file
370
safsaf/response-helpers.scm
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
(define-module (safsaf response-helpers)
|
||||
#:use-module (ice-9 match)
|
||||
#:use-module (web request)
|
||||
#:use-module (web response)
|
||||
#:use-module (web uri)
|
||||
#:use-module (srfi srfi-1)
|
||||
#:use-module (htmlprag)
|
||||
#:autoload (ice-9 binary-ports) (get-bytevector-n put-bytevector)
|
||||
#:autoload (ice-9 textual-ports) (put-string)
|
||||
#:autoload (srfi srfi-19) (current-time time-utc->date make-time time-utc
|
||||
date->time-utc time<=?)
|
||||
#:autoload (json builder) (scm->json)
|
||||
#:autoload (webutils cookie) (set-cookie delete-cookie)
|
||||
#:use-module (safsaf router)
|
||||
#:export (build-response/inherit
|
||||
negotiate-content-type
|
||||
html-response
|
||||
redirect-response
|
||||
json-response
|
||||
streaming-json-response
|
||||
scm-alist->streaming-json
|
||||
list->streaming-json-array
|
||||
text-response
|
||||
not-found-response
|
||||
forbidden-response
|
||||
bad-request-response
|
||||
payload-too-large-response
|
||||
internal-server-error-response
|
||||
set-cookie-header
|
||||
delete-cookie-header
|
||||
make-static-handler))
|
||||
|
||||
;;;
|
||||
;;; Response rebuilding
|
||||
;;;
|
||||
|
||||
(define* (build-response/inherit response #:key
|
||||
(headers (response-headers response)))
|
||||
"Build a new response based on RESPONSE, preserving its version, status
|
||||
code, and reason phrase. HEADERS defaults to the existing headers;
|
||||
override it to modify them.
|
||||
|
||||
Use this in handler wrappers that need to adjust headers on an inner
|
||||
handler's response without losing any response fields."
|
||||
(build-response
|
||||
#:version (response-version response)
|
||||
#:code (response-code response)
|
||||
#:reason-phrase (response-reason-phrase response)
|
||||
#:headers headers))
|
||||
|
||||
;;;
|
||||
;;; Content negotiation
|
||||
;;;
|
||||
|
||||
(define %negotiation-extensions
|
||||
'(("json" . application/json)
|
||||
("html" . text/html)
|
||||
("txt" . text/plain)))
|
||||
|
||||
(define (path-extension request)
|
||||
"Return the file extension of the last segment of REQUEST's URI path,
|
||||
or #f if there is none."
|
||||
(let* ((path (uri-path (request-uri request)))
|
||||
(segments (split-and-decode-uri-path path)))
|
||||
(and (pair? segments)
|
||||
(let* ((last-seg (last segments))
|
||||
(dot (string-rindex last-seg #\.)))
|
||||
(and dot (substring last-seg (1+ dot)))))))
|
||||
|
||||
(define* (negotiate-content-type request
|
||||
#:optional
|
||||
(supported '(text/html application/json))
|
||||
#:key
|
||||
(extensions %negotiation-extensions))
|
||||
"Return the most appropriate MIME type symbol for REQUEST from SUPPORTED.
|
||||
|
||||
Checks the URL path extension first (.json, .html, .txt) — if present and
|
||||
the implied type is in SUPPORTED, it wins. Otherwise, walks the Accept
|
||||
header and returns the first type that appears in SUPPORTED. Falls back
|
||||
to the first element of SUPPORTED if nothing matches.
|
||||
|
||||
EXTENSIONS is an alist mapping file extension strings to MIME type symbols,
|
||||
used for path-based negotiation. Defaults to %negotiation-extensions."
|
||||
(let* ((ext (path-extension request))
|
||||
(ext-type (and ext (assoc-ref extensions
|
||||
(string-downcase ext))))
|
||||
(accept-types (map car (request-accept request)))
|
||||
(preferred (if ext-type
|
||||
(cons ext-type accept-types)
|
||||
accept-types)))
|
||||
(or (find (lambda (type) (memq type supported))
|
||||
preferred)
|
||||
(first supported))))
|
||||
|
||||
;;;
|
||||
;;; Response helpers
|
||||
;;;
|
||||
|
||||
(define* (html-response shtml #:key (code 200) (headers '()) (charset "utf-8"))
|
||||
"Return an HTML response by streaming SHTML to the client.
|
||||
SHTML is an SXML/SHTML tree as accepted by write-shtml-as-html.
|
||||
CHARSET defaults to \"utf-8\"."
|
||||
(values (build-response
|
||||
#:code code
|
||||
#:headers (append `((content-type text/html (charset . ,charset))) headers))
|
||||
(lambda (port)
|
||||
(write-shtml-as-html shtml port))))
|
||||
|
||||
(define* (redirect-response path #:key (code 303) (headers '()))
|
||||
"Return a redirect response to PATH (a string)."
|
||||
(values (build-response
|
||||
#:code code
|
||||
#:headers (append `((location . ,(string->uri-reference path)))
|
||||
headers))
|
||||
""))
|
||||
|
||||
(define* (json-response str #:key (code 200) (headers '()))
|
||||
"Return a JSON response. STR is the JSON string to send."
|
||||
(values (build-response
|
||||
#:code code
|
||||
#:headers (append '((content-type application/json)) headers))
|
||||
str))
|
||||
|
||||
;; Charset?
|
||||
(define* (streaming-json-response thunk #:key (code 200) (headers '()))
|
||||
"Return a JSON response whose body is written incrementally by THUNK.
|
||||
THUNK is a procedure of one argument (the output port). Use
|
||||
scm-alist->streaming-json and list->streaming-json-array inside THUNK
|
||||
to write JSON without materializing the entire response in memory."
|
||||
(values (build-response
|
||||
#:code code
|
||||
#:headers (append '((content-type application/json)) headers))
|
||||
(lambda (port)
|
||||
(thunk port))))
|
||||
|
||||
(define* (scm-alist->streaming-json alist port #:key (unicode #t))
|
||||
"Write ALIST as a JSON object to PORT, streaming each value as it is
|
||||
produced. If a value in the alist is a procedure, it is called with PORT
|
||||
so it can write its own JSON representation directly. Otherwise the value
|
||||
is serialized via scm->json."
|
||||
(put-string port "{")
|
||||
(pair-for-each
|
||||
(lambda (pair)
|
||||
(match (car pair)
|
||||
((k . v)
|
||||
(scm->json (if (string? k) k (symbol->string k)) port)
|
||||
(put-string port ":")
|
||||
(if (procedure? v)
|
||||
(v port)
|
||||
(scm->json v port #:unicode unicode))))
|
||||
(unless (null? (cdr pair))
|
||||
(put-string port ",")))
|
||||
alist)
|
||||
(put-string port "}"))
|
||||
|
||||
(define* (list->streaming-json-array proc lst port #:key (unicode #t))
|
||||
"Write LST as a JSON array to PORT, applying PROC to each element to
|
||||
produce a JSON-serializable value. Each element is written individually
|
||||
via scm->json so the entire array need not be held in memory."
|
||||
(put-string port "[")
|
||||
(pair-for-each
|
||||
(lambda (pair)
|
||||
(scm->json (proc (car pair)) port #:unicode unicode)
|
||||
(unless (null? (cdr pair))
|
||||
(put-string port ",")))
|
||||
lst)
|
||||
(put-string port "]"))
|
||||
|
||||
;; Charset?
|
||||
(define* (text-response str #:key (code 200) (headers '()))
|
||||
"Return a plain text response. STR is the text string to send."
|
||||
(values (build-response
|
||||
#:code code
|
||||
#:headers (append '((content-type text/plain)) headers))
|
||||
str))
|
||||
|
||||
(define* (not-found-response #:optional (body "Not Found") #:key (headers '()))
|
||||
"Return a 404 Not Found response."
|
||||
(values (build-response
|
||||
#:code 404
|
||||
#:headers (append '((content-type text/plain)) headers))
|
||||
body))
|
||||
|
||||
(define* (forbidden-response #:optional (body "Forbidden") #:key (headers '()))
|
||||
"Return a 403 Forbidden response."
|
||||
(values (build-response
|
||||
#:code 403
|
||||
#:headers (append '((content-type text/plain)) headers))
|
||||
body))
|
||||
|
||||
(define* (bad-request-response #:optional (body "Bad Request") #:key (headers '()))
|
||||
"Return a 400 Bad Request response."
|
||||
(values (build-response
|
||||
#:code 400
|
||||
#:headers (append '((content-type text/plain)) headers))
|
||||
body))
|
||||
|
||||
(define* (payload-too-large-response #:optional (body "Payload Too Large")
|
||||
#:key (headers '()))
|
||||
"Return a 413 Payload Too Large response."
|
||||
(values (build-response
|
||||
#:code 413
|
||||
#:headers (append '((content-type text/plain)) headers))
|
||||
body))
|
||||
|
||||
(define* (internal-server-error-response #:optional (body "Internal Server Error")
|
||||
#:key (headers '()))
|
||||
"Return a 500 Internal Server Error response."
|
||||
(values (build-response
|
||||
#:code 500
|
||||
#:headers (append '((content-type text/plain)) headers))
|
||||
body))
|
||||
|
||||
;;;
|
||||
;;; Cookie helpers
|
||||
;;;
|
||||
|
||||
(define* (set-cookie-header name value
|
||||
#:key path domain max-age
|
||||
secure http-only expires)
|
||||
"Return a Set-Cookie header pair suitable for inclusion in a
|
||||
response headers alist. Wraps (webutils cookie) set-cookie.
|
||||
|
||||
Example:
|
||||
(values (build-response #:headers (list (set-cookie-header \"session\" token
|
||||
#:path \"/\" #:http-only #t
|
||||
#:secure #t)))
|
||||
\"ok\")"
|
||||
(set-cookie name value
|
||||
#:path path #:domain domain
|
||||
#:max-age max-age #:secure secure
|
||||
#:http-only http-only #:expires expires))
|
||||
|
||||
(define (delete-cookie-header name)
|
||||
"Return a Set-Cookie header pair that expires cookie NAME.
|
||||
Wraps (webutils cookie) delete-cookie."
|
||||
(delete-cookie name))
|
||||
|
||||
;;;
|
||||
;;; Static file serving
|
||||
;;;
|
||||
|
||||
(define %mime-types
|
||||
'(("html" . (text/html))
|
||||
("htm" . (text/html))
|
||||
("css" . (text/css))
|
||||
("js" . (application/javascript))
|
||||
("json" . (application/json))
|
||||
("xml" . (application/xml))
|
||||
("svg" . (image/svg+xml))
|
||||
("png" . (image/png))
|
||||
("jpg" . (image/jpeg))
|
||||
("jpeg" . (image/jpeg))
|
||||
("gif" . (image/gif))
|
||||
("webp" . (image/webp))
|
||||
("ico" . (image/x-icon))
|
||||
("woff" . (font/woff))
|
||||
("woff2" . (font/woff2))
|
||||
("ttf" . (font/ttf))
|
||||
("otf" . (font/otf))
|
||||
("pdf" . (application/pdf))
|
||||
("txt" . (text/plain))
|
||||
("csv" . (text/csv))
|
||||
("wasm" . (application/wasm))))
|
||||
|
||||
(define (file-extension path)
|
||||
"Return the file extension of PATH (without the dot), or #f."
|
||||
(let ((dot (string-rindex path #\.)))
|
||||
(and dot (substring path (1+ dot)))))
|
||||
|
||||
(define (extension->content-type ext)
|
||||
"Return a content-type value for file extension EXT, or
|
||||
application/octet-stream as default."
|
||||
(or (and ext (assoc-ref %mime-types (string-downcase ext)))
|
||||
'(application/octet-stream)))
|
||||
|
||||
(define (path-safe? segments)
|
||||
"Return #t if the path segments contain no traversal attempts."
|
||||
(not (member ".." segments)))
|
||||
|
||||
(define (mtime->date mtime)
|
||||
"Convert a Unix timestamp to an SRFI-19 date in UTC."
|
||||
(time-utc->date (make-time time-utc 0 mtime) 0))
|
||||
|
||||
(define* (make-static-handler root-dir #:key (cache-control #f))
|
||||
"Return a handler that serves static files from ROOT-DIR.
|
||||
|
||||
The handler expects route params to contain a wildcard capture (the
|
||||
file path segments). Use with a wildcard route:
|
||||
|
||||
(route 'GET '(. path) (make-static-handler \"/path/to/public\"))
|
||||
|
||||
Supports If-Modified-Since for 304 responses. CACHE-CONTROL, if
|
||||
given, is a Cache-Control value in Guile's header format — an alist,
|
||||
e.g. '((max-age . 3600)) or '((no-cache)).
|
||||
|
||||
Works with /gnu/store paths: files with a very low mtime (as produced
|
||||
by the store's timestamp normalization) use the process start time as
|
||||
Last-Modified instead, so that conditional requests behave sensibly."
|
||||
(let ((root (if (string-suffix? "/" root-dir)
|
||||
(substring root-dir 0 (1- (string-length root-dir)))
|
||||
root-dir))
|
||||
(start-date (time-utc->date (current-time time-utc) 0)))
|
||||
(lambda (request body-port)
|
||||
(let* ((params (current-route-params))
|
||||
(segments (assoc-ref params 'path)))
|
||||
(cond
|
||||
;; No path captured or traversal attempt.
|
||||
((or (not segments) (null? segments) (not (path-safe? segments)))
|
||||
(not-found-response))
|
||||
|
||||
(else
|
||||
(let* ((file-path (string-append root "/"
|
||||
(string-join segments "/")))
|
||||
(st (catch 'system-error
|
||||
(lambda () (stat file-path))
|
||||
(lambda _ #f))))
|
||||
(if (and st (eq? 'regular (stat:type st)))
|
||||
(let* ((mtime (stat:mtime st))
|
||||
(mtime-date (if (<= mtime 1)
|
||||
start-date
|
||||
(mtime->date mtime)))
|
||||
(ims (assoc-ref (request-headers request)
|
||||
'if-modified-since))
|
||||
(ext (file-extension file-path))
|
||||
(content-type (extension->content-type ext))
|
||||
(not-modified?
|
||||
(and ims
|
||||
(catch #t
|
||||
(lambda ()
|
||||
(time<=? (date->time-utc mtime-date)
|
||||
(date->time-utc ims)))
|
||||
(lambda _ #f)))))
|
||||
(if not-modified?
|
||||
;; Not modified.
|
||||
(values (build-response #:code 304) #f)
|
||||
;; Serve the file.
|
||||
(let ((hdrs `((content-type . ,content-type)
|
||||
(last-modified . ,mtime-date)
|
||||
,@(if cache-control
|
||||
`((cache-control . ,cache-control))
|
||||
'()))))
|
||||
(values (build-response #:code 200 #:headers hdrs)
|
||||
(lambda (port)
|
||||
(call-with-input-file file-path
|
||||
(lambda (in)
|
||||
(let loop ()
|
||||
(let ((buf (get-bytevector-n in 8192)))
|
||||
(unless (eof-object? buf)
|
||||
(put-bytevector port buf)
|
||||
(loop)))))))))))
|
||||
;; File not found or not a regular file.
|
||||
(not-found-response)))))))))
|
||||
650
safsaf/router.scm
Normal file
650
safsaf/router.scm
Normal file
|
|
@ -0,0 +1,650 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
(define-module (safsaf router)
|
||||
#:use-module (ice-9 match)
|
||||
#:use-module (srfi srfi-1)
|
||||
#:use-module (srfi srfi-9)
|
||||
#:use-module (srfi srfi-71)
|
||||
#:use-module (web uri)
|
||||
#:export (route
|
||||
route?
|
||||
route-method
|
||||
route-pattern
|
||||
route-handler
|
||||
route-name
|
||||
|
||||
route-group
|
||||
make-route-group
|
||||
route-group?
|
||||
route-group-prefix
|
||||
route-group-children
|
||||
route-group-add-children!
|
||||
route-group-name
|
||||
|
||||
wrap-routes
|
||||
|
||||
current-route-params
|
||||
current-reverse-routes
|
||||
|
||||
compile-routes
|
||||
match-route
|
||||
compiled-route-handler
|
||||
find-allowed-methods
|
||||
|
||||
path-for))
|
||||
|
||||
(define current-route-params
|
||||
(make-parameter '()))
|
||||
|
||||
(set-procedure-property! current-route-params 'documentation
|
||||
"Alist of matched route parameter bindings for the current request.
|
||||
Each entry is @code{(name . value)} where @var{name} is the capture
|
||||
symbol from the route pattern.")
|
||||
|
||||
(define current-reverse-routes
|
||||
(make-parameter #f))
|
||||
|
||||
(set-procedure-property! current-reverse-routes 'documentation
|
||||
"The reverse-routes table for the current server, used by
|
||||
@code{path-for} to generate URLs from route names.")
|
||||
|
||||
;;;
|
||||
;;; Route and route-group records
|
||||
;;;
|
||||
|
||||
(define-record-type <route>
|
||||
(%make-route method pattern handler name)
|
||||
route?
|
||||
(method route-method)
|
||||
(pattern route-pattern)
|
||||
(handler route-handler set-route-handler!)
|
||||
(name route-name))
|
||||
|
||||
(define-record-type <route-group>
|
||||
(%make-route-group prefix children name)
|
||||
route-group?
|
||||
(prefix route-group-prefix)
|
||||
(children route-group-children set-route-group-children!)
|
||||
(name route-group-name))
|
||||
|
||||
(set-procedure-property!
|
||||
(macro-transformer (module-ref (current-module) 'route?))
|
||||
'documentation
|
||||
"Return @code{#t} if OBJ is a @code{<route>}.")
|
||||
|
||||
(set-procedure-property!
|
||||
(macro-transformer (module-ref (current-module) 'route-method))
|
||||
'documentation
|
||||
"Return the HTTP method of ROUTE.")
|
||||
|
||||
(set-procedure-property!
|
||||
(macro-transformer (module-ref (current-module) 'route-pattern))
|
||||
'documentation
|
||||
"Return the URL pattern of ROUTE.")
|
||||
|
||||
(set-procedure-property!
|
||||
(macro-transformer (module-ref (current-module) 'route-handler))
|
||||
'documentation
|
||||
"Return the handler procedure of ROUTE.")
|
||||
|
||||
(set-procedure-property!
|
||||
(macro-transformer (module-ref (current-module) 'route-name))
|
||||
'documentation
|
||||
"Return the name of ROUTE, or @code{#f} if unnamed.")
|
||||
|
||||
(set-procedure-property!
|
||||
(macro-transformer (module-ref (current-module) 'route-group?))
|
||||
'documentation
|
||||
"Return @code{#t} if OBJ is a @code{<route-group>}.")
|
||||
|
||||
(set-procedure-property!
|
||||
(macro-transformer (module-ref (current-module) 'route-group-prefix))
|
||||
'documentation
|
||||
"Return the prefix pattern of ROUTE-GROUP.")
|
||||
|
||||
(set-procedure-property!
|
||||
(macro-transformer (module-ref (current-module) 'route-group-children))
|
||||
'documentation
|
||||
"Return the list of child routes and groups of ROUTE-GROUP.")
|
||||
|
||||
(set-procedure-property!
|
||||
(macro-transformer (module-ref (current-module) 'route-group-name))
|
||||
'documentation
|
||||
"Return the name of ROUTE-GROUP, or @code{#f} if unnamed.")
|
||||
|
||||
(define* (make-route-group prefix #:key (name #f))
|
||||
"Create an empty route group with PREFIX. Children can be added later
|
||||
with route-group-add-children!."
|
||||
(%make-route-group prefix '() name))
|
||||
|
||||
(define (route-group-add-children! group new-children)
|
||||
"Append NEW-CHILDREN to GROUP's child list."
|
||||
(set-route-group-children! group
|
||||
(append (route-group-children group)
|
||||
new-children)))
|
||||
|
||||
(define* (route method pattern handler #:key (name #f))
|
||||
"Create a route. METHOD is a symbol, list of symbols, or '* for any.
|
||||
PATTERN is a list of segments: strings (literal), symbols (capture),
|
||||
two-element lists (predicate capture: (proc name)), with optional
|
||||
dotted tail (wildcard capture).
|
||||
HANDLER is a procedure (request body-port) -> (values response body).
|
||||
NAME is an optional symbol used for reverse routing with path-for."
|
||||
(%make-route method pattern handler name))
|
||||
|
||||
(define* (route-group prefix #:key (name #f) #:rest children)
|
||||
"Create a route group. PREFIX is a pattern list (same syntax as route
|
||||
patterns). CHILDREN is an ordered list of routes and route-groups.
|
||||
NAME is an optional symbol for nested path-for lookups."
|
||||
(let ((filtered-children (filter (lambda (child)
|
||||
(not (keyword? child)))
|
||||
(strip-keyword-args children))))
|
||||
(%make-route-group prefix filtered-children name)))
|
||||
|
||||
(define (strip-keyword-args args)
|
||||
"Remove #:key value pairs from ARGS, returning the positional rest."
|
||||
(let loop ((args args)
|
||||
(acc '()))
|
||||
(cond
|
||||
((null? args)
|
||||
(reverse acc))
|
||||
((keyword? (car args))
|
||||
;; Skip the keyword and its value
|
||||
(loop (if (and (pair? (cdr args))
|
||||
(not (keyword? (cadr args))))
|
||||
(cddr args)
|
||||
(cdr args))
|
||||
acc))
|
||||
(else
|
||||
(loop (cdr args) (cons (car args) acc))))))
|
||||
|
||||
;;;
|
||||
;;; Applying a handler wrapper across a route tree
|
||||
;;;
|
||||
|
||||
(define (wrap-routes routes . wrappers)
|
||||
"Apply WRAPPERS to every handler in ROUTES, which may be a route,
|
||||
route-group, or list of either. Returns a new structure with wrapped
|
||||
handlers. When multiple wrappers are given, the first wrapper in the
|
||||
list wraps outermost (runs first on the request, last on the response)."
|
||||
(let ((composed (compose-wrappers wrappers)))
|
||||
(apply-wrapper routes composed)))
|
||||
|
||||
(define (compose-wrappers wrappers)
|
||||
"Compose WRAPPERS into a single (handler -> handler) procedure.
|
||||
The first wrapper in the list wraps outermost."
|
||||
(lambda (handler)
|
||||
(fold (lambda (w h) (w h)) handler (reverse wrappers))))
|
||||
|
||||
(define (apply-wrapper routes wrapper)
|
||||
"Apply a single WRAPPER to every handler in ROUTES.
|
||||
Mutates route handlers and route-group children in place so that the
|
||||
original objects preserve their eq? identity for path-for lookups."
|
||||
(cond
|
||||
((route? routes)
|
||||
(set-route-handler! routes (wrapper (route-handler routes)))
|
||||
routes)
|
||||
((route-group? routes)
|
||||
(for-each (lambda (child) (apply-wrapper child wrapper))
|
||||
(route-group-children routes))
|
||||
routes)
|
||||
((list? routes)
|
||||
(for-each (lambda (child) (apply-wrapper child wrapper))
|
||||
routes)
|
||||
routes)
|
||||
(else
|
||||
(error "wrap-routes: expected route, route-group, or list" routes))))
|
||||
|
||||
;;;
|
||||
;;; Pattern compilation and flattening
|
||||
;;;
|
||||
|
||||
;; A compiled route is a flat, optimised representation for matching.
|
||||
(define-record-type <compiled-route>
|
||||
(make-compiled-route method method-pred segments has-rest? rest-name handler)
|
||||
compiled-route?
|
||||
(method compiled-route-method)
|
||||
(method-pred compiled-route-method-pred)
|
||||
(segments compiled-route-segments)
|
||||
(has-rest? compiled-route-has-rest?)
|
||||
(rest-name compiled-route-rest-name)
|
||||
(handler compiled-route-handler))
|
||||
|
||||
;; A compiled segment is one of:
|
||||
;; (literal . "string") — exact match
|
||||
;; (capture . symbol) — match any, bind to symbol
|
||||
;; (capture-predicate name . proc) — match if (proc segment) is true, bind to name
|
||||
|
||||
(define (compile-pattern-segments pattern)
|
||||
"Compile a route pattern into a list of compiled segments and a rest flag.
|
||||
Returns (values segments has-rest? rest-name)."
|
||||
(let loop ((pat pattern)
|
||||
(acc '()))
|
||||
(cond
|
||||
;; End of proper list
|
||||
((null? pat)
|
||||
(values (reverse acc) #f #f))
|
||||
;; Dotted tail — wildcard capture
|
||||
((symbol? pat)
|
||||
(values (reverse acc) #t pat))
|
||||
;; Pair — process the car
|
||||
((pair? pat)
|
||||
(let ((seg (car pat)))
|
||||
(loop (cdr pat)
|
||||
(cons (compile-segment seg) acc))))
|
||||
(else
|
||||
(error "compile-pattern-segments: invalid pattern element" pat)))))
|
||||
|
||||
(define (compile-segment seg)
|
||||
"Compile a single pattern segment into a tagged pair.
|
||||
SEG is one of: a string (literal), a symbol (capture), or a two-element
|
||||
list (predicate capture) where the first element is a procedure and the
|
||||
second is a symbol name to bind the matched value to."
|
||||
(cond
|
||||
((string? seg) (cons 'literal seg))
|
||||
((symbol? seg) (cons 'capture seg))
|
||||
((and (pair? seg) (procedure? (car seg)) (pair? (cdr seg))
|
||||
(symbol? (cadr seg)) (null? (cddr seg)))
|
||||
(cons 'capture-predicate (cons (cadr seg) (car seg))))
|
||||
(else
|
||||
(error "compile-segment: invalid segment — expected string, symbol, \
|
||||
or (predicate name) list" seg))))
|
||||
|
||||
(define (append-patterns prefix suffix)
|
||||
"Append two patterns, handling dotted tails correctly.
|
||||
A dotted tail in PREFIX is an error (group prefixes must not have rest params)."
|
||||
(let loop ((pat prefix))
|
||||
(cond
|
||||
((null? pat) suffix)
|
||||
((pair? pat) (cons (car pat) (loop (cdr pat))))
|
||||
(else
|
||||
(error "append-patterns: route-group prefix must not have a rest param"
|
||||
prefix)))))
|
||||
|
||||
(define (catch-all-route? cr)
|
||||
"Return #t if CR is a compiled route that matches any method and any path."
|
||||
(and (eq? (compiled-route-method cr) '*)
|
||||
(null? (compiled-route-segments cr))
|
||||
(compiled-route-has-rest? cr)))
|
||||
|
||||
;;; The reverse-routes structure holds two tables:
|
||||
;;;
|
||||
;;; group-table: maps route-group identity tokens to scope records.
|
||||
;;; Each scope contains a local alist of (route-name . full-pattern)
|
||||
;;; and a children alist of (group-name . identity) for nested lookups.
|
||||
|
||||
(define-record-type <reverse-routes>
|
||||
(make-reverse-routes group-table)
|
||||
reverse-routes?
|
||||
(group-table reverse-routes-group-table))
|
||||
|
||||
(define-record-type <group-scope>
|
||||
(make-group-scope routes children)
|
||||
group-scope?
|
||||
(routes group-scope-routes)
|
||||
(children group-scope-children))
|
||||
|
||||
(define (compile-routes routes)
|
||||
"Compile a route tree (route, route-group, or list) into two values:
|
||||
1. An ordered list of <compiled-route> records ready for matching.
|
||||
2. A <reverse-routes> record for use with path-for.
|
||||
|
||||
The last route must be a catch-all ('* pattern with a rest parameter)
|
||||
so that every request is handled."
|
||||
;; group-table: alist of (route-group . <group-scope>), keyed by eq?
|
||||
(define group-table '())
|
||||
|
||||
(define (register-group! group local-routes child-groups)
|
||||
"Add a group scope to the group table, keyed by the route-group object."
|
||||
(set! group-table
|
||||
(cons (cons group (make-group-scope local-routes
|
||||
child-groups))
|
||||
group-table)))
|
||||
|
||||
(define (flatten entry prefix)
|
||||
"Flatten ENTRY into compiled routes, collecting reverse-routing data.
|
||||
Returns (values compiled-routes local-named-routes local-child-groups)."
|
||||
(cond
|
||||
((route? entry)
|
||||
(let ((full-pattern (append-patterns prefix (route-pattern entry))))
|
||||
(let ((local-routes
|
||||
(if (route-name entry)
|
||||
(list (cons (route-name entry) full-pattern))
|
||||
'())))
|
||||
(let ((segments has-rest? rest-name
|
||||
(compile-pattern-segments full-pattern)))
|
||||
(values
|
||||
(list (make-compiled-route
|
||||
(route-method entry)
|
||||
(compile-method (route-method entry))
|
||||
segments has-rest? rest-name
|
||||
(route-handler entry)))
|
||||
local-routes
|
||||
'())))))
|
||||
|
||||
((route-group? entry)
|
||||
(let ((new-prefix (append-patterns prefix
|
||||
(route-group-prefix entry))))
|
||||
(let ((compiled local-routes local-children
|
||||
(flatten-children (route-group-children entry)
|
||||
new-prefix)))
|
||||
;; Register this group's scope.
|
||||
(register-group! entry local-routes local-children)
|
||||
;; Bubble up: our local routes become parent's local routes,
|
||||
;; and we add ourselves as a named child if we have a name.
|
||||
(let ((child-entry
|
||||
(if (route-group-name entry)
|
||||
(list (cons (route-group-name entry) entry))
|
||||
'())))
|
||||
(values compiled local-routes
|
||||
(append child-entry local-children))))))
|
||||
|
||||
((list? entry)
|
||||
(flatten-children entry prefix))
|
||||
|
||||
(else
|
||||
(error "compile-routes: expected route, route-group, or list" entry))))
|
||||
|
||||
(define (flatten-children children prefix)
|
||||
"Flatten a list of children, merging their results.
|
||||
Returns (values compiled-routes local-routes child-groups)."
|
||||
(let loop ((children children)
|
||||
(compiled-acc '())
|
||||
(routes-acc '())
|
||||
(children-acc '()))
|
||||
(if (null? children)
|
||||
(values (reverse compiled-acc) routes-acc children-acc)
|
||||
(let ((compiled local-routes local-children
|
||||
(flatten (car children) prefix)))
|
||||
(loop (cdr children)
|
||||
(append (reverse compiled) compiled-acc)
|
||||
(append routes-acc local-routes)
|
||||
(append children-acc local-children))))))
|
||||
|
||||
(let ((compiled _local-routes _local-children (flatten routes '())))
|
||||
(when (or (null? compiled)
|
||||
(not (catch-all-route? (last compiled))))
|
||||
(error "compile-routes: last route must be a catch-all (* method, rest pattern)"))
|
||||
(values compiled
|
||||
(make-reverse-routes group-table))))
|
||||
|
||||
;;;
|
||||
;;; Method matching
|
||||
;;;
|
||||
|
||||
(define (compile-method method)
|
||||
"Return a predicate that tests whether a request method matches."
|
||||
(cond
|
||||
((eq? method '*) (lambda (_) #t))
|
||||
((symbol? method) (lambda (m) (eq? m method)))
|
||||
((list? method) (lambda (m) (memq m method)))
|
||||
(else
|
||||
(error "compile-method: invalid method spec" method))))
|
||||
|
||||
;;;
|
||||
;;; Route matching
|
||||
;;;
|
||||
|
||||
(define (match-segments compiled-segs path-segs has-rest? rest-name)
|
||||
"Try to match COMPILED-SEGS against PATH-SEGS.
|
||||
Returns an alist of bindings on success, or #f on failure."
|
||||
(let loop ((segs compiled-segs)
|
||||
(path path-segs)
|
||||
(bindings '()))
|
||||
(cond
|
||||
;; Both exhausted — exact match
|
||||
((and (null? segs) (null? path))
|
||||
bindings)
|
||||
;; Pattern exhausted but path remains — check for rest capture
|
||||
((null? segs)
|
||||
(if has-rest?
|
||||
(acons rest-name path bindings)
|
||||
#f))
|
||||
;; Path exhausted but pattern remains — no match
|
||||
((null? path)
|
||||
#f)
|
||||
;; Match current segment
|
||||
(else
|
||||
(let ((seg (car segs))
|
||||
(path-seg (car path)))
|
||||
(case (car seg)
|
||||
((literal)
|
||||
(if (string=? (cdr seg) path-seg)
|
||||
(loop (cdr segs) (cdr path) bindings)
|
||||
#f))
|
||||
((capture)
|
||||
(loop (cdr segs) (cdr path)
|
||||
(acons (cdr seg) path-seg bindings)))
|
||||
((capture-predicate)
|
||||
(let ((name (cadr seg))
|
||||
(pred (cddr seg)))
|
||||
(if (pred path-seg)
|
||||
(loop (cdr segs) (cdr path)
|
||||
(acons name path-seg bindings))
|
||||
#f)))
|
||||
(else
|
||||
(error "match-segments: unknown segment type" (car seg)))))))))
|
||||
|
||||
(define (match-route compiled-routes method path-segments)
|
||||
"Find the first matching route for METHOD and PATH-SEGMENTS.
|
||||
Returns (values handler bindings) on match, or (values #f #f) on no match."
|
||||
(let loop ((routes compiled-routes))
|
||||
(if (null? routes)
|
||||
(values #f #f)
|
||||
(let* ((cr (car routes))
|
||||
(bindings (and ((compiled-route-method-pred cr) method)
|
||||
(match-segments
|
||||
(compiled-route-segments cr)
|
||||
path-segments
|
||||
(compiled-route-has-rest? cr)
|
||||
(compiled-route-rest-name cr)))))
|
||||
(if bindings
|
||||
(values (compiled-route-handler cr) bindings)
|
||||
(loop (cdr routes)))))))
|
||||
|
||||
(define (find-allowed-methods compiled-routes path-segments)
|
||||
"Scan COMPILED-ROUTES for routes whose path matches PATH-SEGMENTS,
|
||||
collecting their HTTP methods. The last route (the catch-all) is excluded.
|
||||
Returns a deduplicated list of method symbols, or '() if no route's path
|
||||
matches."
|
||||
(let loop ((routes (drop-right compiled-routes 1))
|
||||
(methods '()))
|
||||
(if (null? routes)
|
||||
methods
|
||||
(let* ((cr (car routes))
|
||||
(bindings (match-segments
|
||||
(compiled-route-segments cr)
|
||||
path-segments
|
||||
(compiled-route-has-rest? cr)
|
||||
(compiled-route-rest-name cr))))
|
||||
(if bindings
|
||||
(loop (cdr routes)
|
||||
(adjoin-methods (compiled-route-method cr) methods))
|
||||
(loop (cdr routes) methods))))))
|
||||
|
||||
(define (adjoin-methods method-spec methods)
|
||||
"Add methods from METHOD-SPEC to METHODS list, avoiding duplicates."
|
||||
(cond
|
||||
((eq? method-spec '*) methods)
|
||||
((symbol? method-spec)
|
||||
(if (memq method-spec methods) methods (cons method-spec methods)))
|
||||
((list? method-spec)
|
||||
(fold (lambda (m acc) (if (memq m acc) acc (cons m acc)))
|
||||
methods method-spec))
|
||||
(else methods)))
|
||||
|
||||
;;;
|
||||
;;; Reverse routing
|
||||
;;;
|
||||
|
||||
(define* (path-for group name #:optional (params '())
|
||||
#:key (query '()) (fragment #f) (relative? #f))
|
||||
"Generate a URL path for a named route within GROUP.
|
||||
|
||||
GROUP is a route-group value. NAME is either a symbol naming a route
|
||||
within GROUP, or a list of symbols for nested lookup where the last
|
||||
element is the route name and preceding elements are child group names.
|
||||
|
||||
(path-for routes 'users)
|
||||
(path-for routes 'user '((id . \"42\")))
|
||||
(path-for routes '(api items) '((id . \"7\")))
|
||||
|
||||
PARAMS is an alist mapping capture symbols to string values, or to a
|
||||
list of strings for rest parameters.
|
||||
|
||||
Optional keyword arguments:
|
||||
#:query — alist of query parameters ((key . value) ...)
|
||||
#:fragment — fragment string (without the leading #)
|
||||
#:relative? — if #t, omit the leading /"
|
||||
(let* ((rr (current-reverse-routes))
|
||||
(_ (unless rr
|
||||
(error "path-for: no reverse routes available — \
|
||||
is this being called inside a request handler?")))
|
||||
(_ (unless (route-group? group)
|
||||
(error "path-for: first argument must be a route-group" group)))
|
||||
(pattern
|
||||
(cond
|
||||
;; (path-for group 'name) or (path-for group 'name params)
|
||||
((symbol? name)
|
||||
(lookup-scoped rr group name))
|
||||
;; (path-for group '(child ... route-name))
|
||||
;; or (path-for group '(child ... route-name) params)
|
||||
((and (pair? name) (every symbol? name))
|
||||
(lookup-nested rr group name))
|
||||
(else
|
||||
(error "path-for: expected symbol or list of symbols as \
|
||||
name argument" name)))))
|
||||
(build-path-string pattern params relative? query fragment)))
|
||||
|
||||
(define (lookup-group-scope rr group)
|
||||
"Find the scope for GROUP in the reverse-routes group table."
|
||||
(let loop ((table (reverse-routes-group-table rr)))
|
||||
(cond
|
||||
((null? table)
|
||||
(error "path-for: route-group not found in reverse table — \
|
||||
was it included in the route tree passed to compile-routes?" group))
|
||||
((eq? (caar table) group)
|
||||
(cdar table))
|
||||
(else
|
||||
(loop (cdr table))))))
|
||||
|
||||
(define (lookup-scoped rr group name)
|
||||
"Look up NAME in GROUP's local scope."
|
||||
(let* ((scope (lookup-group-scope rr group))
|
||||
(entry (assq name (group-scope-routes scope))))
|
||||
(unless entry
|
||||
(error "path-for: unknown route name in group" name group))
|
||||
(cdr entry)))
|
||||
|
||||
(define (lookup-nested rr group name-path)
|
||||
"Look up a route via a nested name path starting from GROUP.
|
||||
NAME-PATH is a list of symbols: zero or more child group names followed
|
||||
by a route name."
|
||||
(let ((scope (lookup-group-scope rr group)))
|
||||
(resolve-name-path rr scope name-path)))
|
||||
|
||||
(define (resolve-name-path rr scope name-path)
|
||||
"Recurse into SCOPE following NAME-PATH."
|
||||
(if (= (length name-path) 1)
|
||||
;; Last element — look up as a route name
|
||||
(let ((entry (assq (car name-path) (group-scope-routes scope))))
|
||||
(unless entry
|
||||
(error "path-for: unknown route name at end of path"
|
||||
(car name-path)))
|
||||
(cdr entry))
|
||||
;; First element is a child group name — find it and recurse
|
||||
(let ((child-entry (assq (car name-path)
|
||||
(group-scope-children scope))))
|
||||
(unless child-entry
|
||||
(error "path-for: unknown child group in path"
|
||||
(car name-path)))
|
||||
(let ((child-scope (lookup-group-scope rr (cdr child-entry))))
|
||||
(resolve-name-path rr child-scope (cdr name-path))))))
|
||||
|
||||
(define (build-path-string pattern params relative? query fragment)
|
||||
"Build a URL path string from PATTERN and PARAMS."
|
||||
(let* ((segments _rest-name (expand-pattern pattern params))
|
||||
(path-str (string-join segments "/"))
|
||||
(path-str (if relative?
|
||||
path-str
|
||||
(string-append "/" path-str)))
|
||||
(path-str (if (null? query)
|
||||
path-str
|
||||
(string-append path-str "?"
|
||||
(encode-query-string query))))
|
||||
(path-str (if fragment
|
||||
(string-append path-str "#"
|
||||
(uri-encode fragment))
|
||||
path-str)))
|
||||
path-str))
|
||||
|
||||
(define (expand-pattern pattern params)
|
||||
"Walk PATTERN, substituting captures from PARAMS.
|
||||
Returns (values segment-strings rest-name-or-#f)."
|
||||
(let loop ((pat pattern)
|
||||
(acc '()))
|
||||
(cond
|
||||
;; End of proper list
|
||||
((null? pat)
|
||||
(values (reverse acc) #f))
|
||||
;; Dotted tail — rest param
|
||||
((symbol? pat)
|
||||
(let ((val (assq pat params)))
|
||||
(unless val
|
||||
(error "path-for: missing rest parameter" pat))
|
||||
(unless (list? (cdr val))
|
||||
(error "path-for: rest parameter must be a list of strings"
|
||||
pat (cdr val)))
|
||||
(values (append (reverse acc)
|
||||
(map uri-encode (cdr val)))
|
||||
pat)))
|
||||
;; Pair — process the car
|
||||
((pair? pat)
|
||||
(let ((seg (car pat)))
|
||||
(cond
|
||||
((string? seg)
|
||||
(loop (cdr pat) (cons (uri-encode seg) acc)))
|
||||
((symbol? seg)
|
||||
(let ((val (assq seg params)))
|
||||
(unless val
|
||||
(error "path-for: missing parameter" seg))
|
||||
(loop (cdr pat) (cons (uri-encode (cdr val)) acc))))
|
||||
((and (pair? seg) (procedure? (car seg)) (symbol? (cadr seg)))
|
||||
;; Capturing predicate — reverse using the bound name.
|
||||
(let* ((name (cadr seg))
|
||||
(val (assq name params)))
|
||||
(unless val
|
||||
(error "path-for: missing parameter for capturing predicate" name))
|
||||
(loop (cdr pat) (cons (uri-encode (cdr val)) acc))))
|
||||
(else
|
||||
(error "path-for: invalid pattern element" seg)))))
|
||||
(else
|
||||
(error "path-for: invalid pattern" pat)))))
|
||||
|
||||
(define (encode-query-string params)
|
||||
"Encode an alist of query parameters into a query string."
|
||||
(string-join
|
||||
(map (match-lambda
|
||||
((key . value)
|
||||
(string-append (uri-encode (if (symbol? key)
|
||||
(symbol->string key)
|
||||
key))
|
||||
"="
|
||||
(uri-encode value))))
|
||||
params)
|
||||
"&"))
|
||||
129
safsaf/templating.scm
Normal file
129
safsaf/templating.scm
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
(define-module (safsaf templating)
|
||||
#:use-module (web response)
|
||||
#:use-module (srfi srfi-1)
|
||||
#:use-module (srfi srfi-71)
|
||||
#:use-module (htmlprag)
|
||||
#:export (write-shtml-as-html/streaming
|
||||
streaming-html-response))
|
||||
|
||||
(define %slot-prefix "SAFSAF-SLOT-")
|
||||
|
||||
(define (slot-comment index)
|
||||
"Return an SHTML comment node for slot INDEX."
|
||||
`(*COMMENT* ,(string-append %slot-prefix (number->string index))))
|
||||
|
||||
(define (slot-marker index)
|
||||
"Return the HTML comment string for slot INDEX."
|
||||
(string-append "<!-- " %slot-prefix (number->string index) " -->"))
|
||||
|
||||
(define (replace-procs shtml)
|
||||
"Walk SHTML, replacing procedures with numbered comment placeholders.
|
||||
Returns (values new-shtml procs-vector) where procs-vector contains the
|
||||
procedures in slot order."
|
||||
(let ((procs '())
|
||||
(counter 0))
|
||||
(define (walk node)
|
||||
(cond
|
||||
((procedure? node)
|
||||
(let ((index counter))
|
||||
(set! counter (1+ counter))
|
||||
(set! procs (cons (cons index node) procs))
|
||||
(slot-comment index)))
|
||||
((list? node)
|
||||
(map walk node))
|
||||
(else node)))
|
||||
(let ((new-tree (walk shtml)))
|
||||
(values new-tree
|
||||
(list->vector
|
||||
(map cdr (sort procs
|
||||
(lambda (a b) (< (car a) (car b))))))))))
|
||||
|
||||
(define (split-on-slots html-string num-slots)
|
||||
"Split HTML-STRING on the slot comment markers.
|
||||
Returns a list of strings, one more than the number of slots."
|
||||
(if (zero? num-slots)
|
||||
(list html-string)
|
||||
(let loop ((remaining html-string)
|
||||
(index 0)
|
||||
(chunks '()))
|
||||
(if (= index num-slots)
|
||||
(reverse (cons remaining chunks))
|
||||
(let ((marker (slot-marker index)))
|
||||
(let ((pos (string-contains remaining marker)))
|
||||
(if pos
|
||||
(loop (substring remaining
|
||||
(+ pos (string-length marker)))
|
||||
(1+ index)
|
||||
(cons (substring remaining 0 pos) chunks))
|
||||
;; Marker not found — shouldn't happen, but be safe.
|
||||
(reverse (cons remaining chunks)))))))))
|
||||
|
||||
(define (write-shtml-as-html/streaming shtml port)
|
||||
"Write SHTML to PORT, like @code{write-shtml-as-html} from htmlprag,
|
||||
but any procedure encountered in the tree is called as @code{(proc port)}
|
||||
and may write directly to PORT.
|
||||
|
||||
This allows mixing static SHTML with dynamic streaming sections:
|
||||
|
||||
@example
|
||||
(write-shtml-as-html/streaming
|
||||
`(html (body (h1 \"Title\")
|
||||
,(lambda (port) (display \"dynamic\" port))
|
||||
(footer \"bye\")))
|
||||
port)
|
||||
@end example
|
||||
|
||||
Static parts are rendered via htmlprag's @code{shtml->html}, then
|
||||
interleaved with procedure calls at output time."
|
||||
(let ((tree procs (replace-procs shtml)))
|
||||
(let* ((html-string (shtml->html tree))
|
||||
(num-slots (vector-length procs))
|
||||
(chunks (split-on-slots html-string num-slots)))
|
||||
(let loop ((chunks chunks)
|
||||
(index 0))
|
||||
(when (pair? chunks)
|
||||
(display (car chunks) port)
|
||||
(when (< index num-slots)
|
||||
((vector-ref procs index) port)
|
||||
(loop (cdr chunks) (1+ index))))))))
|
||||
|
||||
(define* (streaming-html-response shtml #:key (code 200) (headers '())
|
||||
(charset "utf-8"))
|
||||
"Return an HTML response that streams SHTML to the client.
|
||||
|
||||
SHTML is an SHTML tree that may contain procedures. Each procedure is
|
||||
called as @code{(proc port)} during output and should write HTML to the
|
||||
port. Static parts are rendered via htmlprag.
|
||||
|
||||
@example
|
||||
(streaming-response
|
||||
`(*TOP*
|
||||
(*DECL* DOCTYPE html)
|
||||
(html (head (title \"My Page\"))
|
||||
(body (h1 \"Hello\")
|
||||
,(lambda (port)
|
||||
(write-shtml-as-html '(p \"dynamic\") port))))))
|
||||
@end example"
|
||||
(values (build-response
|
||||
#:code code
|
||||
#:headers (append `((content-type text/html (charset . ,charset)))
|
||||
headers))
|
||||
(lambda (port)
|
||||
(write-shtml-as-html/streaming shtml port))))
|
||||
93
safsaf/utils.scm
Normal file
93
safsaf/utils.scm
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
(define-module (safsaf utils)
|
||||
#:use-module (rnrs bytevectors)
|
||||
#:use-module (web request)
|
||||
#:use-module (web uri)
|
||||
#:use-module (srfi srfi-1)
|
||||
#:use-module (knots web-server)
|
||||
#:use-module (webutils cookie)
|
||||
#:autoload (json parser) (json->scm)
|
||||
#:autoload (webutils multipart) (parse-request-body
|
||||
part-content-disposition-params
|
||||
part-name parts-ref-string)
|
||||
#:export (parse-form-body
|
||||
parse-multipart-body
|
||||
multipart-text-fields
|
||||
parse-query-string
|
||||
request-cookies
|
||||
request-cookie-ref))
|
||||
|
||||
(define (parse-key-value-pairs str)
|
||||
"Parse a URL-encoded key=value&key=value string into an alist."
|
||||
(map (lambda (pair)
|
||||
(let ((eq-pos (string-index pair #\=)))
|
||||
(if eq-pos
|
||||
(cons (uri-decode (substring pair 0 eq-pos))
|
||||
(uri-decode (substring pair (1+ eq-pos))))
|
||||
(cons (uri-decode pair) ""))))
|
||||
(string-split str #\&)))
|
||||
|
||||
(define (parse-form-body request body-port)
|
||||
"Read and parse a URL-encoded form body from REQUEST.
|
||||
Returns an alist of string key-value pairs."
|
||||
(let* ((body (read-request-body/knots request body-port))
|
||||
(str (if body (utf8->string body) "")))
|
||||
(if (string-null? str)
|
||||
'()
|
||||
(parse-key-value-pairs str))))
|
||||
|
||||
(define (parse-multipart-body request body-port)
|
||||
"Read and parse a multipart/form-data body from REQUEST.
|
||||
Returns a list of <part> records from (webutils multipart).
|
||||
Use parts-ref, parts-ref-string, part-body, etc. to access parts."
|
||||
(let ((body (read-request-body/knots request body-port)))
|
||||
(if body
|
||||
(parse-request-body request body)
|
||||
'())))
|
||||
|
||||
(define (multipart-text-fields parts)
|
||||
"Extract text fields from multipart PARTS as an alist of (name . value).
|
||||
File upload parts (those with a filename parameter) are excluded."
|
||||
(filter-map
|
||||
(lambda (p)
|
||||
(let ((params (part-content-disposition-params p)))
|
||||
(and (not (assoc-ref params 'filename))
|
||||
(cons (part-name p)
|
||||
(parts-ref-string parts (part-name p))))))
|
||||
parts))
|
||||
|
||||
(define (parse-query-string request)
|
||||
"Parse the query string from REQUEST.
|
||||
Returns an alist of string key-value pairs, or '() if no query string."
|
||||
(let ((query (uri-query (request-uri request))))
|
||||
(if (or (not query) (string-null? query))
|
||||
'()
|
||||
(parse-key-value-pairs query))))
|
||||
|
||||
(define (request-cookies request)
|
||||
"Return the cookies from REQUEST as an alist of (name . value) pairs.
|
||||
Returns '() if no Cookie header is present. Importing (webutils cookie)
|
||||
registers the Cookie header parser with (web http)."
|
||||
(let ((cookies (assoc-ref (request-headers request) 'cookie)))
|
||||
(or cookies '())))
|
||||
|
||||
(define* (request-cookie-ref request name #:optional default)
|
||||
"Return the value of cookie NAME from REQUEST, or DEFAULT if not found."
|
||||
(let ((pair (assoc name (request-cookies request))))
|
||||
(if pair (cdr pair) default)))
|
||||
73
tests/CLAUDE.md
Normal file
73
tests/CLAUDE.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# Testing
|
||||
|
||||
## Framework
|
||||
|
||||
Tests use a minimal SRFI-269 implementation in `(tests support)`. Three
|
||||
primitives — `is`, `test`, `suite` — build first-class test entities and
|
||||
deliver them to a pluggable runner. Definition is separated from execution.
|
||||
|
||||
## Running tests
|
||||
|
||||
All tests (via Automake):
|
||||
|
||||
make check
|
||||
|
||||
Single file:
|
||||
|
||||
./pre-inst-env guile tests/test-router.scm
|
||||
|
||||
## Writing tests
|
||||
|
||||
```scheme
|
||||
(use-modules (tests support)
|
||||
(safsaf router)) ; module under test
|
||||
|
||||
(define-suite router-tests
|
||||
(suite "route construction"
|
||||
(test "creates route with method and pattern"
|
||||
(let ((r (route 'GET '("users") identity)))
|
||||
(is (route? r))
|
||||
(is (eq? 'GET (route-method r))))))
|
||||
|
||||
(suite "matching"
|
||||
(test "exact path match"
|
||||
...)))
|
||||
|
||||
(run-tests router-tests)
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
- `(is expr)` — assert expr is truthy. Returns the value on success.
|
||||
- `(is (pred arg ...))` — predicate form; on failure shows evaluated args.
|
||||
- `(test "desc" body ...)` — a single test case with one or more assertions.
|
||||
- `(suite "desc" body ...)` — group tests and nested suites.
|
||||
- `(define-suite name body ...)` — bind a suite-thunk to a variable.
|
||||
- `(run-tests thunk)` — run with the simple runner, print summary, exit.
|
||||
- Tests should be self-contained: don't depend on ordering or side effects
|
||||
from other tests.
|
||||
- Use `define` inside `test` bodies for local setup.
|
||||
|
||||
## Synthetic requests
|
||||
|
||||
Many tests need Guile `<request>` objects without a real HTTP server.
|
||||
Build them with `build-request` from `(web request)`:
|
||||
|
||||
```scheme
|
||||
(use-modules (web request) (web uri))
|
||||
|
||||
(define* (make-request method path #:optional (headers '()))
|
||||
(build-request (build-uri 'http #:host "localhost" #:path path)
|
||||
#:method method
|
||||
#:headers headers))
|
||||
```
|
||||
|
||||
Handler signature is `(request body-port) → (values response body)`. When
|
||||
calling handlers or wrapped handlers in tests, pass `#f` as the body-port:
|
||||
|
||||
```scheme
|
||||
(let ((resp body (wrapped (make-request 'GET "/" '()) #f)))
|
||||
(is (= 200 (response-code resp))))
|
||||
```
|
||||
|
||||
For handlers that read `current-route-params`, `parameterize` it directly.
|
||||
243
tests/support.scm
Normal file
243
tests/support.scm
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
(define-module (tests support)
|
||||
#:use-module (ice-9 format)
|
||||
#:use-module (srfi srfi-1)
|
||||
#:export (is
|
||||
test
|
||||
suite
|
||||
suite-thunk
|
||||
define-suite
|
||||
test-runner*
|
||||
test?
|
||||
suite?
|
||||
suite-thunk?
|
||||
run-tests))
|
||||
|
||||
;;;
|
||||
;;; Minimal SRFI-269 implementation for Guile.
|
||||
;;;
|
||||
;;; Three definition primitives — is, test, suite — construct first-class
|
||||
;;; entities (alists) and deliver them to a pluggable test runner via
|
||||
;;; message passing. Definition is separated from execution: the runner
|
||||
;;; decides when and how to run things.
|
||||
;;;
|
||||
|
||||
;;; --- Parameter ---
|
||||
|
||||
(define test-runner* (make-parameter #f))
|
||||
|
||||
;;; --- Predicates ---
|
||||
|
||||
(define (test? obj)
|
||||
(and (pair? obj)
|
||||
(assq 'test/body-thunk obj)
|
||||
(assq 'test/description obj)
|
||||
#t))
|
||||
|
||||
(define (suite? obj)
|
||||
(and (pair? obj)
|
||||
(assq 'suite/body-thunk obj)
|
||||
(assq 'suite/description obj)
|
||||
#t))
|
||||
|
||||
(define (suite-thunk? obj)
|
||||
(procedure? obj))
|
||||
|
||||
;;; --- is ---
|
||||
|
||||
(define-syntax is
|
||||
(lambda (x)
|
||||
(syntax-case x ()
|
||||
;; Predicate form: (is (pred arg ...))
|
||||
((_ (pred arg ...))
|
||||
(with-syntax ((src (datum->syntax x (syntax-source x))))
|
||||
#'(%run-assert
|
||||
(lambda () (pred arg ...))
|
||||
'(pred arg ...)
|
||||
'src
|
||||
(lambda () (list arg ...)))))
|
||||
;; Simple form: (is expr)
|
||||
((_ expr)
|
||||
(with-syntax ((src (datum->syntax x (syntax-source x))))
|
||||
#'(%run-assert
|
||||
(lambda () expr)
|
||||
'expr
|
||||
'src
|
||||
#f))))))
|
||||
|
||||
(define (%run-assert body-thunk body-datum source args-thunk)
|
||||
(let* ((entity `((assert/body-thunk . ,body-thunk)
|
||||
(assert/body . ,body-datum)
|
||||
(assert/location . ,source)
|
||||
,@(if args-thunk
|
||||
`((assert/args-thunk . ,args-thunk))
|
||||
'()))))
|
||||
((test-runner*)
|
||||
`((type . runner/run-assert)
|
||||
(assert . ,entity)))))
|
||||
|
||||
;;; --- test ---
|
||||
|
||||
(define-syntax test
|
||||
(syntax-rules (quote)
|
||||
((_ desc (quote metadata) meta body ...)
|
||||
(%load-test desc 'meta (lambda () body ... (values))))
|
||||
((_ desc body ...)
|
||||
(%load-test desc '() (lambda () body ... (values))))))
|
||||
|
||||
(define (%load-test description metadata body-thunk)
|
||||
((test-runner*)
|
||||
`((type . runner/load-test)
|
||||
(test . ((test/body-thunk . ,body-thunk)
|
||||
(test/description . ,description)
|
||||
(test/metadata . ,metadata))))))
|
||||
|
||||
;;; --- suite ---
|
||||
|
||||
(define-syntax suite
|
||||
(syntax-rules (quote)
|
||||
((_ desc (quote metadata) meta body ...)
|
||||
(%load-suite desc 'meta (lambda () body ... (values))))
|
||||
((_ desc body ...)
|
||||
(%load-suite desc '() (lambda () body ... (values))))))
|
||||
|
||||
(define (%load-suite description metadata body-thunk)
|
||||
((test-runner*)
|
||||
`((type . runner/load-suite)
|
||||
(suite . ((suite/body-thunk . ,body-thunk)
|
||||
(suite/description . ,description)
|
||||
(suite/metadata . ,metadata))))))
|
||||
|
||||
;;; --- suite-thunk ---
|
||||
|
||||
(define-syntax suite-thunk
|
||||
(syntax-rules (quote)
|
||||
((_ desc (quote metadata) meta body ...)
|
||||
(lambda ()
|
||||
(%load-suite desc 'meta (lambda () body ... (values)))))
|
||||
((_ desc body ...)
|
||||
(lambda ()
|
||||
(%load-suite desc '() (lambda () body ... (values)))))))
|
||||
|
||||
;;; --- define-suite ---
|
||||
|
||||
(define-syntax define-suite
|
||||
(syntax-rules (quote)
|
||||
((_ name (quote metadata) meta body ...)
|
||||
(define name
|
||||
(suite-thunk (symbol->string 'name) (quote metadata) meta body ...)))
|
||||
((_ name body ...)
|
||||
(define name
|
||||
(suite-thunk (symbol->string 'name) body ...)))))
|
||||
|
||||
|
||||
;;;
|
||||
;;; Simple immediate-execution test runner.
|
||||
;;;
|
||||
|
||||
(define %depth 0)
|
||||
(define %pass-count 0)
|
||||
(define %fail-count 0)
|
||||
(define %error-count 0)
|
||||
(define %test-failed? #f)
|
||||
|
||||
(define (indent)
|
||||
(make-string (* 2 %depth) #\space))
|
||||
|
||||
(define (format-location loc)
|
||||
"Return a string like \"file.scm:42\" from a source location alist,
|
||||
or #f if location info is unavailable."
|
||||
(and loc
|
||||
(let ((file (assq-ref loc 'filename))
|
||||
(line (assq-ref loc 'line)))
|
||||
(and file line
|
||||
(format #f "~a:~a" file (+ line 1))))))
|
||||
|
||||
(define (simple-test-runner message)
|
||||
(let ((type (assq-ref message 'type)))
|
||||
(case type
|
||||
|
||||
((runner/load-suite)
|
||||
(let* ((s (assq-ref message 'suite))
|
||||
(desc (assq-ref s 'suite/description))
|
||||
(body (assq-ref s 'suite/body-thunk)))
|
||||
(format #t "~a~a~%" (indent) desc)
|
||||
(set! %depth (+ %depth 1))
|
||||
(body)
|
||||
(set! %depth (- %depth 1))))
|
||||
|
||||
((runner/load-test)
|
||||
(let* ((t (assq-ref message 'test))
|
||||
(desc (assq-ref t 'test/description))
|
||||
(body (assq-ref t 'test/body-thunk)))
|
||||
(set! %test-failed? #f)
|
||||
(with-exception-handler
|
||||
(lambda (exn)
|
||||
(set! %error-count (+ %error-count 1))
|
||||
(format #t "~aERROR ~a~%" (indent) desc)
|
||||
(format #t "~a ~a~%" (indent) exn))
|
||||
(lambda ()
|
||||
(body)
|
||||
(if %test-failed?
|
||||
(begin
|
||||
(set! %fail-count (+ %fail-count 1))
|
||||
(format #t "~aFAIL ~a~%" (indent) desc))
|
||||
(begin
|
||||
(set! %pass-count (+ %pass-count 1))
|
||||
(format #t "~aok ~a~%" (indent) desc))))
|
||||
#:unwind? #t)))
|
||||
|
||||
((runner/run-assert)
|
||||
(let* ((a (assq-ref message 'assert))
|
||||
(body-thunk (assq-ref a 'assert/body-thunk))
|
||||
(body-datum (assq-ref a 'assert/body))
|
||||
(loc (assq-ref a 'assert/location)))
|
||||
(let ((result (body-thunk)))
|
||||
(unless result
|
||||
(set! %test-failed? #t)
|
||||
(format #t "~a FAIL: ~s" (indent) body-datum)
|
||||
(let ((loc-str (format-location loc)))
|
||||
(when loc-str
|
||||
(format #t " at ~a" loc-str)))
|
||||
(newline)
|
||||
;; Show evaluated arguments for predicate assertions.
|
||||
(let ((args-thunk (assq-ref a 'assert/args-thunk)))
|
||||
(when args-thunk
|
||||
(with-exception-handler
|
||||
(lambda (_) #f)
|
||||
(lambda ()
|
||||
(let ((args (args-thunk)))
|
||||
(format #t "~a args: ~s~%" (indent) args)))
|
||||
#:unwind? #t))))
|
||||
result))))))
|
||||
|
||||
(define (run-tests thunk)
|
||||
"Set up the simple test runner, call THUNK (typically a suite-thunk),
|
||||
print a summary, and exit with 0 on success or 1 on failure."
|
||||
(set! %depth 0)
|
||||
(set! %pass-count 0)
|
||||
(set! %fail-count 0)
|
||||
(set! %error-count 0)
|
||||
(parameterize ((test-runner* simple-test-runner))
|
||||
(thunk))
|
||||
(newline)
|
||||
(let ((total (+ %pass-count %fail-count %error-count)))
|
||||
(format #t "~a passed, ~a failed, ~a errors (of ~a)~%"
|
||||
%pass-count %fail-count %error-count total)
|
||||
(exit (if (and (zero? %fail-count) (zero? %error-count)) 0 1))))
|
||||
65
tests/test-csrf-validation.scm
Normal file
65
tests/test-csrf-validation.scm
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
;;; test-csrf-validation.scm — Tests for CSRF integration in (safsaf params)
|
||||
;;; and (safsaf handler-wrappers csrf)
|
||||
|
||||
(use-modules (tests support)
|
||||
(safsaf params)
|
||||
(safsaf handler-wrappers csrf))
|
||||
|
||||
(define-suite csrf-validation-tests
|
||||
|
||||
(suite "csrf"
|
||||
|
||||
(test "csrf-token-field produces sxml"
|
||||
(parameterize ((current-csrf-token "abc123"))
|
||||
(let ((field (csrf-token-field)))
|
||||
(is (pair? field))
|
||||
(is (eq? 'input (car field))))))
|
||||
|
||||
(test "parse-form-params checks csrf"
|
||||
(parameterize ((current-csrf-token "tok123"))
|
||||
(let ((result (parse-form-params '()
|
||||
'(("csrf-token" . "tok123")))))
|
||||
(is (not (any-invalid-params? result))))
|
||||
(let ((result (parse-form-params '()
|
||||
'(("csrf-token" . "wrong")))))
|
||||
(is (any-invalid-params? result)))))
|
||||
|
||||
(test "parse-form-params csrf missing"
|
||||
(parameterize ((current-csrf-token "tok123"))
|
||||
(let ((result (parse-form-params '() '())))
|
||||
(is (any-invalid-params? result)))))
|
||||
|
||||
(test "parse-form-params validates other fields too"
|
||||
(parameterize ((current-csrf-token "tok123"))
|
||||
(let ((result (parse-form-params
|
||||
`((name ,as-string #:required))
|
||||
'(("csrf-token" . "tok123") ("name" . "Alice")))))
|
||||
(is (not (any-invalid-params? result)))
|
||||
(is (equal? "Alice" (assq-ref result 'name))))))
|
||||
|
||||
(test "parse-form-params field errors with valid csrf"
|
||||
(parameterize ((current-csrf-token "tok123"))
|
||||
(let ((result (parse-form-params
|
||||
`((name ,as-string #:required))
|
||||
'(("csrf-token" . "tok123")))))
|
||||
(is (any-invalid-params? result))
|
||||
(is (invalid-param? (assq-ref result 'name))))))))
|
||||
|
||||
(run-tests csrf-validation-tests)
|
||||
146
tests/test-exceptions.scm
Normal file
146
tests/test-exceptions.scm
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
;;; test-exceptions.scm — Tests for (safsaf handler-wrappers exceptions)
|
||||
|
||||
(use-modules (tests support)
|
||||
(safsaf handler-wrappers exceptions)
|
||||
(srfi srfi-71)
|
||||
(web request)
|
||||
(web response)
|
||||
(web uri))
|
||||
|
||||
(define (make-request method path headers)
|
||||
(build-request (build-uri 'http #:host "localhost" #:path path)
|
||||
#:method method
|
||||
#:headers headers))
|
||||
|
||||
(define (failing-handler request body-port)
|
||||
(error "test explosion"))
|
||||
|
||||
(define (ok-handler request body-port)
|
||||
(values (build-response #:code 200) "ok"))
|
||||
|
||||
(define html-request
|
||||
(make-request 'GET "/" '((accept . ((text/html))))))
|
||||
|
||||
(define json-request
|
||||
(make-request 'GET "/" '((accept . ((application/json))))))
|
||||
|
||||
(define-suite exceptions-tests
|
||||
|
||||
(suite "passthrough"
|
||||
|
||||
(test "successful handler passes through unchanged"
|
||||
(define wrapped (exceptions-handler-wrapper ok-handler))
|
||||
(let ((resp body (wrapped html-request #f)))
|
||||
(is (= 200 (response-code resp)))
|
||||
(is (equal? "ok" body)))))
|
||||
|
||||
(suite "production mode"
|
||||
|
||||
(test "returns 500 for HTML client"
|
||||
(define wrapped (exceptions-handler-wrapper failing-handler))
|
||||
(let ((resp body (wrapped html-request #f)))
|
||||
(is (= 500 (response-code resp)))
|
||||
;; Body is a streaming procedure from html-response.
|
||||
(is (procedure? body))))
|
||||
|
||||
(test "returns 500 JSON for JSON client"
|
||||
(define wrapped (exceptions-handler-wrapper failing-handler))
|
||||
(let ((resp body (wrapped json-request #f)))
|
||||
(is (= 500 (response-code resp)))
|
||||
(is (string? body))
|
||||
;; Should contain error key but not backtrace.
|
||||
(is (string-contains body "Internal Server Error"))
|
||||
(is (not (string-contains body "test explosion"))))))
|
||||
|
||||
(suite "dev mode"
|
||||
|
||||
(test "returns 500 with backtrace for HTML client"
|
||||
(define wrapped (exceptions-handler-wrapper failing-handler
|
||||
#:dev? #t))
|
||||
(let ((resp body (wrapped html-request #f)))
|
||||
(is (= 500 (response-code resp)))
|
||||
;; Body is a streaming procedure containing backtrace.
|
||||
(is (procedure? body))))
|
||||
|
||||
(test "returns 500 JSON with backtrace for JSON client"
|
||||
(define wrapped (exceptions-handler-wrapper failing-handler
|
||||
#:dev? #t))
|
||||
(let ((resp body (wrapped json-request #f)))
|
||||
(is (= 500 (response-code resp)))
|
||||
(is (string? body))
|
||||
(is (string-contains body "backtrace"))
|
||||
(is (string-contains body "test explosion")))))
|
||||
|
||||
(suite "custom renderers"
|
||||
|
||||
(test "render-error overrides everything"
|
||||
(define wrapped
|
||||
(exceptions-handler-wrapper
|
||||
failing-handler
|
||||
#:render-error
|
||||
(lambda (request code message bt dev?)
|
||||
(values (build-response #:code code) "custom error"))))
|
||||
(let ((resp body (wrapped html-request #f)))
|
||||
(is (= 500 (response-code resp)))
|
||||
(is (equal? "custom error" body))))
|
||||
|
||||
(test "render-html overrides only HTML"
|
||||
(define wrapped
|
||||
(exceptions-handler-wrapper
|
||||
failing-handler
|
||||
#:render-html
|
||||
(lambda (request code message bt dev?)
|
||||
(values (build-response #:code code
|
||||
#:headers '((content-type text/html)))
|
||||
"custom html"))))
|
||||
;; HTML request gets custom renderer.
|
||||
(let ((resp body (wrapped html-request #f)))
|
||||
(is (equal? "custom html" body)))
|
||||
;; JSON request gets default JSON renderer.
|
||||
(let ((resp body (wrapped json-request #f)))
|
||||
(is (string? body))
|
||||
(is (string-contains body "Internal Server Error"))))
|
||||
|
||||
(test "render-json overrides only JSON"
|
||||
(define wrapped
|
||||
(exceptions-handler-wrapper
|
||||
failing-handler
|
||||
#:render-json
|
||||
(lambda (request code message bt dev?)
|
||||
(values (build-response #:code code
|
||||
#:headers '((content-type application/json)))
|
||||
"{\"err\":\"custom\"}"))))
|
||||
;; JSON request gets custom renderer.
|
||||
(let ((resp body (wrapped json-request #f)))
|
||||
(is (equal? "{\"err\":\"custom\"}" body)))
|
||||
;; HTML request gets default HTML renderer.
|
||||
(let ((resp body (wrapped html-request #f)))
|
||||
(is (procedure? body)))))
|
||||
|
||||
(suite "make-exceptions-handler-wrapper"
|
||||
|
||||
(test "factory returns a working wrapper"
|
||||
(define wrapper (make-exceptions-handler-wrapper #:dev? #t))
|
||||
(define wrapped (wrapper failing-handler))
|
||||
(let ((resp body (wrapped json-request #f)))
|
||||
(is (= 500 (response-code resp)))
|
||||
(is (string-contains body "test explosion"))))))
|
||||
|
||||
(run-tests exceptions-tests)
|
||||
274
tests/test-handler-wrappers.scm
Normal file
274
tests/test-handler-wrappers.scm
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
;;; test-handler-wrappers.scm — Tests for standalone handler wrappers
|
||||
|
||||
(use-modules (tests support)
|
||||
(safsaf handler-wrappers security-headers)
|
||||
(safsaf handler-wrappers cors)
|
||||
(safsaf handler-wrappers max-body-size)
|
||||
(safsaf handler-wrappers sessions)
|
||||
(safsaf handler-wrappers trailing-slash)
|
||||
(srfi srfi-71)
|
||||
(web request)
|
||||
(web response)
|
||||
(web uri)
|
||||
(webutils cookie)) ; registers Cookie header parser
|
||||
|
||||
;; A handler that returns a plain 200 response.
|
||||
(define (ok-handler request body-port)
|
||||
(values (build-response #:code 200) "ok"))
|
||||
|
||||
(define* (make-request method path headers #:key (validate? #t))
|
||||
(build-request (build-uri 'http #:host "localhost" #:path path)
|
||||
#:method method
|
||||
#:headers headers
|
||||
#:validate-headers? validate?))
|
||||
|
||||
(define-suite handler-wrappers-tests
|
||||
|
||||
(suite "security-headers"
|
||||
|
||||
(test "adds default headers"
|
||||
(define wrapped (security-headers-handler-wrapper ok-handler))
|
||||
(let ((resp _body (wrapped (make-request 'GET "/" '()) #f)))
|
||||
(is (equal? "nosniff"
|
||||
(assq-ref (response-headers resp)
|
||||
'x-content-type-options)))
|
||||
(is (equal? "DENY"
|
||||
(assq-ref (response-headers resp)
|
||||
'x-frame-options)))))
|
||||
|
||||
(test "disabling a header with #f"
|
||||
(define wrapped
|
||||
(security-headers-handler-wrapper ok-handler
|
||||
#:frame-options #f))
|
||||
(let ((resp _body (wrapped (make-request 'GET "/" '()) #f)))
|
||||
(is (not (assq-ref (response-headers resp)
|
||||
'x-frame-options)))))
|
||||
|
||||
(test "content-security-policy header"
|
||||
(define wrapped
|
||||
(security-headers-handler-wrapper ok-handler
|
||||
#:content-security-policy "default-src 'self'; script-src 'self'"))
|
||||
(let ((resp _body (wrapped (make-request 'GET "/" '()) #f)))
|
||||
(is (equal? "default-src 'self'; script-src 'self'"
|
||||
(assq-ref (response-headers resp)
|
||||
'content-security-policy)))))
|
||||
|
||||
(test "content-security-policy-report-only header"
|
||||
(define wrapped
|
||||
(security-headers-handler-wrapper ok-handler
|
||||
#:content-security-policy-report-only "default-src 'self'"))
|
||||
(let ((resp _body (wrapped (make-request 'GET "/" '()) #f)))
|
||||
(is (equal? "default-src 'self'"
|
||||
(assq-ref (response-headers resp)
|
||||
'content-security-policy-report-only)))
|
||||
;; Enforcing header should not be set.
|
||||
(is (not (assq-ref (response-headers resp)
|
||||
'content-security-policy))))))
|
||||
|
||||
(suite "cors"
|
||||
|
||||
(test "no origin header passes through"
|
||||
(define wrapped (cors-handler-wrapper ok-handler))
|
||||
(let ((resp _body (wrapped (make-request 'GET "/" '()) #f)))
|
||||
(is (= 200 (response-code resp)))
|
||||
(is (not (assq-ref (response-headers resp)
|
||||
'access-control-allow-origin)))))
|
||||
|
||||
(test "preflight returns 204"
|
||||
(define wrapped (cors-handler-wrapper ok-handler))
|
||||
(let ((resp _body
|
||||
(wrapped (make-request 'OPTIONS "/"
|
||||
'((origin . "http://example.com")))
|
||||
#f)))
|
||||
(is (= 204 (response-code resp)))
|
||||
(is (equal? "*" (assq-ref (response-headers resp)
|
||||
'access-control-allow-origin)))))
|
||||
|
||||
(test "normal request with origin adds cors headers"
|
||||
(define wrapped (cors-handler-wrapper ok-handler))
|
||||
(let ((resp _body
|
||||
(wrapped (make-request 'GET "/"
|
||||
'((origin . "http://example.com")))
|
||||
#f)))
|
||||
(is (= 200 (response-code resp)))
|
||||
(is (assq-ref (response-headers resp)
|
||||
'access-control-allow-origin))))
|
||||
|
||||
(test "disallowed origin gets no cors headers"
|
||||
(define wrapped
|
||||
(cors-handler-wrapper ok-handler
|
||||
#:origins '("http://allowed.com")))
|
||||
(let ((resp _body
|
||||
(wrapped (make-request 'GET "/"
|
||||
'((origin . "http://evil.com")))
|
||||
#f)))
|
||||
(is (= 200 (response-code resp)))
|
||||
(is (not (assq-ref (response-headers resp)
|
||||
'access-control-allow-origin))))))
|
||||
|
||||
(suite "sessions"
|
||||
|
||||
(test "round-trip set and read"
|
||||
(define mgr (make-session-config "test-secret-key-1234"))
|
||||
(define wrapper (make-session-handler-wrapper mgr))
|
||||
;; Set a session and extract the cookie name=value.
|
||||
(let* ((cookie-hdr (session-set mgr '((user . "alice"))))
|
||||
;; cdr is (name value attrs...) — build parsed cookie alist
|
||||
(cookie-name (car (cdr cookie-hdr)))
|
||||
(cookie-value (cadr (cdr cookie-hdr)))
|
||||
(cookie-alist (list (cons cookie-name cookie-value))))
|
||||
;; Now make a request with that cookie and read it back.
|
||||
(define reading-handler
|
||||
(lambda (request body-port) (values (build-response #:code 200)
|
||||
(current-session))))
|
||||
(define wrapped (wrapper reading-handler))
|
||||
(let ((_resp body
|
||||
(wrapped (make-request 'GET "/"
|
||||
`((cookie . ,cookie-alist))
|
||||
#:validate? #f)
|
||||
#f)))
|
||||
(is (pair? body))
|
||||
(is (equal? "alice" (assq-ref body 'user))))))
|
||||
|
||||
(test "missing session yields #f"
|
||||
(define mgr (make-session-config "test-secret-key-1234"))
|
||||
(define reading-handler
|
||||
(lambda (request body-port) (values (build-response #:code 200)
|
||||
(current-session))))
|
||||
(define wrapped ((make-session-handler-wrapper mgr) reading-handler))
|
||||
(let ((_resp body (wrapped (make-request 'GET "/" '()) #f)))
|
||||
(is (not body)))))
|
||||
|
||||
(suite "max-body-size"
|
||||
|
||||
(test "allows request under limit"
|
||||
(define wrapper (make-max-body-size-handler-wrapper 1024))
|
||||
(define wrapped (wrapper ok-handler))
|
||||
(let ((resp _body
|
||||
(wrapped (make-request 'GET "/"
|
||||
'((content-length . 512)))
|
||||
#f)))
|
||||
(is (= 200 (response-code resp)))))
|
||||
|
||||
(test "rejects request over limit with 413"
|
||||
(define wrapper (make-max-body-size-handler-wrapper 1024))
|
||||
(define wrapped (wrapper ok-handler))
|
||||
(let ((resp _body
|
||||
(wrapped (make-request 'GET "/"
|
||||
'((content-length . 2048)))
|
||||
#f)))
|
||||
(is (= 413 (response-code resp)))))
|
||||
|
||||
(test "passes through when no content-length"
|
||||
(define wrapper (make-max-body-size-handler-wrapper 1024))
|
||||
(define wrapped (wrapper ok-handler))
|
||||
(let ((resp _body (wrapped (make-request 'GET "/" '()) #f)))
|
||||
(is (= 200 (response-code resp)))))
|
||||
|
||||
(test "custom 413 handler"
|
||||
(define wrapper
|
||||
(make-max-body-size-handler-wrapper
|
||||
100
|
||||
#:handler-413
|
||||
(lambda (request body-port)
|
||||
(values (build-response #:code 413) "too big"))))
|
||||
(define wrapped (wrapper ok-handler))
|
||||
(let ((resp body
|
||||
(wrapped (make-request 'GET "/"
|
||||
'((content-length . 200)))
|
||||
#f)))
|
||||
(is (= 413 (response-code resp)))
|
||||
(is (equal? "too big" body)))))
|
||||
|
||||
(suite "trailing-slash"
|
||||
|
||||
(test "strip mode redirects trailing slash"
|
||||
(define wrapped
|
||||
(trailing-slash-handler-wrapper ok-handler #:mode 'strip))
|
||||
(let ((resp _body (wrapped (make-request 'GET "/foo/" '()) #f)))
|
||||
(is (= 301 (response-code resp)))
|
||||
(is (equal? "/foo"
|
||||
(uri->string
|
||||
(assq-ref (response-headers resp) 'location))))))
|
||||
|
||||
(test "strip mode passes through without trailing slash"
|
||||
(define wrapped
|
||||
(trailing-slash-handler-wrapper ok-handler #:mode 'strip))
|
||||
(let ((resp _body (wrapped (make-request 'GET "/foo" '()) #f)))
|
||||
(is (= 200 (response-code resp)))))
|
||||
|
||||
(test "append mode redirects missing trailing slash"
|
||||
(define wrapped
|
||||
(trailing-slash-handler-wrapper ok-handler #:mode 'append))
|
||||
(let ((resp _body (wrapped (make-request 'GET "/foo" '()) #f)))
|
||||
(is (= 301 (response-code resp)))
|
||||
(is (equal? "/foo/"
|
||||
(uri->string
|
||||
(assq-ref (response-headers resp) 'location))))))
|
||||
|
||||
(test "append mode passes through with trailing slash"
|
||||
(define wrapped
|
||||
(trailing-slash-handler-wrapper ok-handler #:mode 'append))
|
||||
(let ((resp _body (wrapped (make-request 'GET "/foo/" '()) #f)))
|
||||
(is (= 200 (response-code resp)))))
|
||||
|
||||
(test "root path passes through in strip mode"
|
||||
(define wrapped
|
||||
(trailing-slash-handler-wrapper ok-handler #:mode 'strip))
|
||||
(let ((resp _body (wrapped (make-request 'GET "/" '()) #f)))
|
||||
(is (= 200 (response-code resp)))))
|
||||
|
||||
(test "root path passes through in append mode"
|
||||
(define wrapped
|
||||
(trailing-slash-handler-wrapper ok-handler #:mode 'append))
|
||||
(let ((resp _body (wrapped (make-request 'GET "/" '()) #f)))
|
||||
(is (= 200 (response-code resp)))))
|
||||
|
||||
(test "preserves query string"
|
||||
(define wrapped
|
||||
(trailing-slash-handler-wrapper ok-handler #:mode 'strip))
|
||||
(let* ((req (build-request
|
||||
(build-uri 'http #:host "localhost"
|
||||
#:path "/foo/" #:query "bar=1")
|
||||
#:method 'GET #:headers '()))
|
||||
(resp _body (wrapped req #f)))
|
||||
(is (= 301 (response-code resp)))
|
||||
(is (equal? "/foo?bar=1"
|
||||
(uri->string
|
||||
(assq-ref (response-headers resp) 'location))))))
|
||||
|
||||
(test "custom status code"
|
||||
(define wrapped
|
||||
(trailing-slash-handler-wrapper ok-handler
|
||||
#:mode 'strip #:code 302))
|
||||
(let ((resp _body (wrapped (make-request 'GET "/foo/" '()) #f)))
|
||||
(is (= 302 (response-code resp)))))
|
||||
|
||||
(test "make-trailing-slash-handler-wrapper factory"
|
||||
(define wrapper
|
||||
(make-trailing-slash-handler-wrapper #:mode 'append #:code 308))
|
||||
(define wrapped (wrapper ok-handler))
|
||||
(let ((resp _body (wrapped (make-request 'GET "/foo" '()) #f)))
|
||||
(is (= 308 (response-code resp)))
|
||||
(is (equal? "/foo/"
|
||||
(uri->string
|
||||
(assq-ref (response-headers resp) 'location))))))))
|
||||
|
||||
(run-tests handler-wrappers-tests)
|
||||
273
tests/test-integration.scm
Normal file
273
tests/test-integration.scm
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
;;; test-integration.scm — Full-stack integration tests
|
||||
;;;
|
||||
;;; Starts a real HTTP server inside run-fibers and makes requests
|
||||
;;; via a thread pool (Guile's (web client) uses blocking I/O that
|
||||
;;; does not cooperate with the fibers scheduler).
|
||||
|
||||
(use-modules (tests support)
|
||||
(fibers)
|
||||
(knots web-server)
|
||||
(knots thread-pool)
|
||||
(safsaf)
|
||||
(safsaf router)
|
||||
(safsaf response-helpers)
|
||||
(safsaf utils)
|
||||
(srfi srfi-71)
|
||||
(rnrs bytevectors)
|
||||
(web client)
|
||||
(web request)
|
||||
(web response)
|
||||
(web uri))
|
||||
|
||||
(define test-port 8399)
|
||||
(define test-base (string-append "http://127.0.0.1:"
|
||||
(number->string test-port)))
|
||||
|
||||
;;;
|
||||
;;; Test handlers
|
||||
;;;
|
||||
|
||||
(define (echo-form-handler request body-port)
|
||||
"Parse a URL-encoded form body and echo each field as key=value lines."
|
||||
(let ((fields (parse-form-body request body-port)))
|
||||
(text-response
|
||||
(string-join (map (lambda (pair)
|
||||
(string-append (car pair) "=" (cdr pair)))
|
||||
fields)
|
||||
"\n"))))
|
||||
|
||||
(define (echo-multipart-handler request body-port)
|
||||
"Parse a multipart body, extract text fields, echo as key=value lines."
|
||||
(let* ((parts (parse-multipart-body request body-port))
|
||||
(fields (multipart-text-fields parts)))
|
||||
(text-response
|
||||
(string-join (map (lambda (pair)
|
||||
(string-append (car pair) "=" (cdr pair)))
|
||||
fields)
|
||||
"\n"))))
|
||||
|
||||
(define (greet-handler request body-port)
|
||||
(text-response "hello"))
|
||||
|
||||
(define (catch-all-handler request body-port)
|
||||
(not-found-response))
|
||||
|
||||
;;;
|
||||
;;; Route table
|
||||
;;;
|
||||
|
||||
(define test-routes
|
||||
(list
|
||||
(route 'GET '("greet") greet-handler)
|
||||
(route 'POST '("form") echo-form-handler)
|
||||
(route 'POST '("multipart") echo-multipart-handler)
|
||||
(route '* '(. rest) catch-all-handler)))
|
||||
|
||||
;;;
|
||||
;;; Multipart body construction
|
||||
;;;
|
||||
|
||||
(define (make-multipart-body boundary fields)
|
||||
"Build a multipart/form-data body bytevector from FIELDS,
|
||||
an alist of (name . value) string pairs."
|
||||
(let ((parts
|
||||
(string-join
|
||||
(map (lambda (pair)
|
||||
(string-append
|
||||
"--" boundary "\r\n"
|
||||
"Content-Disposition: form-data; name=\""
|
||||
(car pair) "\"\r\n"
|
||||
"\r\n"
|
||||
(cdr pair)))
|
||||
fields)
|
||||
"\r\n")))
|
||||
(string->utf8
|
||||
(string-append parts "\r\n"
|
||||
"--" boundary "--\r\n"))))
|
||||
|
||||
;;;
|
||||
;;; Test runner
|
||||
;;;
|
||||
|
||||
(define %pass 0)
|
||||
(define %fail 0)
|
||||
|
||||
(define (check desc ok?)
|
||||
(if ok?
|
||||
(begin
|
||||
(set! %pass (1+ %pass))
|
||||
(format #t " ok ~a~%" desc))
|
||||
(begin
|
||||
(set! %fail (1+ %fail))
|
||||
(format #t " FAIL ~a~%" desc)))
|
||||
(force-output))
|
||||
|
||||
;;; HTTP client helper — runs requests on a thread pool because
|
||||
;;; Guile's (web client) uses blocking I/O incompatible with fibers.
|
||||
(define http-pool (make-fixed-size-thread-pool 1))
|
||||
|
||||
(define (test-post path headers body)
|
||||
"POST to the test server. Returns (values response body-string)."
|
||||
(call-with-thread
|
||||
http-pool
|
||||
(lambda ()
|
||||
(http-post (string-append test-base path)
|
||||
#:headers headers
|
||||
#:body body))))
|
||||
|
||||
(define (test-get path)
|
||||
"GET from the test server. Returns (values response body-string)."
|
||||
(call-with-thread
|
||||
http-pool
|
||||
(lambda ()
|
||||
(http-get (string-append test-base path)))))
|
||||
|
||||
(define (test-head path)
|
||||
"HEAD to the test server. Returns (values response body-string)."
|
||||
(call-with-thread
|
||||
http-pool
|
||||
(lambda ()
|
||||
(http-head (string-append test-base path)))))
|
||||
|
||||
(define (test-delete path)
|
||||
"DELETE to the test server. Returns (values response body-string)."
|
||||
(call-with-thread
|
||||
http-pool
|
||||
(lambda ()
|
||||
(http-delete (string-append test-base path)))))
|
||||
|
||||
;;;
|
||||
;;; Run everything inside a single run-fibers.
|
||||
;;;
|
||||
|
||||
(run-fibers
|
||||
(lambda ()
|
||||
(run-safsaf test-routes
|
||||
#:host "127.0.0.1"
|
||||
#:port test-port)
|
||||
|
||||
(sleep 1)
|
||||
|
||||
(with-exception-handler
|
||||
(lambda (exn)
|
||||
(format (current-error-port) "~%Test error: ~a~%" exn)
|
||||
(force-output (current-error-port))
|
||||
(primitive-_exit 1))
|
||||
(lambda ()
|
||||
|
||||
(format #t "parse-form-body via HTTP~%")
|
||||
|
||||
;; Test 1: basic url-encoded fields
|
||||
(let ((resp body
|
||||
(test-post "/form"
|
||||
'((content-type
|
||||
. (application/x-www-form-urlencoded)))
|
||||
"name=Alice&age=30")))
|
||||
(check "basic url-encoded fields"
|
||||
(and (= 200 (response-code resp))
|
||||
(string-contains body "name=Alice")
|
||||
(string-contains body "age=30"))))
|
||||
|
||||
;; Test 2: plus signs decoded as spaces
|
||||
(let ((resp body
|
||||
(test-post "/form"
|
||||
'((content-type
|
||||
. (application/x-www-form-urlencoded)))
|
||||
"greeting=hello+world")))
|
||||
(check "plus signs decoded as spaces"
|
||||
(and (= 200 (response-code resp))
|
||||
(string-contains body "greeting=hello world"))))
|
||||
|
||||
;; Test 3: percent-encoded values
|
||||
(let ((resp body
|
||||
(test-post "/form"
|
||||
'((content-type
|
||||
. (application/x-www-form-urlencoded)))
|
||||
"msg=caf%C3%A9")))
|
||||
(check "percent-encoded values"
|
||||
(and (= 200 (response-code resp))
|
||||
(string-contains body "msg=café"))))
|
||||
|
||||
(format #t "~%parse-multipart-body via HTTP~%")
|
||||
|
||||
;; Test 4: basic multipart text fields
|
||||
(let* ((boundary "----TestBoundary12345")
|
||||
(body-bv (make-multipart-body boundary
|
||||
'(("title" . "Hello")
|
||||
("body" . "World")))))
|
||||
(let ((resp body
|
||||
(test-post "/multipart"
|
||||
`((content-type
|
||||
. (multipart/form-data
|
||||
(boundary . ,boundary))))
|
||||
body-bv)))
|
||||
(check "basic multipart text fields"
|
||||
(and (= 200 (response-code resp))
|
||||
(string-contains body "title=Hello")
|
||||
(string-contains body "body=World")))))
|
||||
|
||||
(format #t "~%405 Method Not Allowed~%")
|
||||
|
||||
;; Test 5: POST to a GET-only route returns 405
|
||||
(let ((resp body (test-post "/greet" '() "")))
|
||||
(check "POST to GET-only route returns 405"
|
||||
(= 405 (response-code resp)))
|
||||
(check "405 response includes Allow header with GET and HEAD"
|
||||
(let ((allowed (assq-ref (response-headers resp)
|
||||
'allow)))
|
||||
(and (memq 'GET allowed)
|
||||
(memq 'HEAD allowed)))))
|
||||
|
||||
;; Test 6: DELETE to a GET-only route returns 405
|
||||
(let ((resp _body (test-delete "/greet")))
|
||||
(check "DELETE to GET-only route returns 405"
|
||||
(= 405 (response-code resp))))
|
||||
|
||||
(format #t "~%Automatic HEAD handling~%")
|
||||
|
||||
;; Test 7: HEAD to a GET route returns 200 with empty body
|
||||
(let ((resp body (test-head "/greet")))
|
||||
(check "HEAD to GET route returns 200"
|
||||
(= 200 (response-code resp)))
|
||||
(check "HEAD response has empty body"
|
||||
(or (not body)
|
||||
(and (string? body) (string-null? body)))))
|
||||
|
||||
;; Test 8: HEAD to a non-existent path falls through to catch-all
|
||||
(let ((resp _body (test-head "/no-such-path")))
|
||||
(check "HEAD to unknown path returns 404"
|
||||
(= 404 (response-code resp))))
|
||||
|
||||
;; Test 9: GET to the greet route works normally
|
||||
(let ((resp body (test-get "/greet")))
|
||||
(check "GET to greet route returns 200"
|
||||
(= 200 (response-code resp)))
|
||||
(check "GET to greet route returns body"
|
||||
(string-contains body "hello")))
|
||||
|
||||
;; Summary and exit.
|
||||
(newline)
|
||||
(let ((total (+ %pass %fail)))
|
||||
(format #t "~a passed, ~a failed (of ~a)~%"
|
||||
%pass %fail total)
|
||||
(force-output)
|
||||
(primitive-_exit (if (zero? %fail) 0 1))))
|
||||
#:unwind? #t))
|
||||
#:drain? #f)
|
||||
307
tests/test-params.scm
Normal file
307
tests/test-params.scm
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
(use-modules (tests support)
|
||||
(safsaf params)
|
||||
(safsaf handler-wrappers csrf))
|
||||
|
||||
(define-suite params-tests
|
||||
|
||||
(suite "processors"
|
||||
|
||||
(test "as-string passes through"
|
||||
(is (equal? "hello" (as-string "hello"))))
|
||||
|
||||
(test "as-integer parses integers"
|
||||
(is (equal? 42 (as-integer "42")))
|
||||
(is (equal? -3 (as-integer "-3")))
|
||||
(is (equal? 0 (as-integer "0"))))
|
||||
|
||||
(test "as-integer rejects non-integers"
|
||||
(is (invalid-param? (as-integer "3.14")))
|
||||
(is (invalid-param? (as-integer "abc")))
|
||||
(is (invalid-param? (as-integer ""))))
|
||||
|
||||
(test "as-number parses numbers"
|
||||
(is (equal? 42 (as-number "42")))
|
||||
(is (equal? 3.14 (as-number "3.14")))
|
||||
(is (equal? -1 (as-number "-1"))))
|
||||
|
||||
(test "as-number rejects non-numbers"
|
||||
(is (invalid-param? (as-number "abc")))
|
||||
(is (invalid-param? (as-number ""))))
|
||||
|
||||
(test "as-checkbox"
|
||||
(is (eq? #t (as-checkbox "on")))
|
||||
(is (eq? #f (as-checkbox "off")))
|
||||
(is (eq? #f (as-checkbox ""))))
|
||||
|
||||
(test "as-one-of accepts valid choices"
|
||||
(define proc (as-one-of '("red" "green" "blue")))
|
||||
(is (equal? "red" (proc "red")))
|
||||
(is (equal? "blue" (proc "blue"))))
|
||||
|
||||
(test "as-one-of rejects invalid choices"
|
||||
(define proc (as-one-of '("red" "green" "blue")))
|
||||
(is (invalid-param? (proc "yellow"))))
|
||||
|
||||
(test "as-one-of custom message"
|
||||
(define proc (as-one-of '("a") #:message "nope"))
|
||||
(is (equal? "nope" (invalid-param-message (proc "z")))))
|
||||
|
||||
(test "as-matching accepts matching values"
|
||||
(define proc (as-matching "^[0-9]+$"))
|
||||
(is (equal? "123" (proc "123"))))
|
||||
|
||||
(test "as-matching rejects non-matching values"
|
||||
(define proc (as-matching "^[0-9]+$"))
|
||||
(is (invalid-param? (proc "abc"))))
|
||||
|
||||
(test "as-predicate"
|
||||
(define proc (as-predicate (lambda (s) (> (string-length s) 3))))
|
||||
(is (equal? "hello" (proc "hello")))
|
||||
(is (invalid-param? (proc "hi")))))
|
||||
|
||||
(suite "parse-params"
|
||||
|
||||
(test "basic optional param"
|
||||
(define result
|
||||
(parse-params `((name ,as-string))
|
||||
'(("name" . "Alice"))))
|
||||
(is (equal? "Alice" (assq-ref result 'name))))
|
||||
|
||||
(test "missing optional param omitted"
|
||||
(define result
|
||||
(parse-params `((name ,as-string))
|
||||
'()))
|
||||
(is (not (assq-ref result 'name))))
|
||||
|
||||
(test "empty string treated as absent for optional"
|
||||
(define result
|
||||
(parse-params `((name ,as-string))
|
||||
'(("name" . ""))))
|
||||
(is (not (assq-ref result 'name))))
|
||||
|
||||
(test "required param present"
|
||||
(define result
|
||||
(parse-params `((name ,as-string #:required))
|
||||
'(("name" . "Bob"))))
|
||||
(is (equal? "Bob" (assq-ref result 'name))))
|
||||
|
||||
(test "required param missing"
|
||||
(define result
|
||||
(parse-params `((name ,as-string #:required))
|
||||
'()))
|
||||
(is (invalid-param? (assq-ref result 'name))))
|
||||
|
||||
(test "required param empty"
|
||||
(define result
|
||||
(parse-params `((name ,as-string #:required))
|
||||
'(("name" . ""))))
|
||||
(is (invalid-param? (assq-ref result 'name))))
|
||||
|
||||
(test "default used when absent"
|
||||
(define result
|
||||
(parse-params `((limit ,as-integer #:default 50))
|
||||
'()))
|
||||
(is (equal? 50 (assq-ref result 'limit))))
|
||||
|
||||
(test "default not used when present"
|
||||
(define result
|
||||
(parse-params `((limit ,as-integer #:default 50))
|
||||
'(("limit" . "10"))))
|
||||
(is (equal? 10 (assq-ref result 'limit))))
|
||||
|
||||
(test "default used when empty string"
|
||||
(define result
|
||||
(parse-params `((limit ,as-integer #:default 50))
|
||||
'(("limit" . ""))))
|
||||
(is (equal? 50 (assq-ref result 'limit))))
|
||||
|
||||
(test "multi-value collects all"
|
||||
(define result
|
||||
(parse-params `((color ,as-string #:multi-value))
|
||||
'(("color" . "red") ("color" . "blue"))))
|
||||
(is (equal? '("red" "blue") (assq-ref result 'color))))
|
||||
|
||||
(test "multi-value empty omitted"
|
||||
(define result
|
||||
(parse-params `((color ,as-string #:multi-value))
|
||||
'()))
|
||||
(is (not (assq-ref result 'color))))
|
||||
|
||||
(test "multi-value skips empty strings"
|
||||
(define result
|
||||
(parse-params `((color ,as-string #:multi-value))
|
||||
'(("color" . "red") ("color" . "") ("color" . "blue"))))
|
||||
(is (equal? '("red" "blue") (assq-ref result 'color))))
|
||||
|
||||
(test "multi-value with default"
|
||||
(define result
|
||||
(parse-params `((color ,as-string #:multi-value #:default ("red")))
|
||||
'()))
|
||||
(is (equal? '("red") (assq-ref result 'color))))
|
||||
|
||||
(test "no-default-when suppresses default"
|
||||
(define result
|
||||
(parse-params `((limit ,as-integer
|
||||
#:no-default-when (all_results) #:default 50)
|
||||
(all_results ,as-checkbox))
|
||||
'(("all_results" . "on"))))
|
||||
(is (not (assq-ref result 'limit)))
|
||||
(is (eq? #t (assq-ref result 'all_results))))
|
||||
|
||||
(test "no-default-when uses default when condition absent"
|
||||
(define result
|
||||
(parse-params `((limit ,as-integer
|
||||
#:no-default-when (all_results) #:default 50))
|
||||
'()))
|
||||
(is (equal? 50 (assq-ref result 'limit))))
|
||||
|
||||
(test "processor transforms value"
|
||||
(define result
|
||||
(parse-params `((count ,as-integer))
|
||||
'(("count" . "42"))))
|
||||
(is (equal? 42 (assq-ref result 'count))))
|
||||
|
||||
(test "processor error appears inline"
|
||||
(define result
|
||||
(parse-params `((count ,as-integer))
|
||||
'(("count" . "abc"))))
|
||||
(is (invalid-param? (assq-ref result 'count))))
|
||||
|
||||
(test "multiple params parsed together"
|
||||
(define result
|
||||
(parse-params `((name ,as-string #:required)
|
||||
(age ,as-integer #:default 0)
|
||||
(active ,as-checkbox))
|
||||
'(("name" . "Alice") ("active" . "on"))))
|
||||
(is (equal? "Alice" (assq-ref result 'name)))
|
||||
(is (equal? 0 (assq-ref result 'age)))
|
||||
(is (eq? #t (assq-ref result 'active)))))
|
||||
|
||||
(suite "any-invalid-params?"
|
||||
|
||||
(test "no errors"
|
||||
(is (not (any-invalid-params?
|
||||
'((name . "Alice") (age . 30))))))
|
||||
|
||||
(test "with error"
|
||||
(is (any-invalid-params?
|
||||
`((name . ,(make-invalid-param #f "required"))))))
|
||||
|
||||
(test "error in multi-value list"
|
||||
(is (any-invalid-params?
|
||||
`((colors . ("red" ,(make-invalid-param "x" "bad"))))))))
|
||||
|
||||
(suite "invalid-param-ref"
|
||||
|
||||
(test "returns record when invalid"
|
||||
(define params `((name . ,(make-invalid-param "" "required"))))
|
||||
(is (invalid-param? (invalid-param-ref params 'name))))
|
||||
|
||||
(test "returns #f when valid"
|
||||
(is (not (invalid-param-ref '((name . "Alice")) 'name))))
|
||||
|
||||
(test "returns #f when absent"
|
||||
(is (not (invalid-param-ref '() 'name)))))
|
||||
|
||||
(suite "field-errors"
|
||||
|
||||
(test "returns error messages"
|
||||
(define params `((name . ,(make-invalid-param "" "required"))))
|
||||
(is (equal? '("required") (field-errors params 'name))))
|
||||
|
||||
(test "returns empty for valid field"
|
||||
(is (equal? '() (field-errors '((name . "Alice")) 'name))))
|
||||
|
||||
(test "returns empty for absent field"
|
||||
(is (equal? '() (field-errors '() 'name)))))
|
||||
|
||||
(suite "guard-against-mutually-exclusive-params"
|
||||
|
||||
(test "no conflict"
|
||||
(define result
|
||||
(guard-against-mutually-exclusive-params
|
||||
'((limit . 50))
|
||||
'((limit all_results))))
|
||||
(is (equal? 50 (assq-ref result 'limit))))
|
||||
|
||||
(test "conflict marks both invalid"
|
||||
(define result
|
||||
(guard-against-mutually-exclusive-params
|
||||
'((limit . 50) (all_results . #t))
|
||||
'((limit all_results))))
|
||||
(is (invalid-param? (assq-ref result 'limit)))
|
||||
(is (invalid-param? (assq-ref result 'all_results))))
|
||||
|
||||
(test "preserves existing errors"
|
||||
(define result
|
||||
(guard-against-mutually-exclusive-params
|
||||
`((name . ,(make-invalid-param #f "bad")))
|
||||
'((name other))))
|
||||
(is (invalid-param? (assq-ref result 'name)))))
|
||||
|
||||
(suite "params->query-string"
|
||||
|
||||
(test "simple params"
|
||||
(is (equal? "name=Alice&age=30"
|
||||
(params->query-string '((name . "Alice") (age . 30))))))
|
||||
|
||||
(test "boolean params"
|
||||
(is (equal? "active=on"
|
||||
(params->query-string '((active . #t))))))
|
||||
|
||||
(test "multi-value params"
|
||||
(is (equal? "color=red&color=blue"
|
||||
(params->query-string '((color . ("red" "blue")))))))
|
||||
|
||||
(test "skips invalid params"
|
||||
(is (equal? "name=Alice"
|
||||
(params->query-string
|
||||
`((name . "Alice")
|
||||
(bad . ,(make-invalid-param "x" "err")))))))
|
||||
|
||||
(test "empty result"
|
||||
(is (equal? "" (params->query-string '())))))
|
||||
|
||||
(suite "parse-form-params"
|
||||
|
||||
(test "csrf pass"
|
||||
(parameterize ((current-csrf-token "tok123"))
|
||||
(define result
|
||||
(parse-form-params
|
||||
`((name ,as-string #:required))
|
||||
'(("csrf-token" . "tok123") ("name" . "Alice"))))
|
||||
(is (not (any-invalid-params? result)))
|
||||
(is (equal? "Alice" (assq-ref result 'name)))))
|
||||
|
||||
(test "csrf fail"
|
||||
(parameterize ((current-csrf-token "tok123"))
|
||||
(define result
|
||||
(parse-form-params
|
||||
`((name ,as-string))
|
||||
'(("csrf-token" . "wrong"))))
|
||||
(is (any-invalid-params? result))))
|
||||
|
||||
(test "csrf missing"
|
||||
(parameterize ((current-csrf-token "tok123"))
|
||||
(define result
|
||||
(parse-form-params `() '()))
|
||||
(is (any-invalid-params? result))))))
|
||||
|
||||
(run-tests params-tests)
|
||||
152
tests/test-response-helpers.scm
Normal file
152
tests/test-response-helpers.scm
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
;;; test-response-helpers.scm — Tests for (safsaf response-helpers)
|
||||
|
||||
(use-modules (tests support)
|
||||
(safsaf response-helpers)
|
||||
(safsaf router)
|
||||
(srfi srfi-71)
|
||||
(web response)
|
||||
(web request)
|
||||
(web uri))
|
||||
|
||||
(define (body->string body)
|
||||
"Capture a body (string or writer procedure) as a string."
|
||||
(if (procedure? body)
|
||||
(call-with-output-string body)
|
||||
body))
|
||||
|
||||
(define (make-request method path headers)
|
||||
(build-request (build-uri 'http #:host "localhost" #:path path)
|
||||
#:method method
|
||||
#:headers headers))
|
||||
|
||||
(define-suite response-helpers-tests
|
||||
|
||||
(suite "response constructors"
|
||||
|
||||
(test "html-response"
|
||||
(let ((resp body (html-response '(p "hello"))))
|
||||
(is (= 200 (response-code resp)))
|
||||
(is (equal? '(text/html (charset . "utf-8"))
|
||||
(assq-ref (response-headers resp) 'content-type)))
|
||||
(is (string-contains (body->string body) "hello"))))
|
||||
|
||||
(test "json-response"
|
||||
(let ((resp body (json-response "{\"a\":1}")))
|
||||
(is (= 200 (response-code resp)))
|
||||
(is (equal? '(application/json)
|
||||
(assq-ref (response-headers resp) 'content-type)))
|
||||
(is (equal? "{\"a\":1}" body))))
|
||||
|
||||
(test "text-response"
|
||||
(let ((resp body (text-response "hi")))
|
||||
(is (= 200 (response-code resp)))
|
||||
(is (equal? "hi" body))))
|
||||
|
||||
(test "redirect-response"
|
||||
(let ((resp _body (redirect-response "/foo")))
|
||||
(is (= 303 (response-code resp)))
|
||||
(is (response-headers resp))))
|
||||
|
||||
(test "custom code"
|
||||
(let ((resp _body (text-response "x" #:code 201)))
|
||||
(is (= 201 (response-code resp))))))
|
||||
|
||||
(suite "error responses"
|
||||
|
||||
(test "status codes"
|
||||
(let ((r1 _b1 (not-found-response))
|
||||
(r2 _b2 (forbidden-response))
|
||||
(r3 _b3 (bad-request-response))
|
||||
(r4 _b4 (internal-server-error-response)))
|
||||
(is (= 404 (response-code r1)))
|
||||
(is (= 403 (response-code r2)))
|
||||
(is (= 400 (response-code r3)))
|
||||
(is (= 500 (response-code r4))))))
|
||||
|
||||
(suite "streaming json"
|
||||
|
||||
(test "scm-alist->streaming-json"
|
||||
(let ((out (call-with-output-string
|
||||
(lambda (port)
|
||||
(scm-alist->streaming-json
|
||||
'(("name" . "Alice") ("age" . 30))
|
||||
port)))))
|
||||
(is (string-contains out "\"name\":\"Alice\""))
|
||||
(is (string-contains out "\"age\":30"))))
|
||||
|
||||
(test "list->streaming-json-array"
|
||||
(let ((out (call-with-output-string
|
||||
(lambda (port)
|
||||
(list->streaming-json-array
|
||||
identity '(1 2 3) port)))))
|
||||
(is (equal? "[1,2,3]" out)))))
|
||||
|
||||
(suite "content negotiation"
|
||||
|
||||
(test "path extension takes priority over accept header"
|
||||
(let ((req (make-request 'GET "/things.json"
|
||||
'((accept . ((text/html)))))))
|
||||
(is (eq? 'application/json
|
||||
(negotiate-content-type req '(text/html application/json))))))
|
||||
|
||||
(test "falls back to accept header without extension"
|
||||
(let ((req (make-request 'GET "/things"
|
||||
'((accept . ((application/json)))))))
|
||||
(is (eq? 'application/json
|
||||
(negotiate-content-type req '(text/html application/json))))))
|
||||
|
||||
(test "ignores extension not in supported list"
|
||||
(let ((req (make-request 'GET "/things.txt"
|
||||
'((accept . ((text/html)))))))
|
||||
(is (eq? 'text/html
|
||||
(negotiate-content-type req '(text/html application/json))))))
|
||||
|
||||
(test "defaults to first supported when nothing matches"
|
||||
(let ((req (make-request 'GET "/things"
|
||||
'((accept . ((image/png)))))))
|
||||
(is (eq? 'text/html
|
||||
(negotiate-content-type req '(text/html application/json)))))))
|
||||
|
||||
(suite "static handler"
|
||||
|
||||
(test "serves file and rejects traversal"
|
||||
(let* ((tmp (tmpnam))
|
||||
(_ (mkdir tmp))
|
||||
(f (string-append tmp "/test.txt"))
|
||||
(_ (call-with-output-file f
|
||||
(lambda (p) (display "content" p))))
|
||||
(handler (make-static-handler tmp)))
|
||||
;; Serve existing file.
|
||||
(parameterize ((current-route-params `((path . ("test.txt")))))
|
||||
(let ((resp body (handler (make-request 'GET "/test.txt" '()) #f)))
|
||||
(is (= 200 (response-code resp)))
|
||||
(is (equal? "content" (body->string body)))))
|
||||
;; Traversal rejected.
|
||||
(parameterize ((current-route-params `((path . (".." "etc" "passwd")))))
|
||||
(let ((resp _body (handler (make-request 'GET "/../etc/passwd" '()) #f)))
|
||||
(is (= 404 (response-code resp)))))
|
||||
;; Missing file.
|
||||
(parameterize ((current-route-params `((path . ("nope.txt")))))
|
||||
(let ((resp _body (handler (make-request 'GET "/nope.txt" '()) #f)))
|
||||
(is (= 404 (response-code resp)))))
|
||||
(delete-file f)
|
||||
(rmdir tmp)))))
|
||||
|
||||
(run-tests response-helpers-tests)
|
||||
188
tests/test-router.scm
Normal file
188
tests/test-router.scm
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
;;; test-router.scm — Tests for (safsaf router)
|
||||
|
||||
(use-modules (tests support)
|
||||
(safsaf router)
|
||||
(srfi srfi-71))
|
||||
|
||||
;; Every compile-routes call needs a catch-all as the last route.
|
||||
(define catch-all
|
||||
(route '* '(. rest) (lambda (r) (values 'not-found #f))))
|
||||
|
||||
(define (match-path routes method path)
|
||||
"Compile ROUTES (appending catch-all), match METHOD and PATH segments."
|
||||
(let ((compiled _rr (compile-routes (append routes (list catch-all)))))
|
||||
(let ((handler bindings (match-route compiled method path)))
|
||||
(values handler bindings))))
|
||||
|
||||
(define-suite router-tests
|
||||
|
||||
(suite "match-route"
|
||||
|
||||
(test "literal path"
|
||||
(define h (lambda (r) 'ok))
|
||||
(define routes (list (route 'GET '("users" "list") h)))
|
||||
(let ((handler bindings (match-path routes 'GET '("users" "list"))))
|
||||
(is (eq? h handler))
|
||||
(is (equal? '() bindings))))
|
||||
|
||||
(test "no match falls through to catch-all"
|
||||
(define h (lambda (r) 'ok))
|
||||
(define routes (list (route 'GET '("users") h)))
|
||||
(let ((handler _bindings (match-path routes 'GET '("other"))))
|
||||
(is (not (eq? h handler)))))
|
||||
|
||||
(test "capture segment"
|
||||
(define h (lambda (r) 'ok))
|
||||
(define routes (list (route 'GET '("users" id) h)))
|
||||
(let ((_handler bindings (match-path routes 'GET '("users" "42"))))
|
||||
(is (equal? "42" (assq-ref bindings 'id)))))
|
||||
|
||||
(test "wildcard rest"
|
||||
(define h (lambda (r) 'ok))
|
||||
(define routes (list (route 'GET '("files" . path) h)))
|
||||
(let ((_handler bindings (match-path routes 'GET '("files" "a" "b"))))
|
||||
(is (equal? '("a" "b") (assq-ref bindings 'path)))))
|
||||
|
||||
(test "predicate segment"
|
||||
(define h (lambda (r) 'ok))
|
||||
(define routes
|
||||
(list (route 'GET `("items" (,string->number id)) h)))
|
||||
(let ((handler _b (match-path routes 'GET '("items" "99"))))
|
||||
(is (eq? h handler)))
|
||||
(let ((handler _b (match-path routes 'GET '("items" "abc"))))
|
||||
(is (not (eq? h handler)))))
|
||||
|
||||
(test "method filtering"
|
||||
(define h-get (lambda (r) 'get))
|
||||
(define h-post (lambda (r) 'post))
|
||||
(define routes (list (route 'GET '("x") h-get)
|
||||
(route 'POST '("x") h-post)))
|
||||
(let ((handler _b (match-path routes 'GET '("x"))))
|
||||
(is (eq? h-get handler)))
|
||||
(let ((handler _b (match-path routes 'POST '("x"))))
|
||||
(is (eq? h-post handler))))
|
||||
|
||||
(test "multi-method route"
|
||||
(define h (lambda (r) 'ok))
|
||||
(define routes (list (route '(GET HEAD) '("x") h)))
|
||||
(let ((handler _b (match-path routes 'GET '("x"))))
|
||||
(is (eq? h handler)))
|
||||
(let ((handler _b (match-path routes 'HEAD '("x"))))
|
||||
(is (eq? h handler)))
|
||||
(let ((handler _b (match-path routes 'POST '("x"))))
|
||||
(is (not (eq? h handler))))))
|
||||
|
||||
(suite "route-group"
|
||||
|
||||
(test "prefix nesting"
|
||||
(define h (lambda (r) 'ok))
|
||||
(define routes
|
||||
(list (route-group '("api")
|
||||
(route 'GET '("users") h #:name 'api-users))))
|
||||
(let ((handler _b (match-path routes 'GET '("api" "users"))))
|
||||
(is (eq? h handler)))
|
||||
(let ((handler _b (match-path routes 'GET '("users"))))
|
||||
(is (not (eq? h handler))))))
|
||||
|
||||
(suite "wrap-routes"
|
||||
|
||||
(test "wrapper ordering"
|
||||
;; First wrapper = outermost = runs first on request.
|
||||
;; We verify by building a call log.
|
||||
(define log '())
|
||||
(define (make-wrapper tag)
|
||||
(lambda (handler)
|
||||
(lambda (request)
|
||||
(set! log (append log (list tag)))
|
||||
(handler request))))
|
||||
(define h (lambda (r) (set! log (append log '(handler))) 'ok))
|
||||
(define r (route 'GET '("x") h))
|
||||
(wrap-routes (list r) (make-wrapper 'a) (make-wrapper 'b))
|
||||
((route-handler r) 'fake-request)
|
||||
(is (equal? '(a b handler) log))))
|
||||
|
||||
(suite "find-allowed-methods"
|
||||
|
||||
(test "returns methods for path-matched routes"
|
||||
(define routes
|
||||
(list (route 'GET '("users") identity)
|
||||
(route 'POST '("users") identity)))
|
||||
(let ((compiled _rr (compile-routes (append routes (list catch-all)))))
|
||||
(is (equal? '(POST GET)
|
||||
(find-allowed-methods compiled '("users"))))))
|
||||
|
||||
(test "returns empty for unmatched path"
|
||||
(define routes (list (route 'GET '("users") identity)))
|
||||
(let ((compiled _rr (compile-routes (append routes (list catch-all)))))
|
||||
(is (equal? '() (find-allowed-methods compiled '("other"))))))
|
||||
|
||||
(test "collects from multi-method routes"
|
||||
(define routes (list (route '(GET HEAD) '("x") identity)
|
||||
(route 'POST '("x") identity)))
|
||||
(let ((compiled _rr (compile-routes (append routes (list catch-all)))))
|
||||
(is (equal? '(POST HEAD GET)
|
||||
(find-allowed-methods compiled '("x"))))))
|
||||
|
||||
(test "deduplicates methods"
|
||||
(define routes (list (route 'GET '("x") identity)
|
||||
(route 'GET '("x") identity)))
|
||||
(let ((compiled _rr (compile-routes (append routes (list catch-all)))))
|
||||
(is (equal? '(GET)
|
||||
(find-allowed-methods compiled '("x"))))))
|
||||
|
||||
(test "excludes catch-all from scan"
|
||||
(let ((compiled _rr (compile-routes (list catch-all))))
|
||||
(is (equal? '() (find-allowed-methods compiled '("anything")))))))
|
||||
|
||||
(suite "path-for"
|
||||
|
||||
(test "simple and parameterised"
|
||||
(define grp
|
||||
(route-group '()
|
||||
(route 'GET '("users") identity #:name 'users)
|
||||
(route 'GET '("users" id) identity #:name 'user)))
|
||||
(let ((_compiled rr
|
||||
(compile-routes (list grp catch-all))))
|
||||
(parameterize ((current-reverse-routes rr))
|
||||
(is (equal? "/users" (path-for grp 'users)))
|
||||
(is (equal? "/users/42" (path-for grp 'user '((id . "42"))))))))
|
||||
|
||||
(test "query and fragment"
|
||||
(define grp
|
||||
(route-group '()
|
||||
(route 'GET '("search") identity #:name 'search)))
|
||||
(let ((_compiled rr
|
||||
(compile-routes (list grp catch-all))))
|
||||
(parameterize ((current-reverse-routes rr))
|
||||
(is (equal? "/search?q=hello"
|
||||
(path-for grp 'search '() #:query '((q . "hello")))))
|
||||
(is (equal? "/search#top"
|
||||
(path-for grp 'search '() #:fragment "top"))))))
|
||||
|
||||
(test "scoped lookup in group"
|
||||
(define grp
|
||||
(route-group '("api") #:name 'api
|
||||
(route 'GET '("items") identity #:name 'items)))
|
||||
(let ((_compiled rr
|
||||
(compile-routes (list grp catch-all))))
|
||||
(parameterize ((current-reverse-routes rr))
|
||||
(is (equal? "/api/items" (path-for grp 'items))))))))
|
||||
|
||||
(run-tests router-tests)
|
||||
102
tests/test-templating.scm
Normal file
102
tests/test-templating.scm
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
;;; test-templating.scm — Tests for (safsaf templating)
|
||||
|
||||
(use-modules (tests support)
|
||||
(safsaf templating)
|
||||
(htmlprag)
|
||||
(srfi srfi-71)
|
||||
(web response))
|
||||
|
||||
(define (render shtml)
|
||||
"Write SHTML via write-shtml-as-html/streaming and capture as a string."
|
||||
(call-with-output-string
|
||||
(lambda (port) (write-shtml-as-html/streaming shtml port))))
|
||||
|
||||
(define-suite templating-tests
|
||||
|
||||
(suite "write-shtml-as-html/streaming"
|
||||
|
||||
(test "pure static shtml"
|
||||
(let ((out (render '(p "hello"))))
|
||||
(is (string-contains out "<p>hello</p>"))))
|
||||
|
||||
(test "single proc slot"
|
||||
(let ((out (render
|
||||
`(div ,(lambda (port) (display "dynamic" port))))))
|
||||
(is (string-contains out "dynamic"))))
|
||||
|
||||
(test "multiple slots in order"
|
||||
(let ((out (render
|
||||
`(div ,(lambda (port) (display "AAA" port))
|
||||
,(lambda (port) (display "BBB" port))))))
|
||||
(let ((a (string-contains out "AAA"))
|
||||
(b (string-contains out "BBB")))
|
||||
(is a)
|
||||
(is b)
|
||||
(is (< a b)))))
|
||||
|
||||
(test "static content between slots preserved"
|
||||
(let ((out (render
|
||||
`(div ,(lambda (port) (display "X" port))
|
||||
(hr)
|
||||
,(lambda (port) (display "Y" port))))))
|
||||
(is (string-contains out "<hr"))))
|
||||
|
||||
(test "nested element with proc child"
|
||||
(let ((out (render
|
||||
`(html (body ,(lambda (port) (display "inner" port)))))))
|
||||
(is (string-contains out "<body>inner</body>"))))
|
||||
|
||||
(test "attributes preserved"
|
||||
(let ((out (render
|
||||
`(div (@ (class "box"))
|
||||
,(lambda (port) (display "content" port))))))
|
||||
(is (string-contains out "class=\"box\""))
|
||||
(is (string-contains out "content"))))
|
||||
|
||||
(test "*TOP* with procs"
|
||||
(let ((out (render
|
||||
`(*TOP*
|
||||
(*DECL* DOCTYPE html)
|
||||
(html (body ,(lambda (port) (display "hi" port))))))))
|
||||
(is (string-contains out "<!DOCTYPE html>"))
|
||||
(is (string-contains out "hi"))))
|
||||
|
||||
(test "proc can write shtml via htmlprag"
|
||||
(let ((out (render
|
||||
`(div ,(lambda (port)
|
||||
(write-shtml-as-html '(p "from-proc") port))))))
|
||||
(is (string-contains out "<p>from-proc</p>")))))
|
||||
|
||||
(suite "streaming-html-response"
|
||||
|
||||
(test "returns response and writer"
|
||||
(let ((resp body (streaming-html-response '(p "hi"))))
|
||||
(is (= 200 (response-code resp)))
|
||||
(is (procedure? body))
|
||||
(is (equal? '(text/html (charset . "utf-8"))
|
||||
(assq-ref (response-headers resp) 'content-type)))))
|
||||
|
||||
(test "body writes shtml with procs"
|
||||
(let ((resp body (streaming-html-response
|
||||
`(div ,(lambda (port) (display "streamed" port))))))
|
||||
(let ((out (call-with-output-string body)))
|
||||
(is (string-contains out "streamed")))))))
|
||||
|
||||
(run-tests templating-tests)
|
||||
85
tests/test-utils.scm
Normal file
85
tests/test-utils.scm
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
;; Safsaf, a Guile web framework
|
||||
;; Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
|
||||
;; This program 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.
|
||||
;;
|
||||
;; This program is distributed in the hope that it will be useful, but
|
||||
;; WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
;; Lesser General Public License for more details.
|
||||
;;
|
||||
;; You should have received a copy of the GNU Lesser General Public
|
||||
;; License along with this program. If not, see
|
||||
;; <https://www.gnu.org/licenses/>.
|
||||
|
||||
;;; test-utils.scm — Tests for (safsaf utils)
|
||||
|
||||
(use-modules (tests support)
|
||||
(safsaf utils)
|
||||
(web request)
|
||||
(web uri))
|
||||
|
||||
(define* (make-request method path #:key (headers '()))
|
||||
(build-request (string->uri (string-append "http://localhost" path))
|
||||
#:method method
|
||||
#:headers headers))
|
||||
|
||||
(define-suite utils-tests
|
||||
(suite "parse-query-string"
|
||||
(test "parses key=value pairs"
|
||||
(let* ((req (make-request 'GET "/?foo=bar&baz=qux"))
|
||||
(qs (parse-query-string req)))
|
||||
(is (equal? '(("foo" . "bar") ("baz" . "qux")) qs))))
|
||||
|
||||
(test "returns empty list when no query string"
|
||||
(let* ((req (make-request 'GET "/"))
|
||||
(qs (parse-query-string req)))
|
||||
(is (null? qs))))
|
||||
|
||||
(test "decodes URL-encoded values"
|
||||
(let* ((req (make-request 'GET "/?name=hello%20world"))
|
||||
(qs (parse-query-string req)))
|
||||
(is (equal? "hello world" (assoc-ref qs "name")))))
|
||||
|
||||
(test "handles key without value"
|
||||
(let* ((req (make-request 'GET "/?flag"))
|
||||
(qs (parse-query-string req)))
|
||||
(is (equal? '(("flag" . "")) qs))))
|
||||
|
||||
(test "handles multiple values for same key"
|
||||
(let* ((req (make-request 'GET "/?x=1&x=2"))
|
||||
(qs (parse-query-string req)))
|
||||
(is (= 2 (length qs)))
|
||||
(is (equal? "1" (assoc-ref qs "x"))))))
|
||||
|
||||
(suite "request-cookies"
|
||||
(test "parses cookie header"
|
||||
(let* ((req (make-request 'GET "/"
|
||||
#:headers '((cookie . (("a" . "1")
|
||||
("b" . "2"))))))
|
||||
(cookies (request-cookies req)))
|
||||
(is (equal? '(("a" . "1") ("b" . "2")) cookies))))
|
||||
|
||||
(test "returns empty list when no cookie header"
|
||||
(let* ((req (make-request 'GET "/"))
|
||||
(cookies (request-cookies req)))
|
||||
(is (null? cookies)))))
|
||||
|
||||
(suite "request-cookie-ref"
|
||||
(test "returns cookie value by name"
|
||||
(let ((req (make-request 'GET "/"
|
||||
#:headers '((cookie . (("sid" . "abc123")))))))
|
||||
(is (equal? "abc123" (request-cookie-ref req "sid")))))
|
||||
|
||||
(test "returns #f when cookie not found"
|
||||
(let ((req (make-request 'GET "/")))
|
||||
(is (not (request-cookie-ref req "missing")))))
|
||||
|
||||
(test "returns default when cookie not found"
|
||||
(let ((req (make-request 'GET "/")))
|
||||
(is (equal? "fallback" (request-cookie-ref req "missing" "fallback")))))))
|
||||
|
||||
(run-tests utils-tests)
|
||||
Loading…
Add table
Add a link
Reference in a new issue