Packages
oaspec
0.53.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/encoders.gleam
//// JSON encoder generation.
////
//// Split out of `decoders.gleam` so each module owns one direction of
//// the codec pipeline — decoders produces `decode.gleam`, encoders
//// produces `encode.gleam`. The shared traversal helpers
//// (`escape_for_string_literal`, `list_at_or`, `qualified_schema_ref_type`,
//// `schema_ref_has_bare_option_type`) live in `codec_helpers.gleam` —
//// extracted in #402 to close the follow-up flagged by #212.
import gleam/bool
import gleam/dict
import gleam/list
import gleam/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/runtime_snippets
import oaspec/internal/codegen/schema_dispatch
import oaspec/internal/codegen/schema_utils
import oaspec/internal/openapi/dedup
import oaspec/internal/openapi/schema.{
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/naming
import oaspec/internal/util/string_extra as se
/// Generate the `encode.gleam` module for the resolved spec.
pub fn generate(ctx: Context) -> List(GeneratedFile) {
let operations = context.operations(ctx)
let content = generate_encoders(ctx, operations)
[
GeneratedFile(
path: "encode.gleam",
content: content,
target: context.SharedTarget,
write_mode: context.Overwrite,
),
]
}
/// Generate JSON encoders for all component schemas and anonymous types.
fn generate_encoders(
ctx: Context,
operations: List(context.AnalyzedOperation),
) -> 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)
})
|> list.filter(fn(entry) { !ir_build.is_internal_schema(entry.1) })
None -> []
}
// Check if types module is needed
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
}
})
// Check if dict/list modules are needed (for additionalProperties encoding)
let needs_dict =
list.any(schemas, fn(entry) {
let #(_, schema_ref) = entry
case schema_ref {
Inline(ObjectSchema(additional_properties: Typed(_), ..)) -> True
Inline(ObjectSchema(additional_properties: Untyped, ..)) -> True
_ -> False
}
})
// Check if dynamic module is needed (for untyped additionalProperties encoding)
let needs_dynamic =
list.any(schemas, fn(entry) {
let #(_, schema_ref) = entry
case schema_ref {
Inline(ObjectSchema(additional_properties: Untyped, ..)) -> True
_ -> False
}
})
// Issue #303: object schemas with at least one optional non-nullable
// property emit `case value.<f> { option.None -> [] option.Some(x) -> ... }`
// wrapped in `list.flatten([...])`. Both the option module and the list
// module need to be imported for those modules to resolve.
let needs_option_and_list =
list.any(schemas, fn(entry) {
let #(_, schema_ref) = entry
case schema_ref {
Inline(ObjectSchema(properties:, required:, ..)) ->
properties
|> ir_build.sorted_entries
|> list.any(fn(p) {
let #(prop_name, prop_ref) = p
!list.contains(required, prop_name)
&& !schema_utils.schema_ref_is_nullable(prop_ref, ctx)
})
_ -> False
}
})
let base_imports = case needs_dict, needs_dynamic {
True, True -> [
"gleam/dict",
"gleam/dynamic",
"gleam/dynamic/decode",
"gleam/json",
"gleam/list",
]
True, False -> ["gleam/dict", "gleam/json", "gleam/list"]
_, _ -> ["gleam/json"]
}
// Issue #387: a top-level encoder for a `nullable: true` primitive
// or array schema is generated with a bare `Option(...)` parameter
// type. Bringing `type Option` into scope is only needed in that
// case; the more common record-encoder path that uses
// `option.None`/`option.Some` is fine with just `gleam/option`.
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 option_import_line = case needs_bare_option_type {
True -> "gleam/option.{type Option}"
False -> "gleam/option"
}
let base_imports = case needs_option_and_list {
True ->
base_imports
|> ensure_import("gleam/list")
|> ensure_import(option_import_line)
False -> base_imports
}
let imports = case needs_types {
True ->
list.append(base_imports, [
config.package(context.config(ctx)) <> "/types",
])
False -> base_imports
}
let sb =
se.file_header(context.version)
|> se.imports(imports)
// First pass: generate inline enum encoders
let sb =
list.fold(schemas, sb, fn(sb, entry) {
let #(name, schema_ref) = entry
generate_inline_enum_encoders(sb, name, schema_ref, ctx)
})
// Second pass: generate main type encoders
let sb =
list.fold(schemas, sb, fn(sb, entry) {
let #(name, schema_ref) = entry
generate_encoder(sb, name, schema_ref, ctx)
})
// Generate encoders for anonymous inline schemas from operations
let sb = generate_anonymous_encoders(sb, ctx, operations)
// Generate encode_dynamic helper if needed for untyped additionalProperties.
//
// The fallback `_ -> json.null()` arm covers unsupported dynamic
// classifications (List, Dict, Tuple, etc.). Emitting the type name as a
// string would silently corrupt payloads; null at least fails loud when
// the receiving end doesn't tolerate it.
let sb = case needs_dynamic {
True ->
sb
|> se.doc_comment(
"Encode a Dynamic value to JSON by inspecting its runtime type.",
)
|> se.raw(runtime_snippets.encode_dynamic)
False -> sb
}
se.to_string(sb)
}
/// Generate inline enum encoders found in object/allOf properties.
fn generate_inline_enum_encoders(
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
// matching type in `types.gleam`, so generating an encoder for it
// would reference a non-existent type. Skip it; the parent
// object's encoder inlines the constant `json.string("...")`.
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 =
ir_build.inline_enum_type_name_for(parent_name, prop_name, ctx)
generate_encoder(sb, enum_name, prop_ref, ctx)
}
_ -> sb
}
}
})
}
/// Generate encoders for anonymous inline schemas (response/requestBody).
fn generate_anonymous_encoders(
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
// Only requestBody inline schemas need encoders (for client body encoding)
generate_anonymous_request_body_encoder(sb, op_id, operation, ctx)
})
}
/// Generate encoder for an inline requestBody schema.
fn generate_anonymous_request_body_encoder(
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 encoders
let filtered_schema =
schema_utils.filter_read_only_properties(schema_obj, ctx)
let name = naming.to_snake_case(op_id) <> "_request_body"
generate_encoder(sb, name, Inline(filtered_schema), ctx)
}
_ -> sb
}
_ -> sb
}
}
Some(spec.Ref(_)) -> sb
None -> sb
}
}
/// Generate an encoder function for a schema.
/// Each type gets two functions:
/// encode_x_json(value) -> json.Json (for composition in objects)
/// encode_x(value) -> String (for standalone use)
fn generate_encoder(
sb: se.StringBuilder,
name: String,
schema_ref: SchemaRef,
ctx: Context,
) -> se.StringBuilder {
let type_name = naming.schema_to_type_name(name)
let fn_name = "encode_" <> naming.to_snake_case(name)
let json_fn_name = fn_name <> "_json"
case schema_ref {
Inline(ObjectSchema(properties:, required:, additional_properties:, ..)) -> {
// _json version: returns json.Json
let sb =
sb
|> se.line(
"pub fn "
<> json_fn_name
<> "(value: types."
<> type_name
<> ") -> json.Json {",
)
// When additional_properties is Typed or Untyped we emit a `base_props`
// list and merge in dict entries; Forbidden and Unspecified (Issue #249)
// both go through `json.object([...])` directly with no AP merge.
let has_ap = case additional_properties {
Typed(_) | Untyped -> True
Forbidden | Unspecified -> False
}
// Filter out readOnly properties -- they should not be sent to the server
let all_props = ir_build.sorted_entries(properties)
let all_deduped_names =
dedup.dedup_property_names(list.map(all_props, fn(e) { e.0 }))
// Build list of (prop_name, prop_ref, field_name) with readOnly filtered out
let props_with_names =
list.index_map(all_props, fn(entry, idx) {
let #(prop_name, prop_ref) = entry
let field_name =
codec_helpers.list_at_or(
all_deduped_names,
idx,
naming.to_snake_case(prop_name),
)
#(prop_name, prop_ref, field_name)
})
|> list.filter(fn(entry) {
let #(_, prop_ref, _) = entry
!schema_utils.schema_ref_is_read_only(prop_ref, ctx)
})
// Issue #303: a property that is in `properties` but not in `required`
// and is not `nullable: true` MUST be omitted entirely from the JSON
// object when its `Option` field is `None` — emitting `"<key>": null`
// is schema-invalid (only `nullable: true` allows null on the wire).
// When any property falls into that bucket we switch the encoder
// shape from a static `[<pairs>]` literal to `list.flatten([<lists>])`
// so each optional-non-nullable property can contribute either an
// empty list (None) or a singleton pair list (Some).
let has_omittable =
list.any(props_with_names, fn(entry) {
let #(prop_name, prop_ref, _) = entry
!list.contains(required, prop_name)
&& !schema_utils.schema_ref_is_nullable(prop_ref, ctx)
})
let sb = case has_ap, has_omittable {
True, False ->
sb
|> se.indent(1, "let base_props = [")
True, True ->
sb
|> se.indent(1, "let base_props = list.flatten([")
False, False ->
sb
|> se.indent(1, "json.object([")
False, True ->
sb
|> se.indent(1, "json.object(list.flatten([")
}
let sb =
list.index_fold(props_with_names, sb, fn(sb, entry, idx) {
let #(prop_name, prop_ref, field_name) = entry
let is_required = list.contains(required, prop_name)
let trailing = case idx == list.length(props_with_names) - 1 {
True -> ""
False -> ","
}
// Issue #309: a required inline single-value string-enum
// property is elided from the generated record; the wire
// value is fixed at codegen time. Inline `json.string(...)`
// here so the encoder never tries to read a field that does
// not exist on the Gleam record. The downstream `case`
// branches still pick the right tuple/list-of-lists shape
// because the property is required and non-nullable.
let encoder_expr = case
schema_utils.constant_property_value(prop_ref, prop_name, required)
{
Some(constant_value) ->
"json.string(\""
<> codec_helpers.escape_for_string_literal(constant_value)
<> "\")"
None ->
schema_ref_to_json_encoder(
"value." <> field_name,
prop_ref,
name,
prop_name,
ctx,
)
}
// Issue #296: required+nullable properties have Gleam type
// Option(T) and must use json.nullable, not the bare encoder.
let is_nullable = schema_utils.schema_ref_is_nullable(prop_ref, ctx)
let is_omittable = !is_required && !is_nullable
case has_omittable, is_required, is_nullable, is_omittable {
// No optional-non-nullable in the schema → static list, current shape.
False, True, False, _ ->
sb
|> se.indent(
2,
"#(\"" <> prop_name <> "\", " <> encoder_expr <> ")" <> trailing,
)
False, _, _, _ ->
sb
|> se.indent(
2,
"#(\""
<> prop_name
<> "\", json.nullable(value."
<> field_name
<> ", "
<> schema_ref_to_json_encoder_fn(
prop_ref,
name,
prop_name,
ctx,
)
<> "))"
<> trailing,
)
// Schema has at least one optional-non-nullable → list-of-lists.
True, _, _, True -> {
// Issue #303: omit the key when the value is None.
// Re-derive the encoder expression with `x` substituted for
// `value.<field>` so we encode the unwrapped Some value.
let some_encoder_expr =
schema_ref_to_json_encoder("x", prop_ref, name, prop_name, ctx)
sb
|> se.indent(2, "case value." <> field_name <> " {")
|> se.indent(3, "option.None -> []")
|> se.indent(
3,
"option.Some(x) -> [#(\""
<> prop_name
<> "\", "
<> some_encoder_expr
<> ")]",
)
|> se.indent(2, "}" <> trailing)
}
True, True, False, _ ->
sb
|> se.indent(
2,
"[#(\""
<> prop_name
<> "\", "
<> encoder_expr
<> ")]"
<> trailing,
)
True, _, _, _ ->
// Required+nullable or optional+nullable: keep `json.nullable`
// (None → wire-format null is permitted because nullable: true).
sb
|> se.indent(
2,
"[#(\""
<> prop_name
<> "\", json.nullable(value."
<> field_name
<> ", "
<> schema_ref_to_json_encoder_fn(
prop_ref,
name,
prop_name,
ctx,
)
<> "))]"
<> trailing,
)
}
})
let close_props_list = case has_omittable {
True -> "])"
False -> "]"
}
let sb = case additional_properties {
Typed(ap_ref) -> {
let inner_encoder_fn =
schema_ref_to_json_encoder_fn(
ap_ref,
name,
"additional_properties",
ctx,
)
// Issue #320: each statement of the lambda body is emitted via
// a separate `se.indent` call rather than as embedded `\n`
// inside one string. The previous form was easy to misread
// (the older variant used `;` between statements, which the
// current Gleam parser rejects).
sb
|> se.indent(1, close_props_list)
|> se.indent(1, "let extra_props =")
|> se.indent(2, "dict.to_list(value.additional_properties)")
|> se.indent(2, "|> list.map(fn(entry) {")
|> se.indent(3, "let #(k, v) = entry")
|> se.indent(3, "#(k, " <> inner_encoder_fn <> "(v))")
|> se.indent(2, "})")
|> se.indent(1, "json.object(list.append(base_props, extra_props))")
}
Untyped -> {
// Untyped additional_properties (Dynamic) are re-encoded using
// dynamic type inspection to preserve round-trip fidelity.
// Same multi-step builder shape as the Typed branch.
sb
|> se.indent(1, close_props_list)
|> se.indent(1, "let extra_props =")
|> se.indent(2, "dict.to_list(value.additional_properties)")
|> se.indent(2, "|> list.map(fn(entry) {")
|> se.indent(3, "let #(k, v) = entry")
|> se.indent(3, "#(k, encode_dynamic(v))")
|> se.indent(2, "})")
|> se.indent(1, "json.object(list.append(base_props, extra_props))")
}
Forbidden | Unspecified ->
sb
|> se.indent(1, case has_omittable {
True -> "]))"
False -> "])"
})
}
let sb =
sb
|> se.line("}")
|> se.blank_line()
// String version: wraps _json
sb
|> se.line(
"pub fn " <> fn_name <> "(value: types." <> type_name <> ") -> String {",
)
|> se.indent(1, json_fn_name <> "(value) |> json.to_string()")
|> se.line("}")
|> se.blank_line()
}
Inline(StringSchema(enum_values:, ..)) if enum_values != [] -> {
let enc_deduped_variants = dedup.dedup_enum_variants(enum_values)
// _json version: returns json.Json
let sb =
sb
|> se.line(
"pub fn "
<> json_fn_name
<> "(value: types."
<> type_name
<> ") -> json.Json {",
)
|> se.indent(1, "let str = case value {")
let sb =
list.index_fold(enum_values, sb, fn(sb, value, idx) {
let variant_suffix =
codec_helpers.list_at_or(
enc_deduped_variants,
idx,
naming.to_pascal_case(value),
)
let variant = naming.schema_to_type_name(type_name) <> variant_suffix
sb
|> se.indent(2, "types." <> variant <> " -> \"" <> value <> "\"")
})
let sb =
sb
|> se.indent(1, "}")
|> se.indent(1, "json.string(str)")
|> se.line("}")
|> se.blank_line()
// String version (JSON-encoded with quotes)
let sb =
sb
|> se.line(
"pub fn "
<> fn_name
<> "(value: types."
<> type_name
<> ") -> String {",
)
|> se.indent(1, json_fn_name <> "(value) |> json.to_string()")
|> se.line("}")
|> se.blank_line()
// Plain string version for URL/header serialization (no JSON quotes)
let to_string_fn_name = fn_name <> "_to_string"
let sb =
sb
|> se.line(
"pub fn "
<> to_string_fn_name
<> "(value: types."
<> type_name
<> ") -> String {",
)
|> se.indent(1, "case value {")
let sb =
list.index_fold(enum_values, sb, fn(sb, value, idx) {
let variant_suffix =
codec_helpers.list_at_or(
enc_deduped_variants,
idx,
naming.to_pascal_case(value),
)
let variant = naming.schema_to_type_name(type_name) <> variant_suffix
sb
|> se.indent(2, "types." <> variant <> " -> \"" <> value <> "\"")
})
sb
|> se.indent(1, "}")
|> se.line("}")
|> se.blank_line()
}
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_encoder(sb, name, merged_schema, ctx)
}
Inline(OneOfSchema(schemas:, ..)) -> {
// oneOf encoder: pattern match on tagged union variants
let all_refs =
list.all(schemas, fn(s) {
case s {
Reference(..) -> True
_ -> False
}
})
case all_refs {
False ->
panic as {
"oaspec: oneOf schema '"
<> name
<> "' contains inline variant(s) which are not supported for encoder generation. "
<> "Move all oneOf variants to components/schemas and use $ref instead."
}
True -> {
let sb =
sb
|> se.line(
"pub fn "
<> json_fn_name
<> "(value: types."
<> type_name
<> ") -> json.Json {",
)
|> se.indent(1, "case value {")
let sb =
list.fold(schemas, sb, fn(sb, s_ref) {
case s_ref {
Reference(name:, ..) -> {
let variant_type = naming.schema_to_type_name(name)
let variant_name = type_name <> variant_type
let inner_encoder =
"encode_" <> naming.to_snake_case(name) <> "_json"
sb
|> se.indent(
2,
"types."
<> variant_name
<> "(inner) -> "
<> inner_encoder
<> "(inner)",
)
}
_ -> sb
}
})
let sb =
sb
|> se.indent(1, "}")
|> se.line("}")
|> se.blank_line()
sb
|> se.line(
"pub fn "
<> fn_name
<> "(value: types."
<> type_name
<> ") -> String {",
)
|> se.indent(1, json_fn_name <> "(value) |> json.to_string()")
|> se.line("}")
|> se.blank_line()
}
}
}
Inline(AnyOfSchema(schemas:, ..)) -> {
// anyOf encoder: encode the first non-None field from the record
let all_refs =
list.all(schemas, fn(s) {
case s {
Reference(..) -> True
_ -> False
}
})
case all_refs {
False ->
panic as {
"oaspec: anyOf schema '"
<> name
<> "' contains inline variant(s) which are not supported for encoder generation. "
<> "Move all anyOf variants to components/schemas and use $ref instead."
}
True -> {
let variant_fields =
list.map(schemas, fn(s_ref) {
case s_ref {
Reference(name:, ..) -> #(
naming.to_snake_case(name),
"encode_" <> naming.to_snake_case(name) <> "_json",
)
Inline(_) -> #("unknown", "json.null")
}
})
// _json version: try each field in order, encode first Some
let sb =
sb
|> se.line(
"pub fn "
<> json_fn_name
<> "(value: types."
<> type_name
<> ") -> json.Json {",
)
let sb =
list.fold(variant_fields, sb, fn(sb, field) {
let #(field_name, encoder_fn) = field
sb
|> se.indent(1, "case value." <> field_name <> " {")
|> se.indent(2, "option.Some(v) -> " <> encoder_fn <> "(v)")
|> se.indent(2, "option.None ->")
})
let sb =
sb
|> se.indent({ list.length(variant_fields) + 1 }, "json.null()")
// Close all the case expressions
let sb =
list.fold(variant_fields, sb, fn(sb, _) { sb |> se.indent(1, "}") })
let sb =
sb
|> se.line("}")
|> se.blank_line()
// String version
sb
|> se.line(
"pub fn "
<> fn_name
<> "(value: types."
<> type_name
<> ") -> String {",
)
|> se.indent(1, json_fn_name <> "(value) |> json.to_string()")
|> se.line("}")
|> se.blank_line()
}
}
}
Inline(StringSchema(metadata:, enum_values: [], ..)) -> {
let #(gleam_type, json_expr) = case metadata.nullable {
True -> #("Option(String)", "json.nullable(value, json.string)")
False -> #("String", "json.string(value)")
}
generate_primitive_encoder(
sb,
fn_name,
json_fn_name,
gleam_type,
json_expr,
)
}
Inline(IntegerSchema(metadata:, ..)) -> {
let #(gleam_type, json_expr) = case metadata.nullable {
True -> #("Option(Int)", "json.nullable(value, json.int)")
False -> #("Int", "json.int(value)")
}
generate_primitive_encoder(
sb,
fn_name,
json_fn_name,
gleam_type,
json_expr,
)
}
Inline(NumberSchema(metadata:, ..)) -> {
let #(gleam_type, json_expr) = case metadata.nullable {
True -> #("Option(Float)", "json.nullable(value, json.float)")
False -> #("Float", "json.float(value)")
}
generate_primitive_encoder(
sb,
fn_name,
json_fn_name,
gleam_type,
json_expr,
)
}
Inline(BooleanSchema(metadata:)) -> {
let #(gleam_type, json_expr) = case metadata.nullable {
True -> #("Option(Bool)", "json.nullable(value, json.bool)")
False -> #("Bool", "json.bool(value)")
}
generate_primitive_encoder(
sb,
fn_name,
json_fn_name,
gleam_type,
json_expr,
)
}
Inline(ArraySchema(metadata:, items:, ..)) -> {
// Issue #387: a top-level nullable array schema gets its type
// alias rendered as `Option(List(T))`, so the encoder must accept
// `Option(List(T))` and use `json.nullable(value, ...)` instead
// of the bare `json.array(...)` it had been emitting.
let inner_type = codec_helpers.qualified_schema_ref_type(items, ctx)
let list_type = "List(" <> inner_type <> ")"
let inner_encoder = schema_ref_to_json_encoder_fn(items, name, "", ctx)
let array_expr = "json.array(value, " <> inner_encoder <> ")"
let #(gleam_type, json_expr) = case metadata.nullable {
True -> #(
"Option(" <> list_type <> ")",
"json.nullable(value, fn(value) { " <> array_expr <> " })",
)
False -> #(list_type, array_expr)
}
generate_primitive_encoder(
sb,
fn_name,
json_fn_name,
gleam_type,
json_expr,
)
}
_ -> sb
}
}
/// Generate encoder for a primitive type (String, Int, Float, Bool) or Array.
fn generate_primitive_encoder(
sb: se.StringBuilder,
fn_name: String,
json_fn_name: String,
gleam_type: String,
json_expr: String,
) -> se.StringBuilder {
sb
|> se.line(
"pub fn " <> json_fn_name <> "(value: " <> gleam_type <> ") -> json.Json {",
)
|> se.indent(1, json_expr)
|> se.line("}")
|> se.blank_line()
|> se.line(
"pub fn " <> fn_name <> "(value: " <> gleam_type <> ") -> String {",
)
|> se.indent(1, json_fn_name <> "(value) |> json.to_string()")
|> se.line("}")
|> se.blank_line()
}
/// Convert a SchemaRef to a json.Json encoder expression.
/// Returns an expression that produces json.Json (not String). `ctx`
/// is threaded so inline-enum encoder names can be disambiguated
/// against existing component schema names (Issue #492).
fn schema_ref_to_json_encoder(
value_expr: String,
ref: SchemaRef,
parent_name: String,
prop_name: String,
ctx: Context,
) -> String {
case ref {
Inline(StringSchema(enum_values:, ..)) if enum_values != [] -> {
let fn_name =
"encode_"
<> naming.to_snake_case(ir_build.inline_enum_type_name_for(
parent_name,
prop_name,
ctx,
))
<> "_json"
fn_name <> "(" <> value_expr <> ")"
}
Inline(ArraySchema(items:, ..)) -> {
let inner_fn =
schema_ref_to_json_encoder_fn(items, parent_name, prop_name, ctx)
"json.array(" <> value_expr <> ", " <> inner_fn <> ")"
}
_ -> schema_dispatch.json_encoder_expr(ref, value_expr)
}
}
/// Convert a SchemaRef to a json.Json encoder function reference.
/// Used for json.nullable(value, <fn>) and json.array(list, <fn>).
fn schema_ref_to_json_encoder_fn(
ref: SchemaRef,
parent_name: String,
prop_name: String,
ctx: Context,
) -> String {
case ref {
Inline(StringSchema(enum_values:, ..)) if enum_values != [] -> {
"encode_"
<> naming.to_snake_case(ir_build.inline_enum_type_name_for(
parent_name,
prop_name,
ctx,
))
<> "_json"
}
Inline(ArraySchema(items:, ..)) -> {
let inner =
schema_ref_to_json_encoder_fn(items, parent_name, prop_name, ctx)
"fn(items) { json.array(items, " <> inner <> ") }"
}
_ -> schema_dispatch.json_encoder_fn(ref)
}
}
/// Append `name` to `imports` if it isn't already there. Used by the
/// import builder to keep the list deterministic when more than one
/// codepath wants the same module (issue #303 wires `gleam/option` and
/// `gleam/list` for optional-non-nullable encoding even when those
/// modules were already pulled in by additionalProperties handling).
fn ensure_import(imports: List(String), name: String) -> List(String) {
use <- bool.guard(list.contains(imports, name), imports)
list.append(imports, [name])
}