Packages
oaspec
0.28.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/server.gleam
import gleam/bool
import gleam/dict
import gleam/int
import gleam/list
import gleam/option.{None, Some}
import gleam/string
import oaspec/config
import oaspec/internal/codegen/context.{
type Context, type GeneratedFile, GeneratedFile,
}
import oaspec/internal/codegen/guards
import oaspec/internal/codegen/import_analysis
import oaspec/internal/codegen/server_request_decode as decode_helpers
import oaspec/internal/openapi/dedup
import oaspec/internal/openapi/operations
import oaspec/internal/openapi/schema.{Inline, Reference}
import oaspec/internal/openapi/spec.{type Resolved, Value}
import oaspec/internal/util/content_type
import oaspec/internal/util/http
import oaspec/internal/util/naming
import oaspec/internal/util/string_extra as se
/// Generate server stub files.
///
/// Issue #247 splits the handler surface in two:
///
/// - `handlers.gleam` carries user-editable panic stubs and is emitted
/// only on first generation (`SkipIfExists`). Re-running
/// `oaspec generate` leaves it alone, so user implementations
/// survive regeneration.
/// - `handlers_generated.gleam` is a sealed delegator that `router.gleam`
/// imports. Each operation forwards to `handlers.<op_name>(req)`. It
/// is always overwritten so the wiring stays in lock-step with the
/// spec.
pub fn generate(ctx: Context) -> List(GeneratedFile) {
let operations = operations.collect_operations(ctx)
let handlers_content = generate_handlers(ctx, operations)
let handlers_generated_content = generate_handlers_generated(ctx, operations)
let router_content = generate_router(ctx, operations)
[
GeneratedFile(
path: "handlers.gleam",
content: handlers_content,
target: context.ServerTarget,
write_mode: context.SkipIfExists,
),
GeneratedFile(
path: "handlers_generated.gleam",
content: handlers_generated_content,
target: context.ServerTarget,
write_mode: context.Overwrite,
),
GeneratedFile(
path: "router.gleam",
content: router_content,
target: context.ServerTarget,
write_mode: context.Overwrite,
),
]
}
/// Generate the user-owned `handlers.gleam`. Emitted only on first
/// generation; the writer's `SkipIfExists` mode prevents subsequent
/// runs from clobbering the user's implementation. The `// DO NOT EDIT`
/// banner is intentionally absent — the user owns this file.
fn generate_handlers(
ctx: Context,
operations: List(#(String, spec.Operation(Resolved), String, spec.HttpMethod)),
) -> String {
let sb =
se.line(
se.new(),
"//// Implement these handler functions. This file is emitted once",
)
|> se.line(
"//// by `oaspec generate` and skipped on subsequent runs, so your",
)
|> se.line("//// edits survive regeneration. Router wiring lives in")
|> se.line("//// `handlers_generated.gleam`, which delegates here.")
|> se.blank_line()
|> se.imports([
config.package(context.config(ctx)) <> "/request_types",
config.package(context.config(ctx)) <> "/response_types",
])
|> se.doc_comment("Application state passed to every handler.")
|> se.doc_comment(
"Add fields here for DB connections, config, loggers, etc. Construct a value of this type in your `main` and pass it to `router.route` as the first argument.",
)
|> se.line("pub type State {")
|> se.indent(1, "State")
|> se.line("}")
|> se.blank_line()
let sb =
list.fold(operations, sb, fn(sb, op) {
let #(op_id, operation, _path, _method) = op
generate_handler(sb, op_id, operation, ctx)
})
// Callbacks are parsed and resolved but NOT emitted as handler stubs.
// The previous stubs had the shape `fn(...) -> String` with no request
// type, no response type, and no execution path — more misleading than
// useful. Callback support is now documented as parsed-but-not-codegen
// until a typed codegen story exists (see issue #117).
se.to_string(sb)
}
/// Generate `handlers_generated.gleam`, a sealed delegator that the
/// router imports. Each operation forwards to `handlers.<op_name>(req)`.
/// Issue #247: this file carries the `// DO NOT EDIT` banner and is
/// always overwritten so router/handler wiring stays in sync with the
/// spec without touching the user's `handlers.gleam`.
fn generate_handlers_generated(
ctx: Context,
operations: List(#(String, spec.Operation(Resolved), String, spec.HttpMethod)),
) -> String {
let pkg = config.package(context.config(ctx))
let sb =
se.file_header(context.version)
|> se.imports([
pkg <> "/handlers",
pkg <> "/request_types",
pkg <> "/response_types",
])
let sb =
list.fold(operations, sb, fn(sb, op) {
let #(op_id, operation, _path, _method) = op
generate_handler_delegator(sb, op_id, operation)
})
se.to_string(sb)
}
/// Generate a single delegator function in `handlers_generated.gleam`.
fn generate_handler_delegator(
sb: se.StringBuilder,
op_id: String,
operation: spec.Operation(Resolved),
) -> se.StringBuilder {
let fn_name = naming.operation_to_function_name(op_id)
let request_type = naming.schema_to_type_name(op_id) <> "Request"
let response_type = naming.schema_to_type_name(op_id) <> "Response"
let has_params =
!list.is_empty(operation.parameters)
|| option.is_some(operation.request_body)
case has_params {
True ->
sb
|> se.line(
"pub fn "
<> fn_name
<> "(state: handlers.State, req: request_types."
<> request_type
<> ") -> response_types."
<> response_type
<> " {",
)
|> se.indent(1, "handlers." <> fn_name <> "(state, req)")
|> se.line("}")
|> se.blank_line()
False ->
sb
|> se.line(
"pub fn "
<> fn_name
<> "(state: handlers.State) -> response_types."
<> response_type
<> " {",
)
|> se.indent(1, "handlers." <> fn_name <> "(state)")
|> se.line("}")
|> se.blank_line()
}
}
/// Generate a single handler stub.
fn generate_handler(
sb: se.StringBuilder,
op_id: String,
operation: spec.Operation(Resolved),
_ctx: Context,
) -> se.StringBuilder {
let fn_name = naming.operation_to_function_name(op_id)
let request_type = naming.schema_to_type_name(op_id) <> "Request"
let response_type = naming.schema_to_type_name(op_id) <> "Response"
let sb = case operation.summary {
Some(summary) -> sb |> se.doc_comment(summary)
_ -> sb
}
let sb = case operation.description {
Some(desc) -> sb |> se.doc_comment(desc)
_ -> sb
}
let has_params =
!list.is_empty(operation.parameters)
|| option.is_some(operation.request_body)
let sb = case has_params {
True ->
sb
|> se.line(
"pub fn "
<> fn_name
<> "(state: State, req: request_types."
<> request_type
<> ") -> response_types."
<> response_type
<> " {",
)
False ->
sb
|> se.line(
"pub fn "
<> fn_name
<> "(state: State) -> response_types."
<> response_type
<> " {",
)
}
let sb = sb |> se.indent(1, "let _ = state")
let sb = case has_params {
True -> sb |> se.indent(1, "let _ = req")
False -> sb
}
sb
|> se.indent(1, "panic as \"unimplemented: " <> fn_name <> "\"")
|> se.line("}")
|> se.blank_line()
}
/// Generate a router module that dispatches requests.
fn generate_router(
ctx: Context,
operations: List(#(String, spec.Operation(Resolved), String, spec.HttpMethod)),
) -> String {
let has_deep_object =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
list.any(operation.parameters, fn(ref_p) {
case ref_p {
Value(p) -> decode_helpers.is_deep_object_param(p, ctx)
_ -> False
}
})
})
// Whether any deep object param has additional_properties (Untyped or Typed)
let has_deep_object_with_ap =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
list.any(operation.parameters, fn(ref_p) {
case ref_p {
Value(p) ->
decode_helpers.is_deep_object_param(p, ctx)
&& decode_helpers.deep_object_has_additional_properties(p, ctx)
_ -> False
}
})
})
// Whether any optional deep object param does NOT use deep_object_present_any.
// deep_object_present_any is only used for Untyped AP; all other optional
// deep object params (no AP or Typed AP) use deep_object_present.
let needs_deep_object_present =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
list.any(operation.parameters, fn(ref_p) {
case ref_p {
Value(p) ->
decode_helpers.is_deep_object_param(p, ctx)
&& !p.required
&& !decode_helpers.deep_object_has_untyped_additional_properties(
p,
ctx,
)
_ -> False
}
})
})
// Whether any deep object param has Untyped additional_properties (needs dynamic import)
let has_deep_object_untyped_ap =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
list.any(operation.parameters, fn(ref_p) {
case ref_p {
Value(p) ->
decode_helpers.is_deep_object_param(p, ctx)
&& decode_helpers.deep_object_has_untyped_additional_properties(
p,
ctx,
)
_ -> False
}
})
})
let has_form_urlencoded_body =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
decode_helpers.operation_uses_form_urlencoded_body(operation)
})
let has_multipart_body =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
decode_helpers.operation_uses_multipart_body(operation)
})
let has_nested_form_urlencoded_body =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
case operation.request_body {
Some(Value(rb)) ->
decode_helpers.form_urlencoded_body_has_nested_object(rb, ctx)
_ -> False
}
})
// Determine which imports are needed based on operations.
// Dict is always needed for the route signature, so we skip a conditional
// check here.
let needs_int =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
list.any(operation.parameters, fn(ref_p) {
case ref_p {
Value(p) ->
decode_helpers.query_schema_needs_int(spec.parameter_schema(p))
|| decode_helpers.deep_object_param_needs_int(p, ctx)
_ -> False
}
})
|| case operation.request_body {
Some(Value(rb)) ->
decode_helpers.form_urlencoded_body_needs_int(rb, ctx)
_ -> False
}
|| case operation.request_body {
Some(Value(rb)) -> decode_helpers.multipart_body_needs_int(rb, ctx)
_ -> False
}
})
// Issue #306: integer response headers stringify via int.to_string.
|| operations_have_response_header_of_type(operations, "Int")
let needs_float =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
list.any(operation.parameters, fn(ref_p) {
case ref_p {
Value(p) ->
decode_helpers.query_schema_needs_float(spec.parameter_schema(p))
|| decode_helpers.deep_object_param_needs_float(p, ctx)
_ -> False
}
})
|| case operation.request_body {
Some(Value(rb)) ->
decode_helpers.form_urlencoded_body_needs_float(rb, ctx)
_ -> False
}
|| case operation.request_body {
Some(Value(rb)) -> decode_helpers.multipart_body_needs_float(rb, ctx)
_ -> False
}
})
// Issue #306: float response headers stringify via float.to_string.
|| operations_have_response_header_of_type(operations, "Float")
// Issue #306: boolean response headers stringify via bool.to_string —
// generated routers had no prior need for `gleam/bool`, so this is a
// brand new import condition.
let needs_bool = operations_have_response_header_of_type(operations, "Bool")
let needs_string =
has_form_urlencoded_body
|| has_multipart_body
|| has_deep_object_with_ap
|| list.any(operations, fn(op) {
let #(_, operation, _, _) = op
list.any(operation.parameters, fn(ref_p) {
case ref_p {
Value(p) ->
case p.in_ {
spec.InCookie -> True
spec.InQuery | spec.InHeader ->
decode_helpers.query_schema_needs_string(spec.parameter_schema(
p,
))
|| decode_helpers.deep_object_param_needs_string(p, ctx)
spec.InPath ->
decode_helpers.query_schema_needs_string(spec.parameter_schema(
p,
))
}
_ -> False
}
})
|| case operation.request_body {
Some(Value(rb)) ->
decode_helpers.form_urlencoded_body_needs_string(rb, ctx)
_ -> False
}
})
let needs_cookie_lookup =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
list.any(operation.parameters, fn(ref_p) {
case ref_p {
Value(p) -> p.in_ == spec.InCookie
_ -> False
}
})
})
let needs_list_import =
needs_cookie_lookup
|| has_deep_object
|| has_form_urlencoded_body
|| has_multipart_body
// Issue #306: responses that declare headers materialise the
// ServerResponse `headers:` slot via `list.flatten([...])`.
|| operations_have_response_headers(operations)
|| list.any(operations, fn(op) {
let #(_, operation, _, _) = op
list.any(operation.parameters, fn(ref_p) {
case ref_p {
Value(p) ->
case p.in_, spec.parameter_schema(p) {
spec.InQuery, Some(Inline(schema.ArraySchema(..))) -> True
spec.InHeader, Some(Inline(schema.ArraySchema(..))) -> True
_, _ -> False
}
_ -> False
}
})
})
let needs_uri_import = needs_cookie_lookup || has_form_urlencoded_body
let needs_option =
import_analysis.operations_have_optional_params(operations)
|| import_analysis.operations_have_optional_body(operations)
|| list.any(operations, fn(op) {
let #(_, operation, _, _) = op
let has_optional_deep_object_fields =
list.any(operation.parameters, fn(ref_p) {
case ref_p {
Value(p) ->
decode_helpers.deep_object_param_has_optional_fields(p, ctx)
_ -> False
}
})
let has_optional_form_urlencoded_fields = case operation.request_body {
Some(Value(rb)) ->
decode_helpers.form_urlencoded_body_has_optional_fields(rb, ctx)
_ -> False
}
let has_optional_multipart_fields = case operation.request_body {
Some(Value(rb)) ->
decode_helpers.multipart_body_has_optional_fields(rb, ctx)
_ -> False
}
has_optional_deep_object_fields
|| has_optional_form_urlencoded_fields
|| has_optional_multipart_fields
})
// Issue #306: optional response headers pattern-match `Some(v) | None`,
// which needs `gleam/option` even on operations whose request side
// has no Option-typed parameters.
|| operations_have_optional_response_header(operations)
let needs_json =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
let responses = dict.to_list(operation.responses)
list.any(responses, fn(entry) {
let #(_, ref_or) = entry
case ref_or {
Value(response) -> {
let content_entries = dict.to_list(response.content)
case content_entries {
[#(media_type_name, media_type)] ->
case content_type.is_json_compatible(media_type_name) {
True ->
case media_type.schema {
Some(_) -> True
None -> False
}
False -> False
}
_ -> False
}
}
_ -> False
}
})
})
let needs_decode =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
case operation.request_body {
Some(Value(rb)) ->
list.any(dict.to_list(rb.content), fn(entry) {
let #(content_type, _) = entry
content_type.is_json_compatible(content_type)
})
_ -> False
}
})
let needs_encode = needs_json
let uses_query =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
list.any(operation.parameters, fn(ref_p) {
case ref_p {
Value(p) -> p.in_ == spec.InQuery
_ -> False
}
})
})
let uses_headers =
has_multipart_body
|| list.any(operations, fn(op) {
let #(_, operation, _, _) = op
list.any(operation.parameters, fn(ref_p) {
case ref_p {
Value(p) -> p.in_ == spec.InHeader || p.in_ == spec.InCookie
_ -> False
}
})
})
let uses_body =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
option.is_some(operation.request_body)
})
let has_params_ops =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
!list.is_empty(operation.parameters)
|| option.is_some(operation.request_body)
})
// Build imports list (Dict always needed for route signature)
let std_imports = ["gleam/dict.{type Dict}"]
let std_imports = case needs_list_import {
True -> list.append(std_imports, ["gleam/list"])
False -> std_imports
}
let std_imports = case needs_uri_import {
True -> list.append(std_imports, ["gleam/uri"])
False -> std_imports
}
let std_imports = case needs_int {
True -> list.append(std_imports, ["gleam/int"])
False -> std_imports
}
let std_imports = case needs_float {
True -> list.append(std_imports, ["gleam/float"])
False -> std_imports
}
let std_imports = case needs_bool {
True -> list.append(std_imports, ["gleam/bool"])
False -> std_imports
}
let std_imports = case needs_option {
True -> list.append(std_imports, ["gleam/option.{None, Some}"])
False -> std_imports
}
// json is also needed when guard validation is enabled (for 422 error responses)
let needs_json_for_guards =
config.validate(context.config(ctx))
&& list.any(operations, fn(op) {
let #(_, operation, _, _) = op
operation_needs_guard_validation(operation, ctx)
})
let std_imports = case needs_json || needs_json_for_guards {
True -> list.append(std_imports, ["gleam/json"])
False -> std_imports
}
let std_imports = case needs_string {
True -> list.append(std_imports, ["gleam/string"])
False -> std_imports
}
let std_imports = case has_deep_object_untyped_ap {
True -> list.append(std_imports, ["gleam/dynamic"])
False -> std_imports
}
// Issue #247: router imports the sealed delegator, not the user-owned
// handlers module. handlers_generated.gleam forwards every call to
// handlers.<op_name>, so the router stays in lock-step with the spec
// without ever touching user code.
// Issue #264: also import handlers itself so the route signature can
// reference `handlers.State` for the threaded application state.
let pkg_imports = [
config.package(context.config(ctx)) <> "/handlers",
config.package(context.config(ctx)) <> "/handlers_generated",
]
// Issue #318: enum query / header / cookie parameters resolved through
// `$ref` cause the router body to emit `types.<EnumType><Variant>`
// references (see decode_helpers.enum_match_result_expr and
// enum_match_option_expr). The `types` import must be present in
// those cases too, not just for deep object / form / multipart.
let has_enum_ref_params =
decode_helpers.operations_have_enum_ref_params(operations, ctx)
let pkg_imports = case
has_deep_object
|| has_form_urlencoded_body
|| has_multipart_body
|| has_enum_ref_params
{
True ->
list.append(pkg_imports, [config.package(context.config(ctx)) <> "/types"])
False -> pkg_imports
}
let pkg_imports = case needs_decode {
True ->
list.append(pkg_imports, [
config.package(context.config(ctx)) <> "/decode",
])
False -> pkg_imports
}
let pkg_imports = case needs_encode {
True ->
list.append(pkg_imports, [
config.package(context.config(ctx)) <> "/encode",
])
False -> pkg_imports
}
let pkg_imports = case has_params_ops {
True ->
list.append(pkg_imports, [
config.package(context.config(ctx)) <> "/request_types",
config.package(context.config(ctx)) <> "/response_types",
])
False ->
list.append(pkg_imports, [
config.package(context.config(ctx)) <> "/response_types",
])
}
// Import guards module when validation is enabled and any operation body has validators
let needs_guards =
config.validate(context.config(ctx))
&& list.any(operations, fn(op) {
let #(_, operation, _, _) = op
operation_needs_guard_validation(operation, ctx)
})
let pkg_imports = case needs_guards {
True ->
list.append(pkg_imports, [
config.package(context.config(ctx)) <> "/guards",
])
False -> pkg_imports
}
let all_imports = list.append(std_imports, pkg_imports)
let sb =
se.file_header(context.version)
|> se.imports(all_imports)
// Generate ResponseBody and ServerResponse types.
//
// `ResponseBody` keeps text and binary payloads distinct end-to-end so a
// spec that declares `application/octet-stream` or `image/*` responses
// round-trips real bytes instead of being smuggled through `String`
// (issue #304). Framework adapters pattern-match on the variant to call
// their text- or bytes-shaped response constructor.
let sb =
sb
|> se.doc_comment(
"Response body payload — text, raw bytes, or no body. The router
emits `BytesBody` for `application/octet-stream` (and other binary
content types) so adapters never have to round-trip bytes through a
String.",
)
|> se.line("pub type ResponseBody {")
|> se.indent(1, "TextBody(String)")
|> se.indent(1, "BytesBody(BitArray)")
|> se.indent(1, "EmptyBody")
|> se.line("}")
|> se.blank_line()
|> se.doc_comment("A server response with status code, body, and headers.")
|> se.line("pub type ServerResponse {")
|> se.indent(
1,
"ServerResponse(status: Int, body: ResponseBody, headers: List(#(String, String)))",
)
|> se.line("}")
|> se.blank_line()
// deep_object_present: only when optional deep object params without AP exist
let sb = case needs_deep_object_present {
True ->
sb
|> se.line(
"fn deep_object_present(query: Dict(String, List(String)), prefix: String, props: List(String)) -> Bool {",
)
|> se.indent(
1,
"list.any(props, fn(prop) { dict.has_key(query, prefix <> \"[\" <> prop <> \"]\") })",
)
|> se.line("}")
|> se.blank_line()
False -> sb
}
// deep_object_present_any and deep_object_additional_properties:
// only when deep object params with additional_properties exist
let sb = case has_deep_object_with_ap {
True ->
sb
|> se.line(
"fn deep_object_present_any(query: Dict(String, List(String)), prefix: String) -> Bool {",
)
|> se.indent(1, "let prefix_bracket = prefix <> \"[\"")
|> se.indent(
1,
"dict.fold(query, False, fn(found, k, _v) { found || string.starts_with(k, prefix_bracket) })",
)
|> se.line("}")
|> se.blank_line()
|> se.line(
"fn deep_object_additional_properties(query: Dict(String, List(String)), prefix: String, known_props: List(String)) -> Dict(String, List(String)) {",
)
|> se.indent(1, "let prefix_bracket = prefix <> \"[\"")
|> se.indent(1, "let prefix_len = string.length(prefix_bracket)")
|> se.indent(1, "dict.fold(query, dict.new(), fn(acc, k, v) {")
|> se.indent(
2,
"case string.starts_with(k, prefix_bracket) && string.ends_with(k, \"]\") {",
)
|> se.indent(3, "True -> {")
|> se.indent(
4,
"let prop = string.slice(k, prefix_len, string.length(k) - prefix_len - 1)",
)
|> se.indent(
4,
"case list.contains(known_props, prop) { True -> acc False -> dict.insert(acc, prop, v) }",
)
|> se.indent(3, "}")
|> se.indent(3, "False -> acc")
|> se.indent(2, "}")
|> se.indent(1, "})")
|> se.line("}")
|> se.blank_line()
False -> sb
}
// coerce_dict: type-safe identity for converting Dict value types at compile time
// Only needed when deepObject params with Untyped additional_properties exist
let sb = case has_deep_object_untyped_ap {
True ->
sb
|> se.line("@external(erlang, \"gleam_stdlib\", \"identity\")")
|> se.line(
"fn coerce_dict(value: Dict(String, List(String))) -> Dict(String, dynamic.Dynamic)",
)
|> se.blank_line()
False -> sb
}
let sb = case has_form_urlencoded_body {
True ->
sb
|> se.line("fn form_url_decode(value: String) -> String {")
|> se.indent(1, "let value = string.replace(value, \"+\", \" \")")
|> se.indent(
1,
"case uri.percent_decode(value) { Ok(decoded) -> decoded Error(_) -> value }",
)
|> se.line("}")
|> se.blank_line()
|> se.line(
"fn parse_form_body(body: String) -> Dict(String, List(String)) {",
)
|> se.indent(
1,
"let parts = case body { \"\" -> [] _ -> string.split(body, \"&\") }",
)
|> se.indent(1, "list.fold(parts, dict.new(), fn(acc, part) {")
|> se.indent(2, "case part {")
|> se.indent(3, "\"\" -> acc")
|> se.indent(3, "_ ->")
|> se.indent(4, "case string.split_once(part, on: \"=\") {")
|> se.indent(5, "Ok(#(raw_key, raw_value)) -> {")
|> se.indent(6, "let key = form_url_decode(raw_key)")
|> se.indent(6, "let value = form_url_decode(raw_value)")
|> se.indent(6, "case dict.get(acc, key) {")
|> se.indent(
7,
"Ok(existing) -> dict.insert(acc, key, list.append(existing, [value]))",
)
|> se.indent(7, "Error(_) -> dict.insert(acc, key, [value])")
|> se.indent(6, "}")
|> se.indent(5, "}")
|> se.indent(5, "Error(_) -> {")
|> se.indent(6, "let key = form_url_decode(part)")
|> se.indent(6, "case dict.get(acc, key) {")
|> se.indent(
7,
"Ok(existing) -> dict.insert(acc, key, list.append(existing, [\"\"]))",
)
|> se.indent(7, "Error(_) -> dict.insert(acc, key, [\"\"])")
|> se.indent(6, "}")
|> se.indent(5, "}")
|> se.indent(4, "}")
|> se.indent(2, "}")
|> se.indent(1, "})")
|> se.line("}")
|> se.blank_line()
False -> sb
}
let sb = case has_multipart_body {
True ->
sb
|> se.line(
"fn multipart_boundary(headers: Dict(String, String)) -> Result(String, Nil) {",
)
|> se.indent(1, "case dict.get(headers, \"content-type\") {")
|> se.indent(2, "Ok(content_type) ->")
|> se.indent(
3,
"list.find_map(string.split(content_type, \";\"), fn(part) {",
)
|> se.indent(4, "let trimmed = string.trim(part)")
|> se.indent(4, "case string.starts_with(trimmed, \"boundary=\") {")
|> se.indent(
5,
"True -> Ok(string.replace(trimmed, \"boundary=\", \"\"))",
)
|> se.indent(5, "False -> Error(Nil)")
|> se.indent(4, "}")
|> se.indent(3, "})")
|> se.indent(2, "Error(_) -> Error(Nil)")
|> se.indent(1, "}")
|> se.line("}")
|> se.blank_line()
|> se.line(
"fn multipart_name(raw_headers: String) -> Result(String, Nil) {",
)
|> se.indent(
1,
"list.find_map(string.split(raw_headers, \"\\r\\n\"), fn(line) {",
)
|> se.indent(2, "case string.contains(line, \"name=\") {")
|> se.indent(3, "True ->")
|> se.indent(4, "list.find_map(string.split(line, \";\"), fn(part) {")
|> se.indent(5, "let trimmed = string.trim(part)")
|> se.indent(5, "case string.starts_with(trimmed, \"name=\") {")
|> se.indent(
6,
"True -> Ok(string.replace(string.replace(trimmed, \"name=\", \"\"), \"\\\"\", \"\"))",
)
|> se.indent(6, "False -> Error(Nil)")
|> se.indent(5, "}")
|> se.indent(4, "})")
|> se.indent(3, "False -> Error(Nil)")
|> se.indent(2, "}")
|> se.indent(1, "})")
|> se.line("}")
|> se.blank_line()
|> se.line(
"fn parse_multipart_body(body: String, headers: Dict(String, String)) -> Dict(String, List(String)) {",
)
|> se.indent(1, "case multipart_boundary(headers) {")
|> se.indent(2, "Ok(boundary) -> {")
|> se.indent(3, "let delimiter = \"--\" <> boundary")
|> se.indent(3, "let parts = string.split(body, delimiter)")
|> se.indent(3, "list.fold(parts, dict.new(), fn(acc, part) {")
|> se.indent(
4,
"let normalized_part = part |> string.remove_prefix(\"\\r\\n\") |> string.remove_suffix(\"\\r\\n\")",
)
|> se.indent(
4,
"case normalized_part == \"\" || normalized_part == \"--\" {",
)
|> se.indent(5, "True -> acc")
|> se.indent(5, "False ->")
|> se.indent(
6,
"case string.split_once(normalized_part, on: \"\\r\\n\\r\\n\") {",
)
|> se.indent(7, "Ok(#(raw_part_headers, raw_value)) ->")
|> se.indent(8, "case multipart_name(raw_part_headers) {")
|> se.indent(9, "Ok(name) -> {")
|> se.indent(10, "let value = raw_value")
|> se.indent(10, "case dict.get(acc, name) {")
|> se.indent(
11,
"Ok(existing) -> dict.insert(acc, name, list.append(existing, [value]))",
)
|> se.indent(11, "Error(_) -> dict.insert(acc, name, [value])")
|> se.indent(10, "}")
|> se.indent(9, "}")
|> se.indent(9, "Error(_) -> acc")
|> se.indent(8, "}")
|> se.indent(7, "Error(_) -> acc")
|> se.indent(6, "}")
|> se.indent(4, "}")
|> se.indent(3, "})")
|> se.indent(2, "}")
|> se.indent(2, "Error(_) -> dict.new()")
|> se.indent(1, "}")
|> se.line("}")
|> se.blank_line()
False -> sb
}
let sb = case has_nested_form_urlencoded_body {
True ->
sb
|> se.line(
"fn form_object_present(form_body: Dict(String, List(String)), prefix: String, props: List(String)) -> Bool {",
)
|> se.indent(
1,
"list.any(props, fn(prop) { dict.has_key(form_body, prefix <> \"[\" <> prop <> \"]\") })",
)
|> se.line("}")
|> se.blank_line()
False -> sb
}
// Generate route function. Issue #264 threads `app_state: handlers.State`
// through to handler delegators. The argument is named `app_state` (not
// `state`) because OpenAPI specs occasionally have a parameter named
// `state` (e.g. OAuth2 flows), which would otherwise shadow the route
// argument inside the case body.
let sb =
sb
|> se.doc_comment("Route an incoming request to the appropriate handler.")
|> se.line(
"pub fn route(app_state: handlers.State, method: String, path: List(String), "
<> route_arg_name("query", uses_query)
<> ": Dict(String, List(String)), "
<> route_arg_name("headers", uses_headers)
<> ": Dict(String, String), "
<> route_arg_name("body", uses_body)
<> ": String) -> ServerResponse {",
)
|> se.indent(1, "case method, path {")
let sb =
list.fold(operations, sb, fn(sb, op) {
let #(op_id, operation, path, method) = op
let fn_name = naming.operation_to_function_name(op_id)
let method_str = spec.method_to_string(method)
let path_pattern = path_to_pattern(path)
let has_params =
!list.is_empty(operation.parameters)
|| option.is_some(operation.request_body)
sb
|> se.indent(2, "\"" <> method_str <> "\", " <> path_pattern <> " -> {")
|> generate_route_body(op_id, fn_name, operation, path, has_params, ctx)
|> se.indent(2, "}")
})
let sb =
sb
|> se.indent(2, "_, _ -> " <> problem_response_expr(404, "not found"))
|> se.indent(1, "}")
|> se.line("}")
|> se.blank_line()
let sb = case needs_cookie_lookup {
True -> generate_cookie_lookup(sb)
False -> sb
}
se.to_string(sb)
}
fn route_arg_name(name: String, used: Bool) -> String {
use <- bool.guard(used, name)
"_" <> name
}
fn generate_cookie_lookup(sb: se.StringBuilder) -> se.StringBuilder {
sb
|> se.doc_comment("Extract a cookie value from the Cookie header.")
|> se.line(
"fn cookie_lookup(headers: Dict(String, String), key: String) -> Result(String, Nil) {",
)
|> se.indent(1, "case dict.get(headers, \"cookie\") {")
|> se.indent(2, "Ok(raw) ->")
|> se.indent(3, "list.find_map(string.split(raw, \";\"), fn(part) {")
|> se.indent(4, "let trimmed = string.trim(part)")
|> se.indent(4, "case string.split_once(trimmed, on: \"=\") {")
|> se.indent(5, "Ok(#(cookie_key, cookie_value)) ->")
|> se.indent(6, "case string.trim(cookie_key) == key {")
|> se.indent(7, "True -> uri.percent_decode(string.trim(cookie_value))")
|> se.indent(7, "False -> Error(Nil)")
|> se.indent(6, "}")
|> se.indent(5, "Error(_) -> Error(Nil)")
|> se.indent(4, "}")
|> se.indent(3, "})")
|> se.indent(2, "Error(_) -> Error(Nil)")
|> se.indent(1, "}")
|> se.line("}")
|> se.blank_line()
}
/// Generate the body of a single route case branch.
fn generate_route_body(
sb: se.StringBuilder,
op_id: String,
fn_name: String,
operation: spec.Operation(Resolved),
path: String,
has_params: Bool,
ctx: Context,
) -> se.StringBuilder {
let type_name = naming.schema_to_type_name(op_id)
let response_type_name = type_name <> "Response"
case has_params {
False -> {
// No parameters: call handler directly, convert response
sb
|> se.indent(
3,
"let response = handlers_generated." <> fn_name <> "(app_state)",
)
|> generate_response_conversion(response_type_name, operation, ctx)
}
True -> {
// Has parameters: validate inputs, construct request, call handler
let request_type_name = type_name <> "Request"
generate_safe_request_and_dispatch(
sb,
request_type_name,
response_type_name,
op_id,
fn_name,
operation,
path,
ctx,
)
}
}
}
/// Generate safe request construction wrapped in error handling.
/// Path parameter parsing, required query/header/cookie params, and body
/// decoding are all validated before calling the handler. Parse failures
/// return ServerResponse(status: 400) instead of crashing.
fn generate_safe_request_and_dispatch(
sb: se.StringBuilder,
request_type_name: String,
response_type_name: String,
op_id: String,
fn_name: String,
operation: spec.Operation(Resolved),
_path: String,
ctx: Context,
) -> se.StringBuilder {
let params =
list.filter_map(operation.parameters, fn(r) {
case r {
Value(p) -> Ok(p)
_ -> Error(Nil)
}
})
let deduped_field_names = dedup.dedup_param_field_names(params)
let params_with_field_names = list.zip(params, deduped_field_names)
// Collect path params that need Result-based parsing (int, float)
let path_params_needing_parse =
list.filter(params, fn(p) {
p.in_ == spec.InPath && decode_helpers.param_needs_result_unwrap(p)
})
// Required query / header / cookie params. Issue #263: each of these
// becomes its own enclosing case expression so that a missing value
// returns 400 instead of crashing the BEAM via `let assert`. Deep
// object query params keep their legacy `let assert`-based path for
// now (tracked separately).
let required_query_params =
list.filter(params, fn(p) {
p.in_ == spec.InQuery
&& p.required
&& !decode_helpers.is_deep_object_param(p, ctx)
})
let required_header_params =
list.filter(params, fn(p) { p.in_ == spec.InHeader && p.required })
let required_cookie_params =
list.filter(params, fn(p) { p.in_ == spec.InCookie && p.required })
// Check if the request body needs safe decoding (required JSON body)
let needs_body_guard = case operation.request_body {
Some(Value(rb)) ->
rb.required
&& list.any(dict.to_list(rb.content), fn(entry) {
content_type.is_json_compatible(entry.0)
})
_ -> False
}
// Open nested case expressions for each path param that needs parsing.
let sb =
list.fold(path_params_needing_parse, sb, fn(sb, p) {
let var_name = naming.to_snake_case(p.name)
let parse_expr = decode_helpers.param_parse_expr(var_name, p)
sb
|> se.indent(3, "case " <> parse_expr <> " {")
|> se.indent(4, "Ok(" <> var_name <> "_parsed) -> {")
})
// Open lookup cases for required query params. The router pulls the
// raw value(s) out of `query` first; numeric parsing happens in a
// second pass below so that a parse failure also returns 400.
let sb =
list.fold(required_query_params, sb, fn(sb, p) {
let raw_var = naming.to_snake_case(p.name) <> "_raw"
let pattern = case query_param_explode_array(p) {
True -> "Ok([_, ..] as " <> raw_var <> ")"
False -> "Ok([" <> raw_var <> ", ..])"
}
sb
|> se.indent(3, "case dict.get(query, \"" <> p.name <> "\") {")
|> se.indent(4, pattern <> " -> {")
})
// Open numeric-parse cases for required query params (scalar int/float
// and explode=true int/float arrays, plus `$ref` to string-enum schemas
// per issue #305). Bool / string don't need this.
let sb =
list.fold(required_query_params, sb, fn(sb, p) {
query_required_open_parse_case(sb, p, ctx)
})
// Open lookup cases for required header params.
let sb =
list.fold(required_header_params, sb, fn(sb, p) {
let raw_var = naming.to_snake_case(p.name) <> "_raw"
let key = string.lowercase(p.name)
sb
|> se.indent(3, "case dict.get(headers, \"" <> key <> "\") {")
|> se.indent(4, "Ok(" <> raw_var <> ") -> {")
})
let sb =
list.fold(required_header_params, sb, fn(sb, p) {
single_value_required_open_parse_case(sb, p)
})
// Open lookup cases for required cookie params.
let sb =
list.fold(required_cookie_params, sb, fn(sb, p) {
let raw_var = naming.to_snake_case(p.name) <> "_raw"
sb
|> se.indent(3, "case cookie_lookup(headers, \"" <> p.name <> "\") {")
|> se.indent(4, "Ok(" <> raw_var <> ") -> {")
})
let sb =
list.fold(required_cookie_params, sb, fn(sb, p) {
single_value_required_open_parse_case(sb, p)
})
// Determine the body schema reference name (used for decode and guard validation)
let body_schema_ref_name = case operation.request_body {
Some(Value(rb)) -> {
let content_entries = dict.to_list(rb.content)
case content_entries {
[#(_ct_name, media_type)] ->
case media_type.schema {
Some(schema.Reference(name:, ..)) -> Some(name)
_ -> None
}
_ -> None
}
}
_ -> None
}
// Check if guard validation should be emitted for this body.
// Issue #292: guard validation currently only fires for $ref schemas
// because guards.gleam generates validators keyed by component name.
// Inline request body schemas with constraints are decoded but NOT
// guard-validated; extending guards.gleam to synthesise validators for
// anonymous inline schemas is tracked as a follow-up.
let needs_guard_validation =
config.validate(context.config(ctx))
&& needs_body_guard
&& {
case body_schema_ref_name {
Some(name) -> guards.schema_has_validator(name, ctx)
None -> False
}
}
// Open body decode guard if needed
let sb = case needs_body_guard, operation.request_body {
True, Some(Value(rb)) -> {
let content_entries = dict.to_list(rb.content)
case content_entries {
[#(_ct_name, media_type)] ->
case media_type.schema {
Some(schema.Reference(name:, ..)) -> {
let decode_fn =
"decode.decode_" <> naming.to_snake_case(name) <> "(body)"
sb
|> se.indent(3, "case " <> decode_fn <> " {")
|> se.indent(4, "Ok(decoded_body) -> {")
}
_ -> {
let decode_fn =
"decode.decode_"
<> naming.to_snake_case(op_id)
<> "_request_body(body)"
sb
|> se.indent(3, "case " <> decode_fn <> " {")
|> se.indent(4, "Ok(decoded_body) -> {")
}
}
_ -> sb
}
}
_, _ -> sb
}
// Open guard validation if needed (after body decode succeeds)
let sb = case needs_guard_validation, body_schema_ref_name {
True, Some(name) -> {
let validate_fn =
"guards.validate_" <> naming.to_snake_case(name) <> "(decoded_body)"
sb
|> se.indent(3, "case " <> validate_fn <> " {")
|> se.indent(4, "Ok(decoded_body) -> {")
}
_, _ -> sb
}
// Prepare form/multipart body if needed
let sb = case operation.request_body {
Some(Value(rb)) ->
case decode_helpers.request_body_uses_form_urlencoded(rb) {
True -> sb |> se.indent(3, "let form_body = parse_form_body(body)")
False ->
case decode_helpers.request_body_uses_multipart(rb) {
True ->
sb
|> se.indent(
3,
"let multipart_body = parse_multipart_body(body, headers)",
)
False -> sb
}
}
_ -> sb
}
// Build request constructor
let sb =
sb
|> se.indent(3, "let request = request_types." <> request_type_name <> "(")
let sb =
list.fold(params_with_field_names, sb, fn(sb, entry) {
let #(param, field_name) = entry
let trailing = ","
let value_expr = case param.in_ {
spec.InPath -> {
// Use the parsed variable if it needed Result unwrapping
case decode_helpers.param_needs_result_unwrap(param) {
True -> naming.to_snake_case(param.name) <> "_parsed"
False -> {
let var_name = naming.to_snake_case(param.name)
decode_helpers.param_parse_expr(var_name, param)
}
}
}
spec.InQuery -> {
let key = param.name
let raw_var = naming.to_snake_case(param.name) <> "_raw"
case decode_helpers.is_deep_object_param(param, ctx), param.required {
True, True ->
decode_helpers.deep_object_required_expr(key, param, op_id, ctx)
True, False ->
decode_helpers.deep_object_optional_expr(key, param, op_id, ctx)
False, True ->
decode_helpers.query_required_expr(raw_var, param, ctx)
False, False -> decode_helpers.query_optional_expr(key, param, ctx)
}
}
spec.InHeader -> {
let key = string.lowercase(param.name)
let raw_var = naming.to_snake_case(param.name) <> "_raw"
case param.required {
True -> decode_helpers.header_required_expr(raw_var, param)
False -> decode_helpers.header_optional_expr(key, param)
}
}
spec.InCookie -> {
let key = param.name
let raw_var = naming.to_snake_case(param.name) <> "_raw"
case param.required {
True -> decode_helpers.cookie_required_expr(raw_var, param)
False -> decode_helpers.cookie_optional_expr(key, param)
}
}
}
sb |> se.indent(4, field_name <> ": " <> value_expr <> trailing)
})
// Add body field
let sb = case needs_body_guard {
True -> sb |> se.indent(4, "body: decoded_body,")
False ->
case operation.request_body {
Some(Value(rb)) -> {
let body_expr =
decode_helpers.generate_body_decode_expr(rb, op_id, ctx)
sb |> se.indent(4, "body: " <> body_expr <> ",")
}
_ -> sb
}
}
let sb = sb |> se.indent(3, ")")
// Call handler and convert response
let sb =
sb
|> se.indent(
3,
"let response = handlers_generated." <> fn_name <> "(app_state, request)",
)
|> generate_response_conversion(response_type_name, operation, ctx)
// Close guard validation (returns 422 with error details)
let sb = case needs_guard_validation {
True ->
sb
|> se.indent(4, "}")
|> se.indent(
4,
"Error(errors) -> ServerResponse(status: 422, body: TextBody(json.to_string(json.array(errors, guards.validation_failure_to_json))), headers: [#(\"content-type\", \"application/json\")])",
)
|> se.indent(3, "}")
False -> sb
}
// Close body decode guard
let sb = case needs_body_guard {
True ->
sb
|> se.indent(4, "}")
|> se.indent(
4,
"Error(_) -> " <> problem_response_expr(400, "invalid request body"),
)
|> se.indent(3, "}")
False -> sb
}
// Close required-param case expressions in LIFO (reverse) order:
// cookie parse, cookie lookup, header parse, header lookup, query parse,
// query lookup, then finally the path int/float parse cases. The
// catch-all `_ ->` arm covers `Ok([])` (empty query lists) along with
// `Error(_)` so all failure modes return 400.
let sb =
list.fold(required_cookie_params, sb, fn(sb, p) {
close_single_value_required_parse_case(sb, p, ctx)
})
let sb =
list.fold(required_cookie_params, sb, fn(sb, _p) { close_lookup_case(sb) })
let sb =
list.fold(required_header_params, sb, fn(sb, p) {
close_single_value_required_parse_case(sb, p, ctx)
})
let sb =
list.fold(required_header_params, sb, fn(sb, _p) { close_lookup_case(sb) })
let sb =
list.fold(required_query_params, sb, fn(sb, p) {
close_query_required_parse_case(sb, p, ctx)
})
let sb =
list.fold(required_query_params, sb, fn(sb, _p) { close_lookup_case(sb) })
list.fold(path_params_needing_parse, sb, fn(sb, _p) {
sb
|> se.indent(4, "}")
|> se.indent(
4,
"Error(_) -> " <> problem_response_expr(400, "invalid path parameter"),
)
|> se.indent(3, "}")
})
}
/// True when this query param is treated as a list (explode=true array).
/// Matches the helper used by `query_required_expr` so the open/close
/// scaffolding stays in sync with the value expression.
fn query_param_explode_array(p: spec.Parameter(Resolved)) -> Bool {
case spec.parameter_schema(p) {
Some(schema.Inline(schema.ArraySchema(..))) -> {
case p.explode {
Some(False) -> False
_ -> True
}
}
_ -> False
}
}
/// Open the secondary parse case for required query params that need it.
/// - scalar Integer/Number → `case int.parse(<raw>) { Ok(<raw>_parsed) -> {`
/// - array of Integer/Number with explode=true → `case list.try_map(<raw>, int.parse) { Ok(<raw>_parsed_list) -> {`
/// - array of Integer/Number with explode=false → split first, then try_map.
/// String / Bool / array-of-string / array-of-bool need no extra case.
fn query_required_open_parse_case(
sb: se.StringBuilder,
p: spec.Parameter(Resolved),
ctx: Context,
) -> se.StringBuilder {
let raw_var = naming.to_snake_case(p.name) <> "_raw"
let delim = case p.style {
Some(spec.PipeDelimitedStyle) -> "|"
Some(spec.SpaceDelimitedStyle) -> " "
_ -> ","
}
case spec.parameter_schema(p) {
Some(schema.Inline(schema.IntegerSchema(..))) ->
sb
|> se.indent(3, "case int.parse(" <> raw_var <> ") {")
|> se.indent(4, "Ok(" <> raw_var <> "_parsed) -> {")
Some(schema.Inline(schema.NumberSchema(..))) ->
sb
|> se.indent(3, "case float.parse(" <> raw_var <> ") {")
|> se.indent(4, "Ok(" <> raw_var <> "_parsed) -> {")
Some(schema.Inline(schema.ArraySchema(
items: Inline(schema.IntegerSchema(..)),
..,
))) -> {
let parse_expr = array_int_parse_expr(raw_var, p, delim)
sb
|> se.indent(3, "case " <> parse_expr <> " {")
|> se.indent(4, "Ok(" <> raw_var <> "_parsed_list) -> {")
}
Some(schema.Inline(schema.ArraySchema(
items: Inline(schema.NumberSchema(..)),
..,
))) -> {
let parse_expr = array_float_parse_expr(raw_var, p, delim)
sb
|> se.indent(3, "case " <> parse_expr <> " {")
|> se.indent(4, "Ok(" <> raw_var <> "_parsed_list) -> {")
}
Some(ref) ->
// Issue #305: required `$ref` to a string-enum schema needs the
// same Result-based open/close scaffolding as int / float so an
// unknown enum value falls through to the `_ -> 400` arm in
// `close_single_value_required_parse_case` below.
case decode_helpers.schema_ref_string_enum(ref, ctx) {
Some(#(type_name, values)) ->
sb
|> se.indent(
3,
"case "
<> decode_helpers.enum_match_result_expr(
raw_var,
type_name,
values,
)
<> " {",
)
|> se.indent(4, "Ok(" <> raw_var <> "_parsed) -> {")
None -> sb
}
_ -> sb
}
}
fn array_int_parse_expr(
raw_var: String,
p: spec.Parameter(Resolved),
delim: String,
) -> String {
case p.explode {
Some(False) ->
"list.try_map(list.map(string.split("
<> raw_var
<> ", \""
<> delim
<> "\"), string.trim), int.parse)"
_ -> "list.try_map(list.map(" <> raw_var <> ", string.trim), int.parse)"
}
}
fn array_float_parse_expr(
raw_var: String,
p: spec.Parameter(Resolved),
delim: String,
) -> String {
case p.explode {
Some(False) ->
"list.try_map(list.map(string.split("
<> raw_var
<> ", \""
<> delim
<> "\"), string.trim), float.parse)"
_ -> "list.try_map(list.map(" <> raw_var <> ", string.trim), float.parse)"
}
}
/// Close the matching parse case for a required query param.
fn close_query_required_parse_case(
sb: se.StringBuilder,
p: spec.Parameter(Resolved),
ctx: Context,
) -> se.StringBuilder {
let close = fn(sb: se.StringBuilder) -> se.StringBuilder {
sb
|> se.indent(4, "}")
|> se.indent(
4,
"_ -> " <> problem_response_expr(400, "invalid query parameter"),
)
|> se.indent(3, "}")
}
case spec.parameter_schema(p) {
Some(schema.Inline(schema.IntegerSchema(..)))
| Some(schema.Inline(schema.NumberSchema(..)))
| Some(schema.Inline(schema.ArraySchema(
items: Inline(schema.IntegerSchema(..)),
..,
)))
| Some(schema.Inline(schema.ArraySchema(
items: Inline(schema.NumberSchema(..)),
..,
))) -> close(sb)
Some(ref) ->
// Issue #305: matches the open emitted in
// `query_required_open_parse_case` for required `$ref` to string
// enum schemas.
case decode_helpers.schema_ref_string_enum(ref, ctx) {
Some(_) -> close(sb)
None -> sb
}
_ -> sb
}
}
/// For headers / cookies, open a numeric parse case if the param schema is a
/// scalar Integer/Number. (Header/cookie array parsing is currently a single
/// inline `list.map(string.split(...))` expression and stays unsafe; that's
/// out of scope for the Issue #263 fix and tracked separately.)
fn single_value_required_open_parse_case(
sb: se.StringBuilder,
p: spec.Parameter(Resolved),
) -> se.StringBuilder {
let raw_var = naming.to_snake_case(p.name) <> "_raw"
case spec.parameter_schema(p) {
Some(schema.Inline(schema.IntegerSchema(..))) ->
sb
|> se.indent(3, "case int.parse(" <> raw_var <> ") {")
|> se.indent(4, "Ok(" <> raw_var <> "_parsed) -> {")
Some(schema.Inline(schema.NumberSchema(..))) ->
sb
|> se.indent(3, "case float.parse(" <> raw_var <> ") {")
|> se.indent(4, "Ok(" <> raw_var <> "_parsed) -> {")
Some(schema.Inline(schema.ArraySchema(
items: Inline(schema.IntegerSchema(..)),
..,
))) ->
sb
|> se.indent(
3,
"case list.try_map(list.map(string.split("
<> raw_var
<> ", \",\"), string.trim), int.parse) {",
)
|> se.indent(4, "Ok(" <> raw_var <> "_parsed_list) -> {")
Some(schema.Inline(schema.ArraySchema(
items: Inline(schema.NumberSchema(..)),
..,
))) ->
sb
|> se.indent(
3,
"case list.try_map(list.map(string.split("
<> raw_var
<> ", \",\"), string.trim), float.parse) {",
)
|> se.indent(4, "Ok(" <> raw_var <> "_parsed_list) -> {")
_ -> sb
}
}
fn close_single_value_required_parse_case(
sb: se.StringBuilder,
p: spec.Parameter(Resolved),
ctx: Context,
) -> se.StringBuilder {
let close = fn(sb: se.StringBuilder) -> se.StringBuilder {
sb
|> se.indent(4, "}")
|> se.indent(
4,
"_ -> " <> problem_response_expr(400, "invalid header or cookie"),
)
|> se.indent(3, "}")
}
case spec.parameter_schema(p) {
Some(schema.Inline(schema.IntegerSchema(..)))
| Some(schema.Inline(schema.NumberSchema(..)))
| Some(schema.Inline(schema.ArraySchema(
items: Inline(schema.IntegerSchema(..)),
..,
)))
| Some(schema.Inline(schema.ArraySchema(
items: Inline(schema.NumberSchema(..)),
..,
))) -> close(sb)
Some(ref) ->
// Issue #305: matches the open emitted in
// `query_required_open_parse_case` for required `$ref` to string
// enum schemas. Non-enum refs fall through unchanged.
case decode_helpers.schema_ref_string_enum(ref, ctx) {
Some(_) -> close(sb)
None -> sb
}
_ -> sb
}
}
/// Build the Gleam source expression for a `ServerResponse` carrying an
/// RFC 7807-shaped `application/problem+json` body. Used by every
/// router-side error-emit site so clients receive a structured JSON
/// response instead of plain `Bad Request` / `Not Found` text — a
/// schema-conformant default for specs that declare 4xx responses with
/// `application/problem+json` content (issue #307).
///
/// The body is emitted as a literal JSON string at codegen time —
/// stable, allocation-free, and safe to embed because `detail` is a
/// short codegen-controlled phrase (no user input is interpolated).
/// Specs that need a different Problem encoding can still override at
/// the framework adapter layer.
fn problem_response_expr(status: Int, detail: String) -> String {
let body =
"\"{\\\"type\\\":\\\"about:blank\\\",\\\"title\\\":\\\""
<> detail
<> "\\\"}\""
"ServerResponse(status: "
<> int.to_string(status)
<> ", body: TextBody("
<> body
<> "), headers: [#(\"content-type\", \"application/problem+json\")])"
}
/// Close a required-param lookup case (`case dict.get(...) { Ok(...) -> { ... }`).
/// The catch-all `_ ->` arm covers both `Error(_)` and `Ok([])` (empty list)
/// for query params, plus the bare `Error(_)` for header/cookie lookups.
fn close_lookup_case(sb: se.StringBuilder) -> se.StringBuilder {
sb
|> se.indent(4, "}")
|> se.indent(
4,
"_ -> " <> problem_response_expr(400, "missing or invalid parameter"),
)
|> se.indent(3, "}")
}
/// Check if an operation's request body needs guard validation.
/// True when the body is required, JSON-compatible, references a named schema,
/// and that schema has constraint-based validators.
///
/// Issue #292: inline request body schemas with constraints are NOT covered
/// here because guards.gleam only generates validators for named component
/// schemas. Extending guard generation to anonymous inline schemas is
/// tracked as a follow-up.
fn operation_needs_guard_validation(
operation: spec.Operation(Resolved),
ctx: Context,
) -> Bool {
case operation.request_body {
Some(Value(rb)) ->
rb.required
&& {
let content_entries = dict.to_list(rb.content)
list.any(content_entries, fn(entry) {
content_type.is_json_compatible(entry.0)
})
&& case content_entries {
[#(_, mt)] ->
case mt.schema {
Some(schema.Reference(name:, ..)) ->
guards.schema_has_validator(name, ctx)
_ -> False
}
_ -> False
}
}
_ -> False
}
}
// Parameter parsing, body decoding, and request construction helpers
// have been extracted to server_request_decode.gleam
/// Generate code to convert a handler response to ServerResponse.
fn generate_response_conversion(
sb: se.StringBuilder,
response_type_name: String,
operation: spec.Operation(Resolved),
ctx: Context,
) -> se.StringBuilder {
let responses = http.sort_response_entries(dict.to_list(operation.responses))
case list.is_empty(responses) {
True ->
sb
|> se.indent(
3,
"ServerResponse(status: 200, body: EmptyBody, headers: [])",
)
False -> {
let sb = sb |> se.indent(3, "case response {")
let sb =
list.fold(responses, sb, fn(sb, entry) {
let #(status_code, ref_or) = entry
case ref_or {
Value(response) -> {
let variant_name =
response_type_name <> http.status_code_suffix(status_code)
let status_int = http.status_code_to_int(status_code)
let content_entries = dict.to_list(response.content)
let header_specs = sorted_header_specs(response.headers)
let has_headers = !list.is_empty(header_specs)
case content_entries {
[] ->
// No content body variant
emit_response_arm(
sb,
variant_name,
status_int,
has_data: False,
has_headers: has_headers,
body_expr: "EmptyBody",
content_type_header: None,
header_specs: header_specs,
)
[#(media_type_name, media_type)] ->
case content_type.from_string(media_type_name) {
content_type.ApplicationJson ->
case media_type.schema {
Some(_) -> {
let encode_fn =
get_encode_function(media_type.schema, ctx)
emit_response_arm(
sb,
variant_name,
status_int,
has_data: True,
has_headers: has_headers,
body_expr: "TextBody(json.to_string("
<> encode_fn
<> "(data)))",
content_type_header: Some(media_type_name),
header_specs: header_specs,
)
}
None ->
emit_response_arm(
sb,
variant_name,
status_int,
has_data: False,
has_headers: has_headers,
body_expr: "EmptyBody",
content_type_header: None,
header_specs: header_specs,
)
}
content_type.TextPlain
| content_type.ApplicationXml
| content_type.TextXml ->
case media_type.schema {
Some(_) ->
emit_response_arm(
sb,
variant_name,
status_int,
has_data: True,
has_headers: has_headers,
body_expr: "TextBody(data)",
content_type_header: Some(media_type_name),
header_specs: header_specs,
)
None ->
emit_response_arm(
sb,
variant_name,
status_int,
has_data: False,
has_headers: has_headers,
body_expr: "EmptyBody",
content_type_header: None,
header_specs: header_specs,
)
}
content_type.ApplicationOctetStream ->
// Issue #304: binary responses thread bytes end-to-end
// via `BytesBody(BitArray)` instead of being smuggled
// through `String`. The matching response_types
// variant carries `BitArray` (see ir_build).
case media_type.schema {
Some(_) ->
emit_response_arm(
sb,
variant_name,
status_int,
has_data: True,
has_headers: has_headers,
body_expr: "BytesBody(data)",
content_type_header: Some(media_type_name),
header_specs: header_specs,
)
None ->
emit_response_arm(
sb,
variant_name,
status_int,
has_data: False,
has_headers: has_headers,
body_expr: "EmptyBody",
content_type_header: None,
header_specs: header_specs,
)
}
_ ->
case media_type.schema {
Some(_) -> {
let encode_fn =
get_encode_function(media_type.schema, ctx)
emit_response_arm(
sb,
variant_name,
status_int,
has_data: True,
has_headers: has_headers,
body_expr: "TextBody(json.to_string("
<> encode_fn
<> "(data)))",
content_type_header: Some(media_type_name),
header_specs: header_specs,
)
}
None ->
emit_response_arm(
sb,
variant_name,
status_int,
has_data: False,
has_headers: has_headers,
body_expr: "EmptyBody",
content_type_header: None,
header_specs: header_specs,
)
}
}
// Multiple content types: variant wraps String.
// Use the first content type as default content-type header.
[#(first_media_type, _), _, ..] ->
emit_response_arm(
sb,
variant_name,
status_int,
has_data: True,
has_headers: has_headers,
body_expr: "TextBody(data)",
content_type_header: Some(first_media_type),
header_specs: header_specs,
)
}
}
_ -> sb
}
})
sb |> se.indent(3, "}")
}
}
}
/// True if any response of any operation declares at least one header.
fn operations_have_response_headers(
operations: List(#(String, spec.Operation(Resolved), String, spec.HttpMethod)),
) -> Bool {
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
list.any(dict.to_list(operation.responses), fn(entry) {
let #(_, ref_or) = entry
case ref_or {
Value(response) -> !dict.is_empty(response.headers)
_ -> False
}
})
})
}
/// True if any response header field has the given Gleam type.
/// Used to decide which primitive `gleam/<type>` import the generated
/// router needs for header value stringification (issue #306).
fn operations_have_response_header_of_type(
operations: List(#(String, spec.Operation(Resolved), String, spec.HttpMethod)),
type_name: String,
) -> Bool {
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
list.any(dict.to_list(operation.responses), fn(entry) {
let #(_, ref_or) = entry
case ref_or {
Value(response) ->
list.any(sorted_header_specs(response.headers), fn(spec) {
spec.field_type == type_name
})
_ -> False
}
})
})
}
/// True if any response header is optional. Optional headers emit
/// `case hdrs.<field> { Some(v) -> ... None -> [] }` and need `gleam/option`.
fn operations_have_optional_response_header(
operations: List(#(String, spec.Operation(Resolved), String, spec.HttpMethod)),
) -> Bool {
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
list.any(dict.to_list(operation.responses), fn(entry) {
let #(_, ref_or) = entry
case ref_or {
Value(response) ->
list.any(sorted_header_specs(response.headers), fn(spec) {
!spec.required
})
_ -> False
}
})
})
}
/// Compact spec for a single declared response header, used to render
/// the `headers:` slot of `ServerResponse` when issue #306 plumbing is
/// active. The pair (header_name, field_name) keeps the wire-form name
/// (e.g. "Pagination-Cursor") distinct from the Gleam record field
/// (`pagination_cursor`); `field_type` decides how the value is
/// stringified; `required` decides whether to emit a Some/None case.
type HeaderSpec {
HeaderSpec(
header_name: String,
field_name: String,
field_type: String,
required: Bool,
)
}
fn sorted_header_specs(
headers: dict.Dict(String, spec.Header),
) -> List(HeaderSpec) {
headers
|> dict.to_list
|> list.sort(fn(a, b) { string.compare(a.0, b.0) })
|> list.map(fn(entry) {
let #(header_name, header) = entry
let field_name = naming.to_snake_case(header_name)
let field_type = response_header_field_type(header.schema)
HeaderSpec(
header_name: header_name,
field_name: field_name,
field_type: field_type,
required: header.required,
)
})
}
/// Mirror of `header_schema_to_type` in ir_build — kept here so the
/// router emission and the response_types record stay in sync without a
/// cross-module dependency on the IR layer.
fn response_header_field_type(
schema_opt: option.Option(schema.SchemaRef),
) -> String {
case schema_opt {
Some(Inline(schema.IntegerSchema(..))) -> "Int"
Some(Inline(schema.NumberSchema(..))) -> "Float"
Some(Inline(schema.BooleanSchema(..))) -> "Bool"
Some(Inline(schema.StringSchema(..))) -> "String"
Some(Reference(name:, ..)) -> "types." <> naming.schema_to_type_name(name)
_ -> "String"
}
}
/// Emit one arm of the response dispatch `case` block.
///
/// Issue #306 introduces the headers plumbing: when `has_headers` is
/// True the variant pattern grows an `hdrs` binding and the headers
/// list is materialised via `list.flatten` so spec-declared response
/// headers are appended to the implicit `content-type` tuple.
fn emit_response_arm(
sb: se.StringBuilder,
variant_name: String,
status_int: String,
has_data has_data: Bool,
has_headers has_headers: Bool,
body_expr body_expr: String,
content_type_header content_type_header: option.Option(String),
header_specs header_specs: List(HeaderSpec),
) -> se.StringBuilder {
let pattern_args = case has_data, has_headers {
True, True -> "(data, hdrs)"
True, False -> "(data)"
False, True -> "(hdrs)"
False, False -> ""
}
let headers_expr = headers_slot_expr(content_type_header, header_specs)
sb
|> se.indent(
4,
"response_types."
<> variant_name
<> pattern_args
<> " -> ServerResponse(status: "
<> status_int
<> ", body: "
<> body_expr
<> ", headers: "
<> headers_expr
<> ")",
)
}
/// Build the `headers:` argument for a `ServerResponse(...)` call.
///
/// - No content-type, no declared headers → `[]`
/// - Content-type only → `[#("content-type", "<type>")]`
/// - Declared headers (with or without content-type) → `list.flatten([...])`
/// so optional headers can contribute `[]` and required ones a
/// one-tuple list without repeated allocation. The fold keeps the
/// spec-declared order (alphabetised by `sorted_header_specs`) so
/// regenerated routers stay deterministic.
fn headers_slot_expr(
content_type_header: option.Option(String),
header_specs: List(HeaderSpec),
) -> String {
let content_type_chunk = case content_type_header {
Some(media_type_name) -> [
"[#(\"content-type\", \"" <> media_type_name <> "\")]",
]
None -> []
}
let header_chunks =
list.map(header_specs, fn(spec) { header_chunk_expr(spec) })
case content_type_chunk, header_chunks {
[], [] -> "[]"
[single_chunk], [] -> single_chunk
_, _ ->
"list.flatten(["
<> string.join(content_type_chunk, ", ")
|> append_chunks(header_chunks)
<> "])"
}
}
fn append_chunks(prefix: String, header_chunks: List(String)) -> String {
case header_chunks {
[] -> prefix
_ ->
case prefix {
"" -> string.join(header_chunks, ", ")
_ -> prefix <> ", " <> string.join(header_chunks, ", ")
}
}
}
/// Render one declared response header into a chunk that contributes
/// to the `headers:` `list.flatten` call.
///
/// Required: `[#("Pagination-Cursor", hdrs.pagination_cursor)]`
/// Optional: `case hdrs.pagination_cursor { Some(v) -> [#(...)] None -> [] }`
/// Non-string types are stringified via `int.to_string` /
/// `float.to_string` / `bool.to_string` (the necessary imports are
/// added by the import-analysis pass).
fn header_chunk_expr(spec: HeaderSpec) -> String {
let render_value = fn(value_expr: String) -> String {
"[#(\""
<> spec.header_name
<> "\", "
<> stringify_header_value(spec.field_type, value_expr)
<> ")]"
}
case spec.required {
True -> render_value("hdrs." <> spec.field_name)
False ->
"case hdrs."
<> spec.field_name
<> " { Some(v) -> "
<> render_value("v")
<> " None -> [] }"
}
}
/// Convert a typed header value into a `String` for the wire. Header
/// fields are limited to primitives (Int / Float / Bool / String) and
/// `types.<X>` aliases (which the codegen treats as String-compatible
/// — non-string aliases would fail to compile and signal the user
/// that header value coercion is unsupported for that schema).
fn stringify_header_value(field_type: String, value_expr: String) -> String {
case field_type {
"Int" -> "int.to_string(" <> value_expr <> ")"
"Float" -> "float.to_string(" <> value_expr <> ")"
"Bool" -> "bool.to_string(" <> value_expr <> ")"
_ -> value_expr
}
}
/// Get the encode function name for a schema reference.
fn get_encode_function(
schema_ref: option.Option(schema.SchemaRef),
_ctx: Context,
) -> String {
case schema_ref {
Some(Reference(name:, ..)) -> {
"encode.encode_" <> naming.to_snake_case(name) <> "_json"
}
Some(Inline(schema.ArraySchema(items:, ..))) -> {
case items {
Reference(name:, ..) ->
"fn(items) { json.array(items, encode.encode_"
<> naming.to_snake_case(name)
<> "_json) }"
Inline(schema.StringSchema(..)) ->
"fn(items) { json.array(items, json.string) }"
Inline(schema.IntegerSchema(..)) ->
"fn(items) { json.array(items, json.int) }"
Inline(schema.NumberSchema(..)) ->
"fn(items) { json.array(items, json.float) }"
Inline(schema.BooleanSchema(..)) ->
"fn(items) { json.array(items, json.bool) }"
// Inline non-primitive items (e.g. nested ArraySchema, anonymous
// ObjectSchema) at the top level of a response are not yet hoisted
// into reusable encoders. Falling back to the previous "json.string"
// shape would emit code that fails to compile against List(_); emit a
// homogeneous String array instead so the call still type-checks even
// when the items are not actually strings. Once such schemas get a
// hoist + per-item encoder, replace this branch.
_ -> "fn(items) { json.array(items, json.string) }"
}
}
Some(Inline(schema.StringSchema(..))) -> "json.string"
Some(Inline(schema.IntegerSchema(..))) -> "json.int"
Some(Inline(schema.NumberSchema(..))) -> "json.float"
Some(Inline(schema.BooleanSchema(..))) -> "json.bool"
_ -> "json.string"
}
}
/// Convert an OpenAPI path to a Gleam pattern match expression.
fn path_to_pattern(path: String) -> String {
let segments =
path
|> string.split("/")
|> list.filter(fn(s) { s != "" })
let patterns =
list.map(segments, fn(seg) {
case is_path_param(seg) {
True -> {
let param_name = extract_param_name(seg)
naming.to_snake_case(param_name)
}
False -> "\"" <> seg <> "\""
}
})
"[" <> se.join_with(patterns, ", ") <> "]"
}
/// Check if a path segment is a parameter.
fn is_path_param(segment: String) -> Bool {
case segment {
"{" <> _ -> True
_ -> False
}
}
/// Extract parameter name from {name}.
fn extract_param_name(segment: String) -> String {
segment
|> string.replace("{", "")
|> string.replace("}", "")
}