Packages
oaspec
0.56.0
0.68.0
0.67.0
0.66.0
0.65.0
0.64.0
0.63.0
0.62.0
0.61.0
0.60.0
0.59.0
0.58.1
0.58.0
0.57.0
0.56.0
0.55.0
0.54.0
0.53.0
0.52.0
0.51.0
0.50.0
0.49.0
0.48.0
0.47.0
0.46.0
0.45.0
0.44.0
0.43.0
0.42.0
0.41.0
0.40.0
0.39.0
0.38.0
0.37.0
0.36.0
0.35.0
0.34.0
0.33.0
0.32.0
0.31.0
0.30.0
0.29.0
0.28.0
0.27.0
0.26.0
0.25.0
0.24.0
0.23.0
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.0
0.13.0
0.12.0
0.11.0
0.10.0
0.9.0
0.8.0
0.7.0
0.6.3
0.6.1
0.6.0
0.5.0
0.4.0
0.3.0
0.1.3
Generate Gleam code from OpenAPI 3.x specifications
Current section
Files
Jump to
Current section
Files
src/oaspec/internal/codegen/client_response.gleam
import gleam/dict
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/string
import oaspec/internal/codegen/context.{type Context}
import oaspec/internal/openapi/schema.{
type SchemaRef, AllOfSchema, AnyOfSchema, ArraySchema, BooleanSchema, Inline,
IntegerSchema, NumberSchema, ObjectSchema, OneOfSchema, Reference,
StringSchema,
}
import oaspec/internal/openapi/spec.{type Resolved}
import oaspec/internal/util/content_type
import oaspec/internal/util/http
import oaspec/internal/util/naming
import oaspec/internal/util/string_extra as se
/// Issue #483: the `default` response branch must capture the actual
/// runtime status code so the decoded `XxxResponseDefault(...)`
/// variant can carry it through to the caller. For all non-default
/// branches the case pattern is the matched status int (or guard
/// expression for `2xx` ranges); the default branch binds `status`
/// instead of using `_` so the variant constructor can pass it on.
fn status_branch_pattern(status_code: http.HttpStatusCode) -> String {
case status_code {
http.DefaultStatus -> "status"
_ -> http.status_code_to_int_pattern(status_code)
}
}
/// Issue #483: prepend the runtime status binding to a variant
/// constructor's argument list when the status code is `default`. For
/// every other status the variant arity is unchanged.
fn prepend_default_status(
arg: String,
status_code: http.HttpStatusCode,
) -> String {
case status_code {
http.DefaultStatus ->
case arg {
"" -> "status"
_ -> "status, " <> arg
}
_ -> arg
}
}
/// Issue #387: assembled response-headers record. Tracks both the
/// pre-statements (let bindings or `use` chains that extract each
/// header field from `resp.headers`) and the final constructor
/// expression that references those bound names.
///
/// Required headers and parse-required typed headers (Int / Float /
/// Bool) emit `use ... <- result.try(...)` so a missing or
/// unparseable header short-circuits with
/// `Error(InvalidResponse(detail: ...))`. Optional headers emit a
/// plain `let`, defaulting to `None` when the lookup or parse fails.
pub type HeadersRecord {
HeadersRecord(pre_statements: List(String), record_expr: String)
}
/// Generate response handling for a single content type. Emits a branch
/// of the `case resp.status { ... }` ladder inside a generated
/// `decode_<op>_response` function.
///
/// Body extraction is performed before decoding: text-shaped responses
/// extract via `text_body(resp.body)` and binary responses via
/// `bytes_body(resp.body)`. Both helpers return `ClientError` values so
/// the caller can short-circuit with `use`.
pub fn generate_single_content_response(
sb: se.StringBuilder,
status_code: http.HttpStatusCode,
variant_name: String,
media_type_name: String,
media_type: spec.MediaType,
op_id: String,
ctx: Context,
headers: Option(HeadersRecord),
) -> se.StringBuilder {
case content_type.from_string(media_type_name) {
content_type.ApplicationOctetStream | content_type.Wildcard ->
case media_type.schema {
Some(_) ->
body_branch(sb, status_code, variant_name, "bytes", headers, fn(sb) {
se.indent(sb, 3, "use bytes <- result.try(bytes_body(resp.body))")
})
_ -> empty_body_branch(sb, status_code, variant_name, headers)
}
content_type.TextPlain | content_type.ApplicationXml | content_type.TextXml ->
case media_type.schema {
Some(_) ->
body_branch(sb, status_code, variant_name, "text", headers, fn(sb) {
se.indent(sb, 3, "use text <- result.try(text_body(resp.body))")
})
_ -> empty_body_branch(sb, status_code, variant_name, headers)
}
_ ->
case media_type.schema {
Some(schema_ref) -> {
let decode_expr =
get_response_decode_expr(schema_ref, op_id, status_code, ctx)
let sb =
sb
|> se.indent(2, status_branch_pattern(status_code) <> " -> {")
|> se.indent(3, "use text <- result.try(text_body(resp.body))")
|> emit_pre_statements(headers)
sb
|> se.indent(3, "case " <> decode_expr <> " {")
|> se.indent(
4,
"Ok(decoded) -> Ok("
<> variant_name
<> "("
<> prepend_default_status(
append_headers_arg("decoded", headers),
status_code,
)
<> "))",
)
|> se.indent(
4,
"Error(_) -> Error(DecodeFailure(detail: \"Failed to decode response body\"))",
)
|> se.indent(3, "}")
|> se.indent(2, "}")
}
_ -> empty_body_branch(sb, status_code, variant_name, headers)
}
}
}
/// Common branch shape for content branches that extract a body
/// value (`text` / `bytes`) directly into the variant constructor
/// without an intervening decoder. The `extract_body` callback emits
/// the `use <body> <- ...` line; the rest is reused across binary
/// and text/xml branches.
fn body_branch(
sb: se.StringBuilder,
status_code: http.HttpStatusCode,
variant_name: String,
body_arg: String,
headers: Option(HeadersRecord),
extract_body: fn(se.StringBuilder) -> se.StringBuilder,
) -> se.StringBuilder {
sb
|> se.indent(2, status_branch_pattern(status_code) <> " -> {")
|> extract_body
|> emit_pre_statements(headers)
|> se.indent(
3,
"Ok("
<> variant_name
<> "("
<> prepend_default_status(
append_headers_arg(body_arg, headers),
status_code,
)
<> "))",
)
|> se.indent(2, "}")
}
/// Issue #387: shared empty-body branch for status entries that have
/// no schema (e.g. 204 No Content) but may still carry a typed
/// headers record. Emits a single-line `<status> -> Ok(...)` when the
/// headers record has no pre-statements, or a multi-line block when
/// any required header forces a `use` chain.
fn empty_body_branch(
sb: se.StringBuilder,
status_code: http.HttpStatusCode,
variant_name: String,
headers: Option(HeadersRecord),
) -> se.StringBuilder {
case headers {
Some(HeadersRecord(pre_statements: [_, ..], record_expr: expr)) ->
sb
|> se.indent(2, status_branch_pattern(status_code) <> " -> {")
|> emit_pre_statements(headers)
|> se.indent(
3,
"Ok("
<> variant_name
<> "("
<> prepend_default_status(expr, status_code)
<> "))",
)
|> se.indent(2, "}")
Some(HeadersRecord(record_expr: expr, ..)) ->
sb
|> se.indent(
2,
status_branch_pattern(status_code)
<> " -> Ok("
<> variant_name
<> "("
<> prepend_default_status(expr, status_code)
<> "))",
)
None ->
sb
|> se.indent(
2,
status_branch_pattern(status_code)
<> " -> Ok("
<> variant_name
<> case status_code {
http.DefaultStatus -> "(status)"
_ -> ""
}
<> ")",
)
}
}
/// Emit each header pre-statement at the appropriate indentation
/// level (3, inside the per-status block). No-op when the response
/// declares no headers.
fn emit_pre_statements(
sb: se.StringBuilder,
headers: Option(HeadersRecord),
) -> se.StringBuilder {
case headers {
Some(HeadersRecord(pre_statements: stmts, ..)) ->
list.fold(stmts, sb, fn(acc, line) { se.indent(acc, 3, line) })
None -> sb
}
}
/// Issue #387: when a response declares headers, the variant
/// constructor takes the body value followed by the typed headers
/// record. This helper appends the headers expression to a body
/// argument so callers do not need to know whether headers exist.
fn append_headers_arg(
body_arg: String,
headers: Option(HeadersRecord),
) -> String {
case headers {
Some(HeadersRecord(record_expr: expr, ..)) -> body_arg <> ", " <> expr
None -> body_arg
}
}
/// Generate the per-status branch for responses that declare no
/// `content` entries (e.g. 204 No Content, or anywhere the spec uses
/// just `description:` and optional `headers:`). When typed headers
/// are present this still emits the headers extraction; otherwise
/// it collapses to a single `<status> -> Ok(<Variant>)` line.
pub fn generate_empty_content_response(
sb: se.StringBuilder,
status_code: http.HttpStatusCode,
variant_name: String,
headers: Option(HeadersRecord),
) -> se.StringBuilder {
empty_body_branch(sb, status_code, variant_name, headers)
}
/// Generate response handling for multiple content types. Multi-content
/// response variants carry the raw text body, so the branch just
/// extracts text and constructs the variant.
pub fn generate_multi_content_response(
sb: se.StringBuilder,
status_code: http.HttpStatusCode,
variant_name: String,
_content_entries: List(#(String, spec.MediaType)),
_op_id: String,
_ctx: Context,
headers: Option(HeadersRecord),
) -> se.StringBuilder {
body_branch(sb, status_code, variant_name, "text", headers, fn(sb) {
se.indent(sb, 3, "use text <- result.try(text_body(resp.body))")
})
}
/// Issue #387: build the typed-headers record for a response, or
/// `None` if the response declares no headers. Returns a
/// `HeadersRecord` carrying both the per-field extraction
/// pre-statements and the final constructor expression that
/// references the bound names.
///
/// Header type plumbing (no longer scoped down to "String only"):
/// - Optional + String → `let <fn> = list.key_find(...) |> result.map(Some) |> result.unwrap(None)`
/// - Required + String → `use <fn> <- result.try(list.key_find(...) |> result.map_error(...))`
/// - Optional + Int / Float → same as String, plus `result.try(int.parse)` / `result.try(float.parse)`
/// - Required + Int / Float → use chain that errors on missing OR unparseable values
/// - Optional + Bool → case match for "true" / "false" (anything else → None)
/// - Required + Bool → case match wrapped in `use ... <- result.try(...)`
///
/// `$ref`-typed response headers (`schema: $ref: ...`) and complex
/// inline shapes (Object / Array / allOf / oneOf / anyOf) are
/// rejected at generation time with a clear `panic` so the user
/// sees the gap explicitly rather than getting a cryptic compile
/// error in the generated module.
pub fn build_headers_record(
op_id: String,
status_code: http.HttpStatusCode,
response: spec.Response(Resolved),
) -> Option(HeadersRecord) {
let headers =
response.headers
|> dict.to_list
|> list.sort(fn(a, b) { string.compare(a.0, b.0) })
case headers {
[] -> None
_ -> {
let record_name =
"response_types."
<> naming.schema_to_type_name(op_id)
<> "Response"
<> http.status_code_suffix(status_code)
<> "Headers"
let fields =
list.map(headers, fn(entry) {
let #(header_name, header) = entry
let field_name = naming.to_snake_case(header_name)
let pre_statement =
header_field_extraction(field_name, header_name, header)
#(field_name, pre_statement)
})
let pre_statements = list.map(fields, fn(p) { p.1 })
let assignments = list.map(fields, fn(p) { p.0 <> ": " <> p.0 })
Some(HeadersRecord(
pre_statements: pre_statements,
record_expr: record_name <> "(" <> string.join(assignments, ", ") <> ")",
))
}
}
}
/// Classification of a response header's schema, restricted to
/// shapes the client extractor can produce code for. Anything
/// outside this set falls back to a codegen-time panic via
/// `header_field_extraction`.
type HeaderFieldType {
StringHeader
IntHeader
FloatHeader
BoolHeader
}
fn classify_header_schema(
schema_opt: Option(SchemaRef),
header_name: String,
) -> HeaderFieldType {
case schema_opt {
None -> StringHeader
Some(Inline(StringSchema(..))) -> StringHeader
Some(Inline(IntegerSchema(..))) -> IntHeader
Some(Inline(NumberSchema(..))) -> FloatHeader
Some(Inline(BooleanSchema(..))) -> BoolHeader
Some(Reference(name:, ..)) ->
panic as response_header_unsupported(
header_name,
"$ref to component schema '" <> name <> "'",
)
Some(Inline(ObjectSchema(..))) ->
panic as response_header_unsupported(header_name, "inline object schema")
Some(Inline(ArraySchema(..))) ->
panic as response_header_unsupported(header_name, "inline array schema")
Some(Inline(AllOfSchema(..))) ->
panic as response_header_unsupported(header_name, "allOf composition")
Some(Inline(OneOfSchema(..))) ->
panic as response_header_unsupported(header_name, "oneOf composition")
Some(Inline(AnyOfSchema(..))) ->
panic as response_header_unsupported(header_name, "anyOf composition")
}
}
fn response_header_unsupported(header_name: String, kind: String) -> String {
"Cannot generate client extractor for response header '"
<> header_name
<> "' (unsupported schema: "
<> kind
<> "). Supported shapes today are inline String / Int / Float / Bool. "
<> "File a follow-up to oaspec issue #387 if you need typed extraction "
<> "for this header."
}
fn header_field_extraction(
field_name: String,
header_name: String,
header: spec.Header,
) -> String {
let lookup = header_lookup_expr(header_name)
let kind = classify_header_schema(header.schema, header_name)
case header.required, kind {
False, StringHeader ->
"let "
<> field_name
<> " = "
<> lookup
<> " |> result.map(Some) |> result.unwrap(None)"
True, StringHeader ->
"use "
<> field_name
<> " <- result.try("
<> lookup
<> " |> result.map_error(fn(_) { "
<> missing_required_error(header_name, "string")
<> " }))"
False, IntHeader ->
"let "
<> field_name
<> " = "
<> lookup
<> " |> result.try(int.parse) |> result.map(Some) |> result.unwrap(None)"
True, IntHeader ->
"use "
<> field_name
<> " <- result.try("
<> lookup
<> " |> result.try(int.parse) |> result.map_error(fn(_) { "
<> missing_required_error(header_name, "integer")
<> " }))"
False, FloatHeader ->
"let "
<> field_name
<> " = "
<> lookup
<> " |> result.try(float.parse) |> result.map(Some) |> result.unwrap(None)"
True, FloatHeader ->
"use "
<> field_name
<> " <- result.try("
<> lookup
<> " |> result.try(float.parse) |> result.map_error(fn(_) { "
<> missing_required_error(header_name, "number")
<> " }))"
False, BoolHeader ->
"let "
<> field_name
<> " = case "
<> lookup
<> " { Ok(\"true\") -> Some(True) Ok(\"false\") -> Some(False) _ -> None }"
True, BoolHeader ->
"use "
<> field_name
<> " <- result.try(case "
<> lookup
<> " { Ok(\"true\") -> Ok(True) Ok(\"false\") -> Ok(False) _ -> Error("
<> missing_required_error_inner(header_name, "boolean")
<> ") })"
}
}
fn missing_required_error(header_name: String, kind: String) -> String {
"InvalidResponse(detail: \"missing or invalid required "
<> kind
<> " header: "
<> header_name
<> "\")"
}
fn missing_required_error_inner(header_name: String, kind: String) -> String {
// Same payload as `missing_required_error/2` but built without the
// outer `fn(_) { ... }` wrapper, used inside an inline
// `case ... { ... -> Error(...) }` branch.
"InvalidResponse(detail: \"missing or invalid required "
<> kind
<> " header: "
<> header_name
<> "\")"
}
fn header_lookup_expr(header_name: String) -> String {
// HTTP headers are case-insensitive on the wire; normalise to
// lowercase so the lookup works regardless of how the upstream
// server cased the name.
"list.key_find(resp.headers, \"" <> string.lowercase(header_name) <> "\")"
}
/// Decoder expression for a response body. The emitted expression
/// references `text` (which the caller's surrounding `use` brings into
/// scope from `text_body(resp.body)`).
pub fn get_response_decode_expr(
schema_ref: schema.SchemaRef,
op_id: String,
status_code: http.HttpStatusCode,
_ctx: Context,
) -> String {
case schema_ref {
Reference(name:, ..) -> {
"decode.decode_" <> naming.to_snake_case(name) <> "(text)"
}
Inline(schema.ArraySchema(items:, ..)) ->
case items {
Reference(name:, ..) -> {
// Issue #493 / CodeRabbit follow-up: parse with
// `decode.list(<schema>_decoder())` directly instead of
// calling the synthetic `decode_<schema>_list` wrapper.
// The wrapper is only emitted for object schemas, so an
// array of `$ref` to an enum / primitive / oneOf / anyOf
// would otherwise reference a non-existent function.
// Going through the per-schema decoder works for every
// schema kind and also dodges the `XxxList` rename pass.
"json.parse(text, dyn_decode.list(decode."
<> naming.to_snake_case(name)
<> "_decoder()))"
}
Inline(inner) -> {
let inner_decoder = inline_schema_to_decoder(inner)
"json.parse(text, decode.list(" <> inner_decoder <> "))"
}
}
Inline(schema.StringSchema(..)) -> "json.parse(text, dyn_decode.string)"
Inline(schema.IntegerSchema(..)) -> "json.parse(text, dyn_decode.int)"
Inline(schema.NumberSchema(..)) -> "json.parse(text, dyn_decode.float)"
Inline(schema.BooleanSchema(..)) -> "json.parse(text, dyn_decode.bool)"
Inline(_) -> {
let fn_name =
"decode_"
<> naming.to_snake_case(op_id)
<> "_response_"
<> naming.to_snake_case(http.status_code_suffix(status_code))
"decode." <> fn_name <> "(text)"
}
}
}
/// Convert an inline schema to a decoder expression for use in generated
/// client code (uses `dyn_decode` to avoid colliding with the `decode`
/// module that holds the spec-derived decoders).
pub fn inline_schema_to_decoder(s: schema.SchemaObject) -> String {
case s {
schema.StringSchema(..) -> "dyn_decode.string"
schema.IntegerSchema(..) -> "dyn_decode.int"
schema.NumberSchema(..) -> "dyn_decode.float"
schema.BooleanSchema(..) -> "dyn_decode.bool"
_ -> "dyn_decode.string"
}
}