Skip to content

Commit c915365

Browse files
authored
Add cookie parsing and serialization (#80)
Stallion users can now read cookies from requests and build validated Set-Cookie response headers without pulling in a web framework. Cookie signing (HMAC) stays in pyrois. Request cookies are automatically parsed from Cookie headers and available via request'.cookies.get("name") or .values(). ParseCookies provides direct parsing for use outside the request lifecycle. SetCookieBuilder constructs Set-Cookie headers with secure defaults (Secure, HttpOnly, SameSite=Lax). It validates names (RFC 2616 token), values (RFC 6265 cookie-octets), path and domain attributes (US-ASCII 0x20-0x7E, no semicolons), __Host-/__Secure- prefix rules (case-sensitive per RFC 6265bis), and SameSite=None + Secure consistency, returning typed errors on failure. Headers.values() now yields Header val objects instead of (String, String) tuples — a breaking change for code that destructures header values. Design: #74 Closes #79
1 parent 304fd50 commit c915365

28 files changed

+2002
-21
lines changed

.release-notes/next-release.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
## Added cookie parsing and serialization
2+
3+
Stallion now provides built-in cookie support in two directions: reading cookies from requests and building `Set-Cookie` response headers.
4+
5+
Cookies are automatically parsed from `Cookie` request headers and available on the `Request` object. Use `request'.cookies.get("name")` to look up a cookie by name, or `request'.cookies.values()` to iterate over all parsed cookies:
6+
7+
```pony
8+
fun ref on_request_complete(request': stallion.Request val,
9+
responder: stallion.Responder)
10+
=>
11+
match request'.cookies.get("session")
12+
| let token: String val =>
13+
// Use the session token
14+
end
15+
```
16+
17+
For direct parsing outside the request lifecycle, `ParseCookies` accepts a raw `Cookie` header value string or a `Headers val` collection.
18+
19+
To build `Set-Cookie` response headers, use `SetCookieBuilder`. It defaults to `Secure`, `HttpOnly`, and `SameSite=Lax` — override explicitly when needed:
20+
21+
```pony
22+
match stallion.SetCookieBuilder("session", token)
23+
.with_path("/")
24+
.with_max_age(3600)
25+
.build()
26+
| let sc: stallion.SetCookie val =>
27+
// Add to response: .add_header("Set-Cookie", sc.header_value())
28+
| let err: stallion.SetCookieBuildError =>
29+
// Handle validation error
30+
end
31+
```
32+
33+
The builder validates cookie names (RFC 2616 token), values (RFC 6265 cookie-octets), and path/domain attributes (no CTLs or semicolons), enforces `__Host-` and `__Secure-` prefix rules, and checks `SameSite=None` + `Secure` consistency.
34+
35+
New types: `Header`, `RequestCookie`, `RequestCookies`, `ParseCookies`, `SetCookie`, `SetCookieBuilder`, `SetCookieBuildError`, `SameSite` (`SameSiteStrict`, `SameSiteLax`, `SameSiteNone`).
36+
37+
## Changed Headers.values() to yield Header val instead of tuples
38+
39+
`Headers.values()` now yields `Header val` objects instead of `(String, String)` tuples. Code that destructures header values needs to change from field access on tuples to field access on the `Header` class:
40+
41+
Before:
42+
43+
```pony
44+
for (name, value) in headers.values() do
45+
env.out.print(name + ": " + value)
46+
end
47+
```
48+
49+
After:
50+
51+
```pony
52+
for hdr in headers.values() do
53+
env.out.print(hdr.name + ": " + hdr.value)
54+
end
55+
```

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@ All notable changes to this project will be documented in this file. This projec
99

1010
### Added
1111

12+
- Add cookie parsing and serialization ([PR #80](https://github.com/ponylang/stallion/pull/80))
1213

1314
### Changed
1415

16+
- Change Headers.values() to yield Header val instead of tuples ([PR #80](https://github.com/ponylang/stallion/pull/80))
17+
1518

1619
## [0.4.0] - 2026-03-03
1720

CLAUDE.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,16 @@ Follow the standard ponylang release notes conventions. Create individual `.md`
5555
- `method.pony` — HTTP method types (`Method` interface, 9 primitives, `Methods` parse/enumerate)
5656
- `version.pony` — HTTP version types (`HTTP10`, `HTTP11`, `Version` closed union)
5757
- `status.pony` — HTTP status codes (`Status` interface, 35 standard primitives)
58-
- `headers.pony` — Case-insensitive header collection (`Headers` class)
58+
- `header.pony` — Single HTTP header name-value pair (`Header val` class)
59+
- `headers.pony` — Case-insensitive header collection (`Headers` class, stores `Array[Header val]`)
5960
- `_response_serializer.pony` — Response wire-format serializer (package-private)
6061
- `_mort.pony` — Runtime enforcement primitives (`_IllegalState`, `_Unreachable`)
6162
- `parse_error.pony` — Parse error types (`ParseError` union, 8 error primitives)
6263
- `_parser_config.pony` — Parser size limit configuration
6364
- `_request_parser_notify.pony` — Parser callback trait (synchronous `ref` methods)
6465
- `_parser_state.pony` — Parser state machine (state interface, 6 state classes, `_BufferScan`)
6566
- `_request_parser.pony` — Request parser class (entry point, buffer management)
66-
- `request.pony` — Immutable request metadata bundle (`Request val`: method, URI, version, headers)
67+
- `request.pony` — Immutable request metadata bundle (`Request val`: method, URI, version, headers, cookies)
6768
- `chunk_send_token.pony` — Opaque chunk send token (`ChunkSendToken val`, `Equatable`, private constructor)
6869
- `http_server_lifecycle_event_receiver.pony` — HTTP callback trait (`HTTPServerLifecycleEventReceiver`: on_request, on_body_chunk, on_request_complete, on_chunk_sent, on_closed, on_throttled, on_unthrottled)
6970
- `http_server_actor.pony` — Server actor trait (`HTTPServerActor`: extends `TCPConnectionActor` and `HTTPServerLifecycleEventReceiver`, provides `_connection()` default)
@@ -79,10 +80,20 @@ Follow the standard ponylang release notes conventions. Create individual `.md`
7980
- `server_config.pony` — Server configuration (`ServerConfig` class with `max_requests_per_connection: (MaxRequestsPerConnection | None)`, `DefaultIdleTimeout` primitive)
8081
- `_error_response.pony` — Pre-built error response strings (`_ErrorResponse` primitive)
8182
- `_connection_state.pony` — Connection lifecycle states (`_Active`, `_Closed`; routes `on_sent` for chunk token delivery)
83+
- `request_cookie.pony` — Single parsed cookie name-value pair (`RequestCookie val`, private constructor)
84+
- `request_cookies.pony` — Immutable collection of parsed request cookies (`RequestCookies val`, `get()`, `values()`, `size()`)
85+
- `parse_cookies.pony` — Cookie parser (`ParseCookies` primitive: `from_headers()`, `apply()`, lenient RFC 6265 §5.4 parsing)
86+
- `same_site.pony` — SameSite attribute types (`SameSiteStrict`, `SameSiteLax`, `SameSiteNone`, `SameSite` union)
87+
- `set_cookie_build_error.pony` — Build error types (`InvalidCookieName`, `InvalidCookieValue`, `InvalidCookiePath`, `InvalidCookieDomain`, `CookiePrefixViolation`, `SameSiteRequiresSecure`, `SetCookieBuildError` union)
88+
- `set_cookie.pony` — Validated, pre-serialized `Set-Cookie` header (`SetCookie val`, `header_value()`, private constructor)
89+
- `set_cookie_builder.pony``Set-Cookie` header builder (`SetCookieBuilder ref`, secure defaults, chaining, prefix rules)
90+
- `_cookie_validator.pony` — Cookie name/value/attribute validation (RFC 2616 token, RFC 6265 cookie-octet, path/domain safety)
91+
- `_http_date.pony` — IMF-fixdate formatter for `Expires` attribute (`_HTTPDate` primitive)
8292
- `assets/` — test assets
8393
- `cert.pem` — Self-signed test certificate for SSL examples
8494
- `key.pem` — Test private key for SSL examples
8595
- `examples/` — example programs
96+
- `cookies/main.pony` — Visit counter demonstrating `Request.cookies` and `SetCookieBuilder`
8697
- `hello/main.pony` — Greeting server with URI parsing and query parameter extraction
8798
- `ssl/main.pony` — HTTPS server using SSL/TLS
8899
- `streaming/main.pony` — Flow-controlled chunked transfer encoding streaming response using `on_chunk_sent()` callbacks

examples/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ Each subdirectory is a self-contained Pony program demonstrating a different par
66

77
Greeting server that responds with "Hello, World!" by default, or "Hello, {name}!" when a `?name=X` query parameter is provided. Demonstrates the core API: a listener actor implements `lori.TCPListenerActor`, creates connection actors in `_on_accept`, and each connection actor uses `HTTPServerActor`, `HTTPServer`, `Request`, `Responder`, `ResponseBuilder`, and `ServerConfig`. Start here if you're new to the library.
88

9+
## [cookies](cookies/)
10+
11+
Visit counter that reads the `visits` cookie from incoming requests, increments it, and sets it back via `Set-Cookie`. Demonstrates both `Request.cookies` for reading cookies parsed from request headers and `SetCookieBuilder` for building validated `Set-Cookie` response headers with secure defaults.
12+
913
## [ssl](ssl/)
1014

1115
HTTPS server using SSL/TLS. Demonstrates creating an `SSLContext`, loading certificate and key files, and passing the context to connection actors via `_on_accept`. Actors use `HTTPServer.ssl` instead of `HTTPServer` to create an HTTPS connection.

examples/cookies/main.pony

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""
2+
Visit counter using cookies. Reads the `visits` cookie from the request,
3+
increments it, and sets it back via `Set-Cookie`. Demonstrates both
4+
`Request.cookies` for reading and `SetCookieBuilder` for writing cookies.
5+
6+
First visit returns "Visit #1", subsequent visits increment the counter.
7+
"""
8+
use stallion = "../../stallion"
9+
use lori = "lori"
10+
11+
actor Main
12+
new create(env: Env) =>
13+
let auth = lori.TCPListenAuth(env.root)
14+
Listener(auth, "0.0.0.0", "8080", env.out)
15+
16+
actor Listener is lori.TCPListenerActor
17+
var _tcp_listener: lori.TCPListener = lori.TCPListener.none()
18+
let _out: OutStream
19+
let _config: stallion.ServerConfig
20+
let _server_auth: lori.TCPServerAuth
21+
22+
new create(
23+
auth: lori.TCPListenAuth,
24+
host: String,
25+
port: String,
26+
out: OutStream)
27+
=>
28+
_out = out
29+
_server_auth = lori.TCPServerAuth(auth)
30+
_config = stallion.ServerConfig(host, port)
31+
_tcp_listener = lori.TCPListener(auth, host, port, this)
32+
33+
fun ref _listener(): lori.TCPListener => _tcp_listener
34+
35+
fun ref _on_accept(fd: U32): lori.TCPConnectionActor =>
36+
CookieServer(_server_auth, fd, _config)
37+
38+
fun ref _on_listening() =>
39+
try
40+
(let host, let port) = _tcp_listener.local_address().name()?
41+
_out.print("Server listening on " + host + ":" + port)
42+
else
43+
_out.print("Server listening")
44+
end
45+
46+
fun ref _on_listen_failure() =>
47+
_out.print("Failed to start server")
48+
49+
fun ref _on_closed() =>
50+
_out.print("Server closed")
51+
52+
actor CookieServer is stallion.HTTPServerActor
53+
var _http: stallion.HTTPServer = stallion.HTTPServer.none()
54+
55+
new create(
56+
auth: lori.TCPServerAuth,
57+
fd: U32,
58+
config: stallion.ServerConfig)
59+
=>
60+
_http = stallion.HTTPServer(auth, fd, this, config)
61+
62+
fun ref _http_connection(): stallion.HTTPServer => _http
63+
64+
fun ref on_request_complete(request': stallion.Request val,
65+
responder: stallion.Responder)
66+
=>
67+
// Read the visits cookie, defaulting to "0"
68+
var visits: U64 = 0
69+
match request'.cookies.get("visits")
70+
| let v: String val =>
71+
try visits = v.u64()? end
72+
end
73+
visits = visits + 1
74+
75+
let resp_body: String val = "Visit #" + visits.string()
76+
77+
// Build a Set-Cookie header to store the updated count
78+
let set_cookie = match stallion.SetCookieBuilder("visits",
79+
visits.string())
80+
.with_path("/")
81+
.with_http_only(false)
82+
.with_secure(false)
83+
.with_same_site(stallion.SameSiteLax)
84+
.build()
85+
| let sc: stallion.SetCookie val => sc
86+
| let err: stallion.SetCookieBuildError =>
87+
// Cookie name/value are always valid here, so this is unreachable
88+
_respond_error(responder)
89+
return
90+
end
91+
92+
let response = stallion.ResponseBuilder(stallion.StatusOK)
93+
.add_header("Content-Type", "text/plain")
94+
.add_header("Content-Length", resp_body.size().string())
95+
.add_header("Set-Cookie", set_cookie.header_value())
96+
.finish_headers()
97+
.add_chunk(resp_body)
98+
.build()
99+
responder.respond(response)
100+
101+
fun _respond_error(responder: stallion.Responder ref) =>
102+
let body: String val = "Internal Server Error"
103+
let response = stallion.ResponseBuilder(
104+
stallion.StatusInternalServerError)
105+
.add_header("Content-Length", body.size().string())
106+
.finish_headers()
107+
.add_chunk(body)
108+
.build()
109+
responder.respond(response)

stallion/_cookie_validator.pony

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
primitive _CookieValidator
2+
"""
3+
Validate cookie names and values per RFC 6265 and RFC 2616.
4+
5+
Cookie names must be RFC 2616 tokens: ASCII 33–126 excluding
6+
separators `( ) < > @ , ; : \\ " / [ ] ? = { }`.
7+
8+
Cookie values must be RFC 6265 cookie-octets: 0x21, 0x23–0x2B,
9+
0x2D–0x3A, 0x3C–0x5B, 0x5D–0x7E.
10+
"""
11+
12+
fun valid_name(name: String box): Bool =>
13+
"""Return true if `name` is a valid cookie name (RFC 2616 token)."""
14+
if name.size() == 0 then return false end
15+
for byte in name.values() do
16+
if not _is_token_char(byte) then return false end
17+
end
18+
true
19+
20+
fun valid_value(value: String box): Bool =>
21+
"""Return true if `value` contains only valid cookie-octets."""
22+
for byte in value.values() do
23+
if not _is_cookie_octet(byte) then return false end
24+
end
25+
true
26+
27+
fun _is_token_char(b: U8): Bool =>
28+
"""
29+
RFC 2616 token character: ASCII 33–126 minus separators.
30+
31+
Separators: ( ) < > @ , ; : \\ " / [ ] ? = { }
32+
"""
33+
if (b < 33) or (b > 126) then return false end
34+
// Check separators
35+
match b
36+
| '(' | ')' | '<' | '>' | '@'
37+
| ',' | ';' | ':' | '\\' | '"'
38+
| '/' | '[' | ']' | '?' | '='
39+
| '{' | '}' => false
40+
else
41+
true
42+
end
43+
44+
fun _is_cookie_octet(b: U8): Bool =>
45+
"""
46+
RFC 6265 cookie-octet: 0x21, 0x23–0x2B, 0x2D–0x3A, 0x3C–0x5B, 0x5D–0x7E.
47+
"""
48+
if b == 0x21 then return true end
49+
if (b >= 0x23) and (b <= 0x2B) then return true end
50+
if (b >= 0x2D) and (b <= 0x3A) then return true end
51+
if (b >= 0x3C) and (b <= 0x5B) then return true end
52+
if (b >= 0x5D) and (b <= 0x7E) then return true end
53+
false
54+
55+
fun valid_attr_value(value: String box): Bool =>
56+
"""
57+
Return true if `value` is safe as a Set-Cookie attribute value.
58+
59+
RFC 6265 section 4.1.1 defines path-value as any US-ASCII character
60+
(0x20–0x7E) except semicolons. Control characters (0x00–0x1F, 0x7F)
61+
and non-ASCII bytes (0x80–0xFF) are rejected. The same constraint
62+
prevents attribute injection in domain values.
63+
"""
64+
for byte in value.values() do
65+
if (byte < 0x20) or (byte > 0x7E) or (byte == ';') then
66+
return false
67+
end
68+
end
69+
true

stallion/_http_date.pony

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
use "time"
2+
3+
primitive _HTTPDate
4+
"""
5+
Format epoch seconds as an IMF-fixdate string (RFC 7231 §7.1.1.1).
6+
7+
Example: `Thu, 01 Jan 1970 00:00:00 GMT`
8+
9+
Used by `SetCookieBuilder` for the `Expires` attribute.
10+
"""
11+
12+
fun apply(epoch_seconds: I64): String val =>
13+
"""Format epoch seconds as an IMF-fixdate string."""
14+
let d = PosixDate(epoch_seconds)
15+
16+
// PosixDate.day_of_week uses C's tm_wday: 0=Sunday through 6=Saturday
17+
let day_names = [as String val:
18+
"Sun"; "Mon"; "Tue"; "Wed"; "Thu"; "Fri"; "Sat"]
19+
let month_names = [as String val:
20+
"Jan"; "Feb"; "Mar"; "Apr"; "May"; "Jun"
21+
"Jul"; "Aug"; "Sep"; "Oct"; "Nov"; "Dec"]
22+
23+
let day_name = try
24+
day_names(d.day_of_week.usize())?
25+
else
26+
_Unreachable()
27+
"Thu"
28+
end
29+
30+
let month_name = try
31+
month_names((d.month - 1).usize())?
32+
else
33+
_Unreachable()
34+
"Jan"
35+
end
36+
37+
let buf = recover val
38+
String(29)
39+
.>append(day_name)
40+
.>append(", ")
41+
.>append(_pad2(d.day_of_month))
42+
.>append(" ")
43+
.>append(month_name)
44+
.>append(" ")
45+
.>append(d.year.string())
46+
.>append(" ")
47+
.>append(_pad2(d.hour))
48+
.>append(":")
49+
.>append(_pad2(d.min))
50+
.>append(":")
51+
.>append(_pad2(d.sec))
52+
.>append(" GMT")
53+
end
54+
buf
55+
56+
fun _pad2(n: I32): String val =>
57+
"""Zero-pad an integer to two digits."""
58+
if n < 10 then
59+
"0" + n.string()
60+
else
61+
n.string()
62+
end

stallion/_response_serializer.pony

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,10 @@ primitive _ResponseSerializer
3131
.>append("\r\n")
3232

3333
// Headers
34-
for (name, value) in headers.values() do
35-
buf.>append(name)
34+
for hdr in headers.values() do
35+
buf.>append(hdr.name)
3636
.>append(": ")
37-
.>append(value)
37+
.>append(hdr.value)
3838
.>append("\r\n")
3939
end
4040

0 commit comments

Comments
 (0)