Packages
oaspec
0.51.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/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)
}
_ -> []
}
}