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:

  1. :handler/reitit - Handles routing, default handlers (404, static files, swagger-ui), and route-specific middleware

  2. :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):

  1. logger/wrap-log-request-start - Logs request start and adds :saas.logging/start-ms to the request for timing. Sets a log context :req-id so all logs in the chain share the same request ID for filtering.

  2. gzip/wrap-gzip - On the request phase, does nothing. Will compress the response on the way back.

  3. logger/wrap-log-response - Takes the start-ms from earlier middleware and calls next. Will log the response on the way back.

  4. muuntaja/wrap-format-negotiate - Adds muuntaja/request-format and muuntaja/response-format to the request based on Content-Type and Accept headers. This information is used by subsequent muuntaja middleware.

  5. exception/exception-middleware (first) - Wraps the rest of the chain in try/catch for potential errors. See Exception Handling.

  6. muuntaja/wrap-format-request - Decodes the request body based on Content-Type into EDN and adds content to :body-params. Note: This is conditionally skipped for /webhooks/stripe to preserve the raw body for signature verification.

  7. ring.middleware.defaults/wrap-defaults - Configurable middleware from system.edn. Adds cookies, session, query params, serves static assets from public, and more.

  8. muuntaja/wrap-params - Merges :body-params (from step 6) into :params along with query/form params added by wrap-defaults.

  9. logger/wrap-log-request-params - Logs all request params from the :params key.

  10. muuntaja/wrap-format-response - Does nothing on request phase. Will encode the response body on the way back.

  11. 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.

  12. 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.

  13. Final Handler - Executes and returns a response map like {:status 200 :body {:message "success"}}.

Response Phase (top to bottom):

  1. Reitit router passes through the response.

  2. Inner exception middleware returns the response (nothing was thrown).

  3. muuntaja/wrap-format-response - Encodes the response body based on Accept header (JSON, EDN, or Transit).

  4. logger/wrap-log-request-params - Already logged, passes through.

  5. muuntaja/wrap-params - Passes through (request-only logic).

  6. ring.middleware.defaults/wrap-defaults - May set content-type, charset, not-modified headers.

  7. muuntaja/wrap-format-request - Passes through (request-only logic).

  8. Outer exception middleware passes through.

  9. muuntaja/wrap-format-negotiate - Passes through (request-only logic).

  10. logger/wrap-log-response - Logs response status, content-type, and duration of the entire request.

  11. gzip/wrap-gzip - Compresses the response body if the client supports it.

  12. 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-params according to the malli schemas defined in routes (:parameters). Populates the :parameters key 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 from ex-data :response (for early-exit patterns using http-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.json

  • Swagger UI: /openapi

Routes can define schemas for automatic documentation:

Last updated