Current section

Files

Jump to
oaspec src oaspec internal codegen guards.gleam
Raw

src/oaspec/internal/codegen/guards.gleam

import gleam/bool
import gleam/dict
import gleam/float
import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/order
import gleam/set.{type Set}
import gleam/string
import oaspec/config
import oaspec/internal/codegen/allof_merge
import oaspec/internal/codegen/context.{
type Context, type GeneratedFile, GeneratedFile,
}
import oaspec/internal/codegen/ir_build
import oaspec/internal/codegen/schema_dispatch
import oaspec/internal/openapi/schema.{
type SchemaObject, type SchemaRef, AllOfSchema, ArraySchema, Inline,
IntegerSchema, NumberSchema, ObjectSchema, Reference, StringSchema,
}
import oaspec/internal/util/naming
import oaspec/internal/util/string_extra as se
pub type GuardFunctionKind {
FieldValidator
DelegatingFieldValidator(canonical_name: String)
CompositeValidator
}
pub type GuardFunction {
GuardFunction(
name: String,
docs: List(String),
param_decl: String,
return_type: String,
body: String,
kind: GuardFunctionKind,
)
}
pub type GuardModule {
GuardModule(imports: List(String), functions: List(GuardFunction))
}
/// Check whether a named component schema has a composite validator.
/// Used by server/client generators to decide whether to emit guard calls.
pub fn schema_has_validator(name: String, ctx: Context) -> Bool {
case context.spec(ctx).components {
Some(components) ->
case dict.get(components.schemas, name) {
Ok(schema_ref) ->
!ir_build.is_internal_schema(schema_ref)
&& !list.is_empty(collect_guard_calls(name, schema_ref, ctx))
// nolint: thrown_away_error -- unknown schema name simply has no validator
Error(_) -> False
}
None -> False
}
}
/// Generate guard/validation functions from OpenAPI schemas that have constraints.
pub fn generate(ctx: Context) -> List(GeneratedFile) {
let module = build_module(ctx)
case list.is_empty(module.functions) {
True -> []
False -> [
GeneratedFile(
path: "guards.gleam",
content: render_module(module),
target: context.SharedTarget,
write_mode: context.Overwrite,
),
]
}
}
/// Build the structured guard module before rendering.
pub fn build_module(ctx: Context) -> GuardModule {
let schemas = case context.spec(ctx).components {
Some(components) ->
list.sort(dict.to_list(components.schemas), fn(a, b) {
string.compare(a.0, b.0)
})
|> list.filter(fn(entry) { !ir_build.is_internal_schema(entry.1) })
None -> []
}
// Determine which imports are needed based on constraint types present.
// Generated guard functions use string/list.length for validation;
// constraint values (min/max) are baked as literals at generation time,
// so gleam/int and gleam/float are NOT needed in the generated output.
// gleam/json is always imported because the ValidationFailure encoder
// emitted below uses it.
let constraint_types = collect_constraint_types(schemas, ctx)
let imports = ["gleam/json"]
let imports = case constraint_types.has_string {
True -> ["gleam/string", ..imports]
False -> imports
}
let imports = case constraint_types.has_regexp {
True -> ["gleam/regexp", ..imports]
False -> imports
}
let imports = case constraint_types.has_list {
True -> ["gleam/list", ..imports]
False -> imports
}
let imports = case constraint_types.has_dict {
True -> ["gleam/dict.{type Dict}", ..imports]
False -> imports
}
let imports = case constraint_types.has_float_multiple_of {
True -> ["gleam/int", "gleam/float", ..imports]
False -> imports
}
// Import types module when composite validators reference named types
let needs_types =
list.any(schemas, fn(entry) {
let #(name, schema_ref) = entry
let guard_calls = collect_guard_calls(name, schema_ref, ctx)
case list.is_empty(guard_calls) {
True -> False
False -> {
let resolved = context.resolve_schema_ref(schema_ref, ctx)
case resolved {
Ok(ObjectSchema(..)) | Ok(AllOfSchema(..)) -> True
_ -> False
}
}
}
})
let imports = case needs_types {
True -> [config.package(context.config(ctx)) <> "/types", ..imports]
False -> imports
}
// Import option module when composite validators handle optional fields
let needs_option =
list.any(schemas, fn(entry) {
let #(name, schema_ref) = entry
let guard_calls = collect_guard_calls(name, schema_ref, ctx)
list.any(guard_calls, fn(call) {
let #(_, _, is_required) = call
!is_required
})
})
let imports = case needs_option {
True -> ["gleam/option", ..imports]
False -> imports
}
let field_validators =
list.flat_map(schemas, fn(entry) {
let #(name, schema_ref) = entry
collect_guard_functions_for_schema(name, schema_ref, ctx)
})
|> dedupe_guard_functions()
let composite_validators =
list.flat_map(schemas, fn(entry) {
let #(name, schema_ref) = entry
maybe_one(build_composite_guard_function(name, schema_ref, ctx))
})
GuardModule(
imports: imports,
functions: list.append(field_validators, composite_validators),
)
}
/// Render the structured guard module to Gleam source.
fn render_module(module: GuardModule) -> String {
let sb =
se.file_header(context.version)
|> se.imports(module.imports)
|> emit_validation_failure_type()
list.fold(module.functions, sb, render_guard_function)
|> se.to_string()
}
fn render_guard_function(
sb: se.StringBuilder,
function: GuardFunction,
) -> se.StringBuilder {
let sb =
list.fold(function.docs, sb, fn(sb, doc) { sb |> se.line("/// " <> doc) })
let sb =
sb
|> se.line(
"pub fn "
<> function.name
<> "("
<> function.param_decl
<> ") -> "
<> function.return_type
<> " {",
)
let sb =
function.body
|> string.split(on: "\n")
|> list.fold(sb, fn(sb, line) { sb |> se.line(line) })
sb
|> se.line("}")
|> se.blank_line()
}
fn dedupe_guard_functions(functions: List(GuardFunction)) -> List(GuardFunction) {
let canonical_by_key =
list.fold(functions, dict.new(), fn(acc, function) {
case function.kind {
FieldValidator ->
case dict.get(acc, dedupe_key(function)) {
Error(Nil) -> dict.insert(acc, dedupe_key(function), function.name)
Ok(existing) ->
case string.compare(function.name, existing) {
order.Lt ->
dict.insert(acc, dedupe_key(function), function.name)
_ -> acc
}
}
_ -> acc
}
})
list.map(functions, fn(function) {
case function.kind {
FieldValidator ->
case dict.get(canonical_by_key, dedupe_key(function)) {
Ok(canonical_name) ->
case canonical_name == function.name {
True -> function
False -> emit_guard_delegator(function, canonical_name)
}
Error(Nil) -> function
}
_ -> function
}
})
}
fn dedupe_key(function: GuardFunction) -> String {
function.param_decl <> "->" <> function.return_type <> "{\n" <> function.body
}
fn emit_guard_delegator(
function: GuardFunction,
canonical_name: String,
) -> GuardFunction {
GuardFunction(
..function,
body: " " <> canonical_name <> "(value)",
kind: DelegatingFieldValidator(canonical_name),
)
}
fn collect_guard_functions_for_schema(
name: String,
schema_ref: SchemaRef,
ctx: Context,
) -> List(GuardFunction) {
case schema_ref {
Inline(schema) ->
collect_guard_functions_for_schema_object(name, schema, ctx)
Reference(name:, ..) ->
case context.resolve_schema_ref(schema_ref, ctx) {
Ok(schema) ->
collect_guard_functions_for_schema_object(name, schema, ctx)
_ -> []
}
}
}
fn collect_guard_functions_for_schema_object(
name: String,
schema: SchemaObject,
ctx: Context,
) -> List(GuardFunction) {
case schema {
ObjectSchema(properties:, min_properties:, max_properties:, ..) ->
list.append(
maybe_one(build_properties_count_guard_function(
name,
"",
min_properties,
max_properties,
)),
ir_build.sorted_entries(properties)
|> list.flat_map(fn(entry) {
let #(prop_name, prop_ref) = entry
collect_field_guard_functions(name, prop_name, prop_ref, ctx)
}),
)
AllOfSchema(schemas:, ..) ->
list.append(
[],
ir_build.sorted_entries(
allof_merge.merge_allof_schemas(schemas, ctx).properties,
)
|> list.flat_map(fn(entry) {
let #(prop_name, prop_ref) = entry
collect_field_guard_functions(name, prop_name, prop_ref, ctx)
}),
)
StringSchema(min_length:, max_length:, pattern:, ..) ->
list.flatten([
maybe_one(build_string_guard_function(name, "", min_length, max_length)),
maybe_one(build_string_pattern_guard_function(name, "", pattern)),
])
IntegerSchema(
minimum:,
maximum:,
exclusive_minimum:,
exclusive_maximum:,
multiple_of:,
..,
) ->
list.flatten([
maybe_one(build_integer_guard_function(name, "", minimum, maximum)),
maybe_one(build_integer_exclusive_guard_function(
name,
"",
exclusive_minimum,
exclusive_maximum,
)),
maybe_one(build_integer_multiple_of_guard_function(
name,
"",
multiple_of,
)),
])
NumberSchema(
minimum:,
maximum:,
exclusive_minimum:,
exclusive_maximum:,
multiple_of:,
..,
) ->
list.flatten([
maybe_one(build_float_guard_function(name, "", minimum, maximum)),
maybe_one(build_float_exclusive_guard_function(
name,
"",
exclusive_minimum,
exclusive_maximum,
)),
maybe_one(build_float_multiple_of_guard_function(name, "", multiple_of)),
])
ArraySchema(min_items:, max_items:, unique_items:, ..) ->
list.flatten([
maybe_one(build_list_guard_function(name, "", min_items, max_items)),
maybe_one(build_unique_items_guard_function(name, "", unique_items)),
])
_ -> []
}
}
fn collect_field_guard_functions(
schema_name: String,
prop_name: String,
prop_ref: SchemaRef,
ctx: Context,
) -> List(GuardFunction) {
let resolved = context.resolve_schema_ref(prop_ref, ctx)
case resolved {
Ok(StringSchema(min_length:, max_length:, pattern:, ..)) ->
list.flatten([
maybe_one(build_string_guard_function(
schema_name,
prop_name,
min_length,
max_length,
)),
maybe_one(build_string_pattern_guard_function(
schema_name,
prop_name,
pattern,
)),
])
Ok(IntegerSchema(
minimum:,
maximum:,
exclusive_minimum:,
exclusive_maximum:,
multiple_of:,
..,
)) ->
list.flatten([
maybe_one(build_integer_guard_function(
schema_name,
prop_name,
minimum,
maximum,
)),
maybe_one(build_integer_exclusive_guard_function(
schema_name,
prop_name,
exclusive_minimum,
exclusive_maximum,
)),
maybe_one(build_integer_multiple_of_guard_function(
schema_name,
prop_name,
multiple_of,
)),
])
Ok(NumberSchema(
minimum:,
maximum:,
exclusive_minimum:,
exclusive_maximum:,
multiple_of:,
..,
)) ->
list.flatten([
maybe_one(build_float_guard_function(
schema_name,
prop_name,
minimum,
maximum,
)),
maybe_one(build_float_exclusive_guard_function(
schema_name,
prop_name,
exclusive_minimum,
exclusive_maximum,
)),
maybe_one(build_float_multiple_of_guard_function(
schema_name,
prop_name,
multiple_of,
)),
])
Ok(ArraySchema(min_items:, max_items:, unique_items:, ..)) ->
list.flatten([
maybe_one(build_list_guard_function(
schema_name,
prop_name,
min_items,
max_items,
)),
maybe_one(build_unique_items_guard_function(
schema_name,
prop_name,
unique_items,
)),
])
_ -> []
}
}
fn maybe_one(value: Option(a)) -> List(a) {
case value {
Some(v) -> [v]
None -> []
}
}
// ---------------------------------------------------------------------------
// #339: per-field validator de-duplication across allOf children.
// ---------------------------------------------------------------------------
/// Post-process a generated `guards.gleam` source string and collapse
/// per-field validator definitions whose bodies are byte-identical
/// (the case that arises when an allOf parent's constrained property
/// is flattened into multiple children). One canonical definition is
/// kept; the others are rewritten into one-line stubs that forward to
/// the canonical one. The composite validators continue to call the
/// per-field validators by the original names, so call-site code is
/// unchanged.
/// Track which constraint types exist in the schema set.
type ConstraintTypes {
ConstraintTypes(
has_string: Bool,
has_regexp: Bool,
has_integer: Bool,
has_float: Bool,
has_list: Bool,
has_float_multiple_of: Bool,
has_dict: Bool,
)
}
/// Scan all schemas to find which constraint types are present.
fn collect_constraint_types(
schemas: List(#(String, SchemaRef)),
ctx: Context,
) -> ConstraintTypes {
list.fold(
schemas,
ConstraintTypes(False, False, False, False, False, False, False),
fn(acc, entry) {
let #(_name, schema_ref) = entry
collect_schema_constraint_types(acc, schema_ref, ctx, set.new())
},
)
}
/// Collect constraint types from a single schema ref.
/// Issue #297: `seen` tracks visited $ref names to break circular references.
fn collect_schema_constraint_types(
acc: ConstraintTypes,
schema_ref: SchemaRef,
ctx: Context,
seen: Set(String),
) -> ConstraintTypes {
// Short-circuit on circular $ref to prevent infinite recursion.
case schema_ref {
Reference(name:, ..) ->
case set.contains(seen, name) {
True -> acc
False ->
collect_schema_constraint_types_inner(
acc,
schema_ref,
ctx,
set.insert(seen, name),
)
}
_ -> collect_schema_constraint_types_inner(acc, schema_ref, ctx, seen)
}
}
fn collect_schema_constraint_types_inner(
acc: ConstraintTypes,
schema_ref: SchemaRef,
ctx: Context,
seen: Set(String),
) -> ConstraintTypes {
let schema = context.resolve_schema_ref(schema_ref, ctx)
case schema {
Ok(StringSchema(min_length:, max_length:, pattern:, ..)) -> {
let acc = case min_length, max_length {
None, None -> acc
_, _ -> ConstraintTypes(..acc, has_string: True)
}
case pattern {
Some(_) -> ConstraintTypes(..acc, has_regexp: True)
None -> acc
}
}
Ok(IntegerSchema(minimum: Some(_), ..))
| Ok(IntegerSchema(maximum: Some(_), ..))
| Ok(IntegerSchema(exclusive_minimum: Some(_), ..))
| Ok(IntegerSchema(exclusive_maximum: Some(_), ..))
| Ok(IntegerSchema(multiple_of: Some(_), ..)) ->
ConstraintTypes(..acc, has_integer: True)
Ok(NumberSchema(minimum: Some(_), ..))
| Ok(NumberSchema(maximum: Some(_), ..))
| Ok(NumberSchema(exclusive_minimum: Some(_), ..))
| Ok(NumberSchema(exclusive_maximum: Some(_), ..)) ->
ConstraintTypes(..acc, has_float: True)
Ok(NumberSchema(multiple_of: Some(_), ..)) ->
ConstraintTypes(..acc, has_float: True, has_float_multiple_of: True)
Ok(ArraySchema(min_items: Some(_), ..))
| Ok(ArraySchema(max_items: Some(_), ..))
| Ok(ArraySchema(unique_items: True, ..)) ->
ConstraintTypes(..acc, has_list: True)
Ok(ObjectSchema(properties:, min_properties:, max_properties:, ..)) -> {
let acc = case min_properties, max_properties {
None, None -> acc
_, _ -> ConstraintTypes(..acc, has_dict: True)
}
dict.to_list(properties)
|> list.fold(acc, fn(a, prop) {
let #(_, prop_ref) = prop
collect_schema_constraint_types(a, prop_ref, ctx, seen)
})
}
Ok(AllOfSchema(schemas:, ..)) ->
list.fold(schemas, acc, fn(a, s) {
collect_schema_constraint_types(a, s, ctx, seen)
})
_ -> acc
}
}
/// Build the guard function name from schema name, property name, and constraint type.
fn guard_function_name(
schema_name: String,
prop_name: String,
constraint: String,
) -> String {
let base = naming.to_snake_case(schema_name)
case prop_name {
"" -> "validate_" <> base <> "_" <> constraint
_ ->
"validate_"
<> base
<> "_"
<> naming.to_snake_case(prop_name)
<> "_"
<> constraint
}
}
/// Format a field label for documentation.
fn field_label(prop_name: String) -> String {
case prop_name {
"" -> ""
_ -> "." <> prop_name
}
}
/// Render a runtime string literal for generated Gleam source.
fn gleam_string_literal(value: String) -> String {
let escaped =
value
|> string.replace("\\", "\\\\")
|> string.replace("\"", "\\\"")
|> string.replace("\n", "\\n")
|> string.replace("\r", "\\r")
|> string.replace("\t", "\\t")
"\"" <> escaped <> "\""
}
/// Emit the `ValidationFailure` type and its JSON encoder. Always
/// generated when guards.gleam is generated (i.e. whenever any schema
/// has constraints), so that routers and clients can rely on the
/// structured shape.
fn emit_validation_failure_type(sb: se.StringBuilder) -> se.StringBuilder {
sb
|> se.doc_comment("A single field-level validation failure.")
|> se.doc_comment(
"Composite validators return `List(ValidationFailure)` so callers can build structured 422 bodies and clients can branch per-field instead of parsing prose messages. `field` is the JSON property name (empty for top-level constraints), `code` is a JSON Schema keyword like `minLength` / `maximum` / `pattern`, and `message` is human-readable.",
)
|> se.line("pub type ValidationFailure {")
|> se.indent(
1,
"ValidationFailure(field: String, code: String, message: String)",
)
|> se.line("}")
|> se.blank_line()
|> se.doc_comment(
"Encode a `ValidationFailure` as JSON for emitting 422 response bodies.",
)
|> se.line(
"pub fn validation_failure_to_json(failure: ValidationFailure) -> json.Json {",
)
|> se.indent(1, "json.object([")
|> se.indent(2, "#(\"field\", json.string(failure.field)),")
|> se.indent(2, "#(\"code\", json.string(failure.code)),")
|> se.indent(2, "#(\"message\", json.string(failure.message)),")
|> se.indent(1, "])")
|> se.line("}")
|> se.blank_line()
}
/// Build a Gleam source expression that constructs a
/// `Error(ValidationFailure(...))` with the given field / code / message.
fn validation_failure_literal(
field: String,
code: String,
message: String,
) -> String {
"Error(ValidationFailure(field: "
<> gleam_string_literal(field)
<> ", code: "
<> gleam_string_literal(code)
<> ", message: "
<> gleam_string_literal(message)
<> "))"
}
/// Like `validation_failure_literal` but the `message` is an arbitrary
/// Gleam source expression evaluated at runtime (e.g. a string-concat
/// pulling the regex compile error). Caller is responsible for the
/// expression already being valid Gleam source.
fn validation_failure_dynamic(
field: String,
code: String,
message_expr: String,
) -> String {
"Error(ValidationFailure(field: "
<> gleam_string_literal(field)
<> ", code: "
<> gleam_string_literal(code)
<> ", message: "
<> message_expr
<> "))"
}
fn build_composite_guard_function(
name: String,
schema_ref: SchemaRef,
ctx: Context,
) -> Option(GuardFunction) {
let guard_calls = collect_guard_calls(name, schema_ref, ctx)
case list.is_empty(guard_calls) {
True -> None
False -> {
let fn_name = "validate_" <> naming.to_snake_case(name)
let type_name = naming.schema_to_type_name(name)
let gleam_type = composite_validator_type(name, schema_ref, ctx)
let call_lines =
list.flat_map(guard_calls, fn(call) {
let #(guard_fn, accessor, is_required) = call
case is_required {
True -> [
" let errors = case " <> guard_fn <> "(" <> accessor <> ") {",
" Ok(_) -> errors",
" Error(failure) -> [failure, ..errors]",
" }",
]
False -> [
" let errors = case " <> accessor <> " {",
" option.Some(v) -> case " <> guard_fn <> "(v) {",
" Ok(_) -> errors",
" Error(failure) -> [failure, ..errors]",
" }",
" option.None -> errors",
" }",
]
}
})
Some(GuardFunction(
name: fn_name,
docs: [
"Validate all constraints for " <> type_name <> ".",
"Auto-calls all field validators and collects failures.",
],
param_decl: "value: " <> gleam_type,
return_type: "Result(" <> gleam_type <> ", List(ValidationFailure))",
body: string.join(
list.flatten([
[" let errors = []"],
call_lines,
[
" case errors {",
" [] -> Ok(value)",
" _ -> Error(errors)",
" }",
],
]),
"\n",
),
kind: CompositeValidator,
))
}
}
}
fn build_string_pattern_guard_function(
schema_name: String,
prop_name: String,
pattern: Option(String),
) -> Option(GuardFunction) {
case pattern {
None -> None
Some(pattern) -> {
let fn_name = guard_function_name(schema_name, prop_name, "pattern")
let pattern_literal = gleam_string_literal(pattern)
let invalid_pattern_prefix =
gleam_string_literal("invalid pattern: " <> pattern <> ": ")
let mismatch_failure =
validation_failure_literal(
prop_name,
"pattern",
"must match pattern: " <> pattern,
)
let invalid_pattern_failure =
validation_failure_dynamic(
prop_name,
"invalidPattern",
invalid_pattern_prefix <> " <> error",
)
Some(GuardFunction(
name: fn_name,
docs: [
"Validate string pattern for "
<> schema_name
<> field_label(prop_name)
<> ".",
],
param_decl: "value: String",
return_type: "Result(String, ValidationFailure)",
body: string.join(
[
" case regexp.from_string(" <> pattern_literal <> ") {",
" Ok(re) -> case regexp.check(re, value) {",
" True -> Ok(value)",
" False -> " <> mismatch_failure,
" }",
" Error(regexp.CompileError(error:, ..)) -> "
<> invalid_pattern_failure,
" }",
],
"\n",
),
kind: FieldValidator,
))
}
}
}
fn character_word(n: Int) -> String {
case n {
1 -> "character"
_ -> "characters"
}
}
// Issue #403: shared range-guard skeleton. The 7 build_*_guard_function
// helpers below differ only in: function-name suffix, docs phrase,
// param / return type, the value-extraction prelude (e.g.
// `let len = string.length(value)`), the variable that gets compared,
// the comparison operator and value-to-string conversion, and the
// failure keyword / phrase. RangeGuardSpec collects every per-call
// difference so the case-block emission lives in exactly one place.
type RangeBound {
RangeBound(
/// Operator placed between `compare_var` and `value_str`. The
/// emitted shape is always `True -> failure / False -> Ok(value)`,
/// so callers express "this is a failure when …" via the operator:
/// inclusive guards use `<` / `>` (and `<.` / `>.` for floats),
/// exclusive guards use `<=` / `>=` (and `<=.` / `>=.`).
operator: String,
value_str: String,
keyword: String,
phrase: String,
)
}
type RangeGuardSpec {
RangeGuardSpec(
name_suffix: String,
doc_what: String,
param_decl: String,
return_type: String,
/// Lines emitted before the case block (e.g.
/// `let len = string.length(value)`). Empty for guards that
/// compare `value` directly.
value_prelude: List(String),
compare_var: String,
min: Option(RangeBound),
max: Option(RangeBound),
)
}
fn build_range_guard(
schema_name: String,
prop_name: String,
spec: RangeGuardSpec,
) -> Option(GuardFunction) {
case spec.min, spec.max {
None, None -> None
_, _ -> {
let fn_name =
guard_function_name(schema_name, prop_name, spec.name_suffix)
let lines =
list.append(
spec.value_prelude,
range_check_lines(spec.compare_var, prop_name, spec.min, spec.max),
)
Some(GuardFunction(
name: fn_name,
docs: [
"Validate "
<> spec.doc_what
<> " for "
<> schema_name
<> field_label(prop_name)
<> ".",
],
param_decl: spec.param_decl,
return_type: spec.return_type,
body: string.join(lines, "\n"),
kind: FieldValidator,
))
}
}
}
fn range_check_lines(
compare_var: String,
prop_name: String,
min: Option(RangeBound),
max: Option(RangeBound),
) -> List(String) {
case min, max {
Some(lo), Some(hi) -> [
" case "
<> compare_var
<> " "
<> lo.operator
<> " "
<> lo.value_str
<> " {",
" True -> "
<> validation_failure_literal(prop_name, lo.keyword, lo.phrase),
" False ->",
" case "
<> compare_var
<> " "
<> hi.operator
<> " "
<> hi.value_str
<> " {",
" True -> "
<> validation_failure_literal(prop_name, hi.keyword, hi.phrase),
" False -> Ok(value)",
" }",
" }",
]
Some(lo), None -> [
" case "
<> compare_var
<> " "
<> lo.operator
<> " "
<> lo.value_str
<> " {",
" True -> "
<> validation_failure_literal(prop_name, lo.keyword, lo.phrase),
" False -> Ok(value)",
" }",
]
None, Some(hi) -> [
" case "
<> compare_var
<> " "
<> hi.operator
<> " "
<> hi.value_str
<> " {",
" True -> "
<> validation_failure_literal(prop_name, hi.keyword, hi.phrase),
" False -> Ok(value)",
" }",
]
None, None -> []
}
}
fn build_string_guard_function(
schema_name: String,
prop_name: String,
min_length: Option(Int),
max_length: Option(Int),
) -> Option(GuardFunction) {
build_range_guard(
schema_name,
prop_name,
RangeGuardSpec(
name_suffix: "length",
doc_what: "string length",
param_decl: "value: String",
return_type: "Result(String, ValidationFailure)",
value_prelude: [" let len = string.length(value)"],
compare_var: "len",
min: option.map(min_length, fn(n) {
RangeBound(
operator: "<",
value_str: int.to_string(n),
keyword: "minLength",
phrase: "must be at least "
<> int.to_string(n)
<> " "
<> character_word(n),
)
}),
max: option.map(max_length, fn(n) {
RangeBound(
operator: ">",
value_str: int.to_string(n),
keyword: "maxLength",
phrase: "must be at most "
<> int.to_string(n)
<> " "
<> character_word(n),
)
}),
),
)
}
fn build_integer_guard_function(
schema_name: String,
prop_name: String,
minimum: Option(Int),
maximum: Option(Int),
) -> Option(GuardFunction) {
build_range_guard(
schema_name,
prop_name,
RangeGuardSpec(
name_suffix: "range",
doc_what: "integer range",
param_decl: "value: Int",
return_type: "Result(Int, ValidationFailure)",
value_prelude: [],
compare_var: "value",
min: option.map(minimum, fn(n) {
RangeBound(
operator: "<",
value_str: int.to_string(n),
keyword: "minimum",
phrase: "must be at least " <> int.to_string(n),
)
}),
max: option.map(maximum, fn(n) {
RangeBound(
operator: ">",
value_str: int.to_string(n),
keyword: "maximum",
phrase: "must be at most " <> int.to_string(n),
)
}),
),
)
}
fn build_float_guard_function(
schema_name: String,
prop_name: String,
minimum: Option(Float),
maximum: Option(Float),
) -> Option(GuardFunction) {
build_range_guard(
schema_name,
prop_name,
RangeGuardSpec(
name_suffix: "range",
doc_what: "float range",
param_decl: "value: Float",
return_type: "Result(Float, ValidationFailure)",
value_prelude: [],
compare_var: "value",
min: option.map(minimum, fn(n) {
RangeBound(
operator: "<.",
value_str: float.to_string(n),
keyword: "minimum",
phrase: "must be at least " <> float.to_string(n),
)
}),
max: option.map(maximum, fn(n) {
RangeBound(
operator: ">.",
value_str: float.to_string(n),
keyword: "maximum",
phrase: "must be at most " <> float.to_string(n),
)
}),
),
)
}
fn build_integer_exclusive_guard_function(
schema_name: String,
prop_name: String,
exclusive_minimum: Option(Int),
exclusive_maximum: Option(Int),
) -> Option(GuardFunction) {
// Exclusive bounds are expressed via `<=` / `>=` so the same
// `True -> failure / False -> Ok(value)` shape applies.
build_range_guard(
schema_name,
prop_name,
RangeGuardSpec(
name_suffix: "exclusive_range",
doc_what: "integer exclusive range",
param_decl: "value: Int",
return_type: "Result(Int, ValidationFailure)",
value_prelude: [],
compare_var: "value",
min: option.map(exclusive_minimum, fn(n) {
RangeBound(
operator: "<=",
value_str: int.to_string(n),
keyword: "exclusiveMinimum",
phrase: "must be greater than " <> int.to_string(n),
)
}),
max: option.map(exclusive_maximum, fn(n) {
RangeBound(
operator: ">=",
value_str: int.to_string(n),
keyword: "exclusiveMaximum",
phrase: "must be less than " <> int.to_string(n),
)
}),
),
)
}
fn build_integer_multiple_of_guard_function(
schema_name: String,
prop_name: String,
multiple_of: Option(Int),
) -> Option(GuardFunction) {
case multiple_of {
None -> None
Some(m) ->
Some(GuardFunction(
name: guard_function_name(schema_name, prop_name, "multiple_of"),
docs: [
"Validate integer multipleOf for "
<> schema_name
<> field_label(prop_name)
<> ".",
],
param_decl: "value: Int",
return_type: "Result(Int, ValidationFailure)",
body: string.join(
[
" case value % " <> int.to_string(m) <> " == 0 {",
" False -> "
<> validation_failure_literal(
prop_name,
"multipleOf",
"must be a multiple of " <> int.to_string(m),
),
" True -> Ok(value)",
" }",
],
"\n",
),
kind: FieldValidator,
))
}
}
fn build_float_exclusive_guard_function(
schema_name: String,
prop_name: String,
exclusive_minimum: Option(Float),
exclusive_maximum: Option(Float),
) -> Option(GuardFunction) {
build_range_guard(
schema_name,
prop_name,
RangeGuardSpec(
name_suffix: "exclusive_range",
doc_what: "float exclusive range",
param_decl: "value: Float",
return_type: "Result(Float, ValidationFailure)",
value_prelude: [],
compare_var: "value",
min: option.map(exclusive_minimum, fn(n) {
RangeBound(
operator: "<=.",
value_str: float.to_string(n),
keyword: "exclusiveMinimum",
phrase: "must be greater than " <> float.to_string(n),
)
}),
max: option.map(exclusive_maximum, fn(n) {
RangeBound(
operator: ">=.",
value_str: float.to_string(n),
keyword: "exclusiveMaximum",
phrase: "must be less than " <> float.to_string(n),
)
}),
),
)
}
fn build_float_multiple_of_guard_function(
schema_name: String,
prop_name: String,
multiple_of: Option(Float),
) -> Option(GuardFunction) {
case multiple_of {
None -> None
Some(m) ->
Some(GuardFunction(
name: guard_function_name(schema_name, prop_name, "multiple_of"),
docs: [
"Validate float multipleOf for "
<> schema_name
<> field_label(prop_name)
<> ".",
],
param_decl: "value: Float",
return_type: "Result(Float, ValidationFailure)",
body: string.join(
[
" let remainder = value -. float.truncate(value /. "
<> float.to_string(m)
<> " |> int.to_float) *. "
<> float.to_string(m),
" case remainder == 0.0 || remainder == -0.0 {",
" False -> "
<> validation_failure_literal(
prop_name,
"multipleOf",
"must be a multiple of " <> float.to_string(m),
),
" True -> Ok(value)",
" }",
],
"\n",
),
kind: FieldValidator,
))
}
}
fn build_list_guard_function(
schema_name: String,
prop_name: String,
min_items: Option(Int),
max_items: Option(Int),
) -> Option(GuardFunction) {
build_range_guard(
schema_name,
prop_name,
RangeGuardSpec(
name_suffix: "length",
doc_what: "list length",
param_decl: "value: List(a)",
return_type: "Result(List(a), ValidationFailure)",
value_prelude: [" let len = list.length(value)"],
compare_var: "len",
min: option.map(min_items, fn(n) {
RangeBound(
operator: "<",
value_str: int.to_string(n),
keyword: "minItems",
phrase: "must have at least " <> int.to_string(n) <> " items",
)
}),
max: option.map(max_items, fn(n) {
RangeBound(
operator: ">",
value_str: int.to_string(n),
keyword: "maxItems",
phrase: "must have at most " <> int.to_string(n) <> " items",
)
}),
),
)
}
fn build_unique_items_guard_function(
schema_name: String,
prop_name: String,
unique_items: Bool,
) -> Option(GuardFunction) {
use <- bool.guard(when: !unique_items, return: None)
Some(GuardFunction(
name: guard_function_name(schema_name, prop_name, "unique"),
docs: [
"Validate unique items for "
<> schema_name
<> field_label(prop_name)
<> ".",
],
param_decl: "value: List(a)",
return_type: "Result(List(a), ValidationFailure)",
body: string.join(
[
" case list.length(value) == list.length(list.unique(value)) {",
" True -> Ok(value)",
" False -> "
<> validation_failure_literal(
prop_name,
"uniqueItems",
"items must be unique",
),
" }",
],
"\n",
),
kind: FieldValidator,
))
}
fn build_properties_count_guard_function(
schema_name: String,
prop_name: String,
min_properties: Option(Int),
max_properties: Option(Int),
) -> Option(GuardFunction) {
build_range_guard(
schema_name,
prop_name,
RangeGuardSpec(
name_suffix: "properties",
doc_what: "property count",
param_decl: "value: Dict(k, v)",
return_type: "Result(Dict(k, v), ValidationFailure)",
value_prelude: [" let count = dict.size(value)"],
compare_var: "count",
min: option.map(min_properties, fn(n) {
RangeBound(
operator: "<",
value_str: int.to_string(n),
keyword: "minProperties",
phrase: "must have at least " <> int.to_string(n) <> " properties",
)
}),
max: option.map(max_properties, fn(n) {
RangeBound(
operator: ">",
value_str: int.to_string(n),
keyword: "maxProperties",
phrase: "must have at most " <> int.to_string(n) <> " properties",
)
}),
),
)
}
/// Determine the Gleam type for the composite validator parameter.
fn composite_validator_type(
name: String,
schema_ref: SchemaRef,
ctx: Context,
) -> String {
let schema = context.resolve_schema_ref(schema_ref, ctx)
case schema {
Ok(ObjectSchema(..)) | Ok(AllOfSchema(..)) ->
"types." <> naming.schema_to_type_name(name)
Ok(s) -> {
schema_dispatch.schema_type(s)
}
_ -> "types." <> naming.schema_to_type_name(name)
}
}
/// A guard call with metadata about whether the field is optional.
/// #(guard_fn_name, accessor_expr, is_optional)
type GuardCall =
#(String, String, Bool)
/// Collect all guard function calls for a schema's constrained fields.
fn collect_guard_calls(
name: String,
schema_ref: SchemaRef,
ctx: Context,
) -> List(GuardCall) {
let schema = context.resolve_schema_ref(schema_ref, ctx)
case schema {
Ok(ObjectSchema(
properties:,
required:,
min_properties:,
max_properties:,
..,
)) -> {
let prop_calls =
ir_build.sorted_entries(properties)
|> list.flat_map(fn(entry) {
let #(prop_name, prop_ref) = entry
let is_required = list.contains(required, prop_name)
collect_field_guard_calls(name, prop_name, prop_ref, is_required, ctx)
})
let size_calls = case min_properties, max_properties {
None, None -> []
_, _ -> [
#(guard_function_name(name, "", "properties"), "value", True),
]
}
list.append(prop_calls, size_calls)
}
Ok(AllOfSchema(schemas:, ..)) -> {
let merged = allof_merge.merge_allof_schemas(schemas, ctx)
ir_build.sorted_entries(merged.properties)
|> list.flat_map(fn(entry) {
let #(prop_name, prop_ref) = entry
let is_required = list.contains(merged.required, prop_name)
collect_field_guard_calls(name, prop_name, prop_ref, is_required, ctx)
})
}
Ok(StringSchema(min_length:, max_length:, pattern:, ..)) -> {
let calls = case min_length, max_length {
None, None -> []
_, _ -> [
#(guard_function_name(name, "", "length"), "value", True),
]
}
case pattern {
None -> calls
Some(_) ->
list.append(calls, [
#(guard_function_name(name, "", "pattern"), "value", True),
])
}
}
Ok(IntegerSchema(
minimum:,
maximum:,
exclusive_minimum:,
exclusive_maximum:,
multiple_of:,
..,
)) -> {
let calls = case minimum, maximum {
None, None -> []
_, _ -> [
#(guard_function_name(name, "", "range"), "value", True),
]
}
let calls = case exclusive_minimum, exclusive_maximum {
None, None -> calls
_, _ ->
list.append(calls, [
#(guard_function_name(name, "", "exclusive_range"), "value", True),
])
}
case multiple_of {
None -> calls
Some(_) ->
list.append(calls, [
#(guard_function_name(name, "", "multiple_of"), "value", True),
])
}
}
Ok(NumberSchema(
minimum:,
maximum:,
exclusive_minimum:,
exclusive_maximum:,
multiple_of:,
..,
)) -> {
let calls = case minimum, maximum {
None, None -> []
_, _ -> [
#(guard_function_name(name, "", "range"), "value", True),
]
}
let calls = case exclusive_minimum, exclusive_maximum {
None, None -> calls
_, _ ->
list.append(calls, [
#(guard_function_name(name, "", "exclusive_range"), "value", True),
])
}
case multiple_of {
None -> calls
Some(_) ->
list.append(calls, [
#(guard_function_name(name, "", "multiple_of"), "value", True),
])
}
}
Ok(ArraySchema(min_items:, max_items:, unique_items:, ..)) -> {
let length_calls = case min_items, max_items {
None, None -> []
_, _ -> [
#(guard_function_name(name, "", "length"), "value", True),
]
}
let unique_calls = case unique_items {
True -> [
#(guard_function_name(name, "", "unique"), "value", True),
]
False -> []
}
list.append(length_calls, unique_calls)
}
_ -> []
}
}
/// Collect guard calls for a single field.
fn collect_field_guard_calls(
schema_name: String,
prop_name: String,
prop_ref: SchemaRef,
is_required: Bool,
ctx: Context,
) -> List(GuardCall) {
let resolved = context.resolve_schema_ref(prop_ref, ctx)
let accessor = "value." <> naming.to_snake_case(prop_name)
case resolved {
Ok(StringSchema(min_length:, max_length:, pattern:, ..)) -> {
let calls = case min_length, max_length {
None, None -> []
_, _ -> [
#(
guard_function_name(schema_name, prop_name, "length"),
accessor,
is_required,
),
]
}
case pattern {
None -> calls
Some(_) ->
list.append(calls, [
#(
guard_function_name(schema_name, prop_name, "pattern"),
accessor,
is_required,
),
])
}
}
Ok(IntegerSchema(
minimum:,
maximum:,
exclusive_minimum:,
exclusive_maximum:,
multiple_of:,
..,
)) -> {
let calls = case minimum, maximum {
None, None -> []
_, _ -> [
#(
guard_function_name(schema_name, prop_name, "range"),
accessor,
is_required,
),
]
}
let calls = case exclusive_minimum, exclusive_maximum {
None, None -> calls
_, _ ->
list.append(calls, [
#(
guard_function_name(schema_name, prop_name, "exclusive_range"),
accessor,
is_required,
),
])
}
case multiple_of {
None -> calls
Some(_) ->
list.append(calls, [
#(
guard_function_name(schema_name, prop_name, "multiple_of"),
accessor,
is_required,
),
])
}
}
Ok(NumberSchema(
minimum:,
maximum:,
exclusive_minimum:,
exclusive_maximum:,
multiple_of:,
..,
)) -> {
let calls = case minimum, maximum {
None, None -> []
_, _ -> [
#(
guard_function_name(schema_name, prop_name, "range"),
accessor,
is_required,
),
]
}
let calls = case exclusive_minimum, exclusive_maximum {
None, None -> calls
_, _ ->
list.append(calls, [
#(
guard_function_name(schema_name, prop_name, "exclusive_range"),
accessor,
is_required,
),
])
}
case multiple_of {
None -> calls
Some(_) ->
list.append(calls, [
#(
guard_function_name(schema_name, prop_name, "multiple_of"),
accessor,
is_required,
),
])
}
}
Ok(ArraySchema(min_items:, max_items:, unique_items:, ..)) -> {
let length_calls = case min_items, max_items {
None, None -> []
_, _ -> [
#(
guard_function_name(schema_name, prop_name, "length"),
accessor,
is_required,
),
]
}
let unique_calls = case unique_items {
True -> [
#(
guard_function_name(schema_name, prop_name, "unique"),
accessor,
is_required,
),
]
False -> []
}
list.append(length_calls, unique_calls)
}
_ -> []
}
}