Packages
oaspec
0.58.1
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/validate.gleam
import gleam/dict.{type Dict}
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/regexp
import gleam/string
import oaspec/config
import oaspec/internal/codegen/context.{type Context}
import oaspec/internal/openapi/schema.{
type SchemaObject, type SchemaRef, AllOfSchema, AnyOfSchema, ArraySchema,
BooleanSchema, Forbidden, Inline, IntegerSchema, NumberSchema, ObjectSchema,
OneOfSchema, Reference, StringSchema, Typed, Unspecified, Untyped,
}
import oaspec/internal/openapi/spec.{type Resolved}
import oaspec/internal/util/content_type
import oaspec/internal/util/http
import oaspec/internal/util/naming
import oaspec/openapi/diagnostic.{type Diagnostic}
/// Validate the parsed spec for unsupported patterns.
/// Returns a list of errors; empty list means validation passed.
///
/// operationId uniqueness is enforced here with a hard error (issue #237):
/// silently renaming duplicates would mutate the generated public API
/// surface without telling the user, which is worse than failing the spec.
pub fn validate(ctx: Context) -> List(Diagnostic) {
let operations = context.operations(ctx)
let op_errors = validate_operations(ctx, operations)
let opid_errors = validate_unique_operation_ids(operations)
let schema_errors = validate_component_schemas(ctx)
let schema_collision_errors = validate_unique_schema_names(ctx)
let security_errors = validate_security_schemes(ctx, operations)
// Issue #493: the previous `validate_decode_list_collisions` check
// hard-rejected any spec that declared both `Foo` and `FooList` as
// component schemas, because the codegen would emit two
// `decode_foo_list` functions (a synthetic one for `List(Foo)` and
// the user's `FooList` decoder). Real-world specs (Kubernetes,
// Stripe) routinely use this naming, and the user does not own the
// upstream definitions. The codegen now disambiguates the synthetic
// name to `decode_foo_list_items` whenever `FooList` is also
// declared (see `naming.synthetic_list_suffix`), so this validator
// pass is no longer needed.
list.flatten([
op_errors,
opid_errors,
schema_errors,
schema_collision_errors,
security_errors,
])
}
/// Fail the spec if two operations end up sharing an operationId, either
/// literally or after snake_case conversion to the generated function
/// name. Returns one diagnostic per distinct colliding name, listing all
/// `METHOD /path` sites that claimed it.
fn validate_unique_operation_ids(
operations: List(context.AnalyzedOperation),
) -> List(Diagnostic) {
let literal = group_operations_by_id(operations, fn(op_id) { op_id })
let by_function =
group_operations_by_id(operations, naming.operation_to_function_name)
let literal_errors =
dict.to_list(literal)
|> list.filter_map(fn(entry) {
let #(op_id, sites) = entry
case sites {
[_, _, ..] -> Ok(duplicate_operation_id_diagnostic(op_id, sites))
_ -> Error(Nil)
}
})
// A spec with "listItems" and "list_items" has no literal collision but
// generates two functions called `list_items/N` — catch that too.
// Skip cases where every site is already covered by a literal-duplicate
// diagnostic (same sites, same name), to avoid emitting two diagnostics
// for the same root cause.
let function_errors =
dict.to_list(by_function)
|> list.filter_map(fn(entry) {
let #(fn_name, sites) = entry
case sites {
[_, _, ..] -> {
case dict.get(literal, fn_name) {
Ok(literal_sites) if literal_sites == sites -> Error(Nil)
_ -> Ok(duplicate_function_name_diagnostic(fn_name, sites))
}
}
_ -> Error(Nil)
}
})
list.append(literal_errors, function_errors)
}
fn group_operations_by_id(
operations: List(context.AnalyzedOperation),
key_fn: fn(String) -> String,
) -> Dict(String, List(String)) {
// Accumulate site lists in reverse (prepend is O(1)) then reverse each
// value once at the end. The previous `list.append(existing, [site])`
// shape was O(N²) on the number of duplicates per key, which matters
// when a large spec accidentally collides many operationIds under the
// same case-folded form.
let reversed =
list.fold(operations, dict.new(), fn(acc, entry) {
let #(op_id, _operation, path, method) = entry
let key = key_fn(op_id)
let site = string.uppercase(spec.method_to_string(method)) <> " " <> path
case dict.get(acc, key) {
Ok(existing) -> dict.insert(acc, key, [site, ..existing])
// nolint: thrown_away_error -- dict.get's Error signals absence of key; we start a new list for the first occurrence
Error(_) -> dict.insert(acc, key, [site])
}
})
dict.map_values(reversed, fn(_key, sites) { list.reverse(sites) })
}
fn duplicate_operation_id_diagnostic(
op_id: String,
sites: List(String),
) -> Diagnostic {
diagnostic.invalid_value(
path: "paths.*.operationId",
detail: "Duplicate operationId '"
<> op_id
<> "' found on: "
<> string.join(sites, ", ")
<> ". operationId must be unique across the entire spec; "
<> "rename one of the operations to keep the generated API stable.",
loc: diagnostic.NoSourceLoc,
)
}
fn duplicate_function_name_diagnostic(
fn_name: String,
sites: List(String),
) -> Diagnostic {
diagnostic.invalid_value(
path: "paths.*.operationId",
detail: "operationIds that normalize to the same generated function name '"
<> fn_name
<> "' found on: "
<> string.join(sites, ", ")
<> ". oaspec converts operationIds to snake_case, so values like "
<> "'listItems' and 'list_items' collide; rename one of them.",
loc: diagnostic.NoSourceLoc,
)
}
/// Convert a validation error to a human-readable string.
pub fn error_to_string(error: Diagnostic) -> String {
diagnostic.to_string(error)
}
/// Validate all operations for unsupported patterns.
fn validate_operations(
ctx: Context,
operations: List(context.AnalyzedOperation),
) -> List(Diagnostic) {
list.flat_map(operations, fn(op) {
let #(op_id, operation, path, _method) = op
// All refs are guaranteed to be resolved by this point
let resolved_params = list.map(operation.parameters, spec.unwrap_ref)
let resolved_request_body = case operation.request_body {
Some(ref_or) -> Some(spec.unwrap_ref(ref_or))
None -> None
}
let resolved_responses =
dict.to_list(operation.responses)
|> list.map(fn(entry) {
let #(status_code, ref_or) = entry
#(status_code, spec.unwrap_ref(ref_or))
})
|> dict.from_list
let path_errors =
validate_path_template_params(op_id, path, resolved_params)
let param_errors = validate_parameters(op_id, resolved_params, ctx)
let body_errors = validate_request_body(op_id, resolved_request_body, ctx)
let response_errors = validate_responses(op_id, resolved_responses, ctx)
let missing_responses_errors = case dict.is_empty(resolved_responses) {
True -> [
diagnostic.validation_error_both(
path: op_id,
detail: "Operation has no responses defined. OpenAPI 3.x requires at least one response.",
hint: Some(
"Add at least one response (e.g., '200': { description: ok }) to this operation.",
),
),
]
False -> []
}
list.flatten([
path_errors,
param_errors,
body_errors,
response_errors,
missing_responses_errors,
])
})
}
/// Validate that all {param} templates in the path have a corresponding
/// path parameter definition. Reports unbound templates that would produce
/// invalid generated code with literal {param} in URLs.
fn validate_path_template_params(
op_id: String,
path: String,
params: List(spec.Parameter(Resolved)),
) -> List(Diagnostic) {
let template_names = extract_path_template_names(path)
let path_param_names =
list.filter_map(params, fn(p) {
case p.in_ {
spec.InPath -> Ok(p.name)
_ -> Error(Nil)
}
})
list.filter_map(template_names, fn(name) {
case list.contains(path_param_names, name) {
True -> Error(Nil)
False -> {
let defined = case path_param_names {
[] -> ""
names ->
" Defined path parameters: " <> string.join(names, ", ") <> "."
}
Ok(diagnostic.validation_error_both(
path: op_id <> ".path",
detail: "Path template parameter '{"
<> name
<> "}' in '"
<> path
<> "' has no corresponding parameter definition.",
hint: Some(
"Add a parameter definition with 'in: path' for this variable, or remove it from the path template."
<> defined,
),
))
}
}
})
}
/// Extract parameter names from path template, e.g. "/items/{id}" -> ["id"].
fn extract_path_template_names(path: String) -> List(String) {
// nolint: assert_ok_pattern -- compile-time constant regex literal cannot fail to parse
let assert Ok(re) = regexp.from_string("\\{([^}]+)\\}")
regexp.scan(re, path)
|> list.filter_map(fn(match) {
case match.submatches {
[Some(name)] -> Ok(name)
_ -> Error(Nil)
}
})
}
/// Validate parameters for unsupported serialization styles.
/// Supported: form (default), deepObject (query+object), exploded array,
/// pipeDelimited / spaceDelimited (query+array only).
/// Unsupported: matrix, label.
fn validate_parameters(
op_id: String,
params: List(spec.Parameter(Resolved)),
ctx: Context,
) -> List(Diagnostic) {
list.flat_map(params, fn(p) {
let path = op_id <> ".parameters." <> p.name
let style_errors = case p.style {
Some(spec.MatrixStyle) | Some(spec.LabelStyle) -> [
diagnostic.validation_error_both(
path: path,
detail: "Parameter style is not supported. Supported styles: form, simple, deepObject, pipeDelimited, spaceDelimited.",
hint: Some(
"Use style 'form', 'simple', 'deepObject', 'pipeDelimited', or 'spaceDelimited' instead.",
),
),
]
Some(spec.PipeDelimitedStyle) ->
validate_delimited_style(path, p, "pipeDelimited", ctx)
Some(spec.SpaceDelimitedStyle) ->
validate_delimited_style(path, p, "spaceDelimited", ctx)
_ -> []
}
// Parameter.payload is ParameterContent when Parameter.content is used instead of schema.
// We don't support the content-based parameter serialization.
let content_errors = case p.payload {
spec.ParameterContent(_) -> [
diagnostic.validation_error_both(
path: path,
detail: "Parameters using 'content' instead of 'schema' are not supported.",
hint: Some(
"Replace the 'content' field with a 'schema' field in the parameter definition.",
),
),
]
spec.ParameterSchema(_) -> []
}
// Object/complex schemas in query/header/cookie params require deepObject
// style. Without it, codegen cannot stringify the value and falls through
// to raw variable name, producing invalid generated code.
let complex_schema_errors = validate_complex_param_schema(path, p, ctx)
let server_structured_param_errors =
validate_server_structured_param(path, p, ctx)
let cookie_errors = validate_server_cookie_param(path, p, ctx)
list.flatten([
style_errors,
content_errors,
complex_schema_errors,
server_structured_param_errors,
cookie_errors,
])
})
}
/// Validate pipeDelimited / spaceDelimited parameter styles.
/// Both are only meaningful for array-typed query parameters; reject elsewhere.
fn validate_delimited_style(
path: String,
param: spec.Parameter(Resolved),
style_name: String,
ctx: Context,
) -> List(Diagnostic) {
let location_errors = case param.in_ {
spec.InQuery -> []
_ -> [
diagnostic.validation_error_both(
path: path,
detail: "Parameter style '"
<> style_name
<> "' is only supported for 'in: query'.",
hint: Some(
"Move this parameter to 'in: query' or switch to a style valid for its location.",
),
),
]
}
let schema_errors = case
resolve_schema_object(spec.parameter_schema(param), ctx)
{
Some(ArraySchema(..)) -> []
_ -> [
diagnostic.validation_error_both(
path: path,
detail: "Parameter style '"
<> style_name
<> "' requires an array schema.",
hint: Some(
"Change the schema to 'type: array' or switch to style 'form'.",
),
),
]
}
list.flatten([location_errors, schema_errors])
}
fn validate_server_structured_param(
path: String,
param: spec.Parameter(Resolved),
ctx: Context,
) -> List(Diagnostic) {
let schema_obj = resolve_schema_object(spec.parameter_schema(param), ctx)
// Bug fix: array parameters with non-primitive items panic in
// both client and server codegen with "inline composite schema
// reached to_string_fn after hoist" because `to_string_fn` has no
// path for composite item types — even after hoist promotes the
// item to a `$ref`, `to_string_fn` recurses into the resolved
// ObjectSchema and hits the same panic. Reject in BOTH modes
// when the items resolve to a non-primitive shape.
let array_errors = case param.in_, schema_obj {
spec.InQuery, Some(ArraySchema(items:, ..)) ->
case array_items_resolve_primitive(items, ctx) {
True -> []
False -> [
diagnostic.validation_error_both(
path: path,
detail: "Query array parameters are only supported for primitive items (string, integer, number, boolean), whether inline or via $ref.",
hint: Some(
"Replace the item schema with a primitive type (string, integer, number, boolean).",
),
),
]
}
spec.InHeader, Some(ArraySchema(items:, ..)) ->
case array_items_resolve_primitive(items, ctx) {
True -> []
False -> [
diagnostic.validation_error_both(
path: path,
detail: "Header array parameters are only supported for primitive items (string, integer, number, boolean), whether inline or via $ref.",
hint: Some(
"Replace the item schema with a primitive type (string, integer, number, boolean).",
),
),
]
}
// CodeRabbit follow-up: cookie array params take the same
// exploded-array codegen path as query/header (see
// `client_request.generate_exploded_array_query_param`-style
// emission), so a non-primitive item schema would panic in
// `to_string_fn` for them too. Reject in both modes.
spec.InCookie, Some(ArraySchema(items:, ..)) ->
case array_items_resolve_primitive(items, ctx) {
True -> []
False -> [
diagnostic.validation_error_both(
path: path,
detail: "Cookie array parameters are only supported for primitive items (string, integer, number, boolean), whether inline or via $ref.",
hint: Some(
"Replace the item schema with a primitive type (string, integer, number, boolean).",
),
),
]
}
_, _ -> []
}
// The deepObject server-only constraint (primitive scalars /
// primitive arrays only) stays gated to server mode — the client
// emitter handles nested objects post-#502.
let deep_object_errors = case config.mode(context.config(ctx)) {
config.Client -> []
_ -> validate_server_deep_object_param(path, param, ctx)
}
list.flatten([array_errors, deep_object_errors])
}
/// True iff `items` resolves to a primitive scalar shape (string,
/// integer, number, boolean), whether the schema is inline or
/// referenced through `$ref`. Hoist may have promoted an inline
/// primitive into a component during preprocessing, so a
/// `Reference` here is not automatically safe — we have to look at
/// what the ref points at.
fn array_items_resolve_primitive(items: SchemaRef, ctx: Context) -> Bool {
case items {
Inline(StringSchema(..))
| Inline(IntegerSchema(..))
| Inline(NumberSchema(..))
| Inline(BooleanSchema(..)) -> True
Reference(..) ->
case context.resolve_schema_ref(items, ctx) {
Ok(StringSchema(..))
| Ok(IntegerSchema(..))
| Ok(NumberSchema(..))
| Ok(BooleanSchema(..)) -> True
Ok(_) -> False
// nolint: thrown_away_error -- unresolved refs surface elsewhere; here we only need the primitive/non-primitive distinction
Error(_) -> False
}
_ -> False
}
}
fn validate_server_deep_object_param(
path: String,
param: spec.Parameter(Resolved),
ctx: Context,
) -> List(Diagnostic) {
case
param.in_,
param.style,
resolve_schema_object(spec.parameter_schema(param), ctx)
{
spec.InQuery,
Some(spec.DeepObjectStyle),
Some(ObjectSchema(properties:, ..))
->
dict.to_list(properties)
|> list.flat_map(fn(entry) {
let #(prop_name, prop_ref) = entry
case deep_object_server_leaf_supported(prop_ref, ctx) {
True -> []
False -> [
diagnostic.validation_error_server(
path: path <> "." <> prop_name,
detail: "deepObject properties are only supported for inline primitive scalars and inline primitive array leaves in server code generation.",
hint: Some(
"Simplify deepObject properties to primitive scalars or primitive arrays.",
),
),
]
}
})
_, _, _ -> []
}
}
fn deep_object_server_leaf_supported(
schema_ref: SchemaRef,
ctx: Context,
) -> Bool {
// Issue #502: client-mode codegen now expands nested object
// properties into bracketed-bracketed query keys, but server-mode
// decoding still requires primitive scalars / primitive arrays.
// Lifting the server constraint requires a router decoder rewrite
// and is tracked as a separate follow-up.
case schema_ref {
Inline(StringSchema(..))
| Inline(IntegerSchema(..))
| Inline(NumberSchema(..))
| Inline(BooleanSchema(..))
| Inline(ArraySchema(items: Inline(StringSchema(..)), ..))
| Inline(ArraySchema(items: Inline(IntegerSchema(..)), ..))
| Inline(ArraySchema(items: Inline(NumberSchema(..)), ..))
| Inline(ArraySchema(items: Inline(BooleanSchema(..)), ..)) -> True
Reference(..) ->
case resolve_schema_object(Some(schema_ref), ctx) {
Some(StringSchema(..))
| Some(IntegerSchema(..))
| Some(NumberSchema(..))
| Some(BooleanSchema(..))
| Some(ArraySchema(items: Inline(StringSchema(..)), ..))
| Some(ArraySchema(items: Inline(IntegerSchema(..)), ..))
| Some(ArraySchema(items: Inline(NumberSchema(..)), ..))
| Some(ArraySchema(items: Inline(BooleanSchema(..)), ..)) -> True
_ -> False
}
_ -> False
}
}
fn validate_server_cookie_param(
_path: String,
_param: spec.Parameter(Resolved),
_ctx: Context,
) -> List(Diagnostic) {
[]
}
/// Check if a parameter has a complex schema (object, oneOf, allOf, anyOf)
/// that is not handled by deepObject style.
///
/// Path parameters: complex schemas in path params are still a hard
/// error for server codegen — there is no sensible way to extract a
/// nested object from a single path segment.
///
/// Query / header / cookie parameters: when the spec author omits
/// `style`, the OpenAPI default is `form`, which only serializes
/// primitives cleanly. Real-world specs (the GitHub REST OpenAPI is
/// the canonical example — `cwes`, `affects`, `has`, `fields` are all
/// `oneOf: [string, array<string>]`) routinely declare complex
/// query parameters without an explicit style. Refusing those was
/// blocking codegen on otherwise-supported specs, so we now emit a
/// warning and let the parameter through. The generated client uses
/// the form-style fallback (the same path that a primitive query
/// parameter takes), which round-trips correctly for `oneOf`-of-
/// primitives and for shallow `object` schemas with primitive
/// properties — the two shapes that show up in practice. Spec
/// authors who need true deepObject serialization should still
/// declare `style: deepObject` explicitly. (issue #352)
fn validate_complex_param_schema(
path: String,
param: spec.Parameter(Resolved),
ctx: Context,
) -> List(Diagnostic) {
case param.style {
Some(spec.DeepObjectStyle) ->
// deepObject supports one level of object nesting only.
// Reject nested object properties since codegen produces
// invalid code (e.g., uri.percent_encode(filter.meta)).
validate_deep_object_no_nested_objects(path, param, ctx)
_ ->
case resolve_schema_object(spec.parameter_schema(param), ctx) {
Some(ObjectSchema(..))
| Some(AllOfSchema(..))
| Some(OneOfSchema(..))
| Some(AnyOfSchema(..)) ->
case param.in_ {
spec.InPath ->
case config.mode(context.config(ctx)) {
config.Client -> []
_ -> [
diagnostic.validation_error_server(
path: path,
detail: "Complex path parameters are not supported for server code generation.",
hint: Some(
"Use a simple scalar type (string, integer, number, boolean) for path parameters.",
),
),
]
}
_ -> [
diagnostic.validation_warning_both(
path: path,
detail: "Complex schema (object/oneOf/allOf/anyOf) parameter has no explicit 'style'; falling back to form-style serialization. This works for oneOf-of-primitives and shallow objects but may not match the spec author's intent for deeply nested objects.",
hint: Some(
"Declare 'style: deepObject' explicitly if the parameter encodes a structured object.",
),
),
]
}
_ -> []
}
}
}
/// Validate that a deepObject parameter has no unsupported property
/// shapes. Issue #502: an object-typed property is now allowed (encoded
/// as `parent[outer][inner]=value` and modeled as `Dict(String, String)`
/// in the generated client). oneOf / anyOf / arrays of objects remain
/// rejected because they don't fit the bracketed-string wire format.
fn validate_deep_object_no_nested_objects(
path: String,
param: spec.Parameter(Resolved),
ctx: Context,
) -> List(Diagnostic) {
case resolve_schema_object(spec.parameter_schema(param), ctx) {
Some(ObjectSchema(properties:, ..)) ->
dict.to_list(properties)
|> list.flat_map(fn(entry) {
let #(prop_name, prop_ref) = entry
case resolve_schema_object(Some(prop_ref), ctx) {
// Object and AllOf-of-objects: accepted (treated as
// bracketed-bracketed Dict(String, String)).
Some(ObjectSchema(..)) | Some(AllOfSchema(..)) -> []
// oneOf / anyOf / arrays at the property level still don't
// map to the bracketed-string wire format.
Some(OneOfSchema(..)) | Some(AnyOfSchema(..)) -> [
diagnostic.validation_error_both(
path: path <> "." <> prop_name,
detail: "Nested oneOf/anyOf properties in deepObject parameters are not supported. Only primitive scalars, primitive arrays, and single-level nested objects (Dict(String, String)) are supported.",
hint: Some(
"Flatten the property structure or pick a concrete branch of the oneOf/anyOf.",
),
),
]
_ -> []
}
})
_ -> []
}
}
fn resolve_schema_object(
schema_ref: Option(SchemaRef),
ctx: Context,
) -> Option(SchemaObject) {
case schema_ref {
Some(Inline(schema_obj)) -> Some(schema_obj)
Some(schema_ref) ->
case context.resolve_schema_ref(schema_ref, ctx) {
Ok(schema_obj) -> Some(schema_obj)
// nolint: thrown_away_error -- unresolved refs surface as absent; the ref error is reported elsewhere in the validator
Error(_) -> None
}
None -> None
}
}
/// Validate request body for unsupported patterns.
fn validate_request_body(
op_id: String,
request_body: Option(spec.RequestBody(Resolved)),
ctx: Context,
) -> List(Diagnostic) {
case request_body {
None -> []
Some(rb) -> {
let content_keys = dict.keys(rb.content)
let unsupported =
list.filter(content_keys, fn(key) {
!content_type.is_supported_request(content_type.from_string(key))
})
let content_type_errors = case unsupported {
[] -> []
[media_type, ..] -> [
diagnostic.validation_error_both(
path: op_id <> ".requestBody",
detail: "Content type '"
<> media_type
<> "' is not supported. Supported request content types: application/json (and +json suffix types), text/plain, multipart/form-data, application/x-www-form-urlencoded, application/octet-stream, */*.",
hint: Some(
"Use application/json (or a +json suffix type like application/problem+json), text/plain, multipart/form-data, application/x-www-form-urlencoded, application/octet-stream, or */*.",
),
),
]
}
// Recurse into request body schemas
let schema_errors =
dict.to_list(rb.content)
|> list.flat_map(fn(entry) {
let #(_media_type, media_type) = entry
case media_type.schema {
Some(schema_ref) ->
validate_schema_ref_recursive(
op_id <> ".requestBody",
schema_ref,
ctx,
)
None -> []
}
})
let multipart_field_errors =
validate_multipart_request_body_fields(op_id, rb.content, ctx)
let form_urlencoded_errors =
validate_form_urlencoded_schema(op_id, rb.content, ctx)
let server_form_urlencoded_errors =
validate_server_form_urlencoded_request_body(
op_id,
rb.content,
content_keys,
ctx,
)
let server_multipart_errors =
validate_server_multipart_request_body(
op_id,
rb.content,
content_keys,
ctx,
)
// Server router has explicit typed support for application/json,
// application/x-www-form-urlencoded, and multipart/form-data. Other
// supported content types still fall back to raw String and must be
// rejected here.
let server_body_errors =
validate_server_request_body_content_types(op_id, content_keys, ctx)
list.flatten([
content_type_errors,
schema_errors,
multipart_field_errors,
form_urlencoded_errors,
server_form_urlencoded_errors,
server_multipart_errors,
server_body_errors,
])
}
}
}
fn validate_multipart_request_body_fields(
op_id: String,
content: dict.Dict(String, spec.MediaType),
ctx: Context,
) -> List(Diagnostic) {
case dict.get(content, "multipart/form-data") {
Ok(media_type) ->
case resolve_schema_object(media_type.schema, ctx) {
Some(ObjectSchema(properties:, ..)) ->
dict.to_list(properties)
|> list.flat_map(fn(entry) {
let #(field_name, field_schema) = entry
case multipart_field_is_stringifiable(field_schema, ctx) {
True -> []
False -> [
diagnostic.validation_error_both(
path: op_id <> ".requestBody.multipart." <> field_name,
detail: "multipart/form-data fields must be string, integer, number, boolean, binary, or string enums.",
hint: Some(
"Use a primitive scalar type, binary, or string enum for multipart fields.",
),
),
]
}
})
Some(_) -> [
diagnostic.validation_error_both(
path: op_id <> ".requestBody",
detail: "multipart/form-data request bodies must use an object schema.",
hint: Some(
"Wrap fields in an object schema with properties for each form field.",
),
),
]
None -> []
}
// nolint: thrown_away_error -- absence of the content type means there is nothing to validate here
Error(_) -> []
}
}
/// Validate that application/x-www-form-urlencoded uses an object schema.
/// Non-object schemas produce empty form bodies in the generated code.
fn validate_form_urlencoded_schema(
op_id: String,
content: dict.Dict(String, spec.MediaType),
ctx: Context,
) -> List(Diagnostic) {
case dict.get(content, "application/x-www-form-urlencoded") {
Ok(media_type) ->
case resolve_schema_object(media_type.schema, ctx) {
Some(ObjectSchema(..)) -> []
Some(_) -> [
diagnostic.validation_error_both(
path: op_id <> ".requestBody",
detail: "application/x-www-form-urlencoded request bodies must use an object schema.",
hint: Some(
"Wrap fields in an object schema with properties for each form field.",
),
),
]
None -> []
}
// nolint: thrown_away_error -- absence of the content type means there is nothing to validate here
Error(_) -> []
}
}
/// Validate that request body content types are supported for server codegen.
/// Server router only handles application/json with typed decode; other types
/// that pass the general is_supported_request check (multipart/form-data,
/// application/x-www-form-urlencoded) are passed as raw String which breaks
/// the typed body contract.
fn validate_server_form_urlencoded_request_body(
op_id: String,
content: dict.Dict(String, spec.MediaType),
content_keys: List(String),
ctx: Context,
) -> List(Diagnostic) {
// Bug fix: nested arrays inside a form-urlencoded body's nested
// objects panic the client codegen too — `generate_form_nested_object`
// routes them through `multipart_field_to_string_fn`, which calls
// `to_string_fn` on an Inline ArraySchema and hits the
// "inline schema reached to_string_fn after hoist" panic. Reject
// those shapes in BOTH modes; the server-mode-only multi-content
// restriction stays gated to server.
let server_mode_errors = case config.mode(context.config(ctx)) {
config.Client -> []
_ ->
case dict.get(content, "application/x-www-form-urlencoded") {
Ok(_) ->
case list.length(content_keys) > 1 {
True -> [
diagnostic.validation_error_server(
path: op_id <> ".requestBody",
detail: "application/x-www-form-urlencoded request bodies are only supported as the sole request content type for server code generation.",
hint: Some(
"Remove other content type definitions from this operation's request body.",
),
),
]
False -> []
}
// nolint: thrown_away_error -- absence of the content type means there is nothing to validate here
Error(_) -> []
}
}
let field_errors = case
dict.get(content, "application/x-www-form-urlencoded")
{
Ok(media_type) ->
case resolve_schema_object(media_type.schema, ctx) {
Some(ObjectSchema(properties:, ..)) ->
dict.to_list(properties)
|> list.flat_map(fn(entry) {
let #(field_name, field_schema) = entry
case form_urlencoded_field_codegen_safe(field_schema, ctx, 0) {
True -> []
False -> [
diagnostic.validation_error_both(
path: op_id <> ".requestBody.form." <> field_name,
detail: "application/x-www-form-urlencoded request bodies only support primitive scalars, primitive arrays at the top level, and nested objects with primitive leaves (max 5 levels). Nested arrays-within-objects, oneOf, anyOf, and allOf properties are not supported in either client or server codegen.",
hint: Some(
"Simplify to primitive scalars, primitive arrays at the top level, or shallow nested objects with primitive leaves.",
),
),
]
}
})
_ -> []
}
// nolint: thrown_away_error -- absence of the content type means there is nothing to validate here
Error(_) -> []
}
list.append(server_mode_errors, field_errors)
}
/// Tighter form-urlencoded shape predicate that prevents the
/// generator from recursing into a path it can't render. Mirrors
/// `form_urlencoded_server_field_supported` but rejects nested
/// arrays (an array nested inside an object property), which the
/// client emitter otherwise sends through `to_string_fn` and
/// crashes.
fn form_urlencoded_field_codegen_safe(
schema_ref: SchemaRef,
ctx: Context,
depth: Int,
) -> Bool {
case resolve_schema_object(Some(schema_ref), ctx) {
Some(StringSchema(..))
| Some(IntegerSchema(..))
| Some(NumberSchema(..))
| Some(BooleanSchema(..)) -> True
Some(ArraySchema(items:, ..)) if depth == 0 ->
form_urlencoded_server_array_item_supported(items, ctx)
// Arrays nested inside object properties are not handled by
// the generator (post-hoist they survive as Inline ArraySchema
// and `multipart_field_to_string_fn` panics on them).
Some(ArraySchema(..)) -> False
Some(ObjectSchema(properties:, ..)) if depth < 5 ->
dict.to_list(properties)
|> list.all(fn(entry) {
let #(_, child_schema) = entry
form_urlencoded_field_codegen_safe(child_schema, ctx, depth + 1)
})
_ -> False
}
}
fn validate_server_request_body_content_types(
op_id: String,
content_keys: List(String),
ctx: Context,
) -> List(Diagnostic) {
case config.mode(context.config(ctx)) {
config.Client -> []
_ -> {
let non_json_but_supported =
list.filter(content_keys, fn(key) {
key != "application/json"
&& key != "application/x-www-form-urlencoded"
&& key != "multipart/form-data"
&& key != "application/octet-stream"
&& key != "text/plain"
// Issue #504: */* lands in the same lane as
// application/octet-stream — raw bytes through the server's
// body parameter — so server codegen accepts it too.
&& key != "*/*"
&& content_type.is_supported_request(content_type.from_string(key))
})
list.map(non_json_but_supported, fn(media_type) {
diagnostic.validation_error_server(
path: op_id <> ".requestBody",
detail: "Content type '"
<> media_type
<> "' is not supported for server code generation. Server router only supports application/json request bodies with typed decoding.",
hint: Some(
"Use application/json for typed server request bodies, or multipart/form-data, application/x-www-form-urlencoded, application/octet-stream, text/plain, or */* for non-JSON payloads.",
),
)
})
}
}
}
fn validate_server_multipart_request_body(
op_id: String,
content: dict.Dict(String, spec.MediaType),
content_keys: List(String),
ctx: Context,
) -> List(Diagnostic) {
case config.mode(context.config(ctx)) {
config.Client -> []
_ ->
case dict.get(content, "multipart/form-data") {
Ok(media_type) -> {
let content_type_errors = case list.length(content_keys) > 1 {
True -> [
diagnostic.validation_error_server(
path: op_id <> ".requestBody",
detail: "multipart/form-data request bodies are only supported as the sole request content type for server code generation.",
hint: Some(
"Remove other content type definitions from this operation's request body.",
),
),
]
False -> []
}
let field_errors = case
resolve_schema_object(media_type.schema, ctx)
{
Some(ObjectSchema(properties:, ..)) ->
dict.to_list(properties)
|> list.flat_map(fn(entry) {
let #(field_name, field_schema) = entry
case multipart_server_field_supported(field_schema, ctx) {
True -> []
False -> [
diagnostic.validation_error_server(
path: op_id <> ".requestBody.multipart." <> field_name,
detail: "multipart/form-data server request bodies only support primitive scalar fields.",
hint: Some(
"Use primitive scalar types or arrays of primitive scalars (string, integer, number, boolean) for multipart form fields.",
),
),
]
}
})
_ -> []
}
list.append(content_type_errors, field_errors)
}
// nolint: thrown_away_error -- absence of the content type means there is nothing to validate here
Error(_) -> []
}
}
}
fn form_urlencoded_server_array_item_supported(
schema_ref: SchemaRef,
ctx: Context,
) -> Bool {
case resolve_schema_object(Some(schema_ref), ctx) {
Some(StringSchema(..))
| Some(IntegerSchema(..))
| Some(NumberSchema(..))
| Some(BooleanSchema(..)) -> True
_ -> False
}
}
fn multipart_server_field_supported(schema_ref: SchemaRef, ctx: Context) -> Bool {
case resolve_schema_object(Some(schema_ref), ctx) {
Some(StringSchema(..))
| Some(IntegerSchema(..))
| Some(NumberSchema(..))
| Some(BooleanSchema(..)) -> True
Some(ArraySchema(items:, ..)) ->
multipart_server_array_item_supported(items, ctx)
_ -> False
}
}
fn multipart_server_array_item_supported(
schema_ref: SchemaRef,
ctx: Context,
) -> Bool {
case resolve_schema_object(Some(schema_ref), ctx) {
Some(StringSchema(..))
| Some(IntegerSchema(..))
| Some(NumberSchema(..))
| Some(BooleanSchema(..)) -> True
_ -> False
}
}
fn multipart_field_is_stringifiable(schema_ref: SchemaRef, ctx: Context) -> Bool {
case resolve_schema_object(Some(schema_ref), ctx) {
Some(StringSchema(..))
| Some(IntegerSchema(..))
| Some(NumberSchema(..))
| Some(BooleanSchema(..)) -> True
// Issue #503: arrays of primitives are emitted as repeated parts
// (`name=v1`, `name=v2`, ...) per the OAS 3 multipart serialization
// rules, and objects are emitted as a single part with
// `Content-Type: application/json` carrying the JSON-encoded value.
// Both shapes appear in real specs (Stripe's `POST /v1/files`
// accepts `expand: array of strings` and `file_link_data: object`).
Some(ArraySchema(items:, ..)) ->
multipart_field_array_item_supported(items, ctx)
Some(ObjectSchema(..)) -> True
_ -> False
}
}
fn multipart_field_array_item_supported(
schema_ref: SchemaRef,
ctx: Context,
) -> Bool {
case resolve_schema_object(Some(schema_ref), ctx) {
Some(StringSchema(..))
| Some(IntegerSchema(..))
| Some(NumberSchema(..))
| Some(BooleanSchema(..))
| Some(ObjectSchema(..)) -> True
_ -> False
}
}
/// Validate response schemas and content types.
fn validate_responses(
op_id: String,
responses: dict.Dict(http.HttpStatusCode, spec.Response(Resolved)),
ctx: Context,
) -> List(Diagnostic) {
let entries = dict.to_list(responses)
list.flat_map(entries, fn(entry) {
let #(status_code, response) = entry
let content_entries = dict.to_list(response.content)
list.flat_map(content_entries, fn(ce) {
let #(media_type_name, media_type) = ce
let path =
op_id <> ".responses." <> http.status_code_to_string(status_code)
let content_type_errors = case
content_type.is_supported_response(content_type.from_string(
media_type_name,
))
{
True -> []
False -> [
diagnostic.validation_error_both(
path: path,
detail: "Response content type '"
<> media_type_name
<> "' is not supported. Supported response content types: application/json (and +json suffix types), text/plain, application/x-ndjson, application/octet-stream, application/xml (and +xml suffix types), text/xml, */*.",
hint: Some(
"Use application/json (or a +json suffix type), text/plain, application/x-ndjson, application/octet-stream, application/xml (or a +xml suffix type), text/xml, or */*.",
),
),
]
}
let schema_errors = case media_type.schema {
Some(schema_ref) -> validate_schema_ref_recursive(path, schema_ref, ctx)
None -> []
}
list.append(content_type_errors, schema_errors)
})
})
}
/// Validate component schemas recursively.
/// Detect schema names that differ only in case and would collide when
/// mapped to the same Gleam type name via `schema_to_type_name` (#293).
fn validate_unique_schema_names(ctx: Context) -> List(Diagnostic) {
let schemas = case context.spec(ctx).components {
Some(components) -> dict.keys(components.schemas)
None -> []
}
// Group by the generated Gleam type name — collisions appear as groups of 2+.
let by_type_name =
list.fold(schemas, dict.new(), fn(acc, name) {
let key = naming.schema_to_type_name(name)
case dict.get(acc, key) {
Ok(existing) -> dict.insert(acc, key, [name, ..existing])
// nolint: thrown_away_error -- dict.get's Error signals absence; we start a new list for the first name
Error(_) -> dict.insert(acc, key, [name])
}
})
dict.to_list(by_type_name)
|> list.filter_map(fn(entry) {
let #(type_name, names) = entry
case names {
[_, _, ..] ->
Ok(diagnostic.invalid_value(
path: "components.schemas",
detail: "Schema names "
<> string.join(list.map(names, fn(n) { "\"" <> n <> "\"" }), ", ")
<> " all map to Gleam type `"
<> type_name
<> "` — rename one to avoid the collision",
loc: diagnostic.NoSourceLoc,
))
_ -> Error(Nil)
}
})
}
fn validate_component_schemas(ctx: Context) -> List(Diagnostic) {
let schemas = case context.spec(ctx).components {
Some(components) -> dict.to_list(components.schemas)
None -> []
}
list.flat_map(schemas, fn(entry) {
let #(name, schema_ref) = entry
validate_schema_ref_recursive(
"components.schemas." <> name,
schema_ref,
ctx,
)
})
}
/// Recursively validate a SchemaRef at any depth.
/// Does this `$ref` value look like an absolute URL (http/https)? These
/// are the shape that OpenAPI 3.1 `$id`-backed same-document refs take,
/// and we surface them as a dedicated diagnostic separate from generic
/// external `$ref` errors.
fn is_url_style_ref(ref: String) -> Bool {
string.starts_with(ref, "http://") || string.starts_with(ref, "https://")
}
/// References are checked for resolvability against the spec's components.
fn validate_schema_ref_recursive(
path: String,
schema_ref: SchemaRef,
ctx: Context,
) -> List(Diagnostic) {
case schema_ref {
Reference(ref:, ..) ->
// Detect external refs (not starting with #/) before resolution
case string.starts_with(ref, "#/") {
False -> [
case is_url_style_ref(ref) {
True ->
diagnostic.validation_error_both(
path: path,
detail: "URL-style $ref '"
<> ref
<> "' is not supported. oaspec does not resolve OpenAPI 3.1 / JSON Schema `$id`-backed identifiers — those refs are an explicit boundary.",
hint: Some(
"Rewrite the schema to a local $ref (`#/components/schemas/...`) and drop the `$id` URL, or inline the schema at the use site.",
),
)
False ->
diagnostic.validation_error_both(
path: path,
detail: "External $ref '"
<> ref
<> "' is not supported. Only local references (#/components/...) are supported.",
hint: Some(
"Inline the external schema or copy it into #/components/schemas/ and use a local $ref.",
),
)
},
]
True ->
case context.resolve_schema_ref(schema_ref, ctx) {
Ok(_) -> []
// nolint: thrown_away_error -- resolver error is replaced with a user-facing diagnostic that conveys the same failure
Error(_) -> [
diagnostic.validation_error_both(
path: path,
detail: "Unresolved schema reference: '"
<> ref
<> "'. The referenced schema does not exist in components.",
hint: Some(
"Verify the schema is defined in components.schemas and the $ref path is spelled correctly.",
),
),
]
}
}
Inline(schema_obj) -> validate_schema_recursive(path, schema_obj, ctx)
}
}
/// Recursively validate a SchemaObject, descending into all sub-schemas.
fn validate_schema_recursive(
path: String,
schema_obj: SchemaObject,
ctx: Context,
) -> List(Diagnostic) {
case schema_obj {
ObjectSchema(properties:, additional_properties:, ..) -> {
// additionalProperties: true is supported via Dict(String, Dynamic)
let ap_errors = []
// Recurse into typed additionalProperties schema
let typed_ap_errors = case additional_properties {
Typed(ap_ref) ->
validate_schema_ref_recursive(
path <> ".additionalProperties",
ap_ref,
ctx,
)
Forbidden | Untyped | Unspecified -> []
}
// Recurse into properties
let prop_errors =
dict.to_list(properties)
|> list.flat_map(fn(entry) {
let #(prop_name, prop_ref) = entry
let prop_path = path <> "." <> prop_name
validate_schema_ref_recursive(prop_path, prop_ref, ctx)
})
list.flatten([
ap_errors,
typed_ap_errors,
prop_errors,
])
}
ArraySchema(items:, ..) -> {
validate_schema_ref_recursive(path <> ".items", items, ctx)
}
OneOfSchema(schemas:, ..) ->
list.flat_map(schemas, fn(s_ref) {
validate_schema_ref_recursive(path <> ".oneOf", s_ref, ctx)
})
AnyOfSchema(schemas:, ..) ->
list.flat_map(schemas, fn(s_ref) {
validate_schema_ref_recursive(path <> ".anyOf", s_ref, ctx)
})
AllOfSchema(schemas:, ..) ->
list.flat_map(schemas, fn(s_ref) {
validate_schema_ref_recursive(path <> ".allOf", s_ref, ctx)
})
_ -> []
}
}
/// Validate that all security scheme references in global and operation-level
/// security requirements point to schemes defined in components.securitySchemes.
fn validate_security_schemes(
ctx: Context,
operations: List(context.AnalyzedOperation),
) -> List(Diagnostic) {
let scheme_names = case context.spec(ctx).components {
Some(components) -> dict.keys(components.security_schemes)
None -> []
}
let global_errors =
list.flat_map(context.spec(ctx).security, fn(req) {
list.filter_map(req.schemes, fn(scheme_ref) {
case list.contains(scheme_names, scheme_ref.scheme_name) {
True -> Error(Nil)
False ->
Ok(diagnostic.validation_error_both(
path: "security." <> scheme_ref.scheme_name,
detail: "Security requirement references scheme '"
<> scheme_ref.scheme_name
<> "' which is not defined in components.securitySchemes.",
hint: Some(
"Add the security scheme definition to components.securitySchemes or fix the scheme name.",
),
))
}
})
})
let operation_errors =
list.flat_map(operations, fn(op) {
let #(op_id, operation, _path, _method) = op
case operation.security {
Some(reqs) ->
list.flat_map(reqs, fn(req) {
list.filter_map(req.schemes, fn(scheme_ref) {
case list.contains(scheme_names, scheme_ref.scheme_name) {
True -> Error(Nil)
False ->
Ok(diagnostic.validation_error_both(
path: op_id <> ".security." <> scheme_ref.scheme_name,
detail: "Security requirement references scheme '"
<> scheme_ref.scheme_name
<> "' which is not defined in components.securitySchemes.",
hint: Some(
"Add the security scheme definition to components.securitySchemes or fix the scheme name.",
),
))
}
})
})
None -> []
}
})
list.append(global_errors, operation_errors)
}