├── .gitignore ├── .github └── workflows │ └── test.yml ├── gleam.toml ├── manifest.toml ├── test ├── jscheam_test.gleam ├── types_test.gleam └── constraints_test.gleam ├── README.md └── src └── jscheam └── schema.gleam /.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | /build 4 | erl_crash.dump 5 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: erlef/setup-beam@v1 16 | with: 17 | otp-version: "27.1.2" 18 | gleam-version: "1.11.1" 19 | rebar3-version: "3" 20 | # elixir-version: "1" 21 | - run: gleam deps download 22 | - run: gleam test 23 | - run: gleam format --check src test 24 | -------------------------------------------------------------------------------- /gleam.toml: -------------------------------------------------------------------------------- 1 | name = "jscheam" 2 | version = "2.0.0" 3 | 4 | # Fill out these fields if you intend to generate HTML documentation or publish 5 | # your project to the Hex package manager. 6 | # 7 | description = "A Simple JSON Schema library for Gleam" 8 | licences = ["MIT"] 9 | repository = { type = "github", user = "Neofox", repo = "jscheam" } 10 | # links = [{ title = "Website", href = "" }] 11 | # 12 | # For a full reference of all the available options, you can have a look at 13 | # https://gleam.run/writing-gleam/gleam-toml/. 14 | 15 | [dependencies] 16 | gleam_stdlib = ">= 0.44.0 and < 2.0.0" 17 | gleam_json = ">= 3.0.2 and < 4.0.0" 18 | 19 | [dev-dependencies] 20 | gleeunit = ">= 1.0.0 and < 2.0.0" 21 | -------------------------------------------------------------------------------- /manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, 6 | { name = "gleam_stdlib", version = "0.62.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "DC8872BC0B8550F6E22F0F698CFE7F1E4BDA7312FDEB40D6C3F44C5B706C8310" }, 7 | { name = "gleeunit", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "63022D81C12C17B7F1A60E029964E830A4CBD846BBC6740004FC1F1031AE0326" }, 8 | ] 9 | 10 | [requirements] 11 | gleam_json = { version = ">= 3.0.2 and < 4.0.0" } 12 | gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 13 | gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 14 | -------------------------------------------------------------------------------- /test/jscheam_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/json 2 | import gleam/string 3 | import gleeunit 4 | import gleeunit/should 5 | import jscheam/schema 6 | 7 | pub fn main() -> Nil { 8 | gleeunit.main() 9 | } 10 | 11 | // Test default additional properties behavior (allow any - omit field) 12 | pub fn additional_properties_test() { 13 | let schema_default = 14 | schema.object([ 15 | schema.prop("name", schema.string()), 16 | schema.prop("age", schema.integer()) |> schema.optional(), 17 | ]) 18 | 19 | let json_default = schema.to_json(schema_default) |> json.to_string() 20 | 21 | // Should NOT contain additionalProperties field (defaults to true) 22 | string.contains(json_default, "additionalProperties") |> should.be_false() 23 | } 24 | 25 | // Test additional properties with schema constraint 26 | pub fn additional_properties_with_schema_test() { 27 | let schema = 28 | schema.object([schema.prop("name", schema.string())]) 29 | |> schema.constrain_additional_props(schema.string()) 30 | 31 | let json = schema.to_json(schema) |> json.to_string() 32 | 33 | string.contains(json, "\"additionalProperties\":{\"type\":\"string\"}") 34 | |> should.be_true() 35 | } 36 | 37 | // Test strict additional properties (false) 38 | pub fn additional_properties_strict_test() { 39 | let schema = 40 | schema.object([schema.prop("name", schema.string())]) 41 | |> schema.disallow_additional_props() 42 | 43 | let json = schema.to_json(schema) |> json.to_string() 44 | 45 | string.contains(json, "\"additionalProperties\":false") |> should.be_true() 46 | } 47 | 48 | // Test explicit additional properties (true) 49 | pub fn additional_properties_explicit_test() { 50 | let schema = 51 | schema.object([schema.prop("name", schema.string())]) 52 | |> schema.allow_additional_props() 53 | 54 | let json = schema.to_json(schema) |> json.to_string() 55 | 56 | string.contains(json, "\"additionalProperties\":true") |> should.be_true() 57 | } 58 | 59 | // Test optional properties 60 | pub fn optional_property_test() { 61 | let schema = 62 | schema.object([ 63 | schema.prop("name", schema.string()), 64 | schema.prop("bio", schema.string()) |> schema.optional(), 65 | ]) 66 | |> schema.disallow_additional_props() 67 | 68 | let json = schema.to_json(schema) |> json.to_string() 69 | 70 | string.contains(json, "\"name\":{\"type\":\"string\"}") |> should.be_true() 71 | string.contains(json, "\"bio\":{\"type\":\"string\"}") |> should.be_true() 72 | string.contains(json, "\"required\":[\"name\"]") |> should.be_true() 73 | } 74 | 75 | // Test property descriptions 76 | pub fn description_test() { 77 | let schema = 78 | schema.object([ 79 | schema.prop("name", schema.string()) 80 | |> schema.description("User's full name"), 81 | schema.prop("age", schema.integer()) 82 | |> schema.description("User's age in years"), 83 | ]) 84 | |> schema.disallow_additional_props() 85 | 86 | let json = schema.to_json(schema) |> json.to_string() 87 | 88 | string.contains(json, "\"description\":\"User's full name\"") 89 | |> should.be_true() 90 | } 91 | 92 | // Test chaining optional and description 93 | pub fn chained_modifiers_test() { 94 | let schema = 95 | schema.object([ 96 | schema.prop("nickname", schema.string()) 97 | |> schema.optional() 98 | |> schema.description("Optional user nickname"), 99 | ]) 100 | |> schema.disallow_additional_props() 101 | 102 | let json = schema.to_json(schema) |> json.to_string() 103 | 104 | string.contains(json, "\"required\":[]") |> should.be_true() 105 | string.contains(json, "\"description\":\"Optional user nickname\"") 106 | |> should.be_true() 107 | } 108 | -------------------------------------------------------------------------------- /test/types_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/json 2 | import gleam/string 3 | import gleeunit/should 4 | import jscheam/schema.{Array, Boolean, Float, Integer, Null, String, Union} 5 | 6 | // Test basic object structure 7 | pub fn simple_object_test() { 8 | let schema = 9 | schema.object([ 10 | schema.prop("name", schema.string()), 11 | schema.prop("age", schema.integer()), 12 | ]) 13 | |> schema.disallow_additional_props() 14 | 15 | let json = schema.to_json(schema) |> json.to_string() 16 | 17 | string.contains(json, "\"type\":\"object\"") |> should.be_true() 18 | string.contains(json, "\"name\":{\"type\":\"string\"}") |> should.be_true() 19 | string.contains(json, "\"age\":{\"type\":\"number\"}") |> should.be_true() 20 | string.contains(json, "\"required\":[\"name\",\"age\"]") |> should.be_true() 21 | string.contains(json, "\"additionalProperties\":false") |> should.be_true() 22 | } 23 | 24 | // Test arrays with proper JSON Schema structure 25 | pub fn array_test() { 26 | let schema = 27 | schema.object([schema.prop("scores", schema.array(schema.float()))]) 28 | |> schema.disallow_additional_props() 29 | 30 | let json = schema.to_json(schema) |> json.to_string() 31 | 32 | string.contains(json, "\"type\":\"array\"") |> should.be_true() 33 | string.contains(json, "\"items\":{\"type\":\"number\"}") |> should.be_true() 34 | } 35 | 36 | // Test nested objects 37 | pub fn nested_object_test() { 38 | let schema = 39 | schema.object([ 40 | schema.prop( 41 | "profile", 42 | schema.object([ 43 | schema.prop("bio", schema.string()) |> schema.optional(), 44 | schema.prop("avatar_url", schema.string()), 45 | ]) 46 | |> schema.disallow_additional_props(), 47 | ), 48 | ]) 49 | |> schema.disallow_additional_props() 50 | 51 | let json = schema.to_json(schema) |> json.to_string() 52 | 53 | // Should contain nested object structure 54 | string.contains(json, "\"profile\":{\"type\":\"object\"") |> should.be_true() 55 | string.contains(json, "\"bio\":{\"type\":\"string\"}") |> should.be_true() 56 | string.contains(json, "\"avatar_url\":{\"type\":\"string\"}") 57 | |> should.be_true() 58 | } 59 | 60 | // Test type constructors 61 | pub fn type_constructors_test() { 62 | schema.string() |> should.equal(String) 63 | schema.integer() |> should.equal(Integer) 64 | schema.boolean() |> should.equal(Boolean) 65 | schema.float() |> should.equal(Float) 66 | schema.null() |> should.equal(Null) 67 | schema.array(schema.string()) |> should.equal(Array(String)) 68 | schema.union([schema.string(), schema.null()]) 69 | |> should.equal(Union([String, Null])) 70 | } 71 | 72 | // Test union types 73 | pub fn union_type_test() { 74 | let schema = 75 | schema.object([ 76 | schema.prop("units", schema.union([schema.string(), schema.null()])) 77 | |> schema.description("Units the temperature will be returned in."), 78 | ]) 79 | 80 | let json = schema.to_json(schema) |> json.to_string() 81 | 82 | string.contains(json, "\"type\":\"object\"") |> should.be_true() 83 | string.contains(json, "\"units\":{") |> should.be_true() 84 | string.contains(json, "\"type\":[\"string\",\"null\"]") |> should.be_true() 85 | string.contains( 86 | json, 87 | "\"description\":\"Units the temperature will be returned in.\"", 88 | ) 89 | |> should.be_true() 90 | string.contains(json, "\"required\":[\"units\"]") |> should.be_true() 91 | } 92 | 93 | // Test enum with union base type 94 | pub fn enum_union_test() { 95 | let schema = 96 | schema.object([ 97 | schema.prop("location", schema.string()) 98 | |> schema.description("City and country e.g. Bogotá, Colombia"), 99 | schema.prop("units", schema.union([schema.string(), schema.null()])) 100 | |> schema.enum([json.string("celsius"), json.string("fahrenheit")]) 101 | |> schema.description("Units the temperature will be returned in."), 102 | ]) 103 | |> schema.disallow_additional_props() 104 | 105 | let json = schema.to_json(schema) |> json.to_string() 106 | 107 | string.contains(json, "\"type\":\"object\"") |> should.be_true() 108 | string.contains(json, "\"location\":{") |> should.be_true() 109 | string.contains(json, "\"units\":{") |> should.be_true() 110 | string.contains(json, "\"type\":[\"string\",\"null\"]") |> should.be_true() 111 | string.contains(json, "\"enum\":[\"celsius\",\"fahrenheit\"]") 112 | |> should.be_true() 113 | string.contains(json, "\"required\":[\"location\",\"units\"]") 114 | |> should.be_true() 115 | string.contains(json, "\"additionalProperties\":false") |> should.be_true() 116 | } 117 | -------------------------------------------------------------------------------- /test/constraints_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/json 2 | import gleam/string 3 | import gleeunit/should 4 | import jscheam/schema.{Enum, Pattern, Property} 5 | 6 | // Test enum with string base type 7 | pub fn enum_string_test() { 8 | let schema = 9 | schema.object([ 10 | schema.prop("units", schema.string()) 11 | |> schema.enum([json.string("celsius"), json.string("fahrenheit")]) 12 | |> schema.description("Units the temperature will be returned in."), 13 | ]) 14 | 15 | let json = schema.to_json(schema) |> json.to_string() 16 | 17 | string.contains(json, "\"type\":\"object\"") |> should.be_true() 18 | string.contains(json, "\"units\":{") |> should.be_true() 19 | string.contains(json, "\"type\":\"string\"") |> should.be_true() 20 | string.contains(json, "\"enum\":[\"celsius\",\"fahrenheit\"]") 21 | |> should.be_true() 22 | string.contains( 23 | json, 24 | "\"description\":\"Units the temperature will be returned in.\"", 25 | ) 26 | |> should.be_true() 27 | string.contains(json, "\"required\":[\"units\"]") |> should.be_true() 28 | } 29 | 30 | // Test enum constraint application 31 | pub fn enum_constraint_test() { 32 | let expected_values = [ 33 | json.string("red"), 34 | json.string("green"), 35 | json.string("blue"), 36 | ] 37 | let property = 38 | schema.prop("color", schema.string()) 39 | |> schema.enum(expected_values) 40 | 41 | // Test that the constraint was applied 42 | let Property(_name, _type, _required, _description, constraints) = property 43 | case constraints { 44 | [Enum(values: values)] -> values |> should.equal(expected_values) 45 | _ -> should.fail() 46 | } 47 | } 48 | 49 | // Test mixed-type enum (strings, numbers, null) 50 | pub fn enum_mixed_types_test() { 51 | let mixed_values = [ 52 | json.string("red"), 53 | json.string("amber"), 54 | json.string("green"), 55 | json.null(), 56 | json.int(42), 57 | ] 58 | 59 | let schema = 60 | schema.object([ 61 | schema.prop( 62 | "status", 63 | schema.union([schema.string(), schema.null(), schema.integer()]), 64 | ) 65 | |> schema.enum(mixed_values) 66 | |> schema.description("Traffic light status with special values"), 67 | ]) 68 | 69 | let json = schema.to_json(schema) |> json.to_string() 70 | 71 | string.contains(json, "\"type\":\"object\"") |> should.be_true() 72 | string.contains(json, "\"status\":{") |> should.be_true() 73 | string.contains(json, "\"type\":[\"string\",\"null\",\"number\"]") 74 | |> should.be_true() 75 | string.contains(json, "\"enum\":[\"red\",\"amber\",\"green\",null,42]") 76 | |> should.be_true() 77 | string.contains( 78 | json, 79 | "\"description\":\"Traffic light status with special values\"", 80 | ) 81 | |> should.be_true() 82 | } 83 | 84 | // Test pattern constraint with phone number regex 85 | pub fn pattern_constraint_test() { 86 | let phone_regex = "^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$" 87 | 88 | let schema = 89 | schema.object([ 90 | schema.prop("phone", schema.string()) 91 | |> schema.pattern(phone_regex) 92 | |> schema.description("Phone number in US format"), 93 | ]) 94 | |> schema.disallow_additional_props() 95 | 96 | let json = schema.to_json(schema) |> json.to_string() 97 | 98 | string.contains(json, "\"type\":\"object\"") |> should.be_true() 99 | string.contains(json, "\"phone\":{") |> should.be_true() 100 | string.contains(json, "\"type\":\"string\"") |> should.be_true() 101 | string.contains( 102 | json, 103 | "\"pattern\":\"^(\\\\([0-9]{3}\\\\))?[0-9]{3}-[0-9]{4}$\"", 104 | ) 105 | |> should.be_true() 106 | string.contains(json, "\"description\":\"Phone number in US format\"") 107 | |> should.be_true() 108 | string.contains(json, "\"required\":[\"phone\"]") |> should.be_true() 109 | string.contains(json, "\"additionalProperties\":false") |> should.be_true() 110 | } 111 | 112 | // Test pattern constraint application to property structure 113 | pub fn pattern_constraint_application_test() { 114 | let email_regex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" 115 | 116 | let property = 117 | schema.prop("email", schema.string()) 118 | |> schema.pattern(email_regex) 119 | 120 | // Test that the constraint was applied 121 | let Property(_name, _type, _required, _description, constraints) = property 122 | case constraints { 123 | [Pattern(regex: regex)] -> regex |> should.equal(email_regex) 124 | _ -> should.fail() 125 | } 126 | } 127 | 128 | // Test multiple constraints (enum and pattern) on same property 129 | pub fn multiple_constraints_test() { 130 | let color_regex = "^#[0-9a-fA-F]{6}$" 131 | let color_values = [ 132 | json.string("#FF0000"), 133 | json.string("#00FF00"), 134 | json.string("#0000FF"), 135 | ] 136 | 137 | let schema = 138 | schema.object([ 139 | schema.prop("color", schema.string()) 140 | |> schema.pattern(color_regex) 141 | |> schema.enum(color_values) 142 | |> schema.description("Hex color code from predefined set"), 143 | ]) 144 | 145 | let json = schema.to_json(schema) |> json.to_string() 146 | 147 | string.contains(json, "\"type\":\"string\"") |> should.be_true() 148 | string.contains(json, "\"pattern\":\"^#[0-9a-fA-F]{6}$\"") |> should.be_true() 149 | string.contains(json, "\"enum\":[\"#FF0000\",\"#00FF00\",\"#0000FF\"]") 150 | |> should.be_true() 151 | string.contains( 152 | json, 153 | "\"description\":\"Hex color code from predefined set\"", 154 | ) 155 | |> should.be_true() 156 | } 157 | 158 | // Test simple pattern constraint output format 159 | pub fn simple_pattern_output_test() { 160 | let schema = 161 | schema.object([ 162 | schema.prop("test", schema.string()) 163 | |> schema.pattern("^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$"), 164 | ]) 165 | 166 | let json_string = schema.to_json(schema) |> json.to_string() 167 | 168 | // Should produce output similar to your example 169 | string.contains(json_string, "\"type\":\"string\"") |> should.be_true() 170 | string.contains( 171 | json_string, 172 | "\"pattern\":\"^(\\\\([0-9]{3}\\\\))?[0-9]{3}-[0-9]{4}$\"", 173 | ) 174 | |> should.be_true() 175 | } 176 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jscheam - A Simple JSON Schema Library 2 | 3 | [![Package Version](https://img.shields.io/hexpm/v/jscheam)](https://hex.pm/packages/jscheam) 4 | [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/jscheam/) 5 | 6 | A Gleam library for generating JSON Schema documents (Draft 7 compliant). 7 | I looked for a simple way to create JSON schemas in Gleam but every things I tried where either outdated or incomplete. This library was born out of that need. 8 | This library provides a fluent API for building JSON schemas programmatically, making it easy to create validation schemas for APIs, configuration files, and data structures. 9 | 10 | ## Table of Contents 11 | 12 | - [Installation](#installation) 13 | - [Usage](#usage) 14 | - [Basic Types](#basic-types) 15 | - [Object Schemas](#object-schemas) 16 | - [Optional Properties and Descriptions](#optional-properties-and-descriptions) 17 | - [Arrays](#arrays) 18 | - [Union Types](#union-types) 19 | - [Constraints](#constraints) 20 | - [Nested Objects](#nested-objects) 21 | - [TODO: Future Features](#todo-future-features) 22 | - [Development](#development) 23 | - [Contributing](#contributing) 24 | - [License](#license) 25 | 26 | ## Installation 27 | 28 | ```sh 29 | gleam add jscheam 30 | ``` 31 | 32 | ## Usage 33 | 34 | ### Basic Types 35 | 36 | ```gleam 37 | import jscheam/schema 38 | import gleam/json 39 | 40 | // Create simple types 41 | let name_schema = schema.string() 42 | let age_schema = schema.integer() 43 | let active_schema = schema.boolean() 44 | let score_schema = schema.float() 45 | 46 | // Generate JSON Schema 47 | let json_schema = schema.to_json(name_schema) |> json.to_string() 48 | // Result: {"type":"string"} 49 | ``` 50 | 51 | ### Object Schemas 52 | 53 | ```gleam 54 | import jscheam/schema 55 | import gleam/json 56 | 57 | // Create an object with default additional properties behavior (allows any) 58 | let user_schema = schema.object([ 59 | schema.prop("name", schema.string()), 60 | schema.prop("age", schema.integer()), 61 | schema.prop("email", schema.string()) 62 | ]) 63 | 64 | let json_schema = schema.to_json(user_schema) |> json.to_string() 65 | // Result: { 66 | // "type": "object", 67 | // "properties": { 68 | // "name": {"type": "string"}, 69 | // "age": {"type": "number"}, 70 | // "email": {"type": "string"} 71 | // }, 72 | // "required": ["name", "age", "email"] 73 | // Note: additionalProperties is omitted (defaults to true as per JSON Schema Draft 7) 74 | // } 75 | 76 | // Create an object with strict additional properties 77 | let strict_user_schema = schema.object([schema.prop("name", schema.string())]) 78 | |> schema.disallow_additional_props() 79 | |> schema.to_json(strict_user_schema) |> json.to_string() 80 | // Result: {..., "additionalProperties": false} 81 | 82 | // Create an object with constrained additional properties 83 | let constrained_user_schema = schema.object([schema.prop("id", schema.string())]) 84 | |> schema.constrain_additional_props(schema.string()) 85 | |> schema.to_json(constrained_user_schema) |> json.to_string() 86 | // Result: {..., "additionalProperties": {"type": "string"}} 87 | 88 | // Explicitly allow additional properties 89 | let explicit_allow_schema = schema.object([ 90 | schema.prop("name", schema.string()) 91 | ]) 92 | |> schema.allow_additional_props() 93 | |> schema.to_json(explicit_allow_schema) |> json.to_string() 94 | // Result: {..., "additionalProperties": true} 95 | ``` 96 | 97 | ### Optional Properties and Descriptions 98 | 99 | ```gleam 100 | import jscheam/schema 101 | import gleam/json 102 | 103 | let user_schema = schema.object([ 104 | schema.prop("name", schema.string()) |> schema.description("User's full name"), 105 | schema.prop("age", schema.integer()) |> schema.optional(), 106 | schema.prop("email", schema.string()) 107 | |> schema.description("User's email address") 108 | |> schema.optional() 109 | ]) 110 | 111 | let json_schema = schema.to_json(user_schema) |> json.to_string() 112 | // Result: { 113 | // "type": "object", 114 | // "properties": { 115 | // "name": {"type": "string", "description": "User's full name"}, 116 | // "age": {"type": "number"}, 117 | // "email": {"type": "string", "description": "User's email address"} 118 | // }, 119 | // "required": ["name"] 120 | // } 121 | ``` 122 | 123 | ### Arrays 124 | 125 | ```gleam 126 | import jscheam/schema 127 | import gleam/json 128 | 129 | // Array of strings 130 | let tags_schema = schema.array(schema.string()) 131 | 132 | // Array of objects 133 | let users_schema = schema.array( 134 | schema.object([ 135 | schema.prop("name", schema.string()), 136 | schema.prop("age", schema.integer()) |> schema.optional() 137 | ]) 138 | ) 139 | 140 | let json_schema = schema.to_json(tags_schema) |> json.to_string() 141 | // Result: { 142 | // "type": "array", 143 | // "items": {"type": "string"} 144 | // } 145 | ``` 146 | 147 | ### Union Types 148 | 149 | Union types allow a property to accept multiple types. 150 | Some API require all fields to be "required"" (no optional fields), 151 | so the only way to add nullability is to use union types. 152 | 153 | ```gleam 154 | import jscheam/schema 155 | import gleam/json 156 | 157 | // Simple union: string or null 158 | let nullable_string_schema = schema.union([schema.string(), schema.null()]) 159 | 160 | // Used in an object 161 | let user_schema = schema.object([ 162 | schema.prop("name", schema.string()), 163 | schema.prop("nickname", schema.union([schema.string(), schema.null()])) 164 | |> schema.description("Optional nickname, can be string or null") 165 | ]) 166 | 167 | let json_schema = schema.to_json(user_schema) |> json.to_string() 168 | // Result: { 169 | // "type": "object", 170 | // "properties": { 171 | // "name": {"type": "string"}, 172 | // "nickname": { 173 | // "type": ["string", "null"], 174 | // "description": "Optional nickname, can be string or null" 175 | // } 176 | // }, 177 | // "required": ["name", "nickname"] 178 | // } 179 | ``` 180 | 181 | ### Constraints 182 | 183 | Constraints allow you to add validation rules to your schema properties. 184 | jscheam supports enum and pattern constraints with more to come in the future. 185 | 186 | #### Enum Constraints 187 | 188 | Enum constraints restrict values to a fixed set of allowed values. 189 | It uses the `json` module to define the allowed values as enum values can be any valid JSON type (string, number, boolean, null, ...) 190 | 191 | ```gleam 192 | import jscheam/schema 193 | import gleam/json 194 | 195 | // String enum 196 | let color_schema = schema.object([ 197 | schema.prop("color", schema.string()) 198 | |> schema.enum([ 199 | json.string("red"), 200 | json.string("green"), 201 | json.string("blue") 202 | ]) 203 | |> schema.description("Primary colors only") 204 | ]) 205 | 206 | // Mixed type enum with union 207 | let status_schema = schema.object([ 208 | schema.prop("status", schema.union([schema.string(), schema.null(), schema.integer()])) 209 | |> schema.enum([ 210 | json.string("active"), 211 | json.string("inactive"), 212 | json.null(), 213 | json.int(42) 214 | ]) 215 | |> schema.description("Status with mixed types") 216 | ]) 217 | 218 | let json_schema = schema.to_json(color_schema) |> json.to_string() 219 | // Result: { 220 | // "type": "object", 221 | // "properties": { 222 | // "color": { 223 | // "type": "string", 224 | // "enum": ["red", "green", "blue"], 225 | // "description": "Primary colors only" 226 | // } 227 | // }, 228 | // "required": ["color"] 229 | // } 230 | ``` 231 | 232 | #### Pattern Constraints 233 | 234 | Pattern constraints use regular expressions to validate string values: 235 | 236 | ```gleam 237 | import jscheam 238 | import gleam/json 239 | 240 | // Email validation 241 | let user_schema = schema.object([ 242 | schema.prop("email", schema.string()) 243 | |> schema.pattern("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$") 244 | |> schema.description("Valid email address"), 245 | 246 | schema.prop("phone", schema.string()) 247 | |> schema.pattern("^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$") 248 | |> schema.description("Phone number in US format") 249 | ]) 250 | 251 | let json_schema = schema.to_json(user_schema) |> json.to_string() 252 | // Result: { 253 | // "type": "object", 254 | // "properties": { 255 | // "email": { 256 | // "type": "string", 257 | // "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", 258 | // "description": "Valid email address" 259 | // }, 260 | // "phone": { 261 | // "type": "string", 262 | // "pattern": "^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$", 263 | // "description": "Phone number in US format" 264 | // } 265 | // }, 266 | // "required": ["email", "phone"] 267 | // } 268 | ``` 269 | 270 | ### Nested Objects 271 | 272 | ```gleam 273 | import jscheam 274 | import gleam/json 275 | 276 | let profile_schema = schema.object([ 277 | schema.prop("user", schema.object([ 278 | schema.prop("name", schema.string()), 279 | schema.prop("age", schema.integer()) |> schema.optional() 280 | ])), 281 | schema.prop("preferences", schema.object([ 282 | schema.prop("theme", schema.string()) |> schema.description("UI theme preference"), 283 | schema.prop("notifications", schema.boolean()) |> schema.optional() 284 | ])), 285 | schema.prop("tags", schema.array(schema.string())) |> schema.description("User tags") 286 | ]) 287 | 288 | let json_schema = schema.to_json(profile_schema) |> json.to_string() 289 | // Result: { 290 | // "type": "object", 291 | // "properties": { 292 | // "user": { 293 | // "type": "object", 294 | // "properties": { 295 | // "name": {"type": "string"}, 296 | // "age": {"type": "number"} 297 | // }, 298 | // "required": ["name"] 299 | // }, 300 | // "preferences": { 301 | // "type": "object", 302 | // "properties": { 303 | // "theme": {"type": "string", "description": "UI theme preference"}, 304 | // "notifications": {"type": "boolean"} 305 | // }, 306 | // "required": ["theme"] 307 | // }, 308 | // "tags": { 309 | // "type": "array", 310 | // "items": {"type": "string"}, 311 | // "description": "User tags" 312 | // } 313 | // }, 314 | // "required": ["user", "preferences"] 315 | // } 316 | ``` 317 | 318 | ## TODO: Future Features 319 | 320 | ### Restrictions 321 | 322 | - **Conditional Schema Validation** 323 | - `dependentRequired` - conditionally requires that certain properties must be present based on the presence of other properties 324 | - `dependentSchemas` - conditionally applies different schemas based on the presence of other properties 325 | - `if - then - else` - conditional schema validation based on the value of a property 326 | - **String restrictions**: 327 | - `format(format)` - Format validation (email, uri, date-time, etc.) 328 | - **Number restrictions**: 329 | - `minimum(n)` / `maximum(n)` - Value range constraints 330 | - **Array restrictions**: 331 | - `min_items(n)` / `max_items(n)` - Array length constraints 332 | 333 | ## Development 334 | 335 | ```sh 336 | gleam run # Run the project 337 | gleam test # Run the tests 338 | ``` 339 | 340 | ## Contributing 341 | 342 | Contributions are welcome! Please feel free to submit issues and enhancement requests. 343 | 344 | ## License 345 | 346 | This project is licensed under the MIT License. 347 | -------------------------------------------------------------------------------- /src/jscheam/schema.gleam: -------------------------------------------------------------------------------- 1 | import gleam/json 2 | import gleam/list 3 | import gleam/option 4 | 5 | /// Constraints that can be applied to properties 6 | pub type Constraint { 7 | /// Restrict values to a fixed set of values (can be any JSON value) 8 | Enum(values: List(json.Json)) 9 | /// Pattern constraint using regex 10 | Pattern(regex: String) 11 | } 12 | 13 | /// A type definition for JSON Schema 14 | pub type Type { 15 | Integer 16 | String 17 | Boolean 18 | Float 19 | Null 20 | Object( 21 | properties: List(Property), 22 | additional_properties: AdditionalProperties, 23 | ) 24 | Array(Type) 25 | /// Union type for multiple allowed types (e.g., ["string", "null"]) 26 | Union(List(Type)) 27 | } 28 | 29 | /// Creates a string type for JSON Schema 30 | pub fn string() -> Type { 31 | String 32 | } 33 | 34 | /// Creates an integer/number type for JSON Schema 35 | pub fn integer() -> Type { 36 | Integer 37 | } 38 | 39 | /// Creates a boolean type for JSON Schema 40 | pub fn boolean() -> Type { 41 | Boolean 42 | } 43 | 44 | /// Creates a float/number type for JSON Schema 45 | pub fn float() -> Type { 46 | Float 47 | } 48 | 49 | /// Creates a null type for JSON Schema 50 | pub fn null() -> Type { 51 | Null 52 | } 53 | 54 | /// Creates an array type with the specified item type 55 | pub fn array(item_type: Type) -> Type { 56 | Array(item_type) 57 | } 58 | 59 | /// Creates a union type that accepts multiple types (e.g., string or null) 60 | /// Example: union([string(), null()]) creates a schema that accepts both strings and null values 61 | pub fn union(types: List(Type)) -> Type { 62 | Union(types) 63 | } 64 | 65 | /// Creates an object type with the specified properties 66 | /// By default allows any additional properties (JSON Schema default behavior - omits the field) 67 | pub fn object(properties: List(Property)) -> Type { 68 | Object(properties: properties, additional_properties: AllowAny) 69 | } 70 | 71 | /// Update an object type to allow any additional properties 72 | /// Explicitly allows any additional properties (outputs "additionalProperties": true) 73 | pub fn allow_additional_props(object_type: Type) -> Type { 74 | case object_type { 75 | Object(properties: props, additional_properties: _) -> 76 | Object(properties: props, additional_properties: AllowExplicit) 77 | _ -> object_type 78 | } 79 | } 80 | 81 | /// Update an object type to disallow additional properties 82 | /// Disallows additional properties (outputs "additionalProperties": false) 83 | pub fn disallow_additional_props(object_type: Type) -> Type { 84 | case object_type { 85 | Object(properties: props, additional_properties: _) -> 86 | Object(properties: props, additional_properties: Disallow) 87 | _ -> object_type 88 | } 89 | } 90 | 91 | /// Update an object type to constrain additional properties to a specific schema 92 | /// Example: object([prop("name", string())]) |> constrain_additional_props(string()) 93 | /// This will set "additionalProperties" to the specified schema type 94 | pub fn constrain_additional_props(object_type: Type, schema: Type) -> Type { 95 | case object_type { 96 | Object(properties: props, additional_properties: _) -> 97 | Object(properties: props, additional_properties: Schema(schema)) 98 | _ -> object_type 99 | } 100 | } 101 | 102 | /// A property in a object type 103 | /// Represents a field in an object with a name, type, and optional constraints 104 | pub type Property { 105 | Property( 106 | name: String, 107 | property_type: Type, 108 | is_required: Bool, 109 | description: option.Option(String), 110 | constraints: List(Constraint), 111 | ) 112 | } 113 | 114 | /// Additional properties configuration for object types 115 | pub type AdditionalProperties { 116 | /// Allow any additional properties (JSON Schema Draft 7 default behavior) 117 | /// This is the default and will omit the additionalProperties field from the schema 118 | AllowAny 119 | /// Explicitly allow any additional properties (outputs "additionalProperties": true) 120 | AllowExplicit 121 | /// Disallow any additional properties 122 | Disallow 123 | /// Additional properties must conform to the specified schema 124 | Schema(Type) 125 | } 126 | 127 | // Property builders 128 | /// Creates a property with the specified name and type 129 | /// Properties are required by default 130 | pub fn prop(name: String, property_type: Type) -> Property { 131 | Property( 132 | name: name, 133 | property_type: property_type, 134 | is_required: True, 135 | description: option.None, 136 | constraints: [], 137 | ) 138 | } 139 | 140 | /// Makes a property optional (not required in the schema) 141 | /// Example: object([prop("name", string()) |> optional()]) 142 | pub fn optional(property: Property) -> Property { 143 | Property(..property, is_required: False) 144 | } 145 | 146 | /// Adds a description to a property for documentation purposes 147 | /// Example: prop("name", string()) |> description("The name of the person") 148 | pub fn description(property: Property, desc: String) -> Property { 149 | Property(..property, description: option.Some(desc)) 150 | } 151 | 152 | /// Adds an enum constraint to a property that restricts values to a fixed set 153 | /// Example: prop("color", string()) |> enum(enum_strings(["red", "green", "blue"])) 154 | pub fn enum(property: Property, values: List(json.Json)) -> Property { 155 | let new_constraint = Enum(values: values) 156 | Property(..property, constraints: [new_constraint, ..property.constraints]) 157 | } 158 | 159 | /// Adds a pattern constraint to a property that restricts values to match a regex pattern 160 | /// Example: prop("phone", string()) |> pattern("^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$") 161 | pub fn pattern(property: Property, regex: String) -> Property { 162 | let new_constraint = Pattern(regex: regex) 163 | Property(..property, constraints: [new_constraint, ..property.constraints]) 164 | } 165 | 166 | fn additional_properties_to_json( 167 | add_props: AdditionalProperties, 168 | ) -> List(#(String, json.Json)) { 169 | case add_props { 170 | // Omit the field entirely (JSON Schema Draft 7 default equivalent to "additionalProperties": true) 171 | AllowAny -> [] 172 | AllowExplicit -> [#("additionalProperties", json.bool(True))] 173 | Disallow -> [#("additionalProperties", json.bool(False))] 174 | Schema(schema_type) -> [ 175 | #("additionalProperties", type_to_json_value(schema_type)), 176 | ] 177 | } 178 | } 179 | 180 | fn type_to_type_string(property_type: Type) -> String { 181 | case property_type { 182 | String -> "string" 183 | Integer -> "number" 184 | Boolean -> "boolean" 185 | Null -> "null" 186 | Float -> "number" 187 | Object(_, _) -> "object" 188 | Array(_) -> "array" 189 | Union(_) -> 190 | panic as "Union types should not be converted to single type strings" 191 | } 192 | } 193 | 194 | fn type_to_json_value(property_type: Type) -> json.Json { 195 | case property_type { 196 | String | Integer | Boolean | Null | Float -> 197 | json.object([#("type", json.string(type_to_type_string(property_type)))]) 198 | Object(properties: props, additional_properties: add_props) -> { 199 | let properties_json = list.map(props, property_to_field) |> json.object 200 | let required_json = fields_to_required(props) 201 | let additional_props_fields = additional_properties_to_json(add_props) 202 | 203 | let base_fields = [ 204 | #("type", json.string(type_to_type_string(property_type))), 205 | #("properties", properties_json), 206 | #("required", required_json), 207 | ] 208 | 209 | json.object(list.append(base_fields, additional_props_fields)) 210 | } 211 | Array(item_type) -> 212 | json.object([ 213 | #("type", json.string(type_to_type_string(property_type))), 214 | #("items", type_to_json_value(item_type)), 215 | ]) 216 | Union(types) -> { 217 | let type_strings = list.map(types, type_to_type_string) 218 | json.object([#("type", json.array(type_strings, json.string))]) 219 | } 220 | } 221 | } 222 | 223 | fn property_to_field(property: Property) -> #(String, json.Json) { 224 | let Property(name, property_type, _is_required, description, constraints) = 225 | property 226 | 227 | let base_fields = get_base_type_fields(property_type) 228 | let fields_with_constraints = add_constraint_fields(base_fields, constraints) 229 | let final_fields = case description { 230 | option.Some(desc) -> [ 231 | #("description", json.string(desc)), 232 | ..fields_with_constraints 233 | ] 234 | option.None -> fields_with_constraints 235 | } 236 | 237 | #(name, json.object(final_fields)) 238 | } 239 | 240 | fn get_base_type_fields(property_type: Type) -> List(#(String, json.Json)) { 241 | case property_type { 242 | String | Integer | Boolean | Null | Float -> [ 243 | #("type", json.string(type_to_type_string(property_type))), 244 | ] 245 | Array(item_type) -> [ 246 | #("type", json.string(type_to_type_string(property_type))), 247 | #("items", type_to_json_value(item_type)), 248 | ] 249 | Object(properties: props, additional_properties: add_props) -> { 250 | let properties_json = list.map(props, property_to_field) |> json.object 251 | let required_json = fields_to_required(props) 252 | let additional_props_fields = additional_properties_to_json(add_props) 253 | 254 | let base_fields = [ 255 | #("type", json.string(type_to_type_string(property_type))), 256 | #("properties", properties_json), 257 | #("required", required_json), 258 | ] 259 | 260 | list.append(base_fields, additional_props_fields) 261 | } 262 | Union(types) -> { 263 | let type_strings = list.map(types, type_to_type_string) 264 | [#("type", json.array(type_strings, json.string))] 265 | } 266 | } 267 | } 268 | 269 | fn add_constraint_fields( 270 | base_fields: List(#(String, json.Json)), 271 | constraints: List(Constraint), 272 | ) -> List(#(String, json.Json)) { 273 | case constraints { 274 | [] -> base_fields 275 | [constraint, ..rest] -> { 276 | let fields_with_constraint = 277 | add_single_constraint_field(base_fields, constraint) 278 | add_constraint_fields(fields_with_constraint, rest) 279 | } 280 | } 281 | } 282 | 283 | fn add_single_constraint_field( 284 | fields: List(#(String, json.Json)), 285 | constraint: Constraint, 286 | ) -> List(#(String, json.Json)) { 287 | case constraint { 288 | Enum(values: values) -> [ 289 | #("enum", json.array(values, fn(x) { x })), 290 | ..fields 291 | ] 292 | Pattern(regex: regex) -> [#("pattern", json.string(regex)), ..fields] 293 | } 294 | } 295 | 296 | fn fields_to_required(fields: List(Property)) -> json.Json { 297 | let required_fields = 298 | list.filter(fields, fn(property) { 299 | let Property( 300 | _name, 301 | _property_type, 302 | is_required, 303 | _description, 304 | _constraints, 305 | ) = property 306 | is_required 307 | }) 308 | 309 | let names = 310 | list.map(required_fields, fn(property) { 311 | let Property( 312 | name, 313 | _property_type, 314 | _is_required, 315 | _description, 316 | _constraints, 317 | ) = property 318 | json.string(name) 319 | }) 320 | json.array(names, fn(x) { x }) 321 | } 322 | 323 | /// Converts a Type to a JSON Schema document 324 | /// This is the main function to generate JSON Schema from your type definitions 325 | /// Example: object([prop("name", string()), prop("age", integer())]) |> to_json() 326 | pub fn to_json(object_type: Type) -> json.Json { 327 | type_to_json_value(object_type) 328 | } 329 | --------------------------------------------------------------------------------