Current section

Files

Jump to
oaspec src oaspec internal codegen decoders.gleam
Raw

src/oaspec/internal/codegen/decoders.gleam

import gleam/dict
import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/string
import oaspec/config
import oaspec/internal/codegen/allof_merge
import oaspec/internal/codegen/codec_helpers
import oaspec/internal/codegen/context.{
type Context, type GeneratedFile, GeneratedFile,
}
import oaspec/internal/codegen/ir_build
import oaspec/internal/codegen/schema_dispatch
import oaspec/internal/codegen/schema_utils
import oaspec/internal/openapi/dedup
import oaspec/internal/openapi/resolver
import oaspec/internal/openapi/schema.{
type SchemaObject, type SchemaRef, AllOfSchema, AnyOfSchema, ArraySchema,
BooleanSchema, Forbidden, Inline, IntegerSchema, NumberSchema, ObjectSchema,
OneOfSchema, Reference, StringSchema, Typed, Unspecified, Untyped,
}
import oaspec/internal/openapi/spec.{type Resolved, Value}
import oaspec/internal/util/http
import oaspec/internal/util/naming
import oaspec/internal/util/string_extra as se
/// Generate the `decode.gleam` module for the resolved spec.
///
/// Encoder generation lives in `src/oaspec/internal/codegen/encoders.gleam`; see
/// `generate.gleam::generate_shared` for how the two halves are combined.
pub fn generate(ctx: Context) -> List(GeneratedFile) {
let operations = context.operations(ctx)
let decode_content = generate_decoders(ctx, operations)
[
GeneratedFile(
path: "decode.gleam",
content: decode_content,
target: context.SharedTarget,
write_mode: context.Overwrite,
),
]
}
// ===================================================================
// Decoders
// ===================================================================
/// Generate JSON decoders for all component schemas and anonymous types.
fn generate_decoders(
ctx: Context,
operations: List(context.AnalyzedOperation),
) -> String {
// Issue #435: compute the sorted+filtered schema list once and share it
// across the import-detection `list.any` passes and the emission folds.
// The previous shape sorted the dict twice and filtered separately at
// the second site, which both wasted work and risked the import header
// disagreeing with what the second pass actually emits.
let schemas = case context.spec(ctx).components {
Some(components) ->
list.sort(dict.to_list(components.schemas), fn(a, b) {
string.compare(a.0, b.0)
})
|> list.filter(fn(entry) { !ir_build.is_internal_schema(entry.1) })
None -> []
}
// Check if option module is needed (any schema with optional fields)
let needs_option =
list.any(schemas, fn(entry) {
let #(_, schema_ref) = entry
schema_utils.schema_has_optional_fields(schema_ref, ctx)
})
// Check if dict module is needed (any schema with typed/untyped
// additionalProperties surfaces a Dict in the record; `additionalProperties:
// false` also needs Dict at decode time for the closed-schema unknown-key
// check, even though the field is suppressed from the record).
// Anonymous request/response schemas also reach the same code path via
// `generate_anonymous_decoders`, so they must be inspected too — otherwise
// `gleam/dict` is missing from the import header and the generated module
// fails to compile.
let operation_inline_schemas = collect_operation_inline_schemas(operations)
let component_needs_dict =
list.any(schemas, fn(entry) {
let #(_, schema_ref) = entry
schema_utils.schema_has_additional_properties(schema_ref, ctx)
|| schema_utils.schema_has_forbidden_additional_properties(
schema_ref,
ctx,
)
})
let operation_needs_dict =
list.any(operation_inline_schemas, fn(schema_ref) {
schema_utils.schema_has_additional_properties(schema_ref, ctx)
|| schema_utils.schema_has_forbidden_additional_properties(
schema_ref,
ctx,
)
})
let needs_dict = component_needs_dict || operation_needs_dict
// Strict non-discriminator `oneOf` decoders use `list.filter_map` and
// `result.map` to count matches across branches per JSON Schema 2020-12
// §10.2.1.3 — they must reject when 2+ branches match (Issue #337).
let needs_strict_oneof_runtime =
list.any(schemas, fn(entry) {
let #(_, schema_ref) = entry
schema_utils.schema_has_non_discriminator_oneof(schema_ref, ctx)
})
|| list.any(operation_inline_schemas, fn(schema_ref) {
schema_utils.schema_has_non_discriminator_oneof(schema_ref, ctx)
})
// Check if types module is needed (any non-primitive schema)
let needs_types =
list.any(schemas, fn(entry) {
let #(_, schema_ref) = entry
case schema_ref {
Inline(ObjectSchema(..))
| Inline(AllOfSchema(..))
| Inline(OneOfSchema(..))
| Inline(AnyOfSchema(..)) -> True
Inline(StringSchema(enum_values:, ..)) if enum_values != [] -> True
_ -> False
}
})
let base_imports = case needs_dict {
True -> ["gleam/dict", "gleam/dynamic/decode", "gleam/json"]
False -> ["gleam/dynamic/decode", "gleam/json"]
}
let base_imports = case needs_strict_oneof_runtime {
True -> list.append(base_imports, ["gleam/list", "gleam/result"])
False -> base_imports
}
let base_imports = case needs_types {
True ->
list.append(base_imports, [
config.package(context.config(ctx)) <> "/types",
])
False -> base_imports
}
// Issue #387: when any top-level component schema is a `nullable: true`
// primitive or array (or anyOf/oneOf hoists one), the generated decoder
// emits a bare `decode.Decoder(Option(<inner>))` type expression. The
// bare `Option` only resolves if `type Option` is brought into scope
// explicitly, so include the type in that case. For specs that only
// use `Option` indirectly (e.g. via `decode.optional`), the plain
// `gleam/option` import is enough.
let needs_bare_option_type =
list.any(schemas, fn(entry) {
let #(_, schema_ref) = entry
codec_helpers.schema_ref_has_bare_option_type(schema_ref)
})
let imports = case needs_option, needs_bare_option_type {
True, True -> list.append(base_imports, ["gleam/option.{type Option}"])
True, False -> list.append(base_imports, ["gleam/option"])
False, _ -> base_imports
}
let sb =
se.file_header(context.version)
|> se.imports(imports)
// First pass: generate inline enum decoders from object/allOf properties
let sb =
list.fold(schemas, sb, fn(sb, entry) {
let #(name, schema_ref) = entry
generate_inline_enum_decoders(sb, name, schema_ref, ctx)
})
// Second pass: generate main type decoders
let sb =
list.fold(schemas, sb, fn(sb, entry) {
let #(name, schema_ref) = entry
generate_decoder(sb, name, schema_ref, ctx)
})
// Generate decoders for anonymous inline schemas from operations
let sb = generate_anonymous_decoders(sb, ctx, operations)
se.to_string(sb)
}
/// Generate decoders for anonymous inline schemas (response/requestBody).
fn generate_anonymous_decoders(
sb: se.StringBuilder,
ctx: Context,
operations: List(context.AnalyzedOperation),
) -> se.StringBuilder {
list.fold(operations, sb, fn(sb, op) {
let #(op_id, operation, _path, _method) = op
let sb = generate_anonymous_response_decoders(sb, op_id, operation, ctx)
generate_anonymous_request_body_decoder(sb, op_id, operation, ctx)
})
}
/// Generate decoders for inline response schemas.
fn generate_anonymous_response_decoders(
sb: se.StringBuilder,
op_id: String,
operation: spec.Operation(Resolved),
ctx: Context,
) -> se.StringBuilder {
let responses = http.sort_response_entries(dict.to_list(operation.responses))
list.fold(responses, sb, fn(sb, entry) {
let #(status_code, ref_or_response) = entry
case ref_or_response {
Value(response) -> {
let content_entries = ir_build.sorted_entries(response.content)
case content_entries {
[#(_, media_type), ..] ->
case media_type.schema {
Some(Inline(schema_obj)) -> {
// Filter out writeOnly properties from response decoders
let filtered_schema =
schema_utils.filter_write_only_properties(schema_obj, ctx)
let suffix = "Response" <> http.status_code_suffix(status_code)
generate_anonymous_schema_decoder(
sb,
op_id,
suffix,
filtered_schema,
ctx,
)
}
_ -> sb
}
_ -> sb
}
}
spec.Ref(_) -> sb
}
})
}
/// Generate decoder for an inline requestBody schema.
fn generate_anonymous_request_body_decoder(
sb: se.StringBuilder,
op_id: String,
operation: spec.Operation(Resolved),
ctx: Context,
) -> se.StringBuilder {
case operation.request_body {
Some(Value(rb)) -> {
let content_entries = ir_build.sorted_entries(rb.content)
case content_entries {
[#(_, media_type), ..] ->
case media_type.schema {
Some(Inline(schema_obj)) -> {
// Filter out readOnly properties from request body decoders
let filtered_schema =
schema_utils.filter_read_only_properties(schema_obj, ctx)
generate_anonymous_schema_decoder(
sb,
op_id,
"RequestBody",
filtered_schema,
ctx,
)
}
_ -> sb
}
_ -> sb
}
}
Some(spec.Ref(_)) -> sb
None -> sb
}
}
/// Collect every inline schema reachable via operations' request bodies and
/// responses. Used by the `needs_*` import-decision pass so anonymous closed
/// or open-with-additionalProperties object schemas don't slip through and
/// leave `gleam/dict` un-imported when the body of the generated decoder
/// references it.
fn collect_operation_inline_schemas(
operations: List(context.AnalyzedOperation),
) -> List(SchemaRef) {
list.flat_map(operations, fn(op) {
let #(_op_id, operation, _path, _method) = op
let response_schemas =
list.flat_map(dict.to_list(operation.responses), fn(entry) {
let #(_status, ref_or_response) = entry
case ref_or_response {
Value(response) ->
list.filter_map(dict.to_list(response.content), fn(content_entry) {
let #(_, media_type) = content_entry
case media_type.schema {
Some(Inline(schema_obj)) -> Ok(Inline(schema_obj))
_ -> Error(Nil)
}
})
spec.Ref(_) -> []
}
})
let request_schemas = case operation.request_body {
Some(Value(rb)) ->
list.filter_map(dict.to_list(rb.content), fn(content_entry) {
let #(_, media_type) = content_entry
case media_type.schema {
Some(Inline(schema_obj)) -> Ok(Inline(schema_obj))
_ -> Error(Nil)
}
})
_ -> []
}
list.append(response_schemas, request_schemas)
})
}
/// Generate decoder for an anonymous schema with a composed name.
fn generate_anonymous_schema_decoder(
sb: se.StringBuilder,
op_id: String,
suffix: String,
schema_obj: SchemaObject,
ctx: Context,
) -> se.StringBuilder {
let name = naming.to_snake_case(op_id) <> "_" <> naming.to_snake_case(suffix)
let schema_ref = Inline(schema_obj)
generate_decoder(sb, name, schema_ref, ctx)
}
/// Generate inline enum decoders found in object/allOf properties.
fn generate_inline_enum_decoders(
sb: se.StringBuilder,
parent_name: String,
schema_ref: SchemaRef,
ctx: Context,
) -> se.StringBuilder {
let #(props, required) = case schema_ref {
Inline(ObjectSchema(properties:, required:, ..)) -> #(
ir_build.sorted_entries(properties),
required,
)
Inline(AllOfSchema(schemas:, ..)) -> {
let merged = allof_merge.merge_allof_schemas(schemas, ctx)
#(ir_build.sorted_entries(merged.properties), merged.required)
}
_ -> #([], [])
}
list.fold(props, sb, fn(sb, entry) {
let #(prop_name, prop_ref) = entry
// Issue #309: a required inline single-value string-enum has no
// corresponding type in `types.gleam` (the IR pass elided it), so
// emitting a decoder for it would produce orphan code that
// references a missing type. Skip it; the constant value is
// validated inline by the parent object decoder instead.
case schema_utils.constant_property_value(prop_ref, prop_name, required) {
Some(_) -> sb
None ->
case prop_ref {
Inline(StringSchema(enum_values:, ..)) if enum_values != [] -> {
let enum_name =
naming.schema_to_type_name(parent_name)
<> naming.schema_to_type_name(prop_name)
generate_decoder(sb, enum_name, prop_ref, ctx)
}
_ -> sb
}
}
})
}
/// Generate decoder functions for a schema.
fn generate_decoder(
sb: se.StringBuilder,
name: String,
schema_ref: SchemaRef,
ctx: Context,
) -> se.StringBuilder {
let type_name = naming.schema_to_type_name(name)
let fn_name = "decode_" <> naming.to_snake_case(name)
let decoder_fn_name = naming.to_snake_case(name) <> "_decoder"
case schema_ref {
Inline(ObjectSchema(
properties:,
required:,
additional_properties:,
metadata:,
..,
)) ->
generate_object_decoder(
sb,
name,
type_name,
fn_name,
decoder_fn_name,
metadata.description,
properties,
required,
additional_properties,
ctx,
)
Inline(StringSchema(metadata:, enum_values:, ..)) if enum_values != [] ->
generate_enum_decoder(
sb,
type_name,
fn_name,
decoder_fn_name,
metadata.description,
enum_values,
)
Inline(AllOfSchema(metadata:, schemas:)) -> {
let merged = allof_merge.merge_allof_schemas(schemas, ctx)
let merged_schema =
Inline(ObjectSchema(
metadata:,
properties: merged.properties,
required: merged.required,
additional_properties: merged.additional_properties,
min_properties: option.None,
max_properties: option.None,
))
generate_decoder(sb, name, merged_schema, ctx)
}
Inline(OneOfSchema(metadata:, schemas:, discriminator:)) ->
generate_oneof_decoder(
sb,
name,
type_name,
fn_name,
decoder_fn_name,
metadata.description,
schemas,
discriminator,
ctx,
)
Inline(AnyOfSchema(metadata:, schemas:, ..)) ->
generate_anyof_decoder(
sb,
type_name,
fn_name,
decoder_fn_name,
metadata.description,
schemas,
)
Inline(StringSchema(metadata:, enum_values: [], ..)) -> {
let #(gleam_type, decoder_expr) = case metadata.nullable {
True -> #("Option(String)", "decode.optional(decode.string)")
False -> #("String", "decode.string")
}
generate_primitive_decoder(
sb,
fn_name,
decoder_fn_name,
gleam_type,
decoder_expr,
)
}
Inline(IntegerSchema(metadata:, ..)) -> {
let #(gleam_type, decoder_expr) = case metadata.nullable {
True -> #("Option(Int)", "decode.optional(decode.int)")
False -> #("Int", "decode.int")
}
generate_primitive_decoder(
sb,
fn_name,
decoder_fn_name,
gleam_type,
decoder_expr,
)
}
Inline(NumberSchema(metadata:, ..)) -> {
let #(gleam_type, decoder_expr) = case metadata.nullable {
True -> #("Option(Float)", "decode.optional(decode.float)")
False -> #("Float", "decode.float")
}
generate_primitive_decoder(
sb,
fn_name,
decoder_fn_name,
gleam_type,
decoder_expr,
)
}
Inline(BooleanSchema(metadata:)) -> {
let #(gleam_type, decoder_expr) = case metadata.nullable {
True -> #("Option(Bool)", "decode.optional(decode.bool)")
False -> #("Bool", "decode.bool")
}
generate_primitive_decoder(
sb,
fn_name,
decoder_fn_name,
gleam_type,
decoder_expr,
)
}
Inline(ArraySchema(metadata:, items:, ..)) ->
generate_array_decoder(
sb,
name,
fn_name,
decoder_fn_name,
items,
metadata.nullable,
ctx,
)
_ -> sb
}
}
/// Generate decoder for an ObjectSchema (properties, required, additionalProperties).
fn generate_object_decoder(
sb: se.StringBuilder,
name: String,
type_name: String,
fn_name: String,
decoder_fn_name: String,
description: Option(String),
properties: dict.Dict(String, SchemaRef),
required: List(String),
additional_properties: schema.AdditionalProperties,
ctx: Context,
) -> se.StringBuilder {
let sb = maybe_doc_comment(sb, description)
let sb =
sb
|> se.line(
"pub fn "
<> decoder_fn_name
<> "() -> decode.Decoder(types."
<> type_name
<> ") {",
)
let props = ir_build.sorted_entries(properties)
let deduped_names = dedup.dedup_property_names(list.map(props, fn(e) { e.0 }))
let sb =
list.index_fold(props, sb, fn(sb, entry, idx) {
let #(prop_name, prop_ref) = entry
let field_name =
codec_helpers.list_at_or(
deduped_names,
idx,
naming.to_snake_case(prop_name),
)
// writeOnly fields may not appear in responses, so treat them
// as optional even if listed in required
let is_write_only = schema_utils.schema_ref_is_write_only(prop_ref, ctx)
let is_required = list.contains(required, prop_name) && !is_write_only
let field_decoder = schema_ref_to_decoder(prop_ref, name, prop_name)
let is_nullable_schema =
schema_utils.schema_ref_is_nullable(prop_ref, ctx)
// For nullable schemas, the Gleam type is Option(T),
// so the decoder must be decode.optional(inner_decoder).
let effective_decoder = case is_nullable_schema {
True -> "decode.optional(" <> field_decoder <> ")"
False -> field_decoder
}
// Issue #309: a required, inline single-value string-enum
// property is elided from the generated record. The decoder
// must still observe the wire value and reject mismatches —
// otherwise a spec violation would silently slip through. The
// emitted decoder reads the field as a string, validates it
// against the codegen-time constant via `decode.then`, and
// discards the value (the record has no slot for it).
case schema_utils.constant_property_value(prop_ref, prop_name, required) {
Some(constant_value) ->
sb
|> emit_constant_field_decoder(prop_name, constant_value)
None ->
case is_required {
True ->
sb
|> se.indent(
1,
"use "
<> field_name
<> " <- decode.field(\""
<> prop_name
<> "\", "
<> effective_decoder
<> ")",
)
False ->
case is_nullable_schema {
True ->
// Type is Option(T), default is None
sb
|> se.indent(
1,
"use "
<> field_name
<> " <- decode.optional_field(\""
<> prop_name
<> "\", option.None, "
<> effective_decoder
<> ")",
)
False ->
sb
|> se.indent(
1,
"use "
<> field_name
<> " <- decode.optional_field(\""
<> prop_name
<> "\", option.None, decode.optional("
<> field_decoder
<> "))",
)
}
}
}
})
// Decode additional_properties as Dict, then drop known property keys
// so only unknown/extra keys remain in additional_properties.
let known_keys_expr = case list.is_empty(props) {
True -> "[]"
False ->
"["
<> se.join_with(
list.map(props, fn(entry) {
let #(prop_name, _) = entry
"\"" <> prop_name <> "\""
}),
", ",
)
<> "]"
}
// For additionalProperties, decode the raw dict with dynamic values first
// to avoid forcing the value decoder on known properties (which may have
// incompatible types). Then drop known keys and decode remaining values.
let sb = case additional_properties {
Typed(ap_ref) -> {
let inner_decoder =
schema_ref_to_decoder(ap_ref, name, "additional_properties")
sb
|> se.indent(
1,
"use all_props <- decode.then(decode.dict(decode.string, decode.new_primitive_decoder(\"Dynamic\", fn(x) { Ok(x) })))",
)
|> se.indent(
1,
"let extra_props = dict.drop(all_props, " <> known_keys_expr <> ")",
)
|> se.indent(
1,
"let additional_properties_result = dict.fold(extra_props, Ok(dict.new()), fn(acc, k, v) {",
)
|> se.indent(2, "case acc {")
|> se.indent(3, "Ok(decoded_acc) ->")
|> se.indent(4, "case decode.run(v, " <> inner_decoder <> ") {")
|> se.indent(5, "Ok(decoded) -> Ok(dict.insert(decoded_acc, k, decoded))")
|> se.indent(5, "Error(_) -> Error(Nil)")
|> se.indent(4, "}")
|> se.indent(3, "Error(_) -> Error(Nil)")
|> se.indent(2, "}")
|> se.indent(1, "})")
|> se.indent(
1,
"use additional_properties <- decode.then(case additional_properties_result {",
)
|> se.indent(2, "Ok(decoded) -> decode.success(decoded)")
|> se.indent(
2,
"Error(_) -> decode.failure(dict.new(), \"additionalProperties\")",
)
|> se.indent(1, "})")
}
Untyped -> {
sb
|> se.indent(
1,
"use all_props <- decode.then(decode.dict(decode.string, decode.new_primitive_decoder(\"Dynamic\", fn(x) { Ok(x) })))",
)
|> se.indent(
1,
"let additional_properties = dict.drop(all_props, "
<> known_keys_expr
<> ")",
)
}
// Closed schema: read every key, drop the declared ones, and reject
// the decode if anything remains. The check has to live in the decoder
// body because `gleam/dynamic/decode` consumes only declared fields,
// so a post-decode validator would have nothing to inspect.
Forbidden -> {
sb
|> se.indent(
1,
"use all_props <- decode.then(decode.dict(decode.string, decode.new_primitive_decoder(\"Dynamic\", fn(x) { Ok(x) })))",
)
|> se.indent(
1,
"let extra_props = dict.drop(all_props, " <> known_keys_expr <> ")",
)
|> se.indent(1, "use _ <- decode.then(case dict.is_empty(extra_props) {")
|> se.indent(2, "True -> decode.success(Nil)")
|> se.indent(2, "False -> decode.failure(Nil, \"additionalProperties\")")
|> se.indent(1, "})")
}
Unspecified -> sb
}
let param_names =
list.index_map(props, fn(entry, idx) {
let #(prop_name, prop_ref) = entry
let field_name =
codec_helpers.list_at_or(
deduped_names,
idx,
naming.to_snake_case(prop_name),
)
#(prop_name, prop_ref, field_name)
})
// Issue #309: constant properties are elided from the record, so
// their decoder consumed-and-discarded the wire value above. They
// must NOT appear in the success constructor's argument list.
|> list.filter(fn(entry) {
let #(prop_name, prop_ref, _) = entry
option.is_none(schema_utils.constant_property_value(
prop_ref,
prop_name,
required,
))
})
|> list.map(fn(entry) {
let #(_, _, field_name) = entry
field_name <> ": " <> field_name
})
// Add additional_properties to param names only when the generated record
// surfaces the field (Typed or Untyped). Forbidden / Unspecified suppress
// it — see Issue #249.
let param_names = case additional_properties {
Typed(_) | Untyped ->
list.append(param_names, [
"additional_properties: additional_properties",
])
Forbidden | Unspecified -> param_names
}
let sb =
sb
|> se.indent(
1,
"decode.success(types."
<> type_name
<> "("
<> se.join_with(param_names, ", ")
<> "))",
)
let sb = sb |> se.line("}") |> se.blank_line()
// json.parse wrapper
let sb =
sb
|> se.line(
"pub fn "
<> fn_name
<> "(json_string: String) -> Result(types."
<> type_name
<> ", json.DecodeError) {",
)
|> se.indent(1, "json.parse(json_string, " <> decoder_fn_name <> "())")
|> se.line("}")
|> se.blank_line()
// List decoder for typed client array responses
let list_fn_name = fn_name <> "_list"
let list_decoder_fn_name = decoder_fn_name <> "_list"
sb
|> se.line(
"pub fn "
<> list_decoder_fn_name
<> "() -> decode.Decoder(List(types."
<> type_name
<> ")) {",
)
|> se.indent(1, "decode.list(" <> decoder_fn_name <> "())")
|> se.line("}")
|> se.blank_line()
|> se.line(
"pub fn "
<> list_fn_name
<> "(json_string: String) -> Result(List(types."
<> type_name
<> "), json.DecodeError) {",
)
|> se.indent(1, "json.parse(json_string, " <> list_decoder_fn_name <> "())")
|> se.line("}")
|> se.blank_line()
}
/// Generate decoder for a string enum schema.
fn generate_enum_decoder(
sb: se.StringBuilder,
type_name: String,
fn_name: String,
decoder_fn_name: String,
description: Option(String),
enum_values: List(String),
) -> se.StringBuilder {
let sb = maybe_doc_comment(sb, description)
let sb =
sb
|> se.line(
"pub fn "
<> decoder_fn_name
<> "() -> decode.Decoder(types."
<> type_name
<> ") {",
)
|> se.indent(1, "use value <- decode.then(decode.string)")
|> se.indent(1, "case value {")
let deduped_variants = dedup.dedup_enum_variants(enum_values)
let sb =
list.index_fold(enum_values, sb, fn(sb, value, idx) {
let variant_suffix =
codec_helpers.list_at_or(
deduped_variants,
idx,
naming.to_pascal_case(value),
)
let variant = naming.schema_to_type_name(type_name) <> variant_suffix
sb
|> se.indent(
2,
"\"" <> value <> "\" -> decode.success(types." <> variant <> ")",
)
})
// Unknown enum values → decode failure, not silent fallback
let first_variant_suffix =
codec_helpers.list_at_or(deduped_variants, 0, case enum_values {
[first, ..] -> naming.to_pascal_case(first)
[] -> "Unknown"
})
sb
|> se.indent(
2,
"_ -> decode.failure(types."
<> naming.schema_to_type_name(type_name)
<> first_variant_suffix
<> ", \""
<> type_name
<> ": unknown variant \" <> value)",
)
|> se.indent(1, "}")
|> se.line("}")
|> se.blank_line()
// json.parse wrapper
|> se.line(
"pub fn "
<> fn_name
<> "(json_string: String) -> Result(types."
<> type_name
<> ", json.DecodeError) {",
)
|> se.indent(1, "json.parse(json_string, " <> decoder_fn_name <> "())")
|> se.line("}")
|> se.blank_line()
}
/// Generate decoder for an anyOf (inclusive union) schema.
fn generate_anyof_decoder(
sb: se.StringBuilder,
type_name: String,
fn_name: String,
decoder_fn_name: String,
description: Option(String),
schemas: List(SchemaRef),
) -> se.StringBuilder {
let sb = maybe_doc_comment(sb, description)
// Generate field decoders that try each variant. The decoder
// function for a top-level component schema named `Foo` is emitted
// as `foo_decoder()`; the previous `decode_<snake>_decoder` form
// referenced a function that never exists and broke compilation
// for every anyOf union that survives hoisting (Issue #387).
let variant_fields =
list.map(schemas, fn(s_ref) {
case s_ref {
Reference(name: ref_name, ..) -> {
let field_name = naming.to_snake_case(ref_name)
let decoder_name = naming.to_snake_case(ref_name) <> "_decoder"
#(field_name, decoder_name, ref_name)
}
Inline(_) -> #("unknown", "decode.string", "Unknown")
}
})
// Decoder function
let sb =
sb
|> se.line(
"pub fn "
<> decoder_fn_name
<> "() -> decode.Decoder(types."
<> type_name
<> ") {",
)
// Try each variant decoder, wrap in Some on success, None on failure
let sb =
list.fold(variant_fields, sb, fn(sb, field) {
let #(field_name, decoder_name, _) = field
sb
|> se.indent(
1,
"use "
<> field_name
<> " <- decode.then(decode.one_of("
<> decoder_name
<> "() |> decode.map(option.Some), or: [decode.success(option.None)]))",
)
})
// Construct the record
let field_assignments =
list.map(variant_fields, fn(f) {
let #(field_name, _, _) = f
field_name <> ": " <> field_name
})
sb
|> se.indent(
1,
"decode.success(types."
<> type_name
<> "("
<> string.join(field_assignments, ", ")
<> "))",
)
|> se.line("}")
|> se.blank_line()
// JSON parse wrapper
|> se.line(
"pub fn "
<> fn_name
<> "(json_string: String) -> Result(types."
<> type_name
<> ", json.DecodeError) {",
)
|> se.indent(1, "json.parse(json_string, " <> decoder_fn_name <> "())")
|> se.line("}")
|> se.blank_line()
}
/// Generate decoder for a primitive type (String, Int, Float, Bool).
fn generate_primitive_decoder(
sb: se.StringBuilder,
fn_name: String,
decoder_fn_name: String,
gleam_type: String,
decoder_expr: String,
) -> se.StringBuilder {
sb
|> se.line(
"pub fn "
<> decoder_fn_name
<> "() -> decode.Decoder("
<> gleam_type
<> ") {",
)
|> se.indent(1, decoder_expr)
|> se.line("}")
|> se.blank_line()
|> se.line(
"pub fn "
<> fn_name
<> "(json_string: String) -> Result("
<> gleam_type
<> ", json.DecodeError) {",
)
|> se.indent(1, "json.parse(json_string, " <> decoder_expr <> ")")
|> se.line("}")
|> se.blank_line()
}
/// Generate decoder for an ArraySchema. Issue #387: when the array
/// schema itself carries `nullable: true`, the type generator wraps
/// the resulting type alias in `Option(List(T))` (via
/// `schema_dispatch.schema_type`), so the decoder must wrap its
/// `decode.list(...)` body in `decode.optional(...)` to match.
/// Otherwise the emitted variant decoder returns
/// `Decoder(List(T))` while every caller treats it as
/// `Decoder(Option(List(T)))`.
fn generate_array_decoder(
sb: se.StringBuilder,
name: String,
fn_name: String,
decoder_fn_name: String,
items: SchemaRef,
nullable: Bool,
ctx: Context,
) -> se.StringBuilder {
let inner_decoder = schema_ref_to_decoder(items, name, "")
let inner_type = codec_helpers.qualified_schema_ref_type(items, ctx)
let list_type = "List(" <> inner_type <> ")"
let list_decoder = "decode.list(" <> inner_decoder <> ")"
let #(gleam_type, decoder_expr) = case nullable {
True -> #(
"Option(" <> list_type <> ")",
"decode.optional(" <> list_decoder <> ")",
)
False -> #(list_type, list_decoder)
}
sb
|> se.line(
"pub fn "
<> decoder_fn_name
<> "() -> decode.Decoder("
<> gleam_type
<> ") {",
)
|> se.indent(1, decoder_expr)
|> se.line("}")
|> se.blank_line()
|> se.line(
"pub fn "
<> fn_name
<> "(json_string: String) -> Result("
<> gleam_type
<> ", json.DecodeError) {",
)
|> se.indent(1, "json.parse(json_string, " <> decoder_fn_name <> "())")
|> se.line("}")
|> se.blank_line()
}
/// Generate decoder for oneOf/anyOf union types.
/// With discriminator: decode based on discriminator field value.
/// Without discriminator: try each variant decoder in order.
fn generate_oneof_decoder(
sb: se.StringBuilder,
_name: String,
type_name: String,
fn_name: String,
decoder_fn_name: String,
description: Option(String),
schemas: List(SchemaRef),
discriminator: Option(schema.Discriminator),
_ctx: Context,
) -> se.StringBuilder {
// Only handle $ref variants (inline primitives blocked by validator)
let all_refs =
list.all(schemas, fn(s) {
case s {
Reference(..) -> True
_ -> False
}
})
case all_refs {
False -> sb
True -> {
let sb = maybe_doc_comment(sb, description)
case discriminator {
Some(disc) -> {
// Discriminator-based decoder
let sb =
sb
|> se.line(
"pub fn "
<> decoder_fn_name
<> "() -> decode.Decoder(types."
<> type_name
<> ") {",
)
|> se.indent(
1,
"use disc_value <- decode.field(\""
<> disc.property_name
<> "\", decode.string)",
)
|> se.indent(1, "case disc_value {")
let sb =
list.fold(schemas, sb, fn(sb, s_ref) {
case s_ref {
Reference(ref:, name:) -> {
let ref_name = name
let variant_type = naming.schema_to_type_name(ref_name)
let variant_name = type_name <> variant_type
let variant_decoder =
naming.to_snake_case(ref_name) <> "_decoder()"
// Check discriminator mapping first, fallback to ref name
let disc_value = get_discriminator_value(disc, ref, ref_name)
sb
|> se.indent(2, "\"" <> disc_value <> "\" -> {")
|> se.indent(
3,
"use inner <- decode.then(" <> variant_decoder <> ")",
)
|> se.indent(
3,
"decode.success(types." <> variant_name <> "(inner))",
)
|> se.indent(2, "}")
}
_ -> sb
}
})
// For unknown discriminator values, fail immediately with a
// discriminator-specific error message. The second decode.then
// is unreachable at runtime — the leading decode.failure short-
// circuits decode.then, so the inner variant decoder is never
// invoked — but it's required at compile time to give the case
// branch the right Decoder(types.<TypeName>) type. Without this
// structure, an unknown discriminator whose body also fails to
// match the first variant would surface the *first variant's*
// decode error instead of the discriminator error (issue #308).
let first_ref_decoder = case schemas {
[Reference(name:, ..), ..] -> {
let ref_name = name
naming.to_snake_case(ref_name) <> "_decoder()"
}
_ -> "decode.string"
}
let first_variant_name = case schemas {
[Reference(name:, ..), ..] -> {
let ref_name = name
let variant_type = naming.schema_to_type_name(ref_name)
"types." <> type_name <> variant_type
}
_ -> "types." <> type_name
}
// Build the "expected" list mirroring the discriminator values
// each variant arm matches above. When the spec supplies an
// explicit `mapping`, those keys win; otherwise each variant
// falls back to its ref name (matching `get_discriminator_value`
// semantics on lines 1099-1119).
let valid_disc_values =
schemas
|> list.filter_map(fn(s_ref) {
case s_ref {
Reference(ref:, name:) ->
Ok(get_discriminator_value(disc, ref, name))
_ -> Error(Nil)
}
})
|> list.sort(string.compare)
|> string.join("|")
let sb =
sb
|> se.indent(2, "_ -> {")
|> se.indent(
3,
"use _ <- decode.then(decode.failure(Nil, \""
<> type_name
<> ": unknown discriminator '\" <> disc_value <> \"' (expected "
<> valid_disc_values
<> ")\"))",
)
|> se.indent(3, "use v <- decode.then(" <> first_ref_decoder <> ")")
|> se.indent(
3,
"decode.failure("
<> first_variant_name
<> "(v), \""
<> type_name
<> "\")",
)
|> se.indent(2, "}")
|> se.indent(1, "}")
|> se.line("}")
|> se.blank_line()
// json.parse wrapper
sb
|> se.line(
"pub fn "
<> fn_name
<> "(json_string: String) -> Result(types."
<> type_name
<> ", json.DecodeError) {",
)
|> se.indent(
1,
"json.parse(json_string, " <> decoder_fn_name <> "())",
)
|> se.line("}")
|> se.blank_line()
}
None -> {
// No discriminator: generate a decode.Decoder that tries each variant
let ref_variants =
list.filter_map(schemas, fn(s_ref) {
case s_ref {
Reference(name:, ..) -> {
let ref_name = name
Ok(ref_name)
}
_ -> Error(Nil)
}
})
let sb =
sb
|> se.line(
"pub fn "
<> decoder_fn_name
<> "() -> decode.Decoder(types."
<> type_name
<> ") {",
)
// Issue #337: emit a strict-oneOf body. The previous
// `decode.one_of(first, [rest..])` chain was first-match
// (anyOf semantics); JSON Schema 2020-12 §10.2.1.3 demands
// exactly-one-match for `oneOf`. Run each branch
// independently against the raw Dynamic, count successful
// matches, succeed iff exactly one matched, and emit a
// distinct failure diagnostic when 2+ branches matched.
let sb = case ref_variants {
[] ->
sb
|> se.indent(
1,
"decode.failure(types."
<> type_name
<> ", \""
<> type_name
<> "\")",
)
[first, ..] -> {
let first_variant_type = naming.schema_to_type_name(first)
let first_decoder = naming.to_snake_case(first) <> "_decoder()"
let sb =
sb
|> se.indent(1, "use raw <- decode.then(decode.dynamic)")
let sb =
list.index_fold(ref_variants, sb, fn(sb, ref_name, idx) {
let variant_type = naming.schema_to_type_name(ref_name)
let decoder = naming.to_snake_case(ref_name) <> "_decoder()"
sb
|> se.indent(
1,
"let r"
<> int.to_string(idx)
<> " = decode.run(raw, "
<> decoder
<> ") |> result.map(types."
<> type_name
<> variant_type
<> ")",
)
})
let attempts_list =
"["
<> se.join_with(
list.index_map(ref_variants, fn(_, idx) {
"r" <> int.to_string(idx)
}),
", ",
)
<> "]"
sb
|> se.indent(
1,
"let oks = list.filter_map(" <> attempts_list <> ", fn(r) {",
)
|> se.indent(2, "case r {")
|> se.indent(3, "Ok(v) -> Ok(v)")
|> se.indent(3, "Error(_) -> Error(Nil)")
|> se.indent(2, "}")
|> se.indent(1, "})")
|> se.indent(1, "case oks {")
|> se.indent(2, "[single] -> decode.success(single)")
|> se.indent(
2,
"[first, _, ..] -> decode.failure(first, \""
<> type_name
<> ": matched multiple oneOf branches; expected exactly one\")",
)
|> se.indent(2, "[] -> {")
|> se.indent(
3,
"use fallback <- decode.then(" <> first_decoder <> ")",
)
|> se.indent(
3,
"decode.failure(types."
<> type_name
<> first_variant_type
<> "(fallback), \""
<> type_name
<> ": no oneOf branch matched\")",
)
|> se.indent(2, "}")
|> se.indent(1, "}")
}
}
let sb =
sb
|> se.line("}")
|> se.blank_line()
// json.parse wrapper
sb
|> se.line(
"pub fn "
<> fn_name
<> "(json_string: String) -> Result(types."
<> type_name
<> ", json.DecodeError) {",
)
|> se.indent(
1,
"json.parse(json_string, " <> decoder_fn_name <> "())",
)
|> se.line("}")
|> se.blank_line()
}
}
}
}
}
/// Get the discriminator value for a $ref.
/// OpenAPI discriminator.mapping is keyed by payload values, with $ref paths
/// as values: { "dog": "#/components/schemas/Dog" }.
/// Given a ref_name like "Dog", find the mapping key that points to it.
/// Falls back to ref_name if no explicit mapping exists.
fn get_discriminator_value(
disc: schema.Discriminator,
ref: String,
ref_name: String,
) -> String {
// Search mapping entries: key = discriminator value, value = $ref path or schema name
let found =
dict.to_list(disc.mapping)
|> list.find(fn(entry) {
let #(_disc_value, target) = entry
// The target may be a full $ref path or just the schema name
target == ref || resolver.ref_to_name(target) == ref_name
})
case found {
Ok(#(disc_value, _)) -> disc_value
// nolint: thrown_away_error -- missing mapping entry is expected; fall back to the ref name
Error(_) -> ref_name
}
}
/// Convert a SchemaRef to a decoder expression string.
/// parent_name is used to resolve inline enum decoder names.
fn schema_ref_to_decoder(
ref: SchemaRef,
parent_name: String,
prop_name: String,
) -> String {
case ref {
Inline(StringSchema(enum_values:, ..)) if enum_values != [] -> {
// Inline enum: use the generated enum decoder
let decoder_name =
naming.to_snake_case(
naming.schema_to_type_name(parent_name)
<> naming.schema_to_type_name(prop_name),
)
decoder_name <> "_decoder()"
}
Inline(ArraySchema(items:, ..)) -> {
let inner = schema_ref_to_decoder(items, parent_name, prop_name)
"decode.list(" <> inner <> ")"
}
_ -> schema_dispatch.decoder_expr(ref)
}
}
/// Add a doc comment if description is present.
fn maybe_doc_comment(
sb: se.StringBuilder,
description: Option(String),
) -> se.StringBuilder {
case description {
Some(desc) -> sb |> se.doc_comment(desc)
None -> sb
}
}
/// Emit the decoder snippet for a constant (issue #309) field.
///
/// The wire value is required and fully determined at codegen time
/// (an inline `enum: [<single>]`). The decoder reads it as a string
/// and chains a `decode.then` that fails fast on any other value.
/// `_` discards the binding because the surrounding record has no
/// slot for the field. Failing on mismatch keeps the discriminator
/// contract honest — silently accepting `kind: "media"` where only
/// `kind: "text"` is legal would defeat the purpose of the enum.
fn emit_constant_field_decoder(
sb: se.StringBuilder,
prop_name: String,
constant_value: String,
) -> se.StringBuilder {
let escaped = codec_helpers.escape_for_string_literal(constant_value)
sb
|> se.indent(
1,
"use _ <- decode.field(\""
<> prop_name
<> "\", decode.then(decode.string, fn(constant_value) {",
)
|> se.indent(2, "case constant_value {")
|> se.indent(3, "\"" <> escaped <> "\" -> decode.success(Nil)")
|> se.indent(
3,
"other -> decode.failure(Nil, \"expected "
<> prop_name
<> "=\\\""
<> escaped
<> "\\\", got \\\"\" <> other <> \"\\\"\")",
)
|> se.indent(2, "}")
|> se.indent(1, "}))")
}