Repository navigation
Hobby Plan - No Route Groups #3
Closed
SeanTAllen
started this conversation in
Research
Replies: 1 comment
|
This has been implemented. |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Hobby Framework Implementation Plan
Design: #2
Context
Hobby is a new HTTP web framework for Pony with no implementation code yet — just scaffolding (empty
hobby.pony, placeholder_test.pony, placeholder example). The design document (Discussion #2) establishes the framework's principles and identifies five open questions. This plan resolves those open questions and lays out the implementation.Open Question Resolutions
1. Handler Signature
Handlers are
val(shareable, stateless across connections). They receiverefaccess to Context because they execute synchronously inside the connection actor's behavior. Callctx.respond(...)to send a response. The?allows genuine errors to propagate — if the handler errors without responding, the framework sends 500.2. Integration Architecture
Three internal layers, one public type:
Application(class iso): Public API. Route registration via method chaining (get,post, etc.).serve()consumes the Application, builds an immutable router, and creates the listener._Listener(actor): Implementslori.TCPListenerActor. Accepts TCP connections and spawns_Connectionactors._Connection(actor): Implementsstallion.HTTPServerActor. Accumulates body chunks, runs route lookup, executes middleware chain + handler.Users never implement
HTTPServerActoror interact with Stallion's connection lifecycle directly.3. Post-Processing Mechanism
Jennet-style
aftermethod on the Middleware interface with a default no-op:afteris NOT partial — middleware authors handle errors internally if needed. This guarantees after phases cannot fail, simplifying the chain runner.4. Concrete Handled-vs-Errored Representation
Context has
respond()which sends the response and sets_handled = true. The chain runner checksctx.is_handled()after each middleware. No return value needed from middleware — the truth lives in one place (the Context).5. Route Groups
Deferred. Not part of this plan, as the design document states it should only be pursued if we find an API that's genuinely less error-prone than per-route middleware.
Design Decisions
Context is
ref, notisoUnlike Jennet which passed Context as
iso(consumed and returned by each middleware), Hobby usesref. The Context lives as a local variable in_Connection's behavior. Middleware and handlers receive it asref.Rationale: Stallion's
Responderisclass ref. Storing it in Context requires Context to beref. More importantly,refavoids the iso consumption problem — if middleware errors after consuming an iso Context, the Context is lost and after phases can't access it. Withref, the Context survives errors.Chain execution model
Key invariant: after phases run for ALL middleware whose
beforewas invoked — whether the chain completed normally, short-circuited viarespond, or errored. The counter is incremented BEFORE callingbeforeso that even a middleware that errors gets itsaftercalled.Application iso builder pattern
Each route method is
fun iso, consuming and returning the receiver for chaining:Or with variable reassignment:
serve()consumes the Application permanently — routes are frozen.Middleware parameter syntax
Middleware arrays must be
valfor storage in the immutable router:The
recover val [...] endis standard Pony. No helper is provided in v1 — keep the API minimal.404 handling
When the router returns no match,
_Connectionresponds with 404 directly — no middleware chain runs. This is minimal and can be extended later (e.g., global middleware, custom 404 handler).User imports
Users import
hobbyandstallion. Hobby provides the framework machinery (Application, Context, Handler, Middleware). Stallion provides HTTP vocabulary (Status codes, Method, Headers, ServerConfig). Both are available via corral (Stallion as a direct dependency of Hobby).Users also need
lorito createTCPListenAuth(env.root). An alternative would be havingApplication.serve()acceptAmbientAuthand handle auth derivation internally. This would hide lori from users but is less explicit about the capability being granted. We go with explicit for v1:Transitive dependencies
Hobby accesses
request'.uri.pathwhereuriis aURI valfrom theuripackage. This type is already parsed and stored in Stallion'sRequest val— Hobby doesn't construct URIs, just reads fields. Theuripackage is a transitive dependency (via Stallion) and doesn't need to be declared as a direct dependency in Hobby'scorral.json. Similarly,loritypes are used for auth tokens but come transitively through Stallion.Context data map type
The data map is
Map[String, Any val]. Pony'sAnyisinterface tag Any(inbuiltin).Any valis a capability override — it means "any type at val capability." This enables storing different domain types in the map and recovering them viamatch. Thegetsignature isfun box get(key: String): Any val ?(partial — errors on missing key).Trailing slashes
Normalize paths by stripping trailing slashes before lookup (except root
/)./users/and/usersmatch the same route.File Structure
Implementation Phases
This is a single PR — the framework is not functional until all pieces connect.
Phase 1: Dependencies and Foundation
Step 1.1: Add Stallion dependency to
corral.json. Runcorral fetch.Step 1.2: Create
_mort.pony—_Unreachableprimitive following Stallion's pattern but with Hobby's issue URL (https://github.com/ponylang/hobby/issues).Step 1.3: Create
handler.pony—Handlerinterface (val,fun apply(ctx: Context ref) ?). Include docstring explaining the contract: handlers are val/shareable, callctx.respond()to send a response,?is for genuine errors only.Step 1.4: Create
middleware.pony—Middlewareinterface (val,fun before(ctx: Context ref) ?,fun after(ctx: Context ref) => None). Include docstring explaining the two-phase model:beforefor forward processing (short-circuit viactx.respond()),afterfor post-processing (always runs, not partial).Step 1.5: Create
context.pony—Contextclass with request, params, body, data map, respond methods, is_handled. Capability split: mutation methods (respond,respond_with_headers,set) arefun ref; read-only methods (get,is_handled) arefun box. This matters because the typed accessor convention from the design document receivesContext box.Step 1.6: Create
_chain_runner.pony—_ChainRunnerprimitive implementing the forward/after execution model. Note: dedicated unit tests for_ChainRunnerwould be ideal (the counter-before-beforeinvariant, reverse-order after phases, error handling are all worth testing directly), but Context requires a StallionResponderwhich can't be constructed outside the stallion package. The chain runner is tested through integration tests (Step 4.2) instead. If this becomes a pain point, we can add a test seam (e.g., a test-only Context constructor) later.Phase 2: Radix Tree Router
Step 2.1: Create
_route_match.pony—_RouteMatch val(handler, middleware, params).Step 2.2: Create
_route_definition.pony—_RouteDefinition(method, path, handler, middleware).Step 2.3: Create
_router.pony— radix tree implementation._RouterBuilder ref: Mutable builder.add(method, path, handler, middleware),build(): _Router ref^._Router val: Immutable lookup. One tree per HTTP method.lookup(method, path): (_RouteMatch | None)./.Phase 3: Application, Listener, and Connection
Step 3.1: Create
application.pony—Application isoclass. EmbedsArray[_RouteDefinition]to accumulate route registrations. Route methods (get,post,put,delete,patch,head,options, genericroute) push to this array.serve()iterates the array calling_RouterBuilder.add()for each, then calls_RouterBuilder.build()inside arecover valblock to produce_Router val, then creates_Listenerwith the frozen router.Step 3.2: Create
_listener.pony—_Listeneractor. Implementslori.TCPListenerActor. ReceivesTCPListenAuthfromServer.serve(), createsTCPServerAuthfrom it (stored as a field), and passesTCPServerAuthto each_Connectionin_on_accept. Passes the_Router valandServerConfigto each_Connection. Passesmax_concurrent_connectionsfromServerConfigthrough tolori.TCPListener'slimitparameter (this is new — Stallion's examples don't do this passthrough, but the types align: both are(U32 | None)). Note:_on_acceptis partial (?) per lori's trait, but_Connectionconstruction is infallible, so the?satisfies the trait without ever erroring.Step 3.3: Create
_connection.pony—_Connectionactor. Implementsstallion.HTTPServerActor. Fields:_http: HTTPServer(initialized vianone()then set in constructor),_router: _Router val,_body: Array[U8] iso(for body accumulation),_has_body: Bool. Overrideson_body_chunk(data: Array[U8] val)to append data to_bodyand set_has_body = true. Overrideson_request_completeto: harvest accumulated body via destructive read (let body: Array[U8] val = (_body = recover iso Array[U8] end)), look up route via_router.lookup(), createContext, run_ChainRunner, respond with 404 directly if no route matches. Does NOT overrideon_request— the default no-op is correct for the buffered model.Phase 4: Tests
Step 4.1: Create
_test_router.pony— property-based tests using PonyCheck.Generators (derived from validation rules):
_GenPathSegment: Alphanumeric +-_characters, length 1-20. No/,:,*._GenStaticPath: Join 1-5_GenPathSegments with/prefix. Produces paths like/foo/bar/baz._GenParamName: Same charset as_GenPathSegmentbut shorter (1-10). For:nameand*namesegments._GenMethod: One of[stallion.GET; stallion.POST; stallion.PUT; stallion.DELETE; stallion.PATCH; stallion.HEAD; stallion.OPTIONS].Properties:
:namesegments captured correctly*namecaptures remainder/users/newmatches static before/users/:id/users/:id/posts/:post_idextracts bothExample-based tests:
//api/v1/users,/api/v2/users)Step 4.2: Create
_test_integration.pony— HTTP round-trip tests.Tests:
Use
ifdef linux then "127.0.0.2" else "localhost" endfor WSL2 compatibility. Different ports per test to avoid conflicts. Tag integration tests for--exclude=integration.Step 4.3: Update
_test.pony— add all test classes to the runner.Test command:
make test(unit tests + examples build). The Makefile already passes--exclude=integration --sequentialto the test binary, so integration tests are excluded from the default run. Integration tests can be run directly via./build/release/hobby --only=integration.Phase 5: Examples and Documentation
Step 5.1: Update
examples/basic/main.pony— hello world with routing (GET/and GET/greet/:name).Step 5.2: Create
examples/middleware/main.pony— logging middleware with after phase, auth middleware with typed context data and short-circuiting.Step 5.3: Create
examples/README.md.Step 5.4: Update
hobby/hobby.ponypackage docstring — purpose, minimal example, middleware pattern, typed data convention.Step 5.5: Update
CLAUDE.md— architecture, file layout, dependencies, testing approach.Step 5.6: Post open question resolutions as a comment on Discussion #2, then remove
DESIGN.mdfrom the repo (it duplicates the Discussion content and would become stale).Step 5.7: Create release notes (
.release-notes/file). Classification: added. Label:changelog - added.Key References
/home/sean/code/ponylang/stallion/stallion/(HTTPServerActor, HTTPServer, Responder, ResponseBuilder, Request, ServerConfig, Status, Method, Headers)/home/sean/code/ponylang/stallion/examples/hello/main.pony(listener + connection actor pattern)/home/sean/code/ponylang/stallion/stallion/_test_server.pony(destructive read pattern for iso body)/home/sean/code/ponylang/stallion/stallion/_mort.pony/home/sean/code/ponylang/lori/lori/tcp_listener_actor.pony(_on_accept signature)/home/sean/code/ponylang/lori/lori/auth.pony(TCPListenAuth -> TCPServerAuth)/home/sean/code/ponylang/ponyc/packages/pony_check/What's Deferred
Verification
Build and run tests:
Manual verification:
All reactions