Ring API
ShipClojure uses reitit and ring for handling API calls. All of the main middleware and configuration is done through ring at the handler level. Reitit is used for routing and route-specific middleware like coercion and authentication.
This architecture was chosen because ring & reitit have different approaches to middleware configuration that can build confusion when mixing them together. By keeping the main middleware chain in ring and only using reitit for routing and route-specific concerns, the system becomes easier to reason about.
Architecture Overview
The handler system is split into two components:
:handler/reitit- Handles routing, default handlers (404, static files, swagger-ui), and route-specific middleware:handler/ring- Applies the main middleware chain that affects all requests (logging, formatting, sessions, etc.)
Example Routes
(defn api-routes [{:keys [db] :as opts}]
["" {:middleware [[mw/wrap-jwt-auth]]}
["/account" {:swagger {:tags ["Account API"]}}
["/me" {:middleware [[mw/wrap-authenticated]]}
["" {:get {:summary "Get account information"
:handler (auth/get-account db)
:responses {200 {:body s/user-account}}}}]]
["/sign-up" {:post {:summary "Create an account"
:handler (auth/sign-up! opts)
:responses {201 {:body s/sign-up-response}}
:parameters {:body s/create-account}}}]
["/log-in" {:post {:summary "Log in to your account"
:handler (auth/log-in opts)
:responses {200 {:body s/log-in-response}}
:parameters {:body s/log-in}}}]]
["/health"
{:get (healthcheck/healthcheck db)}]])Route-level middleware (like wrap-jwt-auth and wrap-authenticated) runs after the main ring middleware chain but before the final handler.
Middleware Chain
The middleware chain is in handler.clj:
Contrary to first instinct, the execution is from the bottom to the top and then again to the bottom. Think of it as an onion - requests go in through the outer layers, reach the handler, and responses come back out through the same layers.
Order of Execution
Request Phase (bottom to top):
logger/wrap-log-request-start- Logs request start and adds:saas.logging/start-msto the request for timing. Sets a log context:req-idso all logs in the chain share the same request ID for filtering.gzip/wrap-gzip- On the request phase, does nothing. Will compress the response on the way back.logger/wrap-log-response- Takes thestart-msfrom earlier middleware and calls next. Will log the response on the way back.muuntaja/wrap-format-negotiate- Addsmuuntaja/request-formatandmuuntaja/response-formatto the request based onContent-TypeandAcceptheaders. This information is used by subsequent muuntaja middleware.exception/exception-middleware(first) - Wraps the rest of the chain in try/catch for potential errors. See Exception Handling.muuntaja/wrap-format-request- Decodes the request body based onContent-Typeinto EDN and adds content to:body-params. Note: This is conditionally skipped for/webhooks/stripeto preserve the raw body for signature verification.ring.middleware.defaults/wrap-defaults- Configurable middleware from system.edn. Adds cookies, session, query params, serves static assets from public, and more.muuntaja/wrap-params- Merges:body-params(from step 6) into:paramsalong with query/form params added bywrap-defaults.logger/wrap-log-request-params- Logs all request params from the:paramskey.muuntaja/wrap-format-response- Does nothing on request phase. Will encode the response body on the way back.exception/exception-middleware(second) - Another try/catch wrapper. Why two? Because if handlers throw after step 10, errors would bypass response formatting. This middleware catches those errors and generates a response map that goes back through step 10 to be properly formatted.Reitit Handler - Routes the request to the appropriate handler based on URI and method. Route-level middleware (like
wrap-jwt-auth) executes here, before the final handler.Final Handler - Executes and returns a response map like
{:status 200 :body {:message "success"}}.
Response Phase (top to bottom):
Reitit router passes through the response.
Inner exception middleware returns the response (nothing was thrown).
muuntaja/wrap-format-response- Encodes the response body based onAcceptheader (JSON, EDN, or Transit).logger/wrap-log-request-params- Already logged, passes through.muuntaja/wrap-params- Passes through (request-only logic).ring.middleware.defaults/wrap-defaults- May set content-type, charset, not-modified headers.muuntaja/wrap-format-request- Passes through (request-only logic).Outer exception middleware passes through.
muuntaja/wrap-format-negotiate- Passes through (request-only logic).logger/wrap-log-response- Logs response status, content-type, and duration of the entire request.gzip/wrap-gzip- Compresses the response body if the client supports it.logger/wrap-log-request-start- Passes the response to the HTTP server (http-kit by default).
Reitit Router Middleware
In addition to the ring middleware chain, reitit applies router-level middleware for coercion:
This middleware:
coerce-request-middleware- Coerces and validates:path-params,:query-params, and:body-paramsaccording to the malli schemas defined in routes (:parameters). Populates the:parameterskey in the request.coerce-response-middleware- Validates response bodies against schemas defined in:responses.
Exception Handling
The custom exception middleware in saas.web.middleware.exception catches all exceptions and converts them to appropriate HTTP responses.
Default Handlers:
::default- Returns 500 with exception class name:ring.util.http-response/response- Reads response fromex-data :response(for early-exit patterns usinghttp-response/bad-request!etc.):muuntaja/decode- Returns 400 for malformed request bodies
Example using http-response for early exit:
API Documentation
API documentation is available via OpenAPI 3.0:
OpenAPI JSON:
/openapi/openapi.jsonSwagger UI:
/openapi
Routes can define schemas for automatic documentation:
Last updated