Skip to content

Commit 4544c60

Browse files
committed
Add enum and pattern constraints to properties in JSON Schema
1 parent 3423e08 commit 4544c60

3 files changed

Lines changed: 209 additions & 55 deletions

File tree

src/jscheam.gleam

Lines changed: 92 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import gleam/json
22
import gleam/list
33
import gleam/option
44
import jscheam/property.{
5-
type AdditionalProperties, type Property, type Type, AllowAny, AllowExplicit,
6-
Array, Boolean, Disallow, Float, Integer, Null, Object, Property, Schema,
7-
String, Union,
5+
type AdditionalProperties, type Constraint, type Property, type Type, AllowAny,
6+
AllowExplicit, Array, Boolean, Disallow, Enum, Float, Integer, Null, Object,
7+
Pattern, Property, Schema, String, Union,
88
}
99

1010
// Property builders
@@ -16,6 +16,7 @@ pub fn prop(name: String, property_type: Type) -> Property {
1616
property_type: property_type,
1717
is_required: True,
1818
description: option.None,
19+
constraints: [],
1920
)
2021
}
2122

@@ -66,6 +67,13 @@ pub fn union(types: List(Type)) -> Type {
6667
Union(types)
6768
}
6869

70+
/// Adds an enum constraint to a property that restricts values to a fixed set
71+
/// Example: prop("color", string()) |> enum(enum_strings(["red", "green", "blue"]))
72+
pub fn enum(property: Property, values: List(json.Json)) -> Property {
73+
let new_constraint = Enum(values: values)
74+
Property(..property, constraints: [new_constraint, ..property.constraints])
75+
}
76+
6977
/// Creates an object type with the specified properties
7078
/// By default allows any additional properties (JSON Schema default behavior - omits the field)
7179
pub fn object(properties: List(Property)) -> Type {
@@ -157,69 +165,100 @@ fn type_to_json_value(property_type: Type) -> json.Json {
157165
}
158166

159167
fn property_to_field(property: Property) -> #(String, json.Json) {
160-
let Property(name, property_type, _is_required, description) = property
161-
let base_schema = type_to_json_value(property_type)
162-
163-
// Add description if provided
164-
let schema_with_description = case description {
165-
option.Some(desc) -> {
166-
case base_schema {
167-
_ -> {
168-
case property_type {
169-
String | Integer | Boolean | Float | Null ->
170-
json.object([
171-
#("type", json.string(type_to_type_string(property_type))),
172-
#("description", json.string(desc)),
173-
])
174-
Array(item_type) ->
175-
json.object([
176-
#("type", json.string(type_to_type_string(property_type))),
177-
#("items", type_to_json_value(item_type)),
178-
#("description", json.string(desc)),
179-
])
180-
Object(properties: props, additional_properties: add_props) -> {
181-
let properties_json =
182-
list.map(props, property_to_field) |> json.object
183-
let required_json = fields_to_required(props)
184-
let additional_props_fields =
185-
additional_properties_to_json(add_props)
186-
187-
let base_fields = [
188-
#("type", json.string(type_to_type_string(property_type))),
189-
#("properties", properties_json),
190-
#("required", required_json),
191-
#("description", json.string(desc)),
192-
]
193-
194-
json.object(list.append(base_fields, additional_props_fields))
195-
}
196-
Union(types) -> {
197-
let type_strings = list.map(types, type_to_type_string)
198-
json.object([
199-
#("type", json.array(type_strings, json.string)),
200-
#("description", json.string(desc)),
201-
])
202-
}
203-
}
204-
}
205-
}
168+
let Property(name, property_type, _is_required, description, constraints) =
169+
property
170+
171+
let base_fields = get_base_type_fields(property_type)
172+
let fields_with_constraints = add_constraint_fields(base_fields, constraints)
173+
let final_fields = case description {
174+
option.Some(desc) -> [
175+
#("description", json.string(desc)),
176+
..fields_with_constraints
177+
]
178+
option.None -> fields_with_constraints
179+
}
180+
181+
#(name, json.object(final_fields))
182+
}
183+
184+
fn get_base_type_fields(property_type: Type) -> List(#(String, json.Json)) {
185+
case property_type {
186+
String | Integer | Boolean | Null | Float -> [
187+
#("type", json.string(type_to_type_string(property_type))),
188+
]
189+
Array(item_type) -> [
190+
#("type", json.string(type_to_type_string(property_type))),
191+
#("items", type_to_json_value(item_type)),
192+
]
193+
Object(properties: props, additional_properties: add_props) -> {
194+
let properties_json = list.map(props, property_to_field) |> json.object
195+
let required_json = fields_to_required(props)
196+
let additional_props_fields = additional_properties_to_json(add_props)
197+
198+
let base_fields = [
199+
#("type", json.string(type_to_type_string(property_type))),
200+
#("properties", properties_json),
201+
#("required", required_json),
202+
]
203+
204+
list.append(base_fields, additional_props_fields)
205+
}
206+
Union(types) -> {
207+
let type_strings = list.map(types, type_to_type_string)
208+
[#("type", json.array(type_strings, json.string))]
206209
}
207-
option.None -> base_schema
208210
}
211+
}
209212

210-
#(name, schema_with_description)
213+
fn add_constraint_fields(
214+
base_fields: List(#(String, json.Json)),
215+
constraints: List(Constraint),
216+
) -> List(#(String, json.Json)) {
217+
case constraints {
218+
[] -> base_fields
219+
[constraint, ..rest] -> {
220+
let fields_with_constraint =
221+
add_single_constraint_field(base_fields, constraint)
222+
add_constraint_fields(fields_with_constraint, rest)
223+
}
224+
}
225+
}
226+
227+
fn add_single_constraint_field(
228+
fields: List(#(String, json.Json)),
229+
constraint: Constraint,
230+
) -> List(#(String, json.Json)) {
231+
case constraint {
232+
Enum(values: values) -> [
233+
#("enum", json.array(values, fn(x) { x })),
234+
..fields
235+
]
236+
Pattern(regex: regex) -> [#("pattern", json.string(regex)), ..fields]
237+
}
211238
}
212239

213240
fn fields_to_required(fields: List(Property)) -> json.Json {
214241
let required_fields =
215242
list.filter(fields, fn(property) {
216-
let Property(_name, _property_type, is_required, _description) = property
243+
let Property(
244+
_name,
245+
_property_type,
246+
is_required,
247+
_description,
248+
_constraints,
249+
) = property
217250
is_required
218251
})
219252

220253
let names =
221254
list.map(required_fields, fn(property) {
222-
let Property(name, _property_type, _is_required, _description) = property
255+
let Property(
256+
name,
257+
_property_type,
258+
_is_required,
259+
_description,
260+
_constraints,
261+
) = property
223262
json.string(name)
224263
})
225264
json.array(names, fn(x) { x })

src/jscheam/property.gleam

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
1+
import gleam/json
12
import gleam/option
23

4+
/// Constraints that can be applied to properties
5+
pub type Constraint {
6+
/// Restrict values to a fixed set of values (can be any JSON value)
7+
Enum(values: List(json.Json))
8+
/// Pattern constraint using regex
9+
Pattern(regex: String)
10+
}
11+
312
/// A property in a JSON Schema object
413
pub type Property {
514
Property(
615
name: String,
716
property_type: Type,
817
is_required: Bool,
918
description: option.Option(String),
19+
constraints: List(Constraint),
1020
)
1121
}
1222

test/jscheam_test.gleam

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import gleam/string
33
import gleeunit
44
import gleeunit/should
55
import jscheam
6-
import jscheam/property.{Array, Boolean, Float, Integer, Null, String, Union}
6+
import jscheam/property.{
7+
Array, Boolean, Enum, Float, Integer, Null, Property, String, Union,
8+
}
79

810
pub fn main() -> Nil {
911
gleeunit.main()
@@ -180,7 +182,31 @@ pub fn union_type_test() {
180182
let json = jscheam.to_json(schema) |> json.to_string()
181183

182184
string.contains(json, "\"type\":\"object\"") |> should.be_true()
183-
string.contains(json, "\"units\":{\"type\":[\"string\",\"null\"]")
185+
string.contains(json, "\"units\":{") |> should.be_true()
186+
string.contains(json, "\"type\":[\"string\",\"null\"]") |> should.be_true()
187+
string.contains(
188+
json,
189+
"\"description\":\"Units the temperature will be returned in.\"",
190+
)
191+
|> should.be_true()
192+
string.contains(json, "\"required\":[\"units\"]") |> should.be_true()
193+
}
194+
195+
// Test enum with string base type
196+
pub fn enum_string_test() {
197+
let schema =
198+
jscheam.object([
199+
jscheam.prop("units", jscheam.string())
200+
|> jscheam.enum([json.string("celsius"), json.string("fahrenheit")])
201+
|> jscheam.description("Units the temperature will be returned in."),
202+
])
203+
204+
let json = jscheam.to_json(schema) |> json.to_string()
205+
206+
string.contains(json, "\"type\":\"object\"") |> should.be_true()
207+
string.contains(json, "\"units\":{") |> should.be_true()
208+
string.contains(json, "\"type\":\"string\"") |> should.be_true()
209+
string.contains(json, "\"enum\":[\"celsius\",\"fahrenheit\"]")
184210
|> should.be_true()
185211
string.contains(
186212
json,
@@ -189,3 +215,82 @@ pub fn union_type_test() {
189215
|> should.be_true()
190216
string.contains(json, "\"required\":[\"units\"]") |> should.be_true()
191217
}
218+
219+
// Test enum with union base type (as in your example)
220+
pub fn enum_union_test() {
221+
let schema =
222+
jscheam.object([
223+
jscheam.prop("location", jscheam.string())
224+
|> jscheam.description("City and country e.g. Bogotá, Colombia"),
225+
jscheam.prop("units", jscheam.union([jscheam.string(), jscheam.null()]))
226+
|> jscheam.enum([json.string("celsius"), json.string("fahrenheit")])
227+
|> jscheam.description("Units the temperature will be returned in."),
228+
])
229+
|> jscheam.disallow_additional_props()
230+
231+
let json = jscheam.to_json(schema) |> json.to_string()
232+
233+
string.contains(json, "\"type\":\"object\"") |> should.be_true()
234+
string.contains(json, "\"location\":{") |> should.be_true()
235+
string.contains(json, "\"units\":{") |> should.be_true()
236+
string.contains(json, "\"type\":[\"string\",\"null\"]") |> should.be_true()
237+
string.contains(json, "\"enum\":[\"celsius\",\"fahrenheit\"]")
238+
|> should.be_true()
239+
string.contains(json, "\"required\":[\"location\",\"units\"]")
240+
|> should.be_true()
241+
string.contains(json, "\"additionalProperties\":false") |> should.be_true()
242+
}
243+
244+
// Test enum constraint application
245+
pub fn enum_constraint_test() {
246+
let expected_values = [
247+
json.string("red"),
248+
json.string("green"),
249+
json.string("blue"),
250+
]
251+
let property =
252+
jscheam.prop("color", jscheam.string())
253+
|> jscheam.enum(expected_values)
254+
255+
// Test that the constraint was applied
256+
let Property(_name, _type, _required, _description, constraints) = property
257+
case constraints {
258+
[Enum(values: values)] -> values |> should.equal(expected_values)
259+
_ -> should.fail()
260+
}
261+
}
262+
263+
// Test mixed-type enum (strings, numbers, null)
264+
pub fn enum_mixed_types_test() {
265+
let mixed_values = [
266+
json.string("red"),
267+
json.string("amber"),
268+
json.string("green"),
269+
json.null(),
270+
json.int(42),
271+
]
272+
273+
let schema =
274+
jscheam.object([
275+
jscheam.prop(
276+
"status",
277+
jscheam.union([jscheam.string(), jscheam.null(), jscheam.integer()]),
278+
)
279+
|> jscheam.enum(mixed_values)
280+
|> jscheam.description("Traffic light status with special values"),
281+
])
282+
283+
let json = jscheam.to_json(schema) |> json.to_string()
284+
285+
string.contains(json, "\"type\":\"object\"") |> should.be_true()
286+
string.contains(json, "\"status\":{") |> should.be_true()
287+
string.contains(json, "\"type\":[\"string\",\"null\",\"number\"]")
288+
|> should.be_true()
289+
string.contains(json, "\"enum\":[\"red\",\"amber\",\"green\",null,42]")
290+
|> should.be_true()
291+
string.contains(
292+
json,
293+
"\"description\":\"Traffic light status with special values\"",
294+
)
295+
|> should.be_true()
296+
}

0 commit comments

Comments
 (0)