JSON
Edda.Json is a thin layer over saga_json.
It gives you a json response constructor on one side and a
body_json decoder on the other; everything else is the saga_json
trait machinery doing the work.
A record with deriving (ToJson, FromJson) round-trips through HTTP
without a hand-written codec.
Encoding: json
pub fun json : Int -> a -> Response where {a: ToJson}Serializes the value, sets Content-Type: application/json, and
returns a Response with the given status.
import Edda.Json (json)
import SagaJson.Encode (ToJson)
record User {
id: Int,
name: String,
email: String,
} deriving (ToJson)
fun show_user : Request -> Response
show_user _ =
json 200 (User { id: 1, name: "Alice", email: "alice@example.com" })Lists, Maybe, tuples, and the primitive types all have built-in
ToJson impls, so:
json 200 [user1, user2, user3]works without ceremony.
For custom shapes, hand-write the impl or reach for the post-processing
combinators — see the
saga_json encoding guide.
Decoding: body_json
pub type BodyError =
| NoBody
| NotUtf8
| JsonError J.Error
pub fun body_json : Request -> Result a BodyError where {a: FromJson}BodyError keeps body-level problems (missing, non-UTF-8) distinct
from JSON-level problems (malformed, wrong shape), so the response
message can say something useful.
Saga needs a type annotation on the call so it can pick the right
FromJson impl — body_json is generic in a and there's nothing
else for inference to latch onto:
case (body_json req : Result CreateUser BodyError) {
Err e -> body_error_response e
Ok input -> ...
}body_error_response is a reasonable 400 mapping:
BodyError | Response |
|---|---|
NoBody | 400 request body is required |
NotUtf8 | 400 request body is not valid UTF-8 |
JsonError (InvalidJson msg) | 400 invalid json: <msg> |
JsonError (InvalidShape exp found path) | 400 invalid shape at /<path>: expected <exp>, found <found> |
The InvalidShape path is the path-tracking feature of saga_json —
nested decoders prepend their field name onto the error path, so the
client sees exactly where the payload went wrong (/address/zip,
not just "wrong shape").
If you want a structured error envelope (JSON instead of text), skip
body_error_response and write your own mapping from BodyError to
Response.
Two error-handling shapes
Routes that decode bodies tend to fall into one of two patterns. Both work; pick the one that reads better at the call site.
Inline match
Keep the failure path in the route body. Best when only a handful of routes parse bodies and each wants its own response shape:
fun create_user : Request -> Response
create_user req = case (body_json req : Result CreateUser BodyError) {
Err e -> body_error_response e
Ok input -> {
let u = persist input
json 201 u
}
}Per-app effect
Declare a Body effect, have routes declare it in needs, and the
boundary handler maps decode failures to a response once. Best when
many routes share the same body type and the same 400 behavior:
effect Body {
fun decode_create_user : Unit -> CreateUser
}
fun create_user : Request -> Response needs {Body}
create_user _ = {
let input = decode_create_user! ()
let u = persist input
json 201 u
}
# at the boundary
app req = req |> choose [
route POST "/users" create_user,
] with {
decode_create_user () = case (body_json req : Result CreateUser BodyError) {
Ok v -> resume v
Err e -> body_error_response e
}
}The route reads as happy-path code; the failure path lives in one place at the boundary. This is just an opt-in effect handler applied to body decoding — there's nothing Edda-specific about it.
End-to-end example
A small CRUD-ish demo lives in src/Demo/JsonApi.saga
in the repo. It exercises encoding, decoding, both error-handling
shapes, and the path-aware 400 messages. Worth reading once if any of
the above feels abstract.