import castor/decodex import gleam/dict.{type Dict} import gleam/dynamic/decode import gleam/json import gleam/list import gleam/option.{type Option, None, Some} import non_empty_list.{type NonEmptyList, NonEmptyList} import oas/generator/utils /// Node in the Specification that might be represented by a reference. pub type Ref(t) { Ref(ref: String, summary: Option(String), description: Option(String)) Inline(value: t) } /// Create a reference with no summary or description. pub fn ref(thing) { Ref(thing, None, None) } pub fn ref_decoder(of: decode.Decoder(t)) -> decode.Decoder(Ref(t)) { decode.one_of( { use ref <- decode.field("$ref", decode.string) use summary <- decodex.optional_field("summary", decode.string) use description <- decodex.optional_field("description", decode.string) decode.success(Ref(ref, summary, description)) }, [decode.map(of, Inline)], ) } /// Represents a decoded JSON schema. /// /// Chosen to add metadata inline as it doesn't belong on ref object /// https://json-schema.org/draft/2020-12/json-schema-validation#name-a-vocabulary-for-basic-meta pub type Schema { Boolean( nullable: Bool, title: Option(String), description: Option(String), deprecated: Bool, ) Integer( multiple_of: Option(Int), maximum: Option(Int), exclusive_maximum: Option(Int), minimum: Option(Int), exclusive_minimum: Option(Int), nullable: Bool, title: Option(String), description: Option(String), deprecated: Bool, ) Number( multiple_of: Option(Int), maximum: Option(Float), exclusive_maximum: Option(Float), minimum: Option(Float), exclusive_minimum: Option(Float), nullable: Bool, title: Option(String), description: Option(String), deprecated: Bool, ) String( max_length: Option(Int), min_length: Option(Int), pattern: Option(String), // There is an enum of accepted formats but it is extended by OAS spec. format: Option(String), nullable: Bool, title: Option(String), description: Option(String), deprecated: Bool, ) Null(title: Option(String), description: Option(String), deprecated: Bool) Array( max_items: Option(Int), min_items: Option(Int), unique_items: Bool, items: Ref(Schema), nullable: Bool, title: Option(String), description: Option(String), deprecated: Bool, ) Object( properties: Dict(String, Ref(Schema)), required: List(String), additional_properties: Option(Ref(Schema)), max_properties: Option(Int), // "Omitting this keyword has the same behavior as a value of 0" min_properties: Int, nullable: Bool, title: Option(String), description: Option(String), deprecated: Bool, ) AllOf(NonEmptyList(Ref(Schema))) AnyOf(NonEmptyList(Ref(Schema))) OneOf(NonEmptyList(Ref(Schema))) Enum(NonEmptyList(utils.Any)) AlwaysPasses AlwaysFails } fn properties_decoder() { decodex.default_field( "properties", decode.dict(decode.string, ref_decoder(decoder())), dict.new(), decode.success, ) } fn type_decoder() { decode.one_of( decode.string |> decode.map(fn(type_) { #(type_, nullable_decoder()) }), [ decode.list(decode.string) |> decode.then(fn(types) { case types { [type_] -> decode.success(#(type_, nullable_decoder())) ["null", type_] | [type_, "null"] -> decode.success(#(type_, decode.success(True))) _ -> decode.failure(#("", nullable_decoder()), "Type") } }), ], ) } /// Decoder for the JSON Schema type /// /// Use with `json.parse` pub fn decoder() { use <- decode.recursive() decode.one_of( { use #(type_, nullable_decoder) <- decodex.discriminate( "type", type_decoder(), Null(None, None, False), ) case type_ { "boolean" -> { use nullable <- decode.then(nullable_decoder) use title <- decode.then(title_decoder()) use description <- decode.then(description_decoder()) use deprecated <- decode.then(deprecated_decoder()) decode.success(Boolean(nullable, title, description, deprecated)) } |> Ok "integer" -> { use multiple_of <- decodex.optional_field("multipleOf", decode.int) use maximum <- decodex.optional_field("maximum", decode.int) use exclusive_maximum <- decodex.optional_field( "exclusiveMaximum", decode.int, ) use minimum <- decodex.optional_field("minimum", decode.int) use exclusive_minimum <- decodex.optional_field( "exclusiveMinimum", decode.int, ) use nullable <- decode.then(nullable_decoder) use title <- decode.then(title_decoder()) use description <- decode.then(description_decoder()) use deprecated <- decode.then(deprecated_decoder()) decode.success(Integer( multiple_of, maximum, exclusive_maximum, minimum, exclusive_minimum, nullable, title, description, deprecated, )) } |> Ok "number" -> { use multiple_of <- decodex.optional_field("multipleOf", decode.int) use maximum <- decodex.optional_field("maximum", decodex.number()) use exclusive_maximum <- decodex.optional_field( "exclusiveMaximum", decodex.number(), ) use minimum <- decodex.optional_field("minimum", decodex.number()) use exclusive_minimum <- decodex.optional_field( "exclusiveMinimum", decodex.number(), ) use nullable <- decode.then(nullable_decoder) use title <- decode.then(title_decoder()) use description <- decode.then(description_decoder()) use deprecated <- decode.then(deprecated_decoder()) decode.success(Number( multiple_of, maximum, exclusive_maximum, minimum, exclusive_minimum, nullable, title, description, deprecated, )) } |> Ok "string" -> { use max_length <- decodex.optional_field("maxLength", decode.int) use min_length <- decodex.optional_field("minLength", decode.int) use pattern <- decodex.optional_field("pattern", decode.string) use format <- decodex.optional_field("format", decode.string) use nullable <- decode.then(nullable_decoder) use title <- decode.then(title_decoder()) use description <- decode.then(description_decoder()) use deprecated <- decode.then(deprecated_decoder()) decode.success(String( max_length, min_length, pattern, format, nullable, title, description, deprecated, )) } |> Ok "null" -> { use title <- decode.then(title_decoder()) use description <- decode.then(description_decoder()) use deprecated <- decode.then(deprecated_decoder()) decode.success(Null(title, description, deprecated)) } |> Ok "array" -> { { use max_items <- decodex.optional_field("maxItems", decode.int) use min_items <- decodex.optional_field("minItems", decode.int) use unique_items <- decodex.default_field( "uniqueItems", decode.bool, False, ) use items <- decode.field("items", ref_decoder(decoder())) use nullable <- decode.then(nullable_decoder) use title <- decode.then(title_decoder()) use description <- decode.then(description_decoder()) use deprecated <- decode.then(deprecated_decoder()) decode.success(Array( max_items, min_items, unique_items, items, nullable, title, description, deprecated, )) } } |> Ok "object" -> { // The type object can be used as a hint that the allof validations should all be for objects. // This library returns an AllOf type, because that's more accurate. // However a future version might want a special AllOfObject type that enables downstream users to manage this combined case. use all_of <- decodex.optional_field( "allOf", non_empty_list_of_schema_decoder(), ) case all_of { Some(schemas) -> decode.success(AllOf(schemas)) None -> { use properties <- decode.then(properties_decoder()) use required <- decode.then(required_decoder()) use additional_properties <- decodex.optional_field( "additionalProperties", ref_decoder(decoder()), ) use max_properties <- decodex.optional_field( "maxProperties", decode.int, ) // "Omitting this keyword has the same behavior as a value of 0" use min_properties <- decodex.default_field( "minProperties", decode.int, 0, ) use nullable <- decode.then(nullable_decoder) use title <- decode.then(title_decoder()) use description <- decode.then(description_decoder()) use deprecated <- decode.then(deprecated_decoder()) decode.success(Object( properties, required, additional_properties, max_properties, min_properties, nullable, title, description, deprecated, )) } } } |> Ok type_ -> Error("valid schema type got: " <> type_) } }, [ decode.field( "allOf", non_empty_list_of_schema_decoder() |> decode.map(AllOf), decode.success, ), decode.field( "anyOf", non_empty_list_of_schema_decoder() |> decode.map(AnyOf), decode.success, ), decode.field( "oneOf", non_empty_list_of_schema_decoder() |> decode.map(OneOf), decode.success, ), decode.field( "enum", non_empty_list_of_any_decoder() |> decode.map(Enum), decode.success, ), decode.field( "const", utils.any_decoder() |> decode.map(fn(value) { Enum(non_empty_list.single(value)) }), decode.success, ), decode.bool |> decode.map(fn(b) { case b { True -> AlwaysPasses False -> AlwaysFails } }), decode.dict(decode.string, decode.string) |> decode.map(fn(d) { case d == dict.new() { True -> AlwaysPasses False -> AlwaysFails } }), ], ) } fn non_empty_list_of_schema_decoder() { use list <- decode.then(decode.list(ref_decoder(decoder()))) case list { [] -> decode.failure(NonEmptyList(Inline(AlwaysFails), []), "") [a, ..rest] -> decode.success(NonEmptyList(a, rest)) } } fn non_empty_list_of_any_decoder() { use list <- decode.then(decode.list(utils.any_decoder())) case list { [] -> decode.failure(NonEmptyList(utils.Null, []), "") [a, ..rest] -> decode.success(NonEmptyList(a, rest)) } } fn required_decoder() { decodex.default_field( "required", decode.list(decode.string), [], decode.success, ) } fn nullable_decoder() { decodex.default_field("nullable", decode.bool, False, decode.success) } fn title_decoder() { decodex.optional_field("title", decode.string, decode.success) } fn description_decoder() { decodex.optional_field("description", decode.string, decode.success) } fn deprecated_decoder() { decodex.default_field("deprecated", decode.bool, False, decode.success) } /// Construct a schema term requiring a boolean value. pub fn boolean() { Boolean(nullable: False, title: None, description: None, deprecated: False) } /// Construct a schema term requiring an integer value. pub fn integer() { Integer( multiple_of: None, maximum: None, exclusive_maximum: None, minimum: None, exclusive_minimum: None, nullable: False, title: None, description: None, deprecated: False, ) } /// Construct a schema term requiring a float value. pub fn number() { Number( multiple_of: None, maximum: None, exclusive_maximum: None, minimum: None, exclusive_minimum: None, nullable: False, title: None, description: None, deprecated: False, ) } /// Construct a schema term requiring a string value. pub fn string() { String( max_length: None, min_length: None, pattern: None, format: None, nullable: False, title: None, description: None, deprecated: False, ) } /// Construct a schema term requiring a null value. pub fn null() { Null(title: None, description: None, deprecated: False) } /// Construct a schema term requiring an array value. pub fn array(items) { Array( max_items: None, min_items: None, unique_items: False, items:, nullable: False, title: None, description: None, deprecated: False, ) } /// Create a schema for an object with all properties defined. /// /// user `field` and `optional_field` to define fields. pub fn object(properties) { let #(properties, required) = list.fold(properties, #(dict.new(), []), fn(acc, property) { let #(properties, all_required) = acc let #(key, schema, is_required) = property let all_required = case is_required { True -> [key, ..all_required] False -> all_required } let properties = dict.insert(properties, key, schema) #(properties, all_required) }) Object( properties:, required:, additional_properties: None, max_properties: None, min_properties: 0, nullable: False, title: None, description: None, deprecated: False, ) } /// Create a schema for an open dictionary of strings to the given schema type pub fn dict(field_schema) { Object( properties: dict.new(), required: [], additional_properties: Some(Inline(field_schema)), deprecated: False, max_properties: None, min_properties: 0, nullable: False, title: None, description: None, ) } pub fn enum_of_strings(strings) { Enum(non_empty_list.map(strings, utils.String)) } /// Construct a schema for a required field of an object. pub fn field(key, schema) { #(key, Inline(schema), True) } /// Construct a schema for an optional field of an object. pub fn optional_field(key, schema) { #(key, Inline(schema), False) } /// Convert a Schema to a gleam_json type. /// /// encode this to a string using `json.to_string` pub fn encode(schema) { case schema { Boolean(nullable:, title:, description:, deprecated:) -> json_object([ #("type", Some(json.string("boolean"))), #("nullable", Some(json.bool(nullable))), #("title", option.map(title, json.string)), #("description", option.map(description, json.string)), #("deprecated", Some(json.bool(deprecated))), ]) Integer(..) as int -> { json_object([ #("type", Some(json.string("integer"))), #("multipleOf", option.map(int.multiple_of, json.int)), #("maximum", option.map(int.maximum, json.int)), #("exclusiveMaximum", option.map(int.exclusive_maximum, json.int)), #("minimum", option.map(int.minimum, json.int)), #("exclusiveMinimum", option.map(int.exclusive_minimum, json.int)), #("nullable", Some(json.bool(int.nullable))), #("title", option.map(int.title, json.string)), #("description", option.map(int.description, json.string)), #("deprecated", Some(json.bool(int.deprecated))), ]) } Number(..) as number -> { json_object([ #("type", Some(json.string("number"))), #("multipleOf", option.map(number.multiple_of, json.int)), #("maximum", option.map(number.maximum, json.float)), #("exclusiveMaximum", option.map(number.exclusive_maximum, json.float)), #("minimum", option.map(number.minimum, json.float)), #("exclusiveMinimum", option.map(number.exclusive_minimum, json.float)), #("nullable", Some(json.bool(number.nullable))), #("title", option.map(number.title, json.string)), #("description", option.map(number.description, json.string)), #("deprecated", Some(json.bool(number.deprecated))), ]) } String(..) as string -> { json_object([ #("type", Some(json.string("string"))), #("MaxLength", option.map(string.max_length, json.int)), #("MinLength", option.map(string.min_length, json.int)), #("Pattern", option.map(string.pattern, json.string)), #("Format", option.map(string.format, json.string)), #("nullable", Some(json.bool(string.nullable))), #("title", option.map(string.title, json.string)), #("description", option.map(string.description, json.string)), #("deprecated", Some(json.bool(string.deprecated))), ]) } Null(..) as null -> { json_object([ #("type", Some(json.string("null"))), #("title", option.map(null.title, json.string)), #("description", option.map(null.description, json.string)), #("deprecated", Some(json.bool(null.deprecated))), ]) } Array(..) as array -> { json_object([ #("type", Some(json.string("array"))), #("maxItems", option.map(array.max_items, json.int)), #("minItems", option.map(array.min_items, json.int)), #("uniqueItems", Some(json.bool(array.unique_items))), #("items", Some(ref_encode(array.items))), #("nullable", Some(json.bool(array.nullable))), #("title", option.map(array.title, json.string)), #("description", option.map(array.description, json.string)), #("deprecated", Some(json.bool(array.deprecated))), ]) } Object(..) as object -> { json_object([ #("type", Some(json.string("object"))), #( "properties", Some(json.dict(object.properties, fn(x) { x }, ref_encode)), ), #( "additionalProperties", option.map(object.additional_properties, ref_encode), ), #("maxProperties", option.map(object.max_properties, json.int)), #("minProperties", Some(json.int(object.min_properties))), #("required", Some(json.array(object.required, json.string))), #("nullable", Some(json.bool(object.nullable))), #("title", option.map(object.title, json.string)), #("description", option.map(object.description, json.string)), #("deprecated", Some(json.bool(object.deprecated))), ]) } AllOf(varients) -> { json_object([ #( "allOf", Some(json.array(non_empty_list.to_list(varients), ref_encode)), ), ]) } AnyOf(varients) -> { json_object([ #( "anyOf", Some(json.array(non_empty_list.to_list(varients), ref_encode)), ), ]) } OneOf(varients) -> { json_object([ #( "oneOf", Some(json.array(non_empty_list.to_list(varients), ref_encode)), ), ]) } Enum(non_empty_list.NonEmptyList(value, [])) -> json_object([#("const", Some(utils.any_to_json(value)))]) Enum(values) -> { let values = non_empty_list.to_list(values) json_object([#("enum", Some(json.array(values, utils.any_to_json)))]) } AlwaysPasses -> json.bool(True) AlwaysFails -> json.bool(False) } } fn ref_encode(ref) { case ref { Inline(schema) -> encode(schema) Ref(reference, ..) -> json.object([#("$ref", json.string(reference))]) } } fn json_object(properties) { list.filter_map(properties, fn(property) { let #(key, value) = property case value { Some(value) -> Ok(#(key, value)) None -> Error(Nil) } }) |> json.object } /// encode a schema to the fields of the json object. /// A schema will always be an object pub fn to_fields(schema) { case schema { Boolean(nullable:, title:, description:, deprecated:) -> any_object([ #("type", Some(utils.String("boolean"))), #("nullable", Some(utils.Boolean(nullable))), #("title", option.map(title, utils.String)), #("description", option.map(description, utils.String)), #("deprecated", Some(utils.Boolean(deprecated))), ]) Integer(..) as int -> { any_object([ #("type", Some(utils.String("integer"))), #("multipleOf", option.map(int.multiple_of, utils.Integer)), #("maximum", option.map(int.maximum, utils.Integer)), #("exclusiveMaximum", option.map(int.exclusive_maximum, utils.Integer)), #("minimum", option.map(int.minimum, utils.Integer)), #("exclusiveMinimum", option.map(int.exclusive_minimum, utils.Integer)), #("nullable", Some(utils.Boolean(int.nullable))), #("title", option.map(int.title, utils.String)), #("description", option.map(int.description, utils.String)), #("deprecated", Some(utils.Boolean(int.deprecated))), ]) } Number(..) as number -> { any_object([ #("type", Some(utils.String("number"))), #("multipleOf", option.map(number.multiple_of, utils.Integer)), #("maximum", option.map(number.maximum, utils.Number)), #( "exclusiveMaximum", option.map(number.exclusive_maximum, utils.Number), ), #("minimum", option.map(number.minimum, utils.Number)), #( "exclusiveMinimum", option.map(number.exclusive_minimum, utils.Number), ), #("nullable", Some(utils.Boolean(number.nullable))), #("title", option.map(number.title, utils.String)), #("description", option.map(number.description, utils.String)), #("deprecated", Some(utils.Boolean(number.deprecated))), ]) } String(..) as string -> { any_object([ #("type", Some(utils.String("string"))), #("MaxLength", option.map(string.max_length, utils.Integer)), #("MinLength", option.map(string.min_length, utils.Integer)), #("Pattern", option.map(string.pattern, utils.String)), #("Format", option.map(string.format, utils.String)), #("nullable", Some(utils.Boolean(string.nullable))), #("title", option.map(string.title, utils.String)), #("description", option.map(string.description, utils.String)), #("deprecated", Some(utils.Boolean(string.deprecated))), ]) } Null(..) as null -> { any_object([ #("type", Some(utils.String("null"))), #("title", option.map(null.title, utils.String)), #("description", option.map(null.description, utils.String)), #("deprecated", Some(utils.Boolean(null.deprecated))), ]) } Array(..) as array -> { any_object([ #("type", Some(utils.String("array"))), #("maxItems", option.map(array.max_items, utils.Integer)), #("minItems", option.map(array.min_items, utils.Integer)), #("uniqueItems", Some(utils.Boolean(array.unique_items))), #("items", Some(ref_to_fields(array.items))), #("nullable", Some(utils.Boolean(array.nullable))), #("title", option.map(array.title, utils.String)), #("description", option.map(array.description, utils.String)), #("deprecated", Some(utils.Boolean(array.deprecated))), ]) } Object(..) as object -> { any_object([ #("type", Some(utils.String("object"))), #( "properties", Some(utils.Object( object.properties |> dict.map_values(fn(_, v) { ref_to_fields(v) }), )), ), #( "additionalProperties", option.map(object.additional_properties, ref_to_fields), ), #("maxProperties", option.map(object.max_properties, utils.Integer)), #("minProperties", Some(utils.Integer(object.min_properties))), #( "required", Some(utils.Array(list.map(object.required, utils.String))), ), #("nullable", Some(utils.Boolean(object.nullable))), #("title", option.map(object.title, utils.String)), #("description", option.map(object.description, utils.String)), #("deprecated", Some(utils.Boolean(object.deprecated))), ]) } AllOf(varients) -> { any_object([ #( "allOf", Some( utils.Array(list.map( non_empty_list.to_list(varients), ref_to_fields, )), ), ), ]) } AnyOf(varients) -> { any_object([ #( "anyOf", Some( utils.Array(list.map( non_empty_list.to_list(varients), ref_to_fields, )), ), ), ]) } OneOf(varients) -> { any_object([ #( "oneOf", Some( utils.Array(list.map( non_empty_list.to_list(varients), ref_to_fields, )), ), ), ]) } Enum(non_empty_list.NonEmptyList(value, [])) -> any_object([#("const", Some(value))]) Enum(values) -> { let values = non_empty_list.to_list(values) any_object([#("enum", Some(utils.Array(values)))]) } AlwaysPasses -> // These can't be turned to fields panic as "utils.Boolean(True)" AlwaysFails -> panic as "utils.Boolean(False)" } } fn ref_to_fields(ref) { case ref { Inline(schema) -> to_fields(schema) |> utils.Object Ref(reference, ..) -> utils.Object(dict.from_list([#("$ref", utils.String(reference))])) } } fn any_object(properties) { list.filter_map(properties, fn(property) { let #(key, value) = property case value { Some(value) -> Ok(#(key, value)) None -> Error(Nil) } }) |> dict.from_list }