From cc92c3a9be2d4c9fd78dd422e08f4de63584f93e Mon Sep 17 00:00:00 2001 From: lostbean Date: Wed, 4 Dec 2024 12:44:27 -0300 Subject: [PATCH] update tests and docs --- README.md | 355 ++++++++++- src/json/blueprint.gleam | 7 + .../enum_tuple_and_optional_test.gleam | 289 +++++++++ test/examples/recursive_types_test.gleam | 185 ++++++ test/examples/union_type_test.gleam | 104 ++++ test/json_blueprint_test.gleam | 566 +----------------- 6 files changed, 936 insertions(+), 570 deletions(-) create mode 100644 test/examples/enum_tuple_and_optional_test.gleam create mode 100644 test/examples/recursive_types_test.gleam create mode 100644 test/examples/union_type_test.gleam diff --git a/README.md b/README.md index 5abb4bd..e981a6b 100644 --- a/README.md +++ b/README.md @@ -21,15 +21,23 @@ json_blueprint provides utilities for encoding and decoding JSON data, with spec > > While the library supports recursive data types (types with self reference), it does not support cyclical data types (cyclical dependency between multiple data types). Cyclical data types will result in infinite loop during decoding or schema generation. -### Encoding Union Types +## Examples +
+ Encoding Union Types + Here's an example of encoding a union type to JSON: ```gleam -import json/blueprint -import gleam/json import gleam/io +import gleam/json +import gleeunit import gleeunit/should +import json/blueprint + +pub fn main() { + gleeunit.main() +} type Shape { Circle(Float) @@ -74,47 +82,47 @@ fn shape_decoder() -> blueprint.Decoder(Shape) { ]) } -fn simple_test() { +pub fn union_type_test() { + let circle = Circle(5.0) + let rectangle = Rectangle(10.0, 20.0) + let decoder = shape_decoder() - // Test encoding a Circle - let circle = Circle(5.0) + //test decoding encode_shape(circle) |> json.to_string |> blueprint.decode(using: decoder) |> should.equal(Ok(circle)) - // Test encoding a Rectangle - let rectangle = Rectangle(10.0, 20.0) encode_shape(rectangle) |> json.to_string |> blueprint.decode(using: decoder) |> should.equal(Ok(rectangle)) - // Test encoding a Void encode_shape(Void) |> json.to_string |> blueprint.decode(using: decoder) |> should.equal(Ok(Void)) - // Print JSON schema - decoder - |> blueprint.generate_json_schema() + blueprint.generate_json_schema(shape_decoder()) |> json.to_string |> io.println } ``` +#### Generated JSON Schema + ```json { "$schema": "http://json-schema.org/draft-07/schema#", - "oneOf": [ + "anyOf": [ { "required": ["type", "data"], "additionalProperties": false, "type": "object", "properties": { "type": { + "type": "string", "enum": ["circle"] }, "data": { @@ -135,6 +143,7 @@ fn simple_test() { "type": "object", "properties": { "type": { + "type": "string", "enum": ["rectangle"] }, "data": { @@ -158,6 +167,7 @@ fn simple_test() { "type": "object", "properties": { "type": { + "type": "string", "enum": ["void"] }, "data": { @@ -173,9 +183,25 @@ fn simple_test() { This will encode your union types into a standardized JSON format with `type` and `data` fields, making it easy to decode on the receiving end. +
+ +
+ Type aliases and optional fields + And here's an example using type aliases, optional fields, and single constructor types: ```gleam +import gleam/io +import gleam/json +import gleam/option.{type Option, None, Some} +import gleeunit +import gleeunit/should +import json/blueprint + +pub fn main() { + gleeunit.main() +} + type Color { Red Green @@ -186,7 +212,7 @@ type Coordinate = #(Float, Float) type Drawing { - Box(Float, Float, Option(Coordinate), Option(Color)) + Box(Float, Float, Coordinate, Option(Color)) } fn color_decoder() { @@ -239,9 +265,7 @@ fn drawing_decoder() -> blueprint.Decoder(Drawing) { Box, blueprint.field("width", blueprint.float()), blueprint.field("height", blueprint.float()), - // Make this field required by with a possible null value - blueprint.field("position", optional(coordinate_decoder())), - // Make this field optional + blueprint.field("position", coordinate_decoder()), blueprint.optional_field("color", color_decoder()), ), ), @@ -250,7 +274,7 @@ fn drawing_decoder() -> blueprint.Decoder(Drawing) { pub fn drawing_test() { // Test cases - let box = Box(15.0, 25.0, Some(#(30.0, 40.0)), None) + let box = Box(15.0, 25.0, #(30.0, 40.0), None) // Test encoding let encoded_box = encode_drawing(box) @@ -260,9 +284,304 @@ pub fn drawing_test() { |> json.to_string |> blueprint.decode(using: drawing_decoder()) |> should.equal(Ok(box)) + + blueprint.generate_json_schema(drawing_decoder()) + |> json.to_string + |> io.println +} + +``` + +#### Generated JSON Schema + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "required": ["type", "data"], + "additionalProperties": false, + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["box"] + }, + "data": { + "required": ["width", "height", "position"], + "additionalProperties": false, + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + }, + "position": { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "type": "array" + }, + "color": { + "required": ["enum"], + "additionalProperties": false, + "type": "object", + "properties": { + "enum": { + "type": "string", + "enum": ["red", "green", "blue"] + } + } + } + } + } + } } ``` +
+ +
+ Recursive data types + +And here's an example using type aliases, optional fields, and single constructor types: + +```gleam +import gleam/io +import gleam/json +import gleam/option.{type Option, None, Some} +import gleeunit +import gleeunit/should +import json/blueprint + +pub fn main() { + gleeunit.main() +} + +type Tree { + Node(value: Int, left: Option(Tree), right: Option(Tree)) +} + +type ListOfTrees(t) { + ListOfTrees(head: t, tail: ListOfTrees(t)) + NoTrees +} + +fn encode_tree(tree: Tree) -> json.Json { + blueprint.union_type_encoder(tree, fn(node) { + case node { + Node(value, left, right) -> #( + "node", + [ + #("value", json.int(value)), + #("right", json.nullable(right, encode_tree)), + ] + |> blueprint.encode_optional_field("left", left, encode_tree) + |> json.object(), + ) + } + }) +} + +fn encode_list_of_trees(tree: ListOfTrees(Tree)) -> json.Json { + blueprint.union_type_encoder(tree, fn(list) { + case list { + ListOfTrees(head, tail) -> #( + "list", + json.object([ + #("head", encode_tree(head)), + #("tail", encode_list_of_trees(tail)), + ]), + ) + NoTrees -> #("no_trees", json.object([])) + } + }) +} + +// Without reuse_decoder, recursive types would cause infinite schema expansion +fn tree_decoder() { + blueprint.union_type_decoder([ + #( + "node", + blueprint.decode3( + Node, + blueprint.field("value", blueprint.int()), + // testing both an optional field a field with a possible null + blueprint.optional_field("left", blueprint.self_decoder(tree_decoder)), + blueprint.field( + "right", + blueprint.optional(blueprint.self_decoder(tree_decoder)), + ), + ), + ), + ]) + // !!!IMPORTANT!!! Add the reuse_decoder when there are nested recursive types so + // the schema references (`#`) get rewritten correctly and self-references from the + // different types don't get mixed up. As a recommendation, always add it when + // decoding recursive types. + |> blueprint.reuse_decoder +} + +fn decode_list_of_trees() { + blueprint.union_type_decoder([ + #( + "list", + blueprint.decode2( + ListOfTrees, + blueprint.field("head", tree_decoder()), + blueprint.field("tail", blueprint.self_decoder(decode_list_of_trees)), + ), + ), + #("no_trees", blueprint.decode0(NoTrees)), + ]) +} + +pub fn tree_decoder_test() { + // Create a sample tree structure: + // 5 + // / \ + // 3 7 + // / \ + // 1 9 + let tree = + Node( + value: 5, + left: Some(Node(value: 3, left: Some(Node(1, None, None)), right: None)), + right: Some(Node(value: 7, left: None, right: Some(Node(9, None, None)))), + ) + + // Create a list of trees + let tree_list = + ListOfTrees( + Node(value: 1, left: None, right: None), + ListOfTrees( + Node( + value: 10, + left: Some(Node(value: 1, left: None, right: None)), + right: None, + ), + NoTrees, + ), + ) + + // Test encoding + let json_str = tree |> encode_tree |> json.to_string() + let list_json_str = tree_list |> encode_list_of_trees |> json.to_string() + + // Test decoding + let decoded = blueprint.decode(using: tree_decoder(), from: json_str) + + decoded + |> should.equal(Ok(tree)) + + let decoded_list = + blueprint.decode(using: decode_list_of_trees(), from: list_json_str) + + decoded_list + |> should.equal(Ok(tree_list)) + + // Test schema generation + blueprint.generate_json_schema(decode_list_of_trees()) + |> json.to_string + |> io.println +} +``` + +#### Generated JSON Schema + +```json +{ + "$defs": { + "ref_CEF475B4CA96DC7B2C0C206AC7598AFFC4B66FD2": { + "required": ["type", "data"], + "additionalProperties": false, + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["node"] + }, + "data": { + "required": ["value", "right"], + "additionalProperties": false, + "type": "object", + "properties": { + "value": { + "type": "integer" + }, + "left": { + "$ref": "#/$defs/ref_CEF475B4CA96DC7B2C0C206AC7598AFFC4B66FD2" + }, + "right": { + "anyOf": [ + { + "$ref": "#/$defs/ref_CEF475B4CA96DC7B2C0C206AC7598AFFC4B66FD2" + }, + { + "type": "null" + } + ] + } + } + } + } + } + }, + "$schema": "http://json-schema.org/draft-07/schema#", + "anyOf": [ + { + "required": ["type", "data"], + "additionalProperties": false, + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["list"] + }, + "data": { + "required": ["head", "tail"], + "additionalProperties": false, + "type": "object", + "properties": { + "head": { + "$ref": "#/$defs/ref_CEF475B4CA96DC7B2C0C206AC7598AFFC4B66FD2" + }, + "tail": { + "$ref": "#" + } + } + } + } + }, + { + "required": ["type", "data"], + "additionalProperties": false, + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["no_trees"] + }, + "data": { + "additionalProperties": false, + "type": "object", + "properties": {} + } + } + } + ] +} +``` + +
+ ## Features - 🎯 Type-safe JSON encoding and decoding diff --git a/src/json/blueprint.gleam b/src/json/blueprint.gleam index 2be64ea..191257c 100644 --- a/src/json/blueprint.gleam +++ b/src/json/blueprint.gleam @@ -94,6 +94,12 @@ pub fn reuse_decoder(decoder: Decoder(t)) -> Decoder(t) { /// recursive dependency cycle. The returned decoder uses a JSON Schema reference "#" /// to point to the root schema definition. /// +/// > ❗ _**IMPORTANT**_ +/// > Add the reuse_decoder when there are nested recursive types so +/// > the schema references (`#`) get rewritten correctly and self-references from the +/// > different types don't get mixed up. As a recommendation, always add it when +/// > decoding recursive types. +/// /// ## Example /// ```gleam /// // A binary tree type that can contain itself @@ -115,6 +121,7 @@ pub fn reuse_decoder(decoder: Decoder(t)) -> Decoder(t) { /// field("right", optional(self_decoder(tree_decoder))), /// )), /// ]) +/// |> reuse_decoder /// } /// ``` /// diff --git a/test/examples/enum_tuple_and_optional_test.gleam b/test/examples/enum_tuple_and_optional_test.gleam new file mode 100644 index 0000000..a48d7dd --- /dev/null +++ b/test/examples/enum_tuple_and_optional_test.gleam @@ -0,0 +1,289 @@ +import gleam/io +import gleam/json +import gleam/option.{type Option, None, Some} +import gleeunit +import gleeunit/should +import json/blueprint + +pub fn main() { + gleeunit.main() +} + +type Color { + Red + Green + Blue +} + +type Coordinate = + #(Float, Float) + +type Drawing { + Box(Float, Float, Coordinate, Option(Color)) +} + +fn color_decoder() { + blueprint.enum_type_decoder([ + #("red", Red), + #("green", Green), + #("blue", Blue), + ]) +} + +fn color_encoder(input) { + blueprint.enum_type_encoder(input, fn(color) { + case color { + Red -> "red" + Green -> "green" + Blue -> "blue" + } + }) +} + +fn encode_coordinate(coord: Coordinate) -> json.Json { + blueprint.encode_tuple2(coord, json.float, json.float) +} + +fn coordinate_decoder() { + blueprint.tuple2(blueprint.float(), blueprint.float()) +} + +fn encode_drawing(drawing: Drawing) -> json.Json { + blueprint.union_type_encoder(drawing, fn(shape) { + case shape { + Box(width, height, position, color) -> #( + "box", + json.object([ + #("width", json.float(width)), + #("height", json.float(height)), + #("position", encode_coordinate(position)), + #("color", json.nullable(color, color_encoder)), + ]), + ) + } + }) +} + +fn drawing_decoder() -> blueprint.Decoder(Drawing) { + blueprint.union_type_decoder([ + #( + "box", + blueprint.decode4( + Box, + blueprint.field("width", blueprint.float()), + blueprint.field("height", blueprint.float()), + blueprint.field("position", coordinate_decoder()), + blueprint.optional_field("color", color_decoder()), + ), + ), + ]) +} + +pub fn drawing_test() { + // Test cases + let box = Box(15.0, 25.0, #(30.0, 40.0), None) + + // Test encoding + let encoded_box = encode_drawing(box) + + // Test decoding + encoded_box + |> json.to_string + |> blueprint.decode(using: drawing_decoder()) + |> should.equal(Ok(box)) + + blueprint.generate_json_schema(drawing_decoder()) + |> json.to_string + |> io.println +} + +pub fn drawing_match_str_test() { + // Test specific JSON structure + Box(15.0, 25.0, #(30.0, 40.0), None) + |> encode_drawing() + |> should.equal( + json.object([ + #("type", json.string("box")), + #( + "data", + json.object([ + #("width", json.float(15.0)), + #("height", json.float(25.0)), + #( + "position", + json.preprocessed_array([json.float(30.0), json.float(40.0)]), + ), + #("color", json.null()), + ]), + ), + ]), + ) + + // Test invalid data + // Test missing required fields + "{\"type\":\"box\",\"data\":{\"width\":15.0}}" + |> blueprint.decode(using: drawing_decoder()) + |> should.be_error() + + drawing_decoder() + |> blueprint.generate_json_schema + |> json.to_string + |> should.equal( + "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[\"type\",\"data\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"box\"]},\"data\":{\"required\":[\"width\",\"height\",\"position\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"width\":{\"type\":\"number\"},\"height\":{\"type\":\"number\"},\"position\":{\"maxItems\":2,\"minItems\":2,\"prefixItems\":[{\"type\":\"number\"},{\"type\":\"number\"}],\"type\":\"array\"},\"color\":{\"required\":[\"enum\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"enum\":{\"type\":\"string\",\"enum\":[\"red\",\"green\",\"blue\"]}}}}}}}", + ) +} + +pub fn enum_type_test() { + // Test encoding + color_encoder(Red) + |> json.to_string + |> should.equal("{\"enum\":\"red\"}") + + color_encoder(Green) + |> json.to_string + |> should.equal("{\"enum\":\"green\"}") + + color_encoder(Blue) + |> json.to_string + |> should.equal("{\"enum\":\"blue\"}") + + // Test decoding + blueprint.decode(color_decoder(), "{\"enum\":\"red\"}") + |> should.equal(Ok(Red)) + + blueprint.decode(color_decoder(), "{\"enum\":\"green\"}") + |> should.equal(Ok(Green)) + + blueprint.decode(color_decoder(), "{\"enum\":\"blue\"}") + |> should.equal(Ok(Blue)) + + // Test invalid enum value + blueprint.decode(color_decoder(), "{\"enum\":\"yellow\"}") + |> should.be_error + + // Test encoding a Circle + let red = Red + // Test encoding a Rectangle + let blue = Blue + + //test decoding + color_encoder(red) + |> json.to_string + |> blueprint.decode(using: color_decoder()) + |> should.equal(Ok(red)) + + color_encoder(blue) + |> json.to_string + |> blueprint.decode(using: color_decoder()) + |> should.equal(Ok(blue)) +} + +type ColorPair = + #(Color, Color) + +type RBG = + #(Int, Int, Int) + +type Palette { + Palette( + primary: Color, + secondary: Option(Color), + pair: Option(ColorPair), + rgb: Option(RBG), + ) +} + +fn encode_palette(input) { + blueprint.union_type_encoder(input, fn(palette) { + case palette { + Palette(primary, secondary, pair, rgb) -> { + let fields = [ + #("primary", color_encoder(primary)), + #("secondary", json.nullable(secondary, color_encoder)), + #( + "pair", + json.nullable(pair, blueprint.encode_tuple2( + _, + color_encoder, + color_encoder, + )), + ), + #( + "rgb", + json.nullable(rgb, blueprint.encode_tuple3( + _, + json.int, + json.int, + json.int, + )), + ), + ] + #("palette", json.object(fields)) + } + } + }) +} + +fn palette_decoder() { + blueprint.union_type_decoder([ + #( + "palette", + blueprint.decode4( + Palette, + blueprint.field("primary", color_decoder()), + blueprint.optional_field("secondary", color_decoder()), + blueprint.optional_field( + "pair", + blueprint.tuple2(color_decoder(), color_decoder()), + ), + blueprint.optional_field( + "rgb", + blueprint.tuple3(blueprint.int(), blueprint.int(), blueprint.int()), + ), + ), + ), + ]) +} + +pub fn palette_test() { + // Create decoder for Palette + + // Test cases + let palette1 = + Palette( + primary: Red, + secondary: Some(Blue), + pair: Some(#(Green, Red)), + rgb: Some(#(255, 128, 0)), + ) + + let palette2 = Palette(primary: Green, secondary: None, pair: None, rgb: None) + + // Test encoding + let encoded1 = encode_palette(palette1) + let encoded2 = encode_palette(palette2) + + // Test decoding + encoded1 + |> json.to_string + |> blueprint.decode(using: palette_decoder()) + |> should.equal(Ok(palette1)) + + encoded2 + |> json.to_string + |> blueprint.decode(using: palette_decoder()) + |> should.equal(Ok(palette2)) + + // Test specific JSON structure + encoded1 + |> json.to_string + |> should.equal( + "{\"type\":\"palette\",\"data\":{\"primary\":{\"enum\":\"red\"},\"secondary\":{\"enum\":\"blue\"},\"pair\":[{\"enum\":\"green\"},{\"enum\":\"red\"}],\"rgb\":[255,128,0]}}", + ) + + encoded2 + |> json.to_string + |> should.equal( + "{\"type\":\"palette\",\"data\":{\"primary\":{\"enum\":\"green\"},\"secondary\":null,\"pair\":null,\"rgb\":null}}", + ) +} diff --git a/test/examples/recursive_types_test.gleam b/test/examples/recursive_types_test.gleam new file mode 100644 index 0000000..bf2ad11 --- /dev/null +++ b/test/examples/recursive_types_test.gleam @@ -0,0 +1,185 @@ +import gleam/io +import gleam/json +import gleam/option.{type Option, None, Some} +import gleeunit +import gleeunit/should +import json/blueprint + +pub fn main() { + gleeunit.main() +} + +type Tree { + Node(value: Int, left: Option(Tree), right: Option(Tree)) +} + +type ListOfTrees(t) { + ListOfTrees(head: t, tail: ListOfTrees(t)) + NoTrees +} + +fn encode_tree(tree: Tree) -> json.Json { + blueprint.union_type_encoder(tree, fn(node) { + case node { + Node(value, left, right) -> #( + "node", + [ + #("value", json.int(value)), + #("right", json.nullable(right, encode_tree)), + ] + |> blueprint.encode_optional_field("left", left, encode_tree) + |> json.object(), + ) + } + }) +} + +fn encode_list_of_trees(tree: ListOfTrees(Tree)) -> json.Json { + blueprint.union_type_encoder(tree, fn(list) { + case list { + ListOfTrees(head, tail) -> #( + "list", + json.object([ + #("head", encode_tree(head)), + #("tail", encode_list_of_trees(tail)), + ]), + ) + NoTrees -> #("no_trees", json.object([])) + } + }) +} + +// Without reuse_decoder, recursive types would cause infinite schema expansion +fn tree_decoder() { + blueprint.union_type_decoder([ + #( + "node", + blueprint.decode3( + Node, + blueprint.field("value", blueprint.int()), + // testing both an optional field a field with a possible null + blueprint.optional_field("left", blueprint.self_decoder(tree_decoder)), + blueprint.field( + "right", + blueprint.optional(blueprint.self_decoder(tree_decoder)), + ), + ), + ), + ]) + // !!!IMPORTANT!!! Add the reuse_decoder when there are nested recursive types so + // the schema references (`#`) get rewritten correctly and self-references from the + // different types don't get mixed up. As a recommendation, always add it when + // decoding recursive types. + |> blueprint.reuse_decoder +} + +fn decode_list_of_trees() { + blueprint.union_type_decoder([ + #( + "list", + blueprint.decode2( + ListOfTrees, + blueprint.field("head", tree_decoder()), + blueprint.field("tail", blueprint.self_decoder(decode_list_of_trees)), + ), + ), + #("no_trees", blueprint.decode0(NoTrees)), + ]) +} + +pub fn tree_decoder_test() { + // Create a sample tree structure: + // 5 + // / \ + // 3 7 + // / \ + // 1 9 + let tree = + Node( + value: 5, + left: Some(Node(value: 3, left: Some(Node(1, None, None)), right: None)), + right: Some(Node(value: 7, left: None, right: Some(Node(9, None, None)))), + ) + + // Create a list of trees + let tree_list = + ListOfTrees( + Node(value: 1, left: None, right: None), + ListOfTrees( + Node( + value: 10, + left: Some(Node(value: 1, left: None, right: None)), + right: None, + ), + NoTrees, + ), + ) + + // Test encoding + let json_str = tree |> encode_tree |> json.to_string() + let list_json_str = tree_list |> encode_list_of_trees |> json.to_string() + + // Test decoding + let decoded = blueprint.decode(using: tree_decoder(), from: json_str) + + decoded + |> should.equal(Ok(tree)) + + let decoded_list = + blueprint.decode(using: decode_list_of_trees(), from: list_json_str) + + decoded_list + |> should.equal(Ok(tree_list)) + + // Test schema generation + blueprint.generate_json_schema(decode_list_of_trees()) + |> json.to_string + |> io.println +} + +pub fn tree_decoder_match_str_test() { + let tree = + Node( + value: 5, + left: Some(Node(value: 3, left: Some(Node(1, None, None)), right: None)), + right: Some(Node(value: 7, left: None, right: Some(Node(9, None, None)))), + ) + + // Create a list of trees + let tree_list = + ListOfTrees( + Node(value: 1, left: None, right: None), + ListOfTrees( + Node( + value: 10, + left: Some(Node(value: 1, left: None, right: None)), + right: None, + ), + NoTrees, + ), + ) + + // Test encoding + let json_str = tree |> encode_tree |> json.to_string() + let list_json_str = tree_list |> encode_list_of_trees |> json.to_string() + + // Test specific JSON structure + json_str + |> should.equal( + "{\"type\":\"node\",\"data\":{\"left\":{\"type\":\"node\",\"data\":{\"left\":{\"type\":\"node\",\"data\":{\"value\":1,\"right\":null}},\"value\":3,\"right\":null}},\"value\":5,\"right\":{\"type\":\"node\",\"data\":{\"value\":7,\"right\":{\"type\":\"node\",\"data\":{\"value\":9,\"right\":null}}}}}}", + ) + + list_json_str + |> should.equal( + "{\"type\":\"list\",\"data\":{\"head\":{\"type\":\"node\",\"data\":{\"value\":1,\"right\":null}},\"tail\":{\"type\":\"list\",\"data\":{\"head\":{\"type\":\"node\",\"data\":{\"left\":{\"type\":\"node\",\"data\":{\"value\":1,\"right\":null}},\"value\":10,\"right\":null}},\"tail\":{\"type\":\"no_trees\",\"data\":{}}}}}}", + ) + + // Test schema generation + let schema = blueprint.generate_json_schema(decode_list_of_trees()) + + schema + |> json.to_string + |> should.equal( + "{\"$defs\":{\"ref_CEF475B4CA96DC7B2C0C206AC7598AFFC4B66FD2\":{\"required\":[\"type\",\"data\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"node\"]},\"data\":{\"required\":[\"value\",\"right\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"integer\"},\"left\":{\"$ref\":\"#/$defs/ref_CEF475B4CA96DC7B2C0C206AC7598AFFC4B66FD2\"},\"right\":{\"anyOf\":[{\"$ref\":\"#/$defs/ref_CEF475B4CA96DC7B2C0C206AC7598AFFC4B66FD2\"},{\"type\":\"null\"}]}}}}}},\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"anyOf\":[{\"required\":[\"type\",\"data\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"list\"]},\"data\":{\"required\":[\"head\",\"tail\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"head\":{\"$ref\":\"#/$defs/ref_CEF475B4CA96DC7B2C0C206AC7598AFFC4B66FD2\"},\"tail\":{\"$ref\":\"#\"}}}}},{\"required\":[\"type\",\"data\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"no_trees\"]},\"data\":{\"additionalProperties\":false,\"type\":\"object\",\"properties\":{}}}}]}", + ) +} diff --git a/test/examples/union_type_test.gleam b/test/examples/union_type_test.gleam new file mode 100644 index 0000000..7b8497c --- /dev/null +++ b/test/examples/union_type_test.gleam @@ -0,0 +1,104 @@ +import gleam/io +import gleam/json +import gleeunit +import gleeunit/should +import json/blueprint + +pub fn main() { + gleeunit.main() +} + +type Shape { + Circle(Float) + Rectangle(Float, Float) + Void +} + +fn encode_shape(shape: Shape) -> json.Json { + blueprint.union_type_encoder(shape, fn(shape_case) { + case shape_case { + Circle(radius) -> #( + "circle", + json.object([#("radius", json.float(radius))]), + ) + Rectangle(width, height) -> #( + "rectangle", + json.object([ + #("width", json.float(width)), + #("height", json.float(height)), + ]), + ) + Void -> #("void", json.object([])) + } + }) +} + +fn shape_decoder() -> blueprint.Decoder(Shape) { + blueprint.union_type_decoder([ + #( + "circle", + blueprint.decode1(Circle, blueprint.field("radius", blueprint.float())), + ), + #( + "rectangle", + blueprint.decode2( + Rectangle, + blueprint.field("width", blueprint.float()), + blueprint.field("height", blueprint.float()), + ), + ), + #("void", blueprint.decode0(Void)), + ]) +} + +pub fn union_type_test() { + let circle = Circle(5.0) + let rectangle = Rectangle(10.0, 20.0) + + let decoder = shape_decoder() + + //test decoding + encode_shape(circle) + |> json.to_string + |> blueprint.decode(using: decoder) + |> should.equal(Ok(circle)) + + encode_shape(rectangle) + |> json.to_string + |> blueprint.decode(using: decoder) + |> should.equal(Ok(rectangle)) + + encode_shape(Void) + |> json.to_string + |> blueprint.decode(using: decoder) + |> should.equal(Ok(Void)) + + blueprint.generate_json_schema(shape_decoder()) + |> json.to_string + |> io.println +} + +pub fn constructor_type_decoder_test() { + let circle_json = "{\"type\":\"circle\",\"data\":{\"radius\":5.0}}" + let rectangle_json = + "{\"type\":\"rectangle\",\"data\":{\"width\":10.0,\"height\":20.0}}" + let void_json = "{\"type\":\"void\",\"data\":{}}" + + blueprint.decode(using: shape_decoder(), from: circle_json) + |> should.equal(Ok(Circle(5.0))) + + blueprint.decode(using: shape_decoder(), from: rectangle_json) + |> should.equal(Ok(Rectangle(10.0, 20.0))) + + blueprint.decode(using: shape_decoder(), from: void_json) + |> should.equal(Ok(Void)) + + let schema = blueprint.generate_json_schema(shape_decoder()) + + let expected_schema_str = + "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"anyOf\":[{\"required\":[\"type\",\"data\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"circle\"]},\"data\":{\"required\":[\"radius\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"radius\":{\"type\":\"number\"}}}}},{\"required\":[\"type\",\"data\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"rectangle\"]},\"data\":{\"required\":[\"width\",\"height\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"width\":{\"type\":\"number\"},\"height\":{\"type\":\"number\"}}}}},{\"required\":[\"type\",\"data\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"void\"]},\"data\":{\"additionalProperties\":false,\"type\":\"object\",\"properties\":{}}}}]}" + + schema + |> json.to_string + |> should.equal(expected_schema_str) +} diff --git a/test/json_blueprint_test.gleam b/test/json_blueprint_test.gleam index e21ffd8..736ee76 100644 --- a/test/json_blueprint_test.gleam +++ b/test/json_blueprint_test.gleam @@ -1,4 +1,3 @@ -import gleam/io import gleam/json import gleam/option.{type Option, None, Some} import gleeunit @@ -14,20 +13,6 @@ fn get_schema_header() { #("$schema", json.string(json_schema.json_schema_version)) } -// Test type for object decoding -pub type Person { - Person(name: String, age: Int, email: Option(String)) -} - -// Test type for nested objects -pub type Address { - Address(street: String, city: String, zip: String) -} - -pub type PersonWithAddress { - PersonWithAddress(person: Person, address: Address) -} - // Basic decoder tests pub fn string_decoder_test() { let decoder = blueprint.string() @@ -117,6 +102,20 @@ pub fn optional_decoder_test() { |> should.equal(Ok(None)) } +// Test type for object decoding +pub type Person { + Person(name: String, age: Int, email: Option(String)) +} + +// Test type for nested objects +pub type Address { + Address(street: String, city: String, zip: String) +} + +pub type PersonWithAddress { + PersonWithAddress(person: Person, address: Address) +} + // Object decoder tests pub fn person_decoder_test() { let person_decoder = @@ -281,543 +280,6 @@ pub fn json_schema_number_constraint_test() { ) } -type Shape { - Circle(Float) - Rectangle(Float, Float) - Void -} - -fn encode_shape(shape: Shape) -> json.Json { - blueprint.union_type_encoder(shape, fn(shape_case) { - case shape_case { - Circle(radius) -> #( - "circle", - json.object([#("radius", json.float(radius))]), - ) - Rectangle(width, height) -> #( - "rectangle", - json.object([ - #("width", json.float(width)), - #("height", json.float(height)), - ]), - ) - Void -> #("void", json.object([])) - } - }) -} - -fn shape_decoder() -> blueprint.Decoder(Shape) { - blueprint.union_type_decoder([ - #( - "circle", - blueprint.decode1(Circle, blueprint.field("radius", blueprint.float())), - ), - #( - "rectangle", - blueprint.decode2( - Rectangle, - blueprint.field("width", blueprint.float()), - blueprint.field("height", blueprint.float()), - ), - ), - #("void", blueprint.decode0(Void)), - ]) -} - -pub fn constructor_type_decoder_test() { - let circle_json = "{\"type\":\"circle\",\"data\":{\"radius\":5.0}}" - let rectangle_json = - "{\"type\":\"rectangle\",\"data\":{\"width\":10.0,\"height\":20.0}}" - let void_json = "{\"type\":\"void\",\"data\":{}}" - - blueprint.decode(using: shape_decoder(), from: circle_json) - |> should.equal(Ok(Circle(5.0))) - - blueprint.decode(using: shape_decoder(), from: rectangle_json) - |> should.equal(Ok(Rectangle(10.0, 20.0))) - - blueprint.decode(using: shape_decoder(), from: void_json) - |> should.equal(Ok(Void)) - - let schema = blueprint.generate_json_schema(shape_decoder()) - - let expected_schema_str = - "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"anyOf\":[{\"required\":[\"type\",\"data\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"circle\"]},\"data\":{\"required\":[\"radius\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"radius\":{\"type\":\"number\"}}}}},{\"required\":[\"type\",\"data\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"rectangle\"]},\"data\":{\"required\":[\"width\",\"height\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"width\":{\"type\":\"number\"},\"height\":{\"type\":\"number\"}}}}},{\"required\":[\"type\",\"data\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"void\"]},\"data\":{\"additionalProperties\":false,\"type\":\"object\",\"properties\":{}}}}]}" - - schema - |> json.to_string - |> should.equal(expected_schema_str) -} - -pub fn union_type_encoder_test() { - // Test encoding a Circle - let circle = Circle(5.0) - // Test encoding a Rectangle - let rectangle = Rectangle(10.0, 20.0) - - let decoder = shape_decoder() - - encode_shape(circle) - |> should.equal( - json.object([ - #("type", json.string("circle")), - #("data", json.object([#("radius", json.float(5.0))])), - ]), - ) - - encode_shape(rectangle) - |> should.equal( - json.object([ - #("type", json.string("rectangle")), - #( - "data", - json.object([ - #("width", json.float(10.0)), - #("height", json.float(20.0)), - ]), - ), - ]), - ) - - encode_shape(Void) - |> should.equal( - json.object([#("type", json.string("void")), #("data", json.object([]))]), - ) - //test decoding - encode_shape(circle) - |> json.to_string - |> blueprint.decode(using: decoder) - |> should.equal(Ok(circle)) - - encode_shape(rectangle) - |> json.to_string - |> blueprint.decode(using: decoder) - |> should.equal(Ok(rectangle)) - - encode_shape(Void) - |> json.to_string - |> blueprint.decode(using: decoder) - |> should.equal(Ok(Void)) -} - -type Color { - Red - Green - Blue -} - -fn color_decoder() { - blueprint.enum_type_decoder([ - #("red", Red), - #("green", Green), - #("blue", Blue), - ]) -} - -fn color_encoder(input) { - blueprint.enum_type_encoder(input, fn(color) { - case color { - Red -> "red" - Green -> "green" - Blue -> "blue" - } - }) -} - -pub fn enum_type_test() { - // Test encoding - color_encoder(Red) - |> json.to_string - |> should.equal("{\"enum\":\"red\"}") - - color_encoder(Green) - |> json.to_string - |> should.equal("{\"enum\":\"green\"}") - - color_encoder(Blue) - |> json.to_string - |> should.equal("{\"enum\":\"blue\"}") - - // Test decoding - blueprint.decode(color_decoder(), "{\"enum\":\"red\"}") - |> should.equal(Ok(Red)) - - blueprint.decode(color_decoder(), "{\"enum\":\"green\"}") - |> should.equal(Ok(Green)) - - blueprint.decode(color_decoder(), "{\"enum\":\"blue\"}") - |> should.equal(Ok(Blue)) - - // Test invalid enum value - blueprint.decode(color_decoder(), "{\"enum\":\"yellow\"}") - |> should.be_error - - // Test encoding a Circle - let red = Red - // Test encoding a Rectangle - let blue = Blue - - //test decoding - color_encoder(red) - |> json.to_string - |> blueprint.decode(using: color_decoder()) - |> should.equal(Ok(red)) - - color_encoder(blue) - |> json.to_string - |> blueprint.decode(using: color_decoder()) - |> should.equal(Ok(blue)) -} - -type ColorPair = - #(Color, Color) - -type RBG = - #(Int, Int, Int) - -type Palette { - Palette( - primary: Color, - secondary: Option(Color), - pair: Option(ColorPair), - rgb: Option(RBG), - ) -} - -fn encode_palette(input) { - blueprint.union_type_encoder(input, fn(palette) { - case palette { - Palette(primary, secondary, pair, rgb) -> { - let fields = [ - #("primary", color_encoder(primary)), - #("secondary", json.nullable(secondary, color_encoder)), - #( - "pair", - json.nullable(pair, blueprint.encode_tuple2( - _, - color_encoder, - color_encoder, - )), - ), - #( - "rgb", - json.nullable(rgb, blueprint.encode_tuple3( - _, - json.int, - json.int, - json.int, - )), - ), - ] - #("palette", json.object(fields)) - } - } - }) -} - -fn palette_decoder() { - blueprint.union_type_decoder([ - #( - "palette", - blueprint.decode4( - Palette, - blueprint.field("primary", color_decoder()), - blueprint.optional_field("secondary", color_decoder()), - blueprint.optional_field( - "pair", - blueprint.tuple2(color_decoder(), color_decoder()), - ), - blueprint.optional_field( - "rgb", - blueprint.tuple3(blueprint.int(), blueprint.int(), blueprint.int()), - ), - ), - ), - ]) -} - -pub fn palette_test() { - // Create decoder for Palette - - // Test cases - let palette1 = - Palette( - primary: Red, - secondary: Some(Blue), - pair: Some(#(Green, Red)), - rgb: Some(#(255, 128, 0)), - ) - - let palette2 = Palette(primary: Green, secondary: None, pair: None, rgb: None) - - // Test encoding - let encoded1 = encode_palette(palette1) - let encoded2 = encode_palette(palette2) - - // Test decoding - encoded1 - |> json.to_string - |> blueprint.decode(using: palette_decoder()) - |> should.equal(Ok(palette1)) - - encoded2 - |> json.to_string - |> blueprint.decode(using: palette_decoder()) - |> should.equal(Ok(palette2)) - - // Test specific JSON structure - encoded1 - |> json.to_string - |> should.equal( - "{\"type\":\"palette\",\"data\":{\"primary\":{\"enum\":\"red\"},\"secondary\":{\"enum\":\"blue\"},\"pair\":[{\"enum\":\"green\"},{\"enum\":\"red\"}],\"rgb\":[255,128,0]}}", - ) - - encoded2 - |> json.to_string - |> should.equal( - "{\"type\":\"palette\",\"data\":{\"primary\":{\"enum\":\"green\"},\"secondary\":null,\"pair\":null,\"rgb\":null}}", - ) -} - -type Coordinate = - #(Float, Float) - -type Drawing { - Box(Float, Float, Coordinate, Option(Color)) -} - -fn encode_coordinate(coord: Coordinate) -> json.Json { - blueprint.encode_tuple2(coord, json.float, json.float) -} - -fn coordinate_decoder() { - blueprint.tuple2(blueprint.float(), blueprint.float()) -} - -fn encode_drawing(drawing: Drawing) -> json.Json { - blueprint.union_type_encoder(drawing, fn(shape) { - case shape { - Box(width, height, position, color) -> #( - "box", - json.object([ - #("width", json.float(width)), - #("height", json.float(height)), - #("position", encode_coordinate(position)), - #("color", json.nullable(color, color_encoder)), - ]), - ) - } - }) -} - -fn drawing_decoder() -> blueprint.Decoder(Drawing) { - blueprint.union_type_decoder([ - #( - "box", - blueprint.decode4( - Box, - blueprint.field("width", blueprint.float()), - blueprint.field("height", blueprint.float()), - blueprint.field("position", coordinate_decoder()), - blueprint.optional_field("color", color_decoder()), - ), - ), - ]) -} - -pub fn drawing_test() { - // Test cases - let box = Box(15.0, 25.0, #(30.0, 40.0), None) - - // Test encoding - let encoded_box = encode_drawing(box) - - // Test decoding - encoded_box - |> json.to_string - |> blueprint.decode(using: drawing_decoder()) - |> should.equal(Ok(box)) - - // Test specific JSON structure - encoded_box - |> should.equal( - json.object([ - #("type", json.string("box")), - #( - "data", - json.object([ - #("width", json.float(15.0)), - #("height", json.float(25.0)), - #( - "position", - json.preprocessed_array([json.float(30.0), json.float(40.0)]), - ), - #("color", json.null()), - ]), - ), - ]), - ) - - // Test invalid data - // Test missing required fields - "{\"type\":\"box\",\"data\":{\"width\":15.0}}" - |> blueprint.decode(using: drawing_decoder()) - |> should.be_error() - - drawing_decoder() - |> blueprint.generate_json_schema - |> json.to_string - |> should.equal( - "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[\"type\",\"data\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"box\"]},\"data\":{\"required\":[\"width\",\"height\",\"position\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"width\":{\"type\":\"number\"},\"height\":{\"type\":\"number\"},\"position\":{\"maxItems\":2,\"minItems\":2,\"prefixItems\":[{\"type\":\"number\"},{\"type\":\"number\"}],\"type\":\"array\"},\"color\":{\"required\":[\"enum\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"enum\":{\"type\":\"string\",\"enum\":[\"red\",\"green\",\"blue\"]}}}}}}}", - ) -} - -type Tree { - Node(value: Int, left: Option(Tree), right: Option(Tree)) -} - -type ListOfTrees(t) { - ListOfTrees(head: t, tail: ListOfTrees(t)) - NoTrees -} - -fn encode_tree(tree: Tree) -> json.Json { - blueprint.union_type_encoder(tree, fn(node) { - case node { - Node(value, left, right) -> #( - "node", - [ - #("value", json.int(value)), - #("right", json.nullable(right, encode_tree)), - ] - |> blueprint.encode_optional_field("left", left, encode_tree) - |> json.object(), - ) - } - }) -} - -fn encode_list_of_trees(tree: ListOfTrees(Tree)) -> json.Json { - blueprint.union_type_encoder(tree, fn(list) { - case list { - ListOfTrees(head, tail) -> #( - "list", - json.object([ - #("head", encode_tree(head)), - #("tail", encode_list_of_trees(tail)), - ]), - ) - NoTrees -> #("no_trees", json.object([])) - } - }) -} - -// Without reuse_decoder, recursive types would cause infinite schema expansion -fn tree_decoder() { - blueprint.union_type_decoder([ - #( - "node", - blueprint.decode3( - Node, - blueprint.field("value", blueprint.int()), - // testing both an optional field a field with a possible null - blueprint.optional_field("left", blueprint.self_decoder(tree_decoder)), - blueprint.field( - "right", - blueprint.optional(blueprint.self_decoder(tree_decoder)), - ), - ), - ), - ]) - |> blueprint.reuse_decoder -} - -fn decode_list_of_trees() { - blueprint.union_type_decoder([ - #( - "list", - blueprint.decode2( - ListOfTrees, - blueprint.field("head", tree_decoder()), - blueprint.field("tail", blueprint.self_decoder(decode_list_of_trees)), - ), - ), - #("no_trees", blueprint.decode0(NoTrees)), - ]) -} - -pub fn tree_decoder_test() { - // Create a sample tree structure: - // 5 - // / \ - // 3 7 - // / \ - // 1 9 - let tree = - Node( - value: 5, - left: Some(Node(value: 3, left: Some(Node(1, None, None)), right: None)), - right: Some(Node(value: 7, left: None, right: Some(Node(9, None, None)))), - ) - - // Create a list of trees - let tree_list = - ListOfTrees( - Node(value: 1, left: None, right: None), - ListOfTrees( - Node( - value: 10, - left: Some(Node(value: 1, left: None, right: None)), - right: None, - ), - NoTrees, - ), - ) - - // Test encoding - let json_str = tree |> encode_tree |> json.to_string() - let list_json_str = tree_list |> encode_list_of_trees |> json.to_string() - - // Test decoding - let decoded = blueprint.decode(using: tree_decoder(), from: json_str) - - decoded - |> should.equal(Ok(tree)) - - let decoded_list = - blueprint.decode(using: decode_list_of_trees(), from: list_json_str) - - decoded_list - |> should.equal(Ok(tree_list)) - - // Test specific JSON structure - json_str - |> should.equal( - "{\"type\":\"node\",\"data\":{\"left\":{\"type\":\"node\",\"data\":{\"left\":{\"type\":\"node\",\"data\":{\"value\":1,\"right\":null}},\"value\":3,\"right\":null}},\"value\":5,\"right\":{\"type\":\"node\",\"data\":{\"value\":7,\"right\":{\"type\":\"node\",\"data\":{\"value\":9,\"right\":null}}}}}}", - ) - - list_json_str - |> should.equal( - "{\"type\":\"list\",\"data\":{\"head\":{\"type\":\"node\",\"data\":{\"value\":1,\"right\":null}},\"tail\":{\"type\":\"list\",\"data\":{\"head\":{\"type\":\"node\",\"data\":{\"left\":{\"type\":\"node\",\"data\":{\"value\":1,\"right\":null}},\"value\":10,\"right\":null}},\"tail\":{\"type\":\"no_trees\",\"data\":{}}}}}}", - ) - - // Test schema generation - let schema = blueprint.generate_json_schema(decode_list_of_trees()) - - list_json_str - |> io.println - - schema - |> json.to_string - |> io.println - - schema - |> json.to_string - |> should.equal( - "{\"$defs\":{\"ref_CEF475B4CA96DC7B2C0C206AC7598AFFC4B66FD2\":{\"required\":[\"type\",\"data\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"node\"]},\"data\":{\"required\":[\"value\",\"right\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"integer\"},\"left\":{\"$ref\":\"#/$defs/ref_CEF475B4CA96DC7B2C0C206AC7598AFFC4B66FD2\"},\"right\":{\"anyOf\":[{\"$ref\":\"#/$defs/ref_CEF475B4CA96DC7B2C0C206AC7598AFFC4B66FD2\"},{\"type\":\"null\"}]}}}}}},\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"anyOf\":[{\"required\":[\"type\",\"data\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"list\"]},\"data\":{\"required\":[\"head\",\"tail\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"head\":{\"$ref\":\"#/$defs/ref_CEF475B4CA96DC7B2C0C206AC7598AFFC4B66FD2\"},\"tail\":{\"$ref\":\"#\"}}}}},{\"required\":[\"type\",\"data\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"no_trees\"]},\"data\":{\"additionalProperties\":false,\"type\":\"object\",\"properties\":{}}}}]}", - ) -} - // Helper function to create a Person decoder fn person_decoder() -> blueprint.Decoder(Person) { blueprint.decode3(