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_schemaJsonContract 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_schemaSpec-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_usersErrors
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_userSuccessful 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_userIn 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,
] reqGenerate 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.