These recipes cover common tasks after Getting Started. Every Haskell snippet below comes from the compiled cookbook application. Run it from the repository root:
cabal run spock-cookbook-example -- 8080
cabal test spock-cookbook-example --test-show-details=direct
This loopback example is a public stateless service with sessions disabled. For authenticated forms, JSON requests, and logout, use the browser security guide. The REST tutorial covers Persistent and SQLite, and the rendering guide covers HTML.
Request and response headers
header reads a request header; setHeader prepares a response header.
Set response metadata before sending the body, because json, text, and
other response helpers finish the action.
get "headers" $ do
client <- header "X-Client"
requestId <- getRequestId
setHeader "X-Reply" "received"
logMessage LogInfo "Read headers" []
json $ object ["client" .= client, "requestId" .= requestId]curl -i -H 'X-Client: example' http://localhost:8080/headers
The JSON contains the incoming value and a request ID. X-Reply and
X-Request-Id are response headers. Treat client headers as input; a header
value is not proof of identity.
JSON and form bodies
jsonBody returns Nothing when decoding fails, which lets this route choose
its error representation:
post "json" $ do
value <- jsonBody :: Action (Maybe T.Text)
case value of
Nothing -> errorJson status400 "Expected a JSON string"
Just message -> json $ object ["message" .= message]curl -i -H 'Content-Type: application/json' -d '"hello"' http://localhost:8080/json
curl -i -H 'Content-Type: application/json' -d '{}' http://localhost:8080/json
The first request returns 200; the second returns a JSON 400. jsonBody'
instead sends its own 400 response on failure. A custom generic error handler
does not replace responses explicitly sent by a route or helper.
For form-only fields, use paramsPost and decide how to handle duplicates:
post "form" $ do
fields <- paramsPost
case [value | (name, value) <- fields, name == "name"] of
[name] -> json $ object ["name" .= name]
_ -> errorJson status400 "Expected exactly one name field"curl -i --data-urlencode 'name=Alex+Spock' http://localhost:8080/form
curl -i -d 'name=Alex&name=Spock' http://localhost:8080/form
paramsGet reads the query string. params combines query parameters before
form parameters, so param finds a query value first when names collide.
Route captures are separate arguments passed to the handler. Choose the
appropriate source explicitly for credentials or other sensitive fields.
Status codes and errors
An error object under status 200 still looks successful to an HTTP client. Set the status first:
errorJson :: MonadIO m => Status -> T.Text -> ActionCtxT ctx m a
errorJson status message = do
setStatus status
json (object ["error" .= object ["status" .= statusCode status, "message" .= message]] :: Value)The generic signature lets this helper run in both a full Spock action and
spc_errorHandler’s ActionCtxT () IO. That handler receives a Status, not
an exception, a session, or application state. Return a generic JSON response
there and send diagnostic context to the logging sink.
The example returns JSON 404 for an unknown route and JSON 500 for its
intentional /failure route. The latter’s diagnostic message appears in the
structured error log, not the response body. Its request-size limit produces
JSON 413 when a handler consumes more than 1 MiB; unused request bodies are
not eagerly read just to enforce the limit.
Middleware and CORS
Spock accepts ordinary WAI middleware. Middleware can change request or response metadata and can answer a request before routing, as CORS preflight handling does.
-- Middleware wraps requests and responses, including requests with no route.
middleware $ \application req respond -> application req $
respond . Wai.mapResponseHeaders (("X-Cookbook", "Spock") :)
-- Public stateless demo: allow one development origin, without credentials.
middleware $ cors $ const $ Just simpleCorsResourcePolicy
{ corsOrigins = Just (["http://localhost:3000"], False),
corsMethods = ["GET", "POST", "OPTIONS"],
corsRequestHeaders = ["Content-Type", "X-Client"] }The first middleware adds X-Cookbook even when no route matches. The second
uses wai-cors to allow one development
origin without credentials. For example:
curl -i -X OPTIONS -H 'Origin: http://localhost:3000' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type' http://localhost:8080/json
CORS controls browser access to responses; it is not authentication, and non-browser clients can call these public routes. Configure the origins, methods, and headers your client actually needs. Middleware may send its own error responses outside Spock’s JSON error handler. See the security guide before enabling credentialed cross-origin access.
Repeated file uploads
filesMulti returns every file under a field name. files retains only one
file per field. The temporary paths belong to Spock and are cleaned up after
the request, so consume or copy the data inside the action:
post "upload" $ do
uploads <- filesMulti
let allFiles = [(field, upload) | (field, values) <- HM.toList uploads, upload <- values]
when (null allFiles) $ errorJson status400 "No files uploaded"
summaries <- forM allFiles $ \(field, upload) -> do
-- Read inside the action, before Spock removes its temporary files.
-- The request-size limit bounds this demonstration's in-memory reads.
contents <- liftIO $ BS.readFile (uf_tempLocation upload)
pure $ object ["field" .= field, "name" .= uf_name upload, "bytes" .= BS.length contents]
setStatus status201
json summariescurl -i -F '[email protected]' -F '[email protected]' http://localhost:8080/upload
The example returns both files’ names and byte counts. It never uses the client-supplied filename as a server path. For stored uploads, choose your own destination name, enforce size and content policies, and keep uploaded content away from executable paths. Copy to owned storage before starting background work; a temporary path is not durable storage.
Structured logging and request IDs
The example supplies a LogEvent -> IO () sink and enables it in configuration:
makeApp sink = do
cfg <- defaultSpockCfg () PCNoDatabase ()
let sessions = (spc_sessionCfg cfg) { sc_sessionMode = SessionsDisabled }
spockAsApp $ spock (cfg
{ spc_sessionCfg = sessions,
spc_maxRequestSize = Just (1024 * 1024),
spc_logging = Just $ defaultLoggingConfig sink,
spc_logError = const $ pure (), -- The structured sink receives diagnostics.
spc_errorHandler = \status -> errorJson status "Request failed" }) routesIts executable encodes each event as one JSON line. Access, error, and explicit
logMessage events share a request ID, also returned in X-Request-Id.
The default request context contains the method and raw path, excluding query
strings, headers, and bodies. Do not put secrets in paths or custom log fields.
Incoming request IDs are ignored by default; enable trust only at a controlled
proxy boundary.
spc_errorHandler chooses the response; logging records diagnostics. This
example uses the structured sink for those diagnostics and disables the
separate legacy spc_logError callback to avoid duplicate messages.
See Web.Spock.Logging
for sink behavior and configuration options.