import gleam/json import gleam/list import gleam/option import jscheam/property.{ type AdditionalProperties, type Constraint, type Property, type Type, AllowAny, AllowExplicit, Array, Boolean, Disallow, Enum, Float, Integer, Null, Object, Pattern, Property, Schema, String, Union, } // Property builders /// Creates a property with the specified name and type /// Properties are required by default pub fn prop(name: String, property_type: Type) -> Property { Property( name: name, property_type: property_type, is_required: True, description: option.None, constraints: [], ) } /// Makes a property optional (not required in the schema) pub fn optional(property: Property) -> Property { Property(..property, is_required: False) } /// Adds a description to a property for documentation purposes pub fn description(property: Property, desc: String) -> Property { Property(..property, description: option.Some(desc)) } // Type builders /// Creates a string type for JSON Schema pub fn string() -> Type { String } /// Creates an integer/number type for JSON Schema pub fn integer() -> Type { Integer } /// Creates a boolean type for JSON Schema pub fn boolean() -> Type { Boolean } /// Creates a float/number type for JSON Schema pub fn float() -> Type { Float } /// Creates a null type for JSON Schema pub fn null() -> Type { Null } /// Creates an array type with the specified item type pub fn array(item_type: Type) -> Type { Array(item_type) } /// Creates a union type that accepts multiple types (e.g., string or null) /// Example: union([string(), null()]) creates a schema that accepts both strings and null values pub fn union(types: List(Type)) -> Type { Union(types) } /// Adds an enum constraint to a property that restricts values to a fixed set /// Example: prop("color", string()) |> enum(enum_strings(["red", "green", "blue"])) pub fn enum(property: Property, values: List(json.Json)) -> Property { let new_constraint = Enum(values: values) Property(..property, constraints: [new_constraint, ..property.constraints]) } /// Adds a pattern constraint to a property that restricts values to match a regex pattern /// Example: prop("phone", string()) |> pattern("^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$") pub fn pattern(property: Property, regex: String) -> Property { let new_constraint = Pattern(regex: regex) Property(..property, constraints: [new_constraint, ..property.constraints]) } /// Creates an object type with the specified properties /// By default allows any additional properties (JSON Schema default behavior - omits the field) pub fn object(properties: List(Property)) -> Type { Object(properties: properties, additional_properties: AllowAny) } /// Explicitly allows any additional properties (outputs "additionalProperties": true) pub fn allow_additional_props(object_type: Type) -> Type { case object_type { Object(properties: props, additional_properties: _) -> Object(properties: props, additional_properties: AllowExplicit) _ -> object_type } } /// Disallows additional properties (outputs "additionalProperties": false) pub fn disallow_additional_props(object_type: Type) -> Type { case object_type { Object(properties: props, additional_properties: _) -> Object(properties: props, additional_properties: Disallow) _ -> object_type } } /// Constrains additional properties to conform to the specified schema pub fn constrain_additional_props(object_type: Type, schema: Type) -> Type { case object_type { Object(properties: props, additional_properties: _) -> Object(properties: props, additional_properties: Schema(schema)) _ -> object_type } } fn additional_properties_to_json( add_props: AdditionalProperties, ) -> List(#(String, json.Json)) { case add_props { AllowAny -> [] // Omit the field entirely (JSON Schema default) AllowExplicit -> [#("additionalProperties", json.bool(True))] Disallow -> [#("additionalProperties", json.bool(False))] Schema(schema_type) -> [ #("additionalProperties", type_to_json_value(schema_type)), ] } } fn type_to_type_string(property_type: Type) -> String { case property_type { String -> "string" Integer -> "number" Boolean -> "boolean" Null -> "null" Float -> "number" Object(_, _) -> "object" Array(_) -> "array" Union(_) -> panic as "Union types should not be converted to single type strings" } } fn type_to_json_value(property_type: Type) -> json.Json { case property_type { String | Integer | Boolean | Null | Float -> json.object([#("type", json.string(type_to_type_string(property_type)))]) Object(properties: props, additional_properties: add_props) -> { let properties_json = list.map(props, property_to_field) |> json.object let required_json = fields_to_required(props) let additional_props_fields = additional_properties_to_json(add_props) let base_fields = [ #("type", json.string(type_to_type_string(property_type))), #("properties", properties_json), #("required", required_json), ] json.object(list.append(base_fields, additional_props_fields)) } Array(item_type) -> json.object([ #("type", json.string(type_to_type_string(property_type))), #("items", type_to_json_value(item_type)), ]) Union(types) -> { let type_strings = list.map(types, type_to_type_string) json.object([#("type", json.array(type_strings, json.string))]) } } } fn property_to_field(property: Property) -> #(String, json.Json) { let Property(name, property_type, _is_required, description, constraints) = property let base_fields = get_base_type_fields(property_type) let fields_with_constraints = add_constraint_fields(base_fields, constraints) let final_fields = case description { option.Some(desc) -> [ #("description", json.string(desc)), ..fields_with_constraints ] option.None -> fields_with_constraints } #(name, json.object(final_fields)) } fn get_base_type_fields(property_type: Type) -> List(#(String, json.Json)) { case property_type { String | Integer | Boolean | Null | Float -> [ #("type", json.string(type_to_type_string(property_type))), ] Array(item_type) -> [ #("type", json.string(type_to_type_string(property_type))), #("items", type_to_json_value(item_type)), ] Object(properties: props, additional_properties: add_props) -> { let properties_json = list.map(props, property_to_field) |> json.object let required_json = fields_to_required(props) let additional_props_fields = additional_properties_to_json(add_props) let base_fields = [ #("type", json.string(type_to_type_string(property_type))), #("properties", properties_json), #("required", required_json), ] list.append(base_fields, additional_props_fields) } Union(types) -> { let type_strings = list.map(types, type_to_type_string) [#("type", json.array(type_strings, json.string))] } } } fn add_constraint_fields( base_fields: List(#(String, json.Json)), constraints: List(Constraint), ) -> List(#(String, json.Json)) { case constraints { [] -> base_fields [constraint, ..rest] -> { let fields_with_constraint = add_single_constraint_field(base_fields, constraint) add_constraint_fields(fields_with_constraint, rest) } } } fn add_single_constraint_field( fields: List(#(String, json.Json)), constraint: Constraint, ) -> List(#(String, json.Json)) { case constraint { Enum(values: values) -> [ #("enum", json.array(values, fn(x) { x })), ..fields ] Pattern(regex: regex) -> [#("pattern", json.string(regex)), ..fields] } } fn fields_to_required(fields: List(Property)) -> json.Json { let required_fields = list.filter(fields, fn(property) { let Property( _name, _property_type, is_required, _description, _constraints, ) = property is_required }) let names = list.map(required_fields, fn(property) { let Property( name, _property_type, _is_required, _description, _constraints, ) = property json.string(name) }) json.array(names, fn(x) { x }) } /// Converts a Type to a JSON Schema document /// This is the main function to generate JSON Schema from your type definitions pub fn to_json(object_type: Type) -> json.Json { type_to_json_value(object_type) }