Packages
oaspec
0.26.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/codegen/decoders.gleam
import gleam/dict
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/string
import oaspec/codegen/allof_merge
import oaspec/codegen/context.{type Context, type GeneratedFile, GeneratedFile}
import oaspec/codegen/ir_build
import oaspec/codegen/schema_dispatch
import oaspec/codegen/schema_utils
import oaspec/codegen/types as type_gen
import oaspec/config
import oaspec/openapi/dedup
import oaspec/openapi/operations
import oaspec/openapi/resolver
import oaspec/openapi/schema.{
type SchemaObject, type SchemaRef, AllOfSchema, AnyOfSchema, ArraySchema,
BooleanSchema, Forbidden, Inline, IntegerSchema, NumberSchema, ObjectSchema,
OneOfSchema, Reference, StringSchema, Typed, Unspecified, Untyped,
}
import oaspec/openapi/spec.{type Resolved, Value}
import oaspec/util/http
import oaspec/util/naming
import oaspec/util/string_extra as se
/// Generate the `decode.gleam` module for the resolved spec.
///
/// Encoder generation lives in `src/oaspec/codegen/encoders.gleam`; see
/// `generate.gleam::generate_shared` for how the two halves are combined.
pub fn generate(ctx: Context) -> List(GeneratedFile) {
let operations = operations.collect_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(#(String, spec.Operation(Resolved), String, spec.HttpMethod)),
) -> String {
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)
})
None -> []
}
// Check if option module is needed (any schema with optional fields)
let needs_option =
list.any(schemas, fn(entry) {
let #(_, schema_ref) = entry
type_gen.schema_has_optional_fields(schema_ref, ctx)
})
// Check if dict module is needed (any schema with typed or untyped additionalProperties)
let needs_dict =
list.any(schemas, fn(entry) {
let #(_, schema_ref) = entry
type_gen.schema_has_additional_properties(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_types {
True ->
list.append(base_imports, [
config.package(context.config(ctx)) <> "/types",
])
False -> base_imports
}
let imports = case needs_option {
True -> list.append(base_imports, ["gleam/option"])
False -> base_imports
}
let sb =
se.file_header(context.version)
|> se.imports(imports)
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 -> []
}
// 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(#(String, spec.Operation(Resolved), String, spec.HttpMethod)),
) -> 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 =
type_gen.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 =
type_gen.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
}
}
/// 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 = type_gen.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(items:, ..)) ->
generate_array_decoder(sb, name, fn_name, decoder_fn_name, items, 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 =
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 = type_gen.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_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
<> ")",
)
}
Forbidden | Unspecified -> sb
}
let param_names =
list.index_map(props, fn(entry, idx) {
let #(prop_name, prop_ref) = entry
let field_name =
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 =
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 =
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
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 =
"decode_" <> 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.
fn generate_array_decoder(
sb: se.StringBuilder,
name: String,
fn_name: String,
decoder_fn_name: String,
items: SchemaRef,
ctx: Context,
) -> se.StringBuilder {
let inner_decoder = schema_ref_to_decoder(items, name, "")
let inner_type = qualified_schema_ref_type(items, ctx)
let gleam_type = "List(" <> inner_type <> ")"
sb
|> se.line(
"pub fn "
<> decoder_fn_name
<> "() -> decode.Decoder("
<> gleam_type
<> ") {",
)
|> se.indent(1, "decode.list(" <> inner_decoder <> ")")
|> 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
<> ") {",
)
// Build a chain of decode.one_of attempts
let sb = case ref_variants {
[] ->
sb
|> se.indent(
1,
"decode.failure(types."
<> type_name
<> ", \""
<> type_name
<> "\")",
)
[first, ..rest] -> {
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,
"decode.one_of("
<> first_decoder
<> " |> decode.map(types."
<> type_name
<> first_variant_type
<> "), [",
)
let sb =
list.fold(rest, sb, fn(sb, ref_name) {
let variant_type = naming.schema_to_type_name(ref_name)
let decoder = naming.to_snake_case(ref_name) <> "_decoder()"
sb
|> se.indent(
2,
decoder
<> " |> decode.map(types."
<> type_name
<> variant_type
<> "),",
)
})
sb |> 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)
}
}
/// Check if a SchemaRef has nullable: true.
fn schema_ref_is_nullable(ref: SchemaRef, ctx: Context) -> Bool {
case ref {
Inline(schema) -> schema.is_nullable(schema)
Reference(..) ->
case resolver.resolve_schema_ref(ref, context.spec(ctx)) {
Ok(resolved_schema) -> schema.is_nullable(resolved_schema)
// nolint: thrown_away_error -- unresolved refs are treated as non-nullable here; the spec validator reports the ref error separately
Error(_) -> False
}
}
}
/// Get element at index from a list, or return a default.
fn list_at_or(lst: List(String), idx: Int, default: String) -> String {
case lst, idx {
[], _ -> default
[head, ..], 0 -> head
[_, ..rest], n -> list_at_or(rest, n - 1, default)
}
}
/// Convert a SchemaRef to a qualified Gleam type string (with types. prefix for refs).
fn qualified_schema_ref_type(ref: SchemaRef, ctx: Context) -> String {
case ref {
Inline(schema) -> type_gen.schema_to_gleam_type(schema, ctx)
_ -> schema_dispatch.schema_ref_qualified_type(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 = 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, "}))")
}
/// Escape a spec-derived string so it can be safely interpolated
/// inside a generated Gleam string literal. Mirrors the helper in
/// `encoders.gleam` — extracted here to keep the decoder module
/// self-contained for issue #309.
fn escape_for_string_literal(value: String) -> String {
value
|> string.replace(each: "\\", with: "\\\\")
|> string.replace(each: "\"", with: "\\\"")
}