Current section

Files

Jump to
oaspec src oaspec internal codegen validate.gleam
Raw

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)
}