SagaSaga
Edda

OpenAPI specs

Edda.Spec is the optional route-contract layer for JSON APIs. It builds normal Edda routes and an OpenAPI document from the same definition. Plain routing still lives in Edda.route, choose, group, and mount; use Edda.Spec when you want the route definition to own JSON decoding, JSON encoding, and API documentation together.

Contracts

Most spec modules need imports like these:

import Std.Fail (Fail)
import Edda (Request, GET, POST, choose)
import Edda.Spec (
  Success,
  Created,
  Conflict,
  RouteSchema,
  Schema,
  SchemaFor,
  JsonContract,
  JsonDecoderContract,
  OpenApiInfo,
  endpoint,
  no_body,
  json_input,
  returns_json,
  responds_with,
  returns_error_json,
  handled_by,
  handled_by_result,
  handled_by_result_json,
  operation_id,
  summary,
  tag,
  requires_header,
  optional_header,
  returns_header,
  responds_with_header,
  as_route,
  array_contract,
  named_json_contract_for,
  named_json_decoder_contract_for,
  int_schema,
  string_schema,
  plain_text,
  describe,
  scalar_handler,
  to_openapi
)
import SagaHttp.Http (Response, ResponseBody)
import SagaJson as J
import SagaJson.Decode
import SagaJson.Decode (FromJson)
import SagaJson.Encode
import SagaJson.Encode (ToJson)

Schemas are explicit. For records, use the Schema record builder so field names and field types are checked against the constructor:

record User {
  id: Int,
  name: String,
}

impl ToJson for User {
  to_json u = Encode.object [("id", to_json u.id), ("name", to_json u.name)]
}

fun user_schema : SchemaFor User
user_schema = build Schema User { id: int_schema, name: string_schema }

fun user_contract : JsonContract User
user_contract = named_json_contract_for "User" user_schema

JsonContract a pairs SchemaFor a with an encoder. Request bodies use JsonDecoderContract a, which pairs a schema with a decoder. Named contracts also emit entries under components.schemas and use $ref from each operation:

record CreateUser {
  name: String,
}

impl FromJson for CreateUser needs {Fail J.Error} {
  from_json j = CreateUser { name: Decode.at "name" Decode.string j }
}

fun create_user_schema : SchemaFor CreateUser
create_user_schema = build Schema CreateUser { name: string_schema }

fun create_user_contract : JsonDecoderContract CreateUser
create_user_contract = named_json_decoder_contract_for "CreateUser" create_user_schema

Spec-Owned Endpoints

The recommended path for JSON routes is the spec-owned endpoint adapter:

fun create_user : Request -> CreateUser -> User
create_user _ input =
  User { id: 1, name: input.name }

fun create_user_spec : RouteSchema
create_user_spec =
  endpoint POST "/users"
  |> json_input create_user_contract "User fields"
  |> returns_json Created user_contract "Created user"
  |> handled_by create_user
  |> operation_id "createUser"
  |> summary "Create a user"
  |> tag "Users"

handled_by decodes the configured input, calls your domain handler, then encodes the output with the configured response contract. The handler returns ordinary application data, not a Response, so the documented response contract and runtime encoder stay attached. JSON endpoints automatically document a plain-text 400 response for body decode failures. Add your own BadRequest metadata with responds_with when you want a custom description.

For endpoints without a body, use no_body; the handler receives ():

endpoint GET "/users"
|> no_body
|> returns_json Success (array_contract user_contract) "All users"
|> handled_by list_users

Errors

Use returns_error_json plus handled_by_result_json when domain code can fail with a typed JSON error body:

endpoint POST "/users"
|> json_input create_user_contract "User fields"
|> returns_json Created user_contract "Created user"
|> returns_error_json Conflict create_user_problem_contract "User already exists"
|> handled_by_result_json create_user

Successful outputs go through the success response contract. Errors go through the error contract, so the OpenAPI response schema and runtime encoder stay attached.

Use handled_by_result when errors need a custom response mapper:

endpoint POST "/users"
|> json_input create_user_contract "User fields"
|> returns_json Created user_contract "Created user"
|> responds_with Conflict (plain_text |> describe "User already exists")
|> handled_by_result map_create_error create_user

In that looser form, successful outputs still go through the response contract, but errors are mapped to ordinary Response values by your code.

Headers

Header helpers add OpenAPI metadata only; they do not validate requests or rewrite responses at runtime.

endpoint GET "/users"
|> no_body
|> returns_json Success (array_contract user_contract) "All users"
|> handled_by list_users
|> requires_header "Authorization" string_schema "Bearer token"
|> optional_header "If-None-Match" string_schema "Cache validator"
|> returns_header "ETag" string_schema "Entity tag"

Use responds_with_header to document a header on a specific alternate response status.

Routing and Docs

Convert specs into normal Edda routes with as_route:

pub fun app : Request -> Response
app req =
  choose [
    Edda.route GET "/openapi.json" openapi_handler,
    Edda.route GET "/docs" (scalar_handler "/spec/openapi.json"),
    as_route list_users_spec,
    as_route create_user_spec,
  ] req

Generate OpenAPI from the same specs:

fun openapi_handler : Request -> Response
openapi_handler _ = {
  let info = OpenApiInfo { title: "Example API", version: "0.1.0" }
  Response {
    status: 200,
    headers: [("Content-Type", "application/json")],
    body: Buffered (to_openapi info [list_users_spec, create_user_spec]),
  }
}

Middleware works the same way as plain Edda after as_route, because specs lower to ordinary route functions. A complete demo lives in src/Demo/SpecRoutes.saga.