Packages
oaspec
0.58.0
0.68.0
0.67.0
0.66.0
0.65.0
0.64.0
0.63.0
0.62.0
0.61.0
0.60.0
0.59.0
0.58.1
0.58.0
0.57.0
0.56.0
0.55.0
0.54.0
0.53.0
0.52.0
0.51.0
0.50.0
0.49.0
0.48.0
0.47.0
0.46.0
0.45.0
0.44.0
0.43.0
0.42.0
0.41.0
0.40.0
0.39.0
0.38.0
0.37.0
0.36.0
0.35.0
0.34.0
0.33.0
0.32.0
0.31.0
0.30.0
0.29.0
0.28.0
0.27.0
0.26.0
0.25.0
0.24.0
0.23.0
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.0
0.13.0
0.12.0
0.11.0
0.10.0
0.9.0
0.8.0
0.7.0
0.6.3
0.6.1
0.6.0
0.5.0
0.4.0
0.3.0
0.1.3
Generate Gleam code from OpenAPI 3.x specifications
Current section
Files
Jump to
Current section
Files
src/oaspec/internal/codegen/client_request.gleam
import gleam/bool
import gleam/dict.{type Dict}
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/string
import oaspec/internal/codegen/context.{type Context}
import oaspec/internal/codegen/ir_build
import oaspec/internal/codegen/operation_ir
import oaspec/internal/codegen/schema_dispatch
import oaspec/internal/openapi/dedup
import oaspec/internal/openapi/schema.{Inline, Reference}
import oaspec/internal/openapi/spec.{type Resolved, ParameterSchema, Value}
import oaspec/internal/util/content_type
import oaspec/internal/util/naming
import oaspec/internal/util/string_extra as se
/// Map from a parameter's `(wire name, location)` pair to its deduped Gleam
/// field name within an operation. Used to keep type emission, server
/// dispatch, and client builders in sync when two parameters in different
/// locations would otherwise collide on the same snake_case field.
pub type ParamFieldNames =
Dict(#(String, spec.ParameterIn), String)
/// Build a `(name, in)` → deduped-field-name map for a single operation.
/// The dedup order matches the spec's parameter order, so all codegen
/// callers that use this map agree on the final field names.
pub fn build_param_field_names(
operation: spec.Operation(Resolved),
) -> ParamFieldNames {
let resolved =
list.filter_map(operation.parameters, fn(r) {
case r {
Value(p) -> Ok(p)
_ -> Error(Nil)
}
})
let deduped = dedup.dedup_param_field_names(resolved)
list.zip(resolved, deduped)
|> list.fold(dict.new(), fn(acc, pair) {
let #(param, name) = pair
dict.insert(acc, #(param.name, param.in_), name)
})
}
/// Look up the deduped field name for a parameter. The map is built from
/// the same operation the caller iterates, so the lookup always hits —
/// the fallback is just a safety net that keeps the output valid Gleam
/// if a caller ever passes a mismatched map.
pub fn field_name_for(
map: ParamFieldNames,
param: spec.Parameter(Resolved),
) -> String {
case dict.get(map, #(param.name, param.in_)) {
Ok(name) -> name
// nolint: thrown_away_error -- dict.get's Error just means the map did not carry this parameter; fall back to raw snake_case so codegen still produces a valid (if un-deduped) identifier
Error(_) -> naming.to_snake_case(param.name)
}
}
/// Build the call-site argument list for the `_with_request` wrapper that
/// unpacks a `request_types.*Request` record into the flat client function
/// it delegates to. Returns `None` if the operation uses a multi-content
/// body (where the flat API also takes a `content_type` argument that the
/// request type does not carry).
pub fn build_request_object_call_args(
path_params: List(spec.Parameter(Resolved)),
query_params: List(spec.Parameter(Resolved)),
header_params: List(spec.Parameter(Resolved)),
cookie_params: List(spec.Parameter(Resolved)),
operation: spec.Operation(Resolved),
) -> Option(String) {
let all_params =
list.append(path_params, query_params)
|> list.append(header_params)
|> list.append(cookie_params)
let has_body = case operation.request_body {
Some(_) -> True
None -> False
}
// Operations with no parameters and no body produce no `<Op>Request` type,
// so there is nothing for the wrapper to accept. Skip the wrapper.
case list.is_empty(all_params), has_body {
True, False -> None
_, _ -> {
let field_names = build_param_field_names(operation)
let param_refs =
list.map(all_params, fn(p) { "req." <> field_name_for(field_names, p) })
case operation.request_body {
Some(Value(rb)) -> {
let content_entries = ir_build.sorted_entries(rb.content)
case content_entries {
[_, _, ..] -> None
_ -> Some(string.join(list.append(param_refs, ["req.body"]), ", "))
}
}
_ -> Some(string.join(param_refs, ", "))
}
}
}
}
/// Build parameter list for function signature.
pub fn build_param_list(
path_params: List(spec.Parameter(Resolved)),
query_params: List(spec.Parameter(Resolved)),
header_params: List(spec.Parameter(Resolved)),
cookie_params: List(spec.Parameter(Resolved)),
operation: spec.Operation(Resolved),
op_id: String,
ctx: Context,
) -> String {
let all_params =
list.append(path_params, query_params)
|> list.append(header_params)
|> list.append(cookie_params)
let field_names = build_param_field_names(operation)
let param_strs =
list.map(all_params, fn(p) {
let param_name = field_name_for(field_names, p)
let param_type = param_to_type(p, ctx)
", " <> param_name <> " " <> param_name <> ": " <> param_type
})
let body_param = case operation.request_body {
Some(Value(rb)) -> {
let body_type = get_body_type(rb, op_id)
let wrapped_type = case rb.required {
True -> body_type
False -> "Option(" <> body_type <> ")"
}
let content_entries = ir_build.sorted_entries(rb.content)
case content_entries {
// Multi-content: add content_type param before body
[_, _, ..] -> [
", content_type content_type: String",
", body body: " <> wrapped_type,
]
_ -> [", body body: " <> wrapped_type]
}
}
_ -> []
}
string.join(list.append(param_strs, body_param), "")
}
/// Convert a parameter to its Gleam type string.
fn param_to_type(param: spec.Parameter(Resolved), ctx: Context) -> String {
let base =
schema_dispatch.resolve_param_type(spec.parameter_schema(param), ctx)
case param.required {
True -> base
False -> "Option(" <> base <> ")"
}
}
/// Convert a parameter value to its String representation for URL/header use.
pub fn param_to_string_expr(
param: spec.Parameter(Resolved),
param_name: String,
ctx: Context,
) -> String {
case param.payload {
ParameterSchema(Inline(schema.ArraySchema(items:, ..))) -> {
let item_to_str = schema_dispatch.to_string_fn(items, ctx)
"string.join(list.map("
<> param_name
<> ", "
<> item_to_str
<> "), \",\")"
}
ParameterSchema(Inline(s)) -> schema_dispatch.to_string_expr(s, param_name)
ParameterSchema(Reference(..) as schema_ref) -> {
// Resolve the $ref to determine the actual schema type
case context.resolve_schema_ref(schema_ref, ctx) {
Ok(schema.ArraySchema(items:, ..)) -> {
let item_to_str = schema_dispatch.to_string_fn(items, ctx)
"string.join(list.map("
<> param_name
<> ", "
<> item_to_str
<> "), \",\")"
}
_ ->
schema_dispatch.schema_ref_to_string_expr(schema_ref, param_name, ctx)
}
}
_ -> param_name
}
}
/// Convert an optional param value (bound to `v`) to string.
pub fn to_str_for_optional_value(
param: spec.Parameter(Resolved),
ctx: Context,
) -> String {
case param.payload {
ParameterSchema(Inline(schema.ArraySchema(items:, ..))) -> {
let item_to_str = schema_dispatch.to_string_fn(items, ctx)
"string.join(list.map(v, " <> item_to_str <> "), \",\")"
}
ParameterSchema(Inline(s)) -> schema_dispatch.to_string_expr(s, "v")
ParameterSchema(Reference(..) as schema_ref) -> {
case context.resolve_schema_ref(schema_ref, ctx) {
Ok(schema.ArraySchema(items:, ..)) -> {
let item_to_str = schema_dispatch.to_string_fn(items, ctx)
"string.join(list.map(v, " <> item_to_str <> "), \",\")"
}
_ -> schema_dispatch.schema_ref_to_string_expr(schema_ref, "v", ctx)
}
}
_ -> "v"
}
}
/// Get the Gleam type for a request body parameter.
///
/// Issue #485: an `application/octet-stream` request body is raw
/// bytes — the README's mode-specific table promises `BitArray`,
/// the client wraps it in `transport.BytesBody` (which expects
/// `BitArray`), and forcing it through `String` means arbitrary
/// binary payloads cannot round-trip. The `String` fallback for
/// every other content type stays unchanged.
pub fn get_body_type(rb: spec.RequestBody(Resolved), op_id: String) -> String {
let content_entries = ir_build.sorted_entries(rb.content)
case content_entries {
// Multiple content types: use pre-serialized String
[_, _, ..] -> "String"
[#(media_type_name, media_type)] ->
case content_type.from_string(media_type_name) {
content_type.ApplicationOctetStream | content_type.Wildcard ->
"BitArray"
_ ->
case media_type.schema {
Some(Reference(name:, ..)) ->
"types." <> naming.schema_to_type_name(name)
Some(Inline(schema.StringSchema(..))) -> "String"
Some(Inline(schema.IntegerSchema(..))) -> "Int"
Some(Inline(schema.NumberSchema(..))) -> "Float"
Some(Inline(schema.BooleanSchema(..))) -> "Bool"
Some(Inline(_)) ->
"types." <> naming.schema_to_type_name(op_id) <> "RequestBody"
_ -> "String"
}
}
[] -> "String"
}
}
/// Get the encode expression for a request body.
pub fn get_body_encode_expr(
rb: spec.RequestBody(Resolved),
op_id: String,
_ctx: Context,
) -> String {
let content_entries = ir_build.sorted_entries(rb.content)
case content_entries {
[#(_, media_type), ..] ->
case media_type.schema {
Some(Reference(name:, ..)) -> {
"encode.encode_" <> naming.to_snake_case(name) <> "(body)"
}
Some(Inline(schema.StringSchema(..))) ->
"json.to_string(json.string(body))"
Some(Inline(schema.IntegerSchema(..))) ->
"json.to_string(json.int(body))"
Some(Inline(schema.NumberSchema(..))) ->
"json.to_string(json.float(body))"
Some(Inline(schema.BooleanSchema(..))) ->
"json.to_string(json.bool(body))"
Some(Inline(_)) -> {
let fn_name =
"encode_" <> naming.to_snake_case(op_id) <> "_request_body"
"encode." <> fn_name <> "(body)"
}
_ -> "body"
}
[] -> "body"
}
}
/// Generate multipart/form-data body encoding in the client function.
pub fn generate_multipart_body(
sb: se.StringBuilder,
rb: spec.RequestBody(Resolved),
_op_id: String,
ctx: Context,
) -> se.StringBuilder {
let boundary = "----oaspec-boundary"
let content_entries = ir_build.sorted_entries(rb.content)
let #(properties, required_fields) = case content_entries {
[#(_, media_type), ..] ->
case media_type.schema {
Some(Inline(schema.ObjectSchema(properties:, required:, ..))) -> #(
ir_build.sorted_entries(properties),
required,
)
Some(Reference(..) as schema_ref) ->
case context.resolve_schema_ref(schema_ref, ctx) {
Ok(schema.ObjectSchema(properties:, required:, ..)) -> {
#(ir_build.sorted_entries(properties), required)
}
_ -> #([], [])
}
_ -> #([], [])
}
_ -> #([], [])
}
let sb =
sb
|> se.indent(1, "let boundary = \"" <> boundary <> "\"")
|> se.indent(1, "let parts = []")
let sb =
list.fold(properties, sb, fn(sb, prop) {
let #(field_name, field_schema) = prop
let gleam_field = naming.to_snake_case(field_name)
let is_required = list.contains(required_fields, field_name)
case multipart_field_kind(field_schema, ctx) {
MultipartArrayKind(items) ->
emit_multipart_array_field(
sb,
field_name,
gleam_field,
items,
is_required,
ctx,
)
MultipartObjectKind ->
emit_multipart_object_field(
sb,
field_name,
gleam_field,
field_schema,
is_required,
ctx,
)
MultipartBinaryKind ->
emit_multipart_simple_field(
sb,
field_name,
gleam_field,
"",
True,
is_required,
)
MultipartScalarKind ->
emit_multipart_simple_field(
sb,
field_name,
gleam_field,
multipart_field_to_string_fn(field_schema, ctx),
False,
is_required,
)
}
})
sb
|> se.indent(
1,
"let body_str = string.join(parts, \"\") <> \"--\" <> boundary <> \"--\\r\\n\"",
)
|> se.indent(
1,
"let body_content_type = \"multipart/form-data; boundary=\" <> boundary",
)
}
pub fn multipart_field_is_binary(
field_schema: schema.SchemaRef,
ctx: Context,
) -> Bool {
case field_schema {
Inline(schema.StringSchema(format: Some("binary"), ..)) -> True
Reference(..) as schema_ref ->
case context.resolve_schema_ref(schema_ref, ctx) {
Ok(schema.StringSchema(format: Some("binary"), ..)) -> True
_ -> False
}
_ -> False
}
}
/// Issue #503: dispatch each multipart field by its high-level shape so
/// the generator can emit per-element parts for arrays and a single
/// JSON-encoded part for objects, while keeping the existing scalar /
/// binary paths unchanged.
type MultipartFieldKind {
MultipartScalarKind
MultipartBinaryKind
MultipartArrayKind(items: schema.SchemaRef)
MultipartObjectKind
}
fn multipart_field_kind(
field_schema: schema.SchemaRef,
ctx: Context,
) -> MultipartFieldKind {
use <- bool.guard(
multipart_field_is_binary(field_schema, ctx),
MultipartBinaryKind,
)
case field_schema {
Inline(schema.ArraySchema(items:, ..)) -> MultipartArrayKind(items)
Inline(schema.ObjectSchema(..)) -> MultipartObjectKind
Reference(..) as schema_ref ->
case context.resolve_schema_ref(schema_ref, ctx) {
Ok(schema.ArraySchema(items:, ..)) -> MultipartArrayKind(items)
Ok(schema.ObjectSchema(..)) -> MultipartObjectKind
_ -> MultipartScalarKind
}
_ -> MultipartScalarKind
}
}
/// Emit a single multipart part for a scalar or binary field. The
/// scalar path stringifies the value (or uses it directly when the
/// schema is already `String`); the binary path emits the
/// `Content-Type: application/octet-stream` header and passes the
/// value through unchanged.
fn emit_multipart_simple_field(
sb: se.StringBuilder,
field_name: String,
gleam_field: String,
to_string_fn: String,
is_binary: Bool,
is_required: Bool,
) -> se.StringBuilder {
let part_header_binary =
"\"--\" <> boundary <> \"\\r\\nContent-Disposition: form-data; name=\\\""
<> field_name
<> "\\\"; filename=\\\""
<> field_name
<> "\\\"\\r\\nContent-Type: application/octet-stream\\r\\n\\r\\n\""
let part_header_text =
"\"--\" <> boundary <> \"\\r\\nContent-Disposition: form-data; name=\\\""
<> field_name
<> "\\\"\\r\\n\\r\\n\""
let part_header = case is_binary {
True -> part_header_binary
False -> part_header_text
}
case is_required {
True -> {
let value_expr = case to_string_fn {
"" -> "body." <> gleam_field
fn_name -> fn_name <> "(body." <> gleam_field <> ")"
}
sb
|> se.indent(
1,
"let parts = ["
<> part_header
<> " <> "
<> value_expr
<> " <> \"\\r\\n\", ..parts]",
)
}
False -> {
let value_expr = case to_string_fn {
"" -> "v"
fn_name -> fn_name <> "(v)"
}
sb
|> se.indent(1, "let parts = case body." <> gleam_field <> " {")
|> se.indent(
2,
"Some(v) -> ["
<> part_header
<> " <> "
<> value_expr
<> " <> \"\\r\\n\", ..parts]",
)
|> se.indent(2, "None -> parts")
|> se.indent(1, "}")
}
}
}
/// Issue #503: emit one multipart part per element of an array field,
/// folding the input list into the running `parts` accumulator. Each
/// element shares the field name (`name="expand"` repeated) so the
/// receiver assembles the array from the repeated parts. Optional
/// arrays guard the fold behind a `Some(v) -> ... None -> parts` arm.
fn emit_multipart_array_field(
sb: se.StringBuilder,
field_name: String,
gleam_field: String,
items: schema.SchemaRef,
is_required: Bool,
ctx: Context,
) -> se.StringBuilder {
let part_header =
"\"--\" <> boundary <> \"\\r\\nContent-Disposition: form-data; name=\\\""
<> field_name
<> "\\\"\\r\\n\\r\\n\""
let item_to_string =
schema_dispatch.schema_ref_to_string_expr(items, "item", ctx)
let fold_expr = fn(list_expr: String) -> String {
"list.fold("
<> list_expr
<> ", parts, fn(acc, item) { ["
<> part_header
<> " <> "
<> item_to_string
<> " <> \"\\r\\n\", ..acc] })"
}
case is_required {
True ->
sb
|> se.indent(1, "let parts = " <> fold_expr("body." <> gleam_field))
False ->
sb
|> se.indent(1, "let parts = case body." <> gleam_field <> " {")
|> se.indent(2, "Some(v) -> " <> fold_expr("v"))
|> se.indent(2, "None -> parts")
|> se.indent(1, "}")
}
}
/// Issue #503: emit a multipart part for an object field. Per OAS 3
/// the default serialization is a single part with
/// `Content-Type: application/json` carrying the JSON-encoded value.
/// The encoder name is derived from the post-hoist schema reference;
/// inline objects survive only when they would not otherwise be
/// hoisted (rare in real specs), in which case we fall back to the
/// op-id-derived synthetic name produced by `ir_build`.
fn emit_multipart_object_field(
sb: se.StringBuilder,
field_name: String,
gleam_field: String,
field_schema: schema.SchemaRef,
is_required: Bool,
ctx: Context,
) -> se.StringBuilder {
let part_header =
"\"--\" <> boundary <> \"\\r\\nContent-Disposition: form-data; name=\\\""
<> field_name
<> "\\\"\\r\\nContent-Type: application/json\\r\\n\\r\\n\""
let encoded_value = fn(input_expr: String) -> String {
multipart_object_encode_expr(field_schema, input_expr, ctx)
}
case is_required {
True ->
sb
|> se.indent(
1,
"let parts = ["
<> part_header
<> " <> "
<> encoded_value("body." <> gleam_field)
<> " <> \"\\r\\n\", ..parts]",
)
False ->
sb
|> se.indent(1, "let parts = case body." <> gleam_field <> " {")
|> se.indent(
2,
"Some(v) -> ["
<> part_header
<> " <> "
<> encoded_value("v")
<> " <> \"\\r\\n\", ..parts]",
)
|> se.indent(2, "None -> parts")
|> se.indent(1, "}")
}
}
fn multipart_object_encode_expr(
field_schema: schema.SchemaRef,
input_expr: String,
_ctx: Context,
) -> String {
case field_schema {
Reference(name:, ..) ->
// The encoder module exports `encode_<snake>_json/1` for every
// hoisted component schema; call it through the `encode` import
// alias so the generated client compiles unchanged regardless of
// whether `encode.gleam` lives in the same package.
"json.to_string(encode.encode_"
<> naming.to_snake_case(name)
<> "_json("
<> input_expr
<> "))"
_ ->
// Inline object schemas survive only when hoist intentionally
// left them inline; fall back to a `string.inspect` so the
// generated code at least compiles. Real specs (Stripe) put
// these objects under named components, which take the
// Reference branch above.
"string.inspect(" <> input_expr <> ")"
}
}
fn multipart_field_to_string_fn(
field_schema: schema.SchemaRef,
ctx: Context,
) -> String {
let result = schema_dispatch.to_string_fn(field_schema, ctx)
// Return "" for identity functions since callers use "" to mean "no conversion"
case result {
"fn(x) { x }" -> ""
_ -> result
}
}
/// Convert an array field's items to a string expression for form-urlencoded encoding.
/// Returns an expression that converts `item` to a String.
fn form_array_item_to_string(
field_schema: schema.SchemaRef,
ctx: Context,
) -> String {
case field_schema {
Inline(schema.ArraySchema(items:, ..)) ->
schema_dispatch.schema_ref_to_string_expr(items, "item", ctx)
_ -> "string.inspect(item)"
}
}
/// Generate form encoding for a nested object property.
/// Serializes as field[subkey]=value for each sub-property.
fn generate_form_nested_object(
sb: se.StringBuilder,
field_name: String,
gleam_field: String,
field_schema: schema.SchemaRef,
is_required: Bool,
ctx: Context,
) -> se.StringBuilder {
let resolved = context.resolve_schema_ref(field_schema, ctx)
let sub_props = case resolved {
Ok(schema.ObjectSchema(properties:, required:, ..)) -> #(
ir_build.sorted_entries(properties),
required,
)
_ -> #([], [])
}
let #(props, required_fields) = sub_props
let accessor_prefix = case is_required {
True -> "body." <> gleam_field
False -> "obj"
}
let sb = case is_required {
True -> sb
False ->
sb
|> se.indent(1, "let form_parts = case body." <> gleam_field <> " {")
|> se.indent(2, "Some(obj) -> {")
|> se.indent(3, "let fp = form_parts")
}
let indent_base = case is_required {
True -> 1
False -> 3
}
let parts_var = case is_required {
True -> "form_parts"
False -> "fp"
}
let sb =
list.fold(props, sb, fn(sb, entry) {
let #(sub_name, sub_ref) = entry
let sub_field = naming.to_snake_case(sub_name)
let sub_accessor = accessor_prefix <> "." <> sub_field
let sub_required = list.contains(required_fields, sub_name)
// Check if sub-property is an object — need recursive bracket encoding
let is_sub_object = case sub_ref {
Inline(schema.ObjectSchema(..)) -> True
Reference(..) as sr ->
case context.resolve_schema_ref(sr, ctx) {
Ok(schema.ObjectSchema(..)) -> True
_ -> False
}
_ -> False
}
case is_sub_object {
True ->
// Recurse: generate meta[author][name]=value encoding
generate_form_bracket_fields(
sb,
field_name <> "[" <> sub_name <> "]",
sub_accessor,
sub_ref,
sub_required,
indent_base,
parts_var,
ctx,
)
False -> {
let to_str = multipart_field_to_string_fn(sub_ref, ctx)
case sub_required {
True -> {
let value_expr = case to_str {
"" -> sub_accessor
fn_name -> fn_name <> "(" <> sub_accessor <> ")"
}
sb
|> se.indent(
indent_base,
"let "
<> parts_var
<> " = [\""
<> field_name
<> "["
<> sub_name
<> "]=\" <> uri.percent_encode("
<> value_expr
<> "), .."
<> parts_var
<> "]",
)
}
False -> {
sb
|> se.indent(
indent_base,
"let " <> parts_var <> " = case " <> sub_accessor <> " {",
)
|> se.indent(
indent_base + 1,
"Some(v) -> [\""
<> field_name
<> "["
<> sub_name
<> "]=\" <> uri.percent_encode("
<> {
case to_str {
"" -> "v"
fn_name -> fn_name <> "(v)"
}
}
<> "), .."
<> parts_var
<> "]",
)
|> se.indent(indent_base + 1, "None -> " <> parts_var)
|> se.indent(indent_base, "}")
}
}
}
}
})
case is_required {
True -> sb
False ->
sb
|> se.indent(3, "fp")
|> se.indent(2, "}")
|> se.indent(2, "None -> form_parts")
|> se.indent(1, "}")
}
}
/// Recursively generate bracket-encoded form fields for nested objects.
/// Produces key[sub]=value for leaf fields and recurses for object children.
fn generate_form_bracket_fields(
sb: se.StringBuilder,
key_prefix: String,
accessor_prefix: String,
field_schema: schema.SchemaRef,
_is_required: Bool,
indent_base: Int,
parts_var: String,
ctx: Context,
) -> se.StringBuilder {
let resolved = context.resolve_schema_ref(field_schema, ctx)
case resolved {
Ok(schema.ObjectSchema(properties:, required:, ..)) -> {
let props = ir_build.sorted_entries(properties)
list.fold(props, sb, fn(sb, entry) {
let #(prop_name, prop_ref) = entry
let prop_field = naming.to_snake_case(prop_name)
let prop_accessor = accessor_prefix <> "." <> prop_field
let prop_required = list.contains(required, prop_name)
let is_obj = case prop_ref {
Inline(schema.ObjectSchema(..)) -> True
Reference(..) as sr ->
case context.resolve_schema_ref(sr, ctx) {
Ok(schema.ObjectSchema(..)) -> True
_ -> False
}
_ -> False
}
case is_obj {
True ->
generate_form_bracket_fields(
sb,
key_prefix <> "[" <> prop_name <> "]",
prop_accessor,
prop_ref,
prop_required,
indent_base,
parts_var,
ctx,
)
False -> {
let to_str = multipart_field_to_string_fn(prop_ref, ctx)
case prop_required {
True -> {
let value_expr = case to_str {
"" -> prop_accessor
fn_name -> fn_name <> "(" <> prop_accessor <> ")"
}
sb
|> se.indent(
indent_base,
"let "
<> parts_var
<> " = [\""
<> key_prefix
<> "["
<> prop_name
<> "]=\" <> uri.percent_encode("
<> value_expr
<> "), .."
<> parts_var
<> "]",
)
}
False ->
sb
|> se.indent(
indent_base,
"let " <> parts_var <> " = case " <> prop_accessor <> " {",
)
|> se.indent(
indent_base + 1,
"Some(v) -> [\""
<> key_prefix
<> "["
<> prop_name
<> "]=\" <> uri.percent_encode("
<> {
case to_str {
"" -> "v"
fn_name -> fn_name <> "(v)"
}
}
<> "), .."
<> parts_var
<> "]",
)
|> se.indent(indent_base + 1, "None -> " <> parts_var)
|> se.indent(indent_base, "}")
}
}
}
})
}
_ -> sb
}
}
/// Generate application/x-www-form-urlencoded body encoding in the client function.
pub fn generate_form_urlencoded_body(
sb: se.StringBuilder,
rb: spec.RequestBody(Resolved),
_op_id: String,
ctx: Context,
) -> se.StringBuilder {
let content_entries = ir_build.sorted_entries(rb.content)
let #(properties, required_fields) = case content_entries {
[#(_, media_type), ..] ->
case media_type.schema {
Some(Inline(schema.ObjectSchema(properties:, required:, ..))) -> #(
ir_build.sorted_entries(properties),
required,
)
Some(Reference(..) as schema_ref) ->
case context.resolve_schema_ref(schema_ref, ctx) {
Ok(schema.ObjectSchema(properties:, required:, ..)) -> {
#(ir_build.sorted_entries(properties), required)
}
_ -> #([], [])
}
_ -> #([], [])
}
_ -> #([], [])
}
let sb = sb |> se.indent(1, "let form_parts = []")
let sb =
list.fold(properties, sb, fn(sb, prop) {
let #(field_name, field_schema) = prop
let gleam_field = naming.to_snake_case(field_name)
let is_required = list.contains(required_fields, field_name)
let is_array = case field_schema {
Inline(schema.ArraySchema(..)) -> True
Reference(..) as sr ->
case context.resolve_schema_ref(sr, ctx) {
Ok(schema.ArraySchema(..)) -> True
_ -> False
}
_ -> False
}
let is_object = case field_schema {
Inline(schema.ObjectSchema(..)) -> True
Reference(..) as sr ->
case context.resolve_schema_ref(sr, ctx) {
Ok(schema.ObjectSchema(..)) -> True
_ -> False
}
_ -> False
}
case is_object {
True ->
// Nested objects: serialize as field[subkey]=value
generate_form_nested_object(
sb,
field_name,
gleam_field,
field_schema,
is_required,
ctx,
)
False ->
case is_array {
True ->
// Arrays: repeat the key for each element (tags=a&tags=b)
case is_required {
True ->
sb
|> se.indent(
1,
"let form_parts = list.fold(body."
<> gleam_field
<> ", form_parts, fn(acc, item) {",
)
|> se.indent(
2,
"[\""
<> field_name
<> "=\" <> uri.percent_encode("
<> form_array_item_to_string(field_schema, ctx)
<> "), ..acc]",
)
|> se.indent(1, "})")
False ->
sb
|> se.indent(
1,
"let form_parts = case body." <> gleam_field <> " {",
)
|> se.indent(
2,
"Some(items) -> list.fold(items, form_parts, fn(acc, item) {",
)
|> se.indent(
3,
"[\""
<> field_name
<> "=\" <> uri.percent_encode("
<> form_array_item_to_string(field_schema, ctx)
<> "), ..acc]",
)
|> se.indent(2, "})")
|> se.indent(2, "None -> form_parts")
|> se.indent(1, "}")
}
False -> {
let to_str = multipart_field_to_string_fn(field_schema, ctx)
case is_required {
True -> {
let value_expr = case to_str {
"" -> "body." <> gleam_field
fn_name -> fn_name <> "(body." <> gleam_field <> ")"
}
sb
|> se.indent(
1,
"let form_parts = [\""
<> field_name
<> "=\" <> uri.percent_encode("
<> value_expr
<> "), ..form_parts]",
)
}
False ->
sb
|> se.indent(
1,
"let form_parts = case body." <> gleam_field <> " {",
)
|> se.indent(
2,
"Some(v) -> [\""
<> field_name
<> "=\" <> uri.percent_encode("
<> {
case to_str {
"" -> "v"
fn_name -> fn_name <> "(v)"
}
}
<> "), ..form_parts]",
)
|> se.indent(2, "None -> form_parts")
|> se.indent(1, "}")
}
}
}
}
})
sb
|> se.indent(1, "let body_str = string.join(form_parts, \"&\")")
|> se.indent(
1,
"let body_content_type = \"application/x-www-form-urlencoded\"",
)
}
/// Check if a parameter is an array with explode behavior.
/// OpenAPI default: style: form has explode: true by default.
pub fn is_exploded_array_param(
param: spec.Parameter(Resolved),
ctx: Context,
) -> Bool {
let is_array = case param.payload {
ParameterSchema(Inline(schema.ArraySchema(..))) -> True
ParameterSchema(Reference(..) as sr) ->
case context.resolve_schema_ref(sr, ctx) {
Ok(schema.ArraySchema(..)) -> True
_ -> False
}
_ -> False
}
case is_array {
False -> False
True -> operation_ir.effective_explode(param)
}
}
/// Returns Some(joiner) if the parameter is a non-exploded delimited array
/// (pipeDelimited or spaceDelimited). Returns None for everything else
/// including form arrays — we keep that on the existing path.
pub fn is_delimited_array_param(
param: spec.Parameter(Resolved),
ctx: Context,
) -> option.Option(String) {
let is_array = case param.payload {
ParameterSchema(Inline(schema.ArraySchema(..))) -> True
ParameterSchema(Reference(..) as sr) ->
case context.resolve_schema_ref(sr, ctx) {
Ok(schema.ArraySchema(..)) -> True
_ -> False
}
_ -> False
}
// Use spec-default explode rules (false for pipe/space) so that omitting
// `explode` yields the delimited path, matching OpenAPI semantics.
let is_non_exploded = !operation_ir.effective_explode(param)
case is_array, is_non_exploded, param.style {
True, True, option.Some(spec.PipeDelimitedStyle)
| True, True, option.Some(spec.SpaceDelimitedStyle)
-> option.Some(operation_ir.delimiter_for_style(param.style))
_, _, _ -> option.None
}
}
/// Generate non-exploded delimited array query parameter:
/// tags=a|b|c (pipeDelimited) or tags=a%20b%20c (spaceDelimited).
pub fn generate_delimited_array_query_param(
sb: se.StringBuilder,
param: spec.Parameter(Resolved),
param_name: String,
joiner: String,
ctx: Context,
) -> se.StringBuilder {
let item_to_str = case param.payload {
ParameterSchema(Inline(schema.ArraySchema(items:, ..))) ->
array_item_to_string_fn(items, ctx)
ParameterSchema(Reference(..) as sr) ->
case context.resolve_schema_ref(sr, ctx) {
Ok(schema.ArraySchema(items:, ..)) ->
array_item_to_string_fn(items, ctx)
_ -> "fn(x) { x }"
}
_ -> "fn(x) { x }"
}
// Empty arrays produce no query entry, matching the exploded path.
case param.required {
True ->
sb
|> se.indent(1, "let query = case " <> param_name <> " {")
|> se.indent(2, "[] -> query")
|> se.indent(2, "items -> {")
|> se.indent(
3,
"let joined = string.join(list.map(items, "
<> item_to_str
<> "), \""
<> joiner
<> "\")",
)
|> se.indent(3, "[#(\"" <> param.name <> "\", joined), ..query]")
|> se.indent(2, "}")
|> se.indent(1, "}")
False ->
sb
|> se.indent(1, "let query = case " <> param_name <> " {")
|> se.indent(2, "Some([]) -> query")
|> se.indent(2, "Some(items) -> {")
|> se.indent(
3,
"let joined = string.join(list.map(items, "
<> item_to_str
<> "), \""
<> joiner
<> "\")",
)
|> se.indent(3, "[#(\"" <> param.name <> "\", joined), ..query]")
|> se.indent(2, "}")
|> se.indent(2, "None -> query")
|> se.indent(1, "}")
}
}
/// Generate exploded array query parameter: tags=a&tags=b
pub fn generate_exploded_array_query_param(
sb: se.StringBuilder,
param: spec.Parameter(Resolved),
param_name: String,
ctx: Context,
) -> se.StringBuilder {
let item_to_str = case param.payload {
ParameterSchema(Inline(schema.ArraySchema(items:, ..))) ->
array_item_to_string_fn(items, ctx)
ParameterSchema(Reference(..) as sr) ->
case context.resolve_schema_ref(sr, ctx) {
Ok(schema.ArraySchema(items:, ..)) ->
array_item_to_string_fn(items, ctx)
_ -> "fn(x) { x }"
}
_ -> "fn(x) { x }"
}
case param.required {
True ->
sb
|> se.indent(
1,
"let query = list.fold(" <> param_name <> ", query, fn(acc, item) {",
)
|> se.indent(
2,
"[#(\"" <> param.name <> "\", " <> item_to_str <> "(item)), ..acc]",
)
|> se.indent(1, "})")
False ->
sb
|> se.indent(1, "let query = case " <> param_name <> " {")
|> se.indent(2, "Some(items) -> list.fold(items, query, fn(acc, item) {")
|> se.indent(
3,
"[#(\"" <> param.name <> "\", " <> item_to_str <> "(item)), ..acc]",
)
|> se.indent(2, "})")
|> se.indent(2, "None -> query")
|> se.indent(1, "}")
}
}
/// Check if a parameter uses deepObject style with an object schema.
pub fn is_deep_object_param(
param: spec.Parameter(Resolved),
ctx: Context,
) -> Bool {
operation_ir.is_deep_object_param(param, ctx)
}
/// Return a function expression that converts an array item to String.
/// Used in generated code: `list.map(param, <fn>)`.
fn array_item_to_string_fn(items: schema.SchemaRef, ctx: Context) -> String {
schema_dispatch.to_string_fn(items, ctx)
}