Packages
oaspec
0.66.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/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 {
// Query array parameters with non-primitive items take the JSON
// escape hatch on the client side (`<param>=<JSON array string>`,
// the same shape PR #542 introduced for form-urlencoded
// composite fields). The server decoder still requires primitive
// items, so the diagnostic remains gated to server mode.
spec.InQuery, Some(ArraySchema(items:, ..)) ->
case array_items_resolve_primitive(items, ctx) {
True -> []
False ->
case config.mode(context.config(ctx)) {
config.Client -> []
_ -> [
diagnostic.validation_error_server(
path: path,
detail: "Query array parameters are only supported for primitive items (string, integer, number, boolean), whether inline or via $ref, in server code generation.",
hint: Some(
"Switch to mode: client to use the JSON escape hatch, or replace the item schema with a primitive type.",
),
),
]
}
}
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 allowed (encoded
/// as `parent[outer][inner]=value` and modeled as `Dict(String, String)`
/// in the generated client). Composite properties (`oneOf` / `anyOf` /
/// `allOf`) take the JSON escape hatch — `parent[<prop>]=<JSON
/// string>` — the same shape PR #542 introduced for form-urlencoded
/// composite fields, on the client side. The server decoder does
/// not yet handle the JSON-escaped form, so the diagnostic stays
/// gated to server mode.
fn validate_deep_object_no_nested_objects(
path: String,
param: spec.Parameter(Resolved),
ctx: Context,
) -> List(Diagnostic) {
case config.mode(context.config(ctx)) {
config.Client -> []
_ ->
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) {
Some(ObjectSchema(..)) | Some(AllOfSchema(..)) -> []
Some(OneOfSchema(..)) | Some(AnyOfSchema(..)) -> [
diagnostic.validation_error_server(
path: path <> "." <> prop_name,
detail: "Nested oneOf/anyOf properties in deepObject parameters are not supported in server code generation. Only primitive scalars, primitive arrays, and single-level nested objects (Dict(String, String)) are supported.",
hint: Some(
"Switch to mode: client to use the JSON escape hatch, or flatten the property structure.",
),
),
]
_ -> []
}
})
_ -> []
}
}
}
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
// Client codegen accepts every shape this predicate
// green-lights — primitives, primitive arrays at any
// depth, nested objects at any depth, arrays of objects.
// The server decoder is more constrained: it still
// requires shallow primitive-leaf shapes. Until the
// server-side helpers catch up, surface a server-only
// diagnostic for the shapes the client now handles but
// the server doesn't.
let client_safe =
form_urlencoded_field_codegen_safe(field_schema, ctx, 0)
let server_safe =
form_urlencoded_server_field_supported(field_schema, ctx, 0)
case client_safe, server_safe {
True, True -> []
True, False -> [
diagnostic.validation_error_server(
path: op_id <> ".requestBody.form." <> field_name,
detail: "application/x-www-form-urlencoded server 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 yet supported on the server side.",
hint: Some(
"Switch to mode: client for now, or simplify to primitive scalars, primitive arrays at the top level, or shallow nested objects with primitive leaves.",
),
),
]
False, _ -> [
diagnostic.validation_error_both(
path: op_id <> ".requestBody.form." <> field_name,
detail: "application/x-www-form-urlencoded request body field has a shape the codegen does not yet know how to serialise.",
hint: Some(
"Switch this field to a concrete primitive, object, array, or composite schema, or set encoding."
<> field_name
<> ".contentType: application/json to opt the field into the JSON escape hatch.",
),
),
]
}
})
_ -> []
}
// nolint: thrown_away_error -- absence of the content type means there is nothing to validate here
Error(_) -> []
}
list.append(server_mode_errors, field_errors)
}
/// Predicate matching the server-side decode contract today:
/// primitive scalars, primitive arrays at any depth (the server
/// reads them as repeated keys via `dict.get(form_body, key)
/// |> Ok(vs)`), and nested objects up to 5 levels with primitive or
/// primitive-array leaves. Object arrays (`features[0][prop]=v`) and
/// nested arrays-of-arrays still need the indexed-key decoder
/// upgrade.
fn form_urlencoded_server_field_supported(
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:, ..)) ->
case resolve_schema_object(Some(items), ctx) {
Some(StringSchema(..))
| Some(IntegerSchema(..))
| Some(NumberSchema(..))
| Some(BooleanSchema(..)) -> True
_ -> False
}
Some(ObjectSchema(properties:, ..)) if depth < 5 ->
dict.to_list(properties)
|> list.all(fn(entry) {
let #(_, child_schema) = entry
form_urlencoded_server_field_supported(child_schema, ctx, depth + 1)
})
_ -> False
}
}
/// Form-urlencoded shape predicate. Accepts every shape the codegen
/// can render via the bracket-index wire format
/// (`field[a][b][0][c]=v`, Stripe / qs `indices` compatible):
/// primitive scalars, primitive arrays at any depth, nested objects
/// at any depth, and arrays-of-objects (whose items the hoist pass
/// has already promoted to a `Reference`, so the codegen sees a
/// nameable `ObjectSchema` here).
///
/// Composite shapes (`oneOf` / `anyOf` / `allOf`) at the field level
/// also pass: they take the JSON-escape-hatch branch in the client
/// emitter (`field=<percent-encoded JSON string>`), the same shape
/// `encoding.<field>.contentType: application/json` would request
/// explicitly. Without this branch real-world specs (Stripe, GitHub)
/// would validate-reject for ~hundreds of fields with no spec change
/// the user can make.
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:, ..)) ->
form_urlencoded_array_items_codegen_safe(items, ctx)
Some(ObjectSchema(properties:, ..)) ->
dict.to_list(properties)
|> list.all(fn(entry) {
let #(_, child_schema) = entry
form_urlencoded_field_codegen_safe(child_schema, ctx, 0)
})
Some(OneOfSchema(..)) | Some(AnyOfSchema(..)) | Some(AllOfSchema(..)) ->
True
_ -> False
}
}
/// Items of an array inside a form-urlencoded body may be a primitive
/// scalar (rendered as `key[i]=v`), a primitive array (rendered as
/// `key[i][j]=v`), or an object whose properties recurse through the
/// same predicate (rendered as `key[i][prop]=v`).
///
/// Composites at the item level (`items: { anyOf: ... }`, common in
/// the Stripe spec on `documents.<kind>.files.items`) escalate the
/// whole containing field to the JSON escape hatch, just like a
/// composite at the field level would. They pass the predicate so
/// the field is not validate-rejected; the client emitter recognises
/// the composite-via-items shape and emits a single JSON-encoded
/// form value for the entire field.
fn form_urlencoded_array_items_codegen_safe(
items: SchemaRef,
ctx: Context,
) -> Bool {
case resolve_schema_object(Some(items), ctx) {
Some(StringSchema(..))
| Some(IntegerSchema(..))
| Some(NumberSchema(..))
| Some(BooleanSchema(..)) -> True
Some(ArraySchema(items: inner_items, ..)) ->
form_urlencoded_array_items_codegen_safe(inner_items, ctx)
Some(ObjectSchema(properties:, ..)) ->
dict.to_list(properties)
|> list.all(fn(entry) {
let #(_, child_schema) = entry
form_urlencoded_field_codegen_safe(child_schema, ctx, 0)
})
Some(OneOfSchema(..)) | Some(AnyOfSchema(..)) | Some(AllOfSchema(..)) ->
True
_ -> 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 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)
let response_path =
op_id <> ".responses." <> http.status_code_to_string(status_code)
let header_errors = validate_response_headers(response_path, response)
let content_errors =
list.flat_map(content_entries, fn(ce) {
let #(media_type_name, media_type) = ce
let path = response_path
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)
})
list.append(header_errors, content_errors)
})
}
// Issue #552: codegen's response-header extractor (`classify_header_schema`
// in `client_response.gleam`) only knows how to emit code for inline
// String / Int / Float / Bool. Composite shapes ($ref, object, array,
// allOf, oneOf, anyOf) used to panic in codegen, taking the whole
// process down on a syntactically valid spec. Catch those shapes here
// at validate time and surface a Diagnostic instead — the codegen
// path now defensively panics with an "unreachable" message that
// should never actually fire if validation ran.
fn validate_response_headers(
response_path: String,
response: spec.Response(Resolved),
) -> List(Diagnostic) {
dict.to_list(response.headers)
|> list.flat_map(fn(entry) {
let #(header_name, header) = entry
let header_path = response_path <> ".headers." <> header_name
case unsupported_header_schema_kind(header.schema) {
None -> []
Some(kind) -> [
diagnostic.validation_error_both(
path: header_path,
detail: "Response header '"
<> header_name
<> "' has an unsupported schema for the client extractor: "
<> kind
<> ". oaspec can only emit typed extraction for inline String, Integer, Number, and Boolean schemas today.",
hint: Some(
"Replace the header schema with an inline { type: string }, { type: integer }, { type: number }, or { type: boolean }, or remove the header from the spec if it is not part of the typed contract. Track issue #387 if you need typed extraction for this shape.",
),
),
]
}
})
}
fn unsupported_header_schema_kind(
schema_opt: Option(SchemaRef),
) -> Option(String) {
case schema_opt {
None -> None
Some(Inline(StringSchema(..))) -> None
Some(Inline(IntegerSchema(..))) -> None
Some(Inline(NumberSchema(..))) -> None
Some(Inline(BooleanSchema(..))) -> None
Some(Reference(name:, ..)) ->
Some("$ref to component schema '" <> name <> "'")
Some(Inline(ObjectSchema(..))) -> Some("inline object schema")
Some(Inline(ArraySchema(..))) -> Some("inline array schema")
Some(Inline(AllOfSchema(..))) -> Some("allOf composition")
Some(Inline(OneOfSchema(..))) -> Some("oneOf composition")
Some(Inline(AnyOfSchema(..))) -> Some("anyOf composition")
}
}
/// 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)
}