From d58df6ad319cf2fae839f1fe1e51183c75216eb4 Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Mon, 31 May 2021 09:51:46 +0200 Subject: [PATCH 01/35] add helper macro to avoid code duplication in tests --- test/json_data_faker_test.exs | 101 ++++++++++++++-------------------- 1 file changed, 42 insertions(+), 59 deletions(-) diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index c38161f..12a60e8 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -1,8 +1,22 @@ +defmodule JsonDataFakerTest.Helpers do + defmacro property_test(name, schema) do + quote do + property unquote(name) do + resolved_schema = ExJsonSchema.Schema.resolve(unquote(schema)) + + check all(data <- JsonDataFaker.generate(unquote(schema))) do + assert ExJsonSchema.Validator.valid?(resolved_schema, data) + end + end + end + end +end + defmodule JsonDataFakerTest do use ExUnit.Case use ExUnitProperties + import JsonDataFakerTest.Helpers - alias ExJsonSchema.{Validator, Schema} doctest JsonDataFaker @complex_object %{ @@ -47,72 +61,41 @@ defmodule JsonDataFakerTest do end Enum.each(["date-time", "email", "hostname", "ipv4", "ipv6", "uri"], fn format -> - property "string #{format} generation should work" do - schema = %{"type" => "string", "format" => unquote(format)} - resolved_schema = Schema.resolve(schema) - - check all(data <- JsonDataFaker.generate(schema)) do - assert Validator.valid?(resolved_schema, data) - end - end + property_test("string #{format} generation should work", %{ + "type" => "string", + "format" => unquote(format) + }) end) - property "string regex generation should work" do - schema = %{"type" => "string", "pattern" => "^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$"} - resolved_schema = Schema.resolve(schema) + property_test("string regex generation should work", %{ + "type" => "string", + "pattern" => "^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$" + }) - check all(data <- JsonDataFaker.generate(schema)) do - assert Validator.valid?(resolved_schema, data) - end - end + property_test("string enum generation should work", %{ + "type" => "string", + "enum" => ["active", "completed"] + }) - property "string enum generation should work" do - schema = %{"type" => "string", "enum" => ["active", "completed"]} - resolved_schema = Schema.resolve(schema) + property_test("string with max / min length should work", %{ + "type" => "string", + "minLength" => 200, + "maxLength" => 201 + }) - check all(data <- JsonDataFaker.generate(schema)) do - assert Validator.valid?(resolved_schema, data) - end - end + property_test("integer generation should work", %{ + "type" => "integer", + "minimum" => 5, + "maximum" => 20 + }) - property "string with max / min length should work" do - schema = %{"type" => "string", "minLength" => 200, "maxLength" => 201} - resolved_schema = Schema.resolve(schema) - check all(data <- JsonDataFaker.generate(schema)) do - assert Validator.valid?(resolved_schema, data) - end - end - - property "integer generation should work" do - schema = %{"type" => "integer", "minimum" => 5, "maximum" => 20} - resolved_schema = Schema.resolve(schema) - - check all(data <- JsonDataFaker.generate(schema)) do - assert Validator.valid?(resolved_schema, data) - end - end + property_test("complex object generation should work", @complex_object) - property "complex object generation should work" do - resolved_schema = Schema.resolve(@complex_object) - - check all(data <- JsonDataFaker.generate(@complex_object)) do - assert Validator.valid?(resolved_schema, data) - end - end - - property "array of object generation should work" do - schema = %{ - "items" => @complex_object, - "type" => "array" - } - - resolved_schema = Schema.resolve(schema) - - check all(data <- JsonDataFaker.generate(schema)) do - assert Validator.valid?(resolved_schema, data) - end - end + property_test("array of object generation should work", %{ + "items" => @complex_object, + "type" => "array" + }) property "empty or invalid schema should return nil" do schema = %{} From a9c01d945528baedd4f6e272b0304d5529dcc3f1 Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Mon, 31 May 2021 10:29:09 +0200 Subject: [PATCH 02/35] handle exclusiveMinimum, exclusiveMaximum and multipleOf in integer generation --- lib/json_data_faker.ex | 46 +++++++++++++++++++++++-- test/json_data_faker_test.exs | 65 +++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/lib/json_data_faker.ex b/lib/json_data_faker.ex index 663c80f..c755844 100644 --- a/lib/json_data_faker.ex +++ b/lib/json_data_faker.ex @@ -45,9 +45,13 @@ defmodule JsonDataFaker do end defp generate_by_type(%{"type" => "integer"} = schema) do - min = schema["minimum"] || 10 - max = schema["maximum"] || 1000 - integer(min..max) + generate_integer( + schema["minimum"], + schema["maximum"], + Map.get(schema, "exclusiveMinimum", false), + Map.get(schema, "exclusiveMaximum", false), + schema["multipleOf"] + ) end defp generate_by_type(%{"type" => "array"} = schema) do @@ -110,6 +114,42 @@ defmodule JsonDataFaker do end) end + defp generate_integer(nil, nil, _, _, nil), do: integer() + + defp generate_integer(nil, nil, _, _, multipleOf), do: map(integer(), &(&1 * multipleOf)) + + defp generate_integer(min, nil, exclusive, _, nil), + do: map(positive_integer(), &(&1 - 1 + min + if(exclusive, do: 1, else: 0))) + + defp generate_integer(nil, max, _, exclusive, nil), + do: map(positive_integer(), &(max + if(exclusive, do: -1, else: 0) - (&1 - 1))) + + defp generate_integer(min, nil, exclusive, _, multipleOf) do + min = min + if(exclusive, do: 1, else: 0) + min = Integer.floor_div(min, multipleOf) + 1 + map(positive_integer(), &((&1 - 1 + min) * multipleOf)) + end + + defp generate_integer(nil, max, _, exclusive, multipleOf) do + max = max + if(exclusive, do: -1, else: 0) + max = Integer.floor_div(max, multipleOf) + map(positive_integer(), &((max - (&1 - 1)) * multipleOf)) + end + + defp generate_integer(min, max, emin, emax, nil) do + min = min + if(emin, do: 1, else: 0) + max = max + if(emax, do: -1, else: 0) + integer(min..max) + end + + defp generate_integer(min, max, emin, emax, multipleOf) do + min = min + if(emin, do: 1, else: 0) + max = max + if(emax, do: -1, else: 0) + min = Integer.floor_div(min, multipleOf) + 1 + max = Integer.floor_div(max, multipleOf) + map(integer(min..max), &(&1 * multipleOf)) + end + defp stream_gen(fun) do StreamData.map(StreamData.constant(nil), fn _ -> fun.() end) end diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index 12a60e8..3a745c5 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -89,6 +89,71 @@ defmodule JsonDataFakerTest do "maximum" => 20 }) + property_test("integer generation with exclusive endpoints should work", %{ + "type" => "integer", + "minimum" => 3, + "maximum" => 7, + "exclusiveMinimum" => true, + "exclusiveMaximum" => true + }) + + property_test("integer generation with exclusive and negative endpoints should work", %{ + "type" => "integer", + "minimum" => -7, + "maximum" => -3, + "exclusiveMinimum" => true, + "exclusiveMaximum" => true + }) + + property_test("integer generation with multipleOf and min should work", %{ + "type" => "integer", + "minimum" => 5, + "multipleOf" => 3 + }) + + property_test("integer generation with multipleOf and negative min should work", %{ + "type" => "integer", + "minimum" => -5, + "multipleOf" => 3 + }) + + property_test("integer generation with multipleOf and max should work", %{ + "type" => "integer", + "maximum" => 20, + "multipleOf" => 3 + }) + + property_test("integer generation with multipleOf and negative max should work", %{ + "type" => "integer", + "maximum" => -20, + "multipleOf" => 3 + }) + + property_test("integer generation with multipleOf should work", %{ + "type" => "integer", + "minimum" => 5, + "maximum" => 20, + "multipleOf" => 3 + }) + + property_test("integer generation with multipleOf and negative endpoints should work", %{ + "type" => "integer", + "minimum" => -20, + "maximum" => -5, + "multipleOf" => 3 + }) + + property_test( + "integer generation with multipleOf and exclusive and negative endpoints should work", + %{ + "type" => "integer", + "minimum" => -21, + "maximum" => -3, + "multipleOf" => 3, + "exclusiveMinimum" => true, + "exclusiveMaximum" => true + } + ) property_test("complex object generation should work", @complex_object) From d5f60a9f72eae2ace295092322b8477d1ce845be Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Mon, 31 May 2021 10:55:26 +0200 Subject: [PATCH 03/35] handle minItems, maxItems and uniqueItems in array generation --- lib/json_data_faker.ex | 24 ++++++++++++++++++++++-- test/json_data_faker_test.exs | 26 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/lib/json_data_faker.ex b/lib/json_data_faker.ex index c755844..d6b7dd4 100644 --- a/lib/json_data_faker.ex +++ b/lib/json_data_faker.ex @@ -56,9 +56,29 @@ defmodule JsonDataFaker do defp generate_by_type(%{"type" => "array"} = schema) do inner_schema = schema["items"] - count = Enum.random(2..5) - StreamData.list_of(generate_by_type(inner_schema), length: count) + opts = + Enum.reduce(schema, [], fn + {"minItems", min}, acc -> Keyword.put(acc, :min_length, min) + {"maxItems", max}, acc -> Keyword.put(acc, :max_length, max) + _, acc -> acc + end) + + case Map.get(schema, "uniqueItems", false) do + false -> + StreamData.list_of(generate_by_type(inner_schema), opts) + + true -> + inner_schema + |> generate_by_type() + |> StreamData.scale(fn size -> + case Keyword.get(opts, :max_length, false) do + false -> size + max -> max * 3 + end + end) + |> StreamData.uniq_list_of(opts) + end end defp generate_by_type(%{"type" => "object"} = schema) do diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index 3a745c5..79c9b98 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -162,6 +162,32 @@ defmodule JsonDataFakerTest do "type" => "array" }) + property_test("minItems array generation should work", %{ + "items" => %{"type" => "string"}, + "type" => "array", + "minItems" => 5 + }) + + property_test("maxItems array generation should work", %{ + "items" => %{"type" => "string"}, + "type" => "array", + "maxItems" => 5 + }) + + property_test("uniqueItems array generation should work", %{ + "items" => %{"type" => "string"}, + "type" => "array", + "uniqueItems" => true + }) + + property_test("array generation with all options should work", %{ + "items" => %{"type" => "integer"}, + "type" => "array", + "minItems" => 5, + "maxItems" => 8, + "uniqueItems" => true + }) + property "empty or invalid schema should return nil" do schema = %{} From 153118785abb2923b8f135144a446c40e069f43e Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Mon, 31 May 2021 11:06:28 +0200 Subject: [PATCH 04/35] enum should work not only for strings --- lib/json_data_faker.ex | 12 ++++-------- test/json_data_faker_test.exs | 29 ++++++++++++++++++++--------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/lib/json_data_faker.ex b/lib/json_data_faker.ex index d6b7dd4..c9a8cb0 100644 --- a/lib/json_data_faker.ex +++ b/lib/json_data_faker.ex @@ -36,13 +36,11 @@ defmodule JsonDataFaker do def generate(_schema), do: nil # private functions - defp generate_by_type(%{"type" => "boolean"}) do - boolean() - end + defp generate_by_type(%{"enum" => choices}), do: StreamData.member_of(choices) - defp generate_by_type(%{"type" => "string"} = schema) do - generate_string(schema) - end + defp generate_by_type(%{"type" => "boolean"}), do: boolean() + + defp generate_by_type(%{"type" => "string"} = schema), do: generate_string(schema) defp generate_by_type(%{"type" => "integer"} = schema) do generate_integer( @@ -114,8 +112,6 @@ defmodule JsonDataFaker do end) end - defp generate_string(%{"enum" => choices}), do: StreamData.member_of(choices) - defp generate_string(%{"pattern" => regex}), do: Randex.stream(Regex.compile!(regex), mod: Randex.Generator.StreamData) diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index 79c9b98..331e0a1 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -1,12 +1,14 @@ defmodule JsonDataFakerTest.Helpers do - defmacro property_test(name, schema) do + defmacro property_test(name, schemas) do quote do property unquote(name) do - resolved_schema = ExJsonSchema.Schema.resolve(unquote(schema)) + Enum.each(List.wrap(unquote(schemas)), fn schema -> + resolved_schema = ExJsonSchema.Schema.resolve(schema) - check all(data <- JsonDataFaker.generate(unquote(schema))) do - assert ExJsonSchema.Validator.valid?(resolved_schema, data) - end + check all(data <- JsonDataFaker.generate(schema)) do + assert ExJsonSchema.Validator.valid?(resolved_schema, data) + end + end) end end end @@ -72,10 +74,19 @@ defmodule JsonDataFakerTest do "pattern" => "^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$" }) - property_test("string enum generation should work", %{ - "type" => "string", - "enum" => ["active", "completed"] - }) + property_test("enum generation should work", [ + %{ + "type" => "string", + "enum" => ["active", "completed"] + }, + %{ + "type" => "integer", + "enum" => [1, 2, 7] + }, + %{ + "enum" => [[1, 2], %{"foo" => "bar"}] + } + ]) property_test("string with max / min length should work", %{ "type" => "string", From 795d7ce48f164ff1399d1e01b7addcfd19117f17 Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Mon, 31 May 2021 11:41:07 +0200 Subject: [PATCH 05/35] not required properties can miss from generated object --- lib/json_data_faker.ex | 20 ++++++++++++++------ test/json_data_faker_test.exs | 9 +++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/lib/json_data_faker.ex b/lib/json_data_faker.ex index c9a8cb0..65a05ff 100644 --- a/lib/json_data_faker.ex +++ b/lib/json_data_faker.ex @@ -19,7 +19,7 @@ defmodule JsonDataFaker do ...> "required" => ["title"], ...> "type" => "object" ...>} - iex> %{"title" => _title, "body" => _body} = JsonDataFaker.generate(schema) |> Enum.take(1) |> List.first() + iex> %{"title" => _title} = JsonDataFaker.generate(schema) |> Enum.take(1) |> List.first() """ def generate(%Schema.Root{} = schema) do generate_by_type(schema.schema) @@ -79,12 +79,20 @@ defmodule JsonDataFaker do end end - defp generate_by_type(%{"type" => "object"} = schema) do - stream_gen(fn -> - Enum.reduce(schema["properties"], %{}, fn {k, inner_schema}, acc -> - v = inner_schema |> generate_by_type() |> Enum.take(1) |> List.first() + defp generate_by_type(%{"type" => "object", "properties" => properties} = schema) do + required = Map.get(schema, "required", []) + {required_props, optional_props} = Enum.split_with(properties, &(elem(&1, 0) in required)) + + [required_map, optional_map] = + Enum.map([required_props, optional_props], fn props -> + Map.new(props, fn {key, inner_schema} -> {key, generate_by_type(inner_schema)} end) + end) - Map.put(acc, k, v) + required_map + |> StreamData.fixed_map() + |> StreamData.bind(fn req_map -> + StreamData.bind(StreamData.optional_map(optional_map), fn opt_map -> + StreamData.constant(Map.merge(opt_map, req_map)) end) end) end diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index 331e0a1..a1bec80 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -166,6 +166,15 @@ defmodule JsonDataFakerTest do } ) + property_test("object generation without required properties should work", %{ + "type" => "object", + "properties" => %{ + "foo" => %{ + "type" => "integer" + } + } + }) + property_test("complex object generation should work", @complex_object) property_test("array of object generation should work", %{ From 901df4a63176d01f637fb188311ffda830033f1a Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Mon, 31 May 2021 12:01:39 +0200 Subject: [PATCH 06/35] handle $ref --- lib/json_data_faker.ex | 29 ++++++++++++++++++----------- test/json_data_faker_test.exs | 23 ++++++++++++++++++----- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/lib/json_data_faker.ex b/lib/json_data_faker.ex index 65a05ff..a1d0190 100644 --- a/lib/json_data_faker.ex +++ b/lib/json_data_faker.ex @@ -22,7 +22,7 @@ defmodule JsonDataFaker do iex> %{"title" => _title} = JsonDataFaker.generate(schema) |> Enum.take(1) |> List.first() """ def generate(%Schema.Root{} = schema) do - generate_by_type(schema.schema) + generate_by_type(schema.schema, schema) end def generate(schema) when is_map(schema) do @@ -36,13 +36,20 @@ defmodule JsonDataFaker do def generate(_schema), do: nil # private functions - defp generate_by_type(%{"enum" => choices}), do: StreamData.member_of(choices) + defp generate_by_type(%{"$ref" => ref}, root) do + case ExJsonSchema.Schema.get_fragment(root, ref) do + {:ok, resolved} -> generate_by_type(resolved, root) + _ -> nil + end + end + + defp generate_by_type(%{"enum" => choices}, _root), do: StreamData.member_of(choices) - defp generate_by_type(%{"type" => "boolean"}), do: boolean() + defp generate_by_type(%{"type" => "boolean"}, _root), do: boolean() - defp generate_by_type(%{"type" => "string"} = schema), do: generate_string(schema) + defp generate_by_type(%{"type" => "string"} = schema, _root), do: generate_string(schema) - defp generate_by_type(%{"type" => "integer"} = schema) do + defp generate_by_type(%{"type" => "integer"} = schema, _root) do generate_integer( schema["minimum"], schema["maximum"], @@ -52,7 +59,7 @@ defmodule JsonDataFaker do ) end - defp generate_by_type(%{"type" => "array"} = schema) do + defp generate_by_type(%{"type" => "array"} = schema, root) do inner_schema = schema["items"] opts = @@ -64,11 +71,11 @@ defmodule JsonDataFaker do case Map.get(schema, "uniqueItems", false) do false -> - StreamData.list_of(generate_by_type(inner_schema), opts) + StreamData.list_of(generate_by_type(inner_schema, root), opts) true -> inner_schema - |> generate_by_type() + |> generate_by_type(root) |> StreamData.scale(fn size -> case Keyword.get(opts, :max_length, false) do false -> size @@ -79,13 +86,13 @@ defmodule JsonDataFaker do end end - defp generate_by_type(%{"type" => "object", "properties" => properties} = schema) do + defp generate_by_type(%{"type" => "object", "properties" => properties} = schema, root) do required = Map.get(schema, "required", []) {required_props, optional_props} = Enum.split_with(properties, &(elem(&1, 0) in required)) [required_map, optional_map] = Enum.map([required_props, optional_props], fn props -> - Map.new(props, fn {key, inner_schema} -> {key, generate_by_type(inner_schema)} end) + Map.new(props, fn {key, inner_schema} -> {key, generate_by_type(inner_schema, root)} end) end) required_map @@ -97,7 +104,7 @@ defmodule JsonDataFaker do end) end - defp generate_by_type(_schema), do: StreamData.constant(nil) + defp generate_by_type(_schema, _root), do: StreamData.constant(nil) defp generate_string(%{"format" => "date-time"}), do: stream_gen(fn -> 30 |> Faker.DateTime.backward() |> DateTime.to_iso8601() end) diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index a1bec80..3ba1d82 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -24,9 +24,7 @@ defmodule JsonDataFakerTest do @complex_object %{ "properties" => %{ "body" => %{ - "maxLength" => 140, - "minLength" => 3, - "type" => "string" + "$ref" => "#/components/schemas/Body" }, "created" => %{ "format" => "date-time", @@ -54,6 +52,20 @@ defmodule JsonDataFakerTest do "type" => "object" } + @components %{ + "schemas" => %{ + "Body" => %{ + "enum" => [ + "active", + "completed" + ], + "type" => "string" + } + } + } + + @full_object Map.put(@complex_object, "components", @components) + property "string uuid generation should work" do schema = %{"type" => "string", "format" => "uuid"} @@ -175,11 +187,12 @@ defmodule JsonDataFakerTest do } }) - property_test("complex object generation should work", @complex_object) + property_test("complex object generation should work", @full_object) property_test("array of object generation should work", %{ "items" => @complex_object, - "type" => "array" + "type" => "array", + "components" => @components }) property_test("minItems array generation should work", %{ From 12fb57d75189ba7b9a6f401f1c98fd8555a73beb Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Mon, 31 May 2021 12:15:19 +0200 Subject: [PATCH 07/35] use StreamData for generating strings we should use a generator that can understand and adapt to size otherwise when used in an array with uniqueItems we can incur in StreamData.TooManyDuplicatesError --- lib/json_data_faker.ex | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/lib/json_data_faker.ex b/lib/json_data_faker.ex index a1d0190..c448406 100644 --- a/lib/json_data_faker.ex +++ b/lib/json_data_faker.ex @@ -131,18 +131,14 @@ defmodule JsonDataFaker do do: Randex.stream(Regex.compile!(regex), mod: Randex.Generator.StreamData) defp generate_string(schema) do - min = schema["minLength"] || 0 - max = schema["maxLength"] || 1024 - - stream_gen(fn -> - s = Faker.Lorem.word() + opts = + Enum.reduce(schema, [], fn + {"minLength", min}, acc -> Keyword.put(acc, :min_length, min) + {"maxLength", max}, acc -> Keyword.put(acc, :max_length, max) + _, acc -> acc + end) - case String.length(s) do - v when v > max -> String.slice(s, 0, max - 1) - v when v < min -> String.slice(Faker.Lorem.sentence(min), 0, min) - _ -> s - end - end) + string(:ascii, opts) end defp generate_integer(nil, nil, _, _, nil), do: integer() From cee3db43794d37d454152fd3fd1a4429a8731837 Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Mon, 31 May 2021 12:15:32 +0200 Subject: [PATCH 08/35] handle oneOf and anyOf --- lib/json_data_faker.ex | 6 ++++++ test/json_data_faker_test.exs | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/lib/json_data_faker.ex b/lib/json_data_faker.ex index c448406..ffdcfaf 100644 --- a/lib/json_data_faker.ex +++ b/lib/json_data_faker.ex @@ -43,6 +43,12 @@ defmodule JsonDataFaker do end end + defp generate_by_type(%{"oneOf" => oneOf}, root), + do: oneOf |> Enum.map(&generate_by_type(&1, root)) |> StreamData.one_of() + + defp generate_by_type(%{"anyOf" => anyOf}, root), + do: anyOf |> Enum.map(&generate_by_type(&1, root)) |> StreamData.one_of() + defp generate_by_type(%{"enum" => choices}, _root), do: StreamData.member_of(choices) defp generate_by_type(%{"type" => "boolean"}, _root), do: boolean() diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index 3ba1d82..d5d9b79 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -221,6 +221,16 @@ defmodule JsonDataFakerTest do "uniqueItems" => true }) + property_test("oneOf generation should work", [ + %{"oneOf" => [%{"type" => "integer"}, %{"type" => "boolean"}]}, + %{"oneOf" => [%{"type" => "integer"}, @complex_object], "components" => @components} + ]) + + property_test("anyOf generation should work", [ + %{"anyOf" => [%{"type" => "integer"}, %{"type" => "boolean"}]}, + %{"anyOf" => [%{"type" => "integer"}, @complex_object], "components" => @components} + ]) + property "empty or invalid schema should return nil" do schema = %{} From 25b586161cb9d96797296682e2590a9df0bb6f48 Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Mon, 31 May 2021 15:18:53 +0200 Subject: [PATCH 09/35] reduce size of strings generated from regex --- lib/json_data_faker.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/json_data_faker.ex b/lib/json_data_faker.ex index ffdcfaf..19370e8 100644 --- a/lib/json_data_faker.ex +++ b/lib/json_data_faker.ex @@ -134,7 +134,7 @@ defmodule JsonDataFaker do end defp generate_string(%{"pattern" => regex}), - do: Randex.stream(Regex.compile!(regex), mod: Randex.Generator.StreamData) + do: Randex.stream(Regex.compile!(regex), mod: Randex.Generator.StreamData, max_repetition: 10) defp generate_string(schema) do opts = From bc3172453dd40db9943596d19be3e31c5dd59f66 Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Mon, 31 May 2021 16:02:14 +0200 Subject: [PATCH 10/35] add options for always generate also not required properties --- lib/json_data_faker.ex | 71 +++++++++++++++++++++++------------ test/json_data_faker_test.exs | 9 +++++ 2 files changed, 55 insertions(+), 25 deletions(-) diff --git a/lib/json_data_faker.ex b/lib/json_data_faker.ex index 19370e8..c6d7254 100644 --- a/lib/json_data_faker.ex +++ b/lib/json_data_faker.ex @@ -21,41 +21,44 @@ defmodule JsonDataFaker do ...>} iex> %{"title" => _title} = JsonDataFaker.generate(schema) |> Enum.take(1) |> List.first() """ - def generate(%Schema.Root{} = schema) do - generate_by_type(schema.schema, schema) + def generate(schema, opts \\ []) + + def generate(%Schema.Root{} = schema, opts) do + generate_by_type(schema.schema, schema, opts) end - def generate(schema) when is_map(schema) do - generate(Schema.resolve(schema)) + def generate(schema, opts) when is_map(schema) do + schema + |> Schema.resolve() + |> generate(opts) rescue e -> Logger.error("Failed to generate data. #{inspect(e)}") nil end - def generate(_schema), do: nil + def generate(_schema, _opts), do: nil # private functions - defp generate_by_type(%{"$ref" => ref}, root) do - case ExJsonSchema.Schema.get_fragment(root, ref) do - {:ok, resolved} -> generate_by_type(resolved, root) - _ -> nil - end + defp generate_by_type(%{"$ref" => _} = schema, root, opts) do + schema + |> resolve(root) + |> generate_by_type(root, opts) end - defp generate_by_type(%{"oneOf" => oneOf}, root), - do: oneOf |> Enum.map(&generate_by_type(&1, root)) |> StreamData.one_of() + defp generate_by_type(%{"oneOf" => oneOf}, root, opts), + do: oneOf |> Enum.map(&generate_by_type(&1, root, opts)) |> StreamData.one_of() - defp generate_by_type(%{"anyOf" => anyOf}, root), - do: anyOf |> Enum.map(&generate_by_type(&1, root)) |> StreamData.one_of() + defp generate_by_type(%{"anyOf" => anyOf}, root, opts), + do: anyOf |> Enum.map(&generate_by_type(&1, root, opts)) |> StreamData.one_of() - defp generate_by_type(%{"enum" => choices}, _root), do: StreamData.member_of(choices) + defp generate_by_type(%{"enum" => choices}, _root, _opts), do: StreamData.member_of(choices) - defp generate_by_type(%{"type" => "boolean"}, _root), do: boolean() + defp generate_by_type(%{"type" => "boolean"}, _root, _opts), do: boolean() - defp generate_by_type(%{"type" => "string"} = schema, _root), do: generate_string(schema) + defp generate_by_type(%{"type" => "string"} = schema, _root, _opts), do: generate_string(schema) - defp generate_by_type(%{"type" => "integer"} = schema, _root) do + defp generate_by_type(%{"type" => "integer"} = schema, _root, _opts) do generate_integer( schema["minimum"], schema["maximum"], @@ -65,7 +68,7 @@ defmodule JsonDataFaker do ) end - defp generate_by_type(%{"type" => "array"} = schema, root) do + defp generate_by_type(%{"type" => "array"} = schema, root, _opts) do inner_schema = schema["items"] opts = @@ -77,11 +80,11 @@ defmodule JsonDataFaker do case Map.get(schema, "uniqueItems", false) do false -> - StreamData.list_of(generate_by_type(inner_schema, root), opts) + StreamData.list_of(generate_by_type(inner_schema, root, opts), opts) true -> inner_schema - |> generate_by_type(root) + |> generate_by_type(root, opts) |> StreamData.scale(fn size -> case Keyword.get(opts, :max_length, false) do false -> size @@ -92,13 +95,30 @@ defmodule JsonDataFaker do end end - defp generate_by_type(%{"type" => "object", "properties" => properties} = schema, root) do + defp generate_by_type(%{"type" => "object", "properties" => _} = schema, root, opts) do + case Keyword.get(opts, :require_optional_properties, false) do + true -> generate_full_object(schema, root, opts) + false -> generate_object(schema, root, opts) + end + end + + defp generate_by_type(_schema, _root, _opts), do: StreamData.constant(nil) + + defp generate_full_object(%{"properties" => properties}, root, opts) do + properties + |> Map.new(fn {key, inner_schema} -> {key, generate_by_type(inner_schema, root, opts)} end) + |> StreamData.fixed_map() + end + + defp generate_object(%{"properties" => properties} = schema, root, opts) do required = Map.get(schema, "required", []) {required_props, optional_props} = Enum.split_with(properties, &(elem(&1, 0) in required)) [required_map, optional_map] = Enum.map([required_props, optional_props], fn props -> - Map.new(props, fn {key, inner_schema} -> {key, generate_by_type(inner_schema, root)} end) + Map.new(props, fn {key, inner_schema} -> + {key, generate_by_type(inner_schema, root, opts)} + end) end) required_map @@ -110,8 +130,6 @@ defmodule JsonDataFaker do end) end - defp generate_by_type(_schema, _root), do: StreamData.constant(nil) - defp generate_string(%{"format" => "date-time"}), do: stream_gen(fn -> 30 |> Faker.DateTime.backward() |> DateTime.to_iso8601() end) @@ -186,4 +204,7 @@ defmodule JsonDataFaker do defp stream_gen(fun) do StreamData.map(StreamData.constant(nil), fn _ -> fun.() end) end + + defp resolve(%{"$ref" => ref}, root), do: ExJsonSchema.Schema.get_fragment!(root, ref) + defp resolve(schema, _root), do: schema end diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index d5d9b79..5f45f01 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -189,6 +189,15 @@ defmodule JsonDataFakerTest do property_test("complex object generation should work", @full_object) + property "require_optional_properties property should work" do + resolved_schema = ExJsonSchema.Schema.resolve(@full_object) + + check all(data <- JsonDataFaker.generate(@full_object, require_optional_properties: true)) do + assert ExJsonSchema.Validator.valid?(resolved_schema, data) + assert Map.has_key?(data, "status") + end + end + property_test("array of object generation should work", %{ "items" => @complex_object, "type" => "array", From b8928154420b23d7b0f739003a0141b9380d2f5d Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Mon, 31 May 2021 17:10:32 +0200 Subject: [PATCH 11/35] make generators unshrinkable --- lib/json_data_faker.ex | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/json_data_faker.ex b/lib/json_data_faker.ex index c6d7254..f185f91 100644 --- a/lib/json_data_faker.ex +++ b/lib/json_data_faker.ex @@ -6,6 +6,12 @@ defmodule JsonDataFaker do require Logger alias ExJsonSchema.Schema + if Mix.env() == :test do + defp unshrink(stream), do: stream + else + defp unshrink(stream), do: StreamData.unshrinkable(stream) + end + @doc """ generate fake data with given schema. It could be a raw json schema or ExJsonSchema.Schema.Root type. @@ -24,13 +30,16 @@ defmodule JsonDataFaker do def generate(schema, opts \\ []) def generate(%Schema.Root{} = schema, opts) do - generate_by_type(schema.schema, schema, opts) + schema.schema + |> generate_by_type(schema, opts) + |> unshrink() end def generate(schema, opts) when is_map(schema) do schema |> Schema.resolve() |> generate(opts) + |> unshrink() rescue e -> Logger.error("Failed to generate data. #{inspect(e)}") From 59f4e22f783d4f7713f8bbe9d2dc32cfef9dc101 Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Mon, 31 May 2021 16:08:59 +0200 Subject: [PATCH 12/35] handle allOf --- lib/json_data_faker.ex | 52 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/lib/json_data_faker.ex b/lib/json_data_faker.ex index f185f91..decb58b 100644 --- a/lib/json_data_faker.ex +++ b/lib/json_data_faker.ex @@ -61,6 +61,12 @@ defmodule JsonDataFaker do defp generate_by_type(%{"anyOf" => anyOf}, root, opts), do: anyOf |> Enum.map(&generate_by_type(&1, root, opts)) |> StreamData.one_of() + defp generate_by_type(%{"allOf" => allOf}, root, opts) do + allOf + |> merge_all_of(root) + |> generate_by_type(root, opts) + end + defp generate_by_type(%{"enum" => choices}, _root, _opts), do: StreamData.member_of(choices) defp generate_by_type(%{"type" => "boolean"}, _root, _opts), do: boolean() @@ -214,6 +220,52 @@ defmodule JsonDataFaker do StreamData.map(StreamData.constant(nil), fn _ -> fun.() end) end + defp merge_all_of(all_ofs, root) do + all_of_merger_root = fn root -> &all_of_merger(&1, &2, &3, root) end + + Enum.reduce(all_ofs, %{}, fn all_of, acc -> + Map.merge(acc, resolve(all_of, root), all_of_merger_root.(root)) + end) + end + + defp all_of_merger(key, v1, v2, _root) + when key in ["minLength", "minProperties", "minimum", "maxItems"], + do: max(v1, v2) + + defp all_of_merger(key, v1, v2, _root) + when key in ["maxLength", "maxProperties", "maximum", "minItems"], + do: min(v1, v2) + + defp all_of_merger(key, v1, v2, _root) + when key in ["uniqueItems", "exclusiveMaximum", "exclusiveMinimum"], + do: v1 or v2 + + defp all_of_merger("multipleOf", v1, v2, _root) do + # TODO fix + case Integer.gcd(v1, v2) do + 1 -> v1 * v2 + _ -> max(v1, v2) + end + end + + defp all_of_merger("enum", v1, v2, _root), do: Enum.filter(v1, &(&1 in v2)) + + defp all_of_merger("required", v1, v2, _root), do: v1 |> Enum.concat(v2) |> Enum.uniq() + + defp all_of_merger(_property, %{"$ref" => _} = v1, %{"$ref" => _} = v2, root) do + f1 = resolve(v1, root) + f2 = resolve(v2, root) + all_of_merger_root = fn root -> &all_of_merger(&1, &2, &3, root) end + Map.merge(f1, f2, all_of_merger_root.(root)) + end + + defp all_of_merger(_key, m1, m2, root) when is_map(m1) and is_map(m2) do + all_of_merger_root = fn root -> &all_of_merger(&1, &2, &3, root) end + Map.merge(m1, m2, all_of_merger_root.(root)) + end + + defp all_of_merger(_key, _v1, v2, _root), do: v2 + defp resolve(%{"$ref" => ref}, root), do: ExJsonSchema.Schema.get_fragment!(root, ref) defp resolve(schema, _root), do: schema end From 1314177492cd4b3ac9cfecb8b1a6c72a1219246d Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Mon, 31 May 2021 17:10:28 +0200 Subject: [PATCH 13/35] handle number type (float or integer) --- lib/json_data_faker.ex | 74 +++++++++++++++++++++++++++++++++++ test/json_data_faker_test.exs | 29 ++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/lib/json_data_faker.ex b/lib/json_data_faker.ex index decb58b..074c471 100644 --- a/lib/json_data_faker.ex +++ b/lib/json_data_faker.ex @@ -83,6 +83,37 @@ defmodule JsonDataFaker do ) end + defp generate_by_type(%{"type" => "number"} = schema, _root, _opts) do + int_generator = + generate_integer( + float_min_to_int(schema["minimum"]), + float_max_to_int(schema["maximum"]), + if(float_is_int(schema["minimum"]), + do: Map.get(schema, "exclusiveMinimum", false), + else: false + ), + if(float_is_int(schema["maximum"]), + do: Map.get(schema, "exclusiveMaximum", false), + else: false + ), + schema["multipleOf"] + ) + + float_generator = + if schema["multipleOf"] != nil do + map(int_generator, &(&1 * 1.0)) + else + generate_float( + schema["minimum"], + schema["maximum"], + Map.get(schema, "exclusiveMinimum", false), + Map.get(schema, "exclusiveMaximum", false) + ) + end + + StreamData.one_of([int_generator, float_generator]) + end + defp generate_by_type(%{"type" => "array"} = schema, root, _opts) do inner_schema = schema["items"] @@ -216,6 +247,23 @@ defmodule JsonDataFaker do map(integer(min..max), &(&1 * multipleOf)) end + defp generate_float(nil, nil, _, _), do: float() + + defp generate_float(min, nil, false, _), do: float(min: min) + + defp generate_float(min, nil, true, _), do: filter(float(min: min), &(&1 != min)) + + defp generate_float(nil, max, _, false), do: float(max: max) + + defp generate_float(nil, max, _, true), do: filter(float(max: max), &(&1 != max)) + + defp generate_float(min, max, emin, emax) do + [min: min, max: max] + |> float() + |> (&if(emin, do: filter(&1, fn val -> val != min end), else: &1)).() + |> (&if(emax, do: filter(&1, fn val -> val != max end), else: &1)).() + end + defp stream_gen(fun) do StreamData.map(StreamData.constant(nil), fn _ -> fun.() end) end @@ -268,4 +316,30 @@ defmodule JsonDataFaker do defp resolve(%{"$ref" => ref}, root), do: ExJsonSchema.Schema.get_fragment!(root, ref) defp resolve(schema, _root), do: schema + + defp float_is_int(num) when is_integer(num), do: true + defp float_is_int(num) when is_float(num), do: Float.round(num) == 1.0 * num + defp float_is_int(_), do: false + + defp float_min_to_int(nil), do: nil + defp float_min_to_int(num) when is_integer(num), do: num + + defp float_min_to_int(num) do + cond do + float_is_int(num) -> trunc(num) + num < 0 -> trunc(num) + num > 0 -> trunc(num) + 1 + end + end + + defp float_max_to_int(nil), do: nil + defp float_max_to_int(num) when is_integer(num), do: num + + defp float_max_to_int(num) do + cond do + float_is_int(num) -> trunc(num) + num < 0 -> trunc(num) - 1 + num > 0 -> trunc(num) + end + end end diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index 5f45f01..480d6ba 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -178,6 +178,35 @@ defmodule JsonDataFakerTest do } ) + property_test("float generation should work", %{ + "type" => "number", + "minimum" => 5.24, + "maximum" => 20.33 + }) + + property_test("float generation with exclusive endpoints should work", %{ + "type" => "number", + "minimum" => 3.0, + "maximum" => 7.789, + "exclusiveMinimum" => true, + "exclusiveMaximum" => true + }) + + property_test("float generation with exclusive and negative endpoints should work", %{ + "type" => "number", + "minimum" => -7.245, + "maximum" => -3.0, + "exclusiveMinimum" => true, + "exclusiveMaximum" => true + }) + + property_test("float generation with multipleOf and negative endpoints should work", %{ + "type" => "number", + "minimum" => -7.245, + "maximum" => -3.0, + "multipleOf" => 2 + }) + property_test("object generation without required properties should work", %{ "type" => "object", "properties" => %{ From ac496f771de71aadfc018eff8849028bb3a6b90d Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Mon, 31 May 2021 17:21:38 +0200 Subject: [PATCH 14/35] type can be a list --- lib/json_data_faker.ex | 7 +++++++ test/json_data_faker_test.exs | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/lib/json_data_faker.ex b/lib/json_data_faker.ex index 074c471..4a53846 100644 --- a/lib/json_data_faker.ex +++ b/lib/json_data_faker.ex @@ -69,6 +69,13 @@ defmodule JsonDataFaker do defp generate_by_type(%{"enum" => choices}, _root, _opts), do: StreamData.member_of(choices) + defp generate_by_type(%{"type" => [_ | _] = types} = schema, root, opts) do + types + |> Enum.map(fn type -> Map.put(schema, "type", type) end) + |> Enum.map(&generate_by_type(&1, root, opts)) + |> StreamData.one_of() + end + defp generate_by_type(%{"type" => "boolean"}, _root, _opts), do: boolean() defp generate_by_type(%{"type" => "string"} = schema, _root, _opts), do: generate_string(schema) diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index 480d6ba..1d7d03a 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -269,6 +269,11 @@ defmodule JsonDataFakerTest do %{"anyOf" => [%{"type" => "integer"}, @complex_object], "components" => @components} ]) + property_test("array of types generation should work", [ + %{"type" => ["integer", "null"], "minimum" => 10}, + %{"type" => ["integer", "string"], "maximum" => 10, "minLength" => 10} + ]) + property "empty or invalid schema should return nil" do schema = %{} From a37fb5e32e58194e0f1397bb6d9c5ec6c5ef8174 Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Mon, 31 May 2021 18:03:52 +0200 Subject: [PATCH 15/35] handle additionalItems and items as an array of schemas --- lib/json_data_faker.ex | 36 +++++++++++++++++++++++++++++++---- test/json_data_faker_test.exs | 22 +++++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/lib/json_data_faker.ex b/lib/json_data_faker.ex index 4a53846..26530a3 100644 --- a/lib/json_data_faker.ex +++ b/lib/json_data_faker.ex @@ -43,10 +43,10 @@ defmodule JsonDataFaker do rescue e -> Logger.error("Failed to generate data. #{inspect(e)}") - nil + StreamData.constant(nil) end - def generate(_schema, _opts), do: nil + def generate(_schema, _opts), do: StreamData.constant(nil) # private functions defp generate_by_type(%{"$ref" => _} = schema, root, opts) do @@ -121,9 +121,37 @@ defmodule JsonDataFaker do StreamData.one_of([int_generator, float_generator]) end - defp generate_by_type(%{"type" => "array"} = schema, root, _opts) do - inner_schema = schema["items"] + defp generate_by_type( + %{"type" => "array", "additionalItems" => ai, "items" => [_ | _] = items}, + root, + opts + ) + when is_boolean(ai) do + items + |> Enum.map(&generate_by_type(&1, root, opts)) + |> StreamData.fixed_list() + end + + defp generate_by_type( + %{"type" => "array", "additionalItems" => schema, "items" => [_ | _] = items}, + root, + opts + ) + when is_map(schema) do + items + |> Enum.map(&generate_by_type(&1, root, opts)) + |> StreamData.fixed_list() + |> StreamData.bind(fn fixed_list -> + additional_generator = StreamData.list_of(generate_by_type(schema, root, opts)) + + StreamData.bind(additional_generator, fn additional -> + StreamData.constant(Enum.concat(fixed_list, additional)) + end) + end) + end + defp generate_by_type(%{"type" => "array", "items" => inner_schema} = schema, root, _opts) + when is_map(inner_schema) do opts = Enum.reduce(schema, [], fn {"minItems", min}, acc -> Keyword.put(acc, :min_length, min) diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index 1d7d03a..254feaa 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -251,6 +251,28 @@ defmodule JsonDataFakerTest do "uniqueItems" => true }) + property_test("array generation with additionalItems bool and array of items should work", [ + %{ + "items" => [%{"type" => "integer"}, %{"type" => "string"}], + "type" => "array", + "additionalItems" => false + }, + %{ + "items" => [%{"type" => "integer"}, %{"type" => "string"}], + "type" => "array", + "additionalItems" => true + } + ]) + + property_test( + "array generation with additionalItems as schema and array of items should work", + %{ + "type" => "array", + "items" => [%{"type" => "integer"}, %{"type" => "string"}], + "additionalItems" => %{"type" => "object"} + } + ) + property_test("array generation with all options should work", %{ "items" => %{"type" => "integer"}, "type" => "array", From dacc754100baaaba1b7599eca028fe3e2ba73103 Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Mon, 31 May 2021 18:04:06 +0200 Subject: [PATCH 16/35] empty objects and arrays --- lib/json_data_faker.ex | 4 ++++ test/json_data_faker_test.exs | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/lib/json_data_faker.ex b/lib/json_data_faker.ex index 26530a3..f4c80ca 100644 --- a/lib/json_data_faker.ex +++ b/lib/json_data_faker.ex @@ -176,6 +176,8 @@ defmodule JsonDataFaker do end end + defp generate_by_type(%{"type" => "array"}, _root, _opts), do: StreamData.constant([]) + defp generate_by_type(%{"type" => "object", "properties" => _} = schema, root, opts) do case Keyword.get(opts, :require_optional_properties, false) do true -> generate_full_object(schema, root, opts) @@ -183,6 +185,8 @@ defmodule JsonDataFaker do end end + defp generate_by_type(%{"type" => "object"}, _root, _opts), do: StreamData.constant(%{}) + defp generate_by_type(_schema, _root, _opts), do: StreamData.constant(nil) defp generate_full_object(%{"properties" => properties}, root, opts) do diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index 254feaa..30cd1d2 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -281,6 +281,11 @@ defmodule JsonDataFakerTest do "uniqueItems" => true }) + property_test("empty objects and arrays generation should work", [ + %{"type" => "object"}, + %{"type" => "array"} + ]) + property_test("oneOf generation should work", [ %{"oneOf" => [%{"type" => "integer"}, %{"type" => "boolean"}]}, %{"oneOf" => [%{"type" => "integer"}, @complex_object], "components" => @components} From 6eefae916e383425aebf491984c7c361de35f8c2 Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Mon, 31 May 2021 18:27:56 +0200 Subject: [PATCH 17/35] split in submodules --- lib/generator/array.ex | 53 ++++++ lib/generator/misc.ex | 79 +++++++++ lib/generator/number.ex | 125 +++++++++++++++ lib/generator/object.ex | 40 +++++ lib/generator/string.ex | 48 ++++++ lib/json_data_faker.ex | 345 +++------------------------------------- 6 files changed, 369 insertions(+), 321 deletions(-) create mode 100644 lib/generator/array.ex create mode 100644 lib/generator/misc.ex create mode 100644 lib/generator/number.ex create mode 100644 lib/generator/object.ex create mode 100644 lib/generator/string.ex diff --git a/lib/generator/array.ex b/lib/generator/array.ex new file mode 100644 index 0000000..45eb149 --- /dev/null +++ b/lib/generator/array.ex @@ -0,0 +1,53 @@ +defmodule JsonDataFaker.Generator.Array do + @moduledoc false + + def generate(%{"additionalItems" => ai, "items" => [_ | _] = items}, root, opts) + when is_boolean(ai) do + items + |> Enum.map(&JsonDataFaker.generate_by_type(&1, root, opts)) + |> StreamData.fixed_list() + end + + def generate(%{"additionalItems" => schema, "items" => [_ | _] = items}, root, opts) + when is_map(schema) do + items + |> Enum.map(&JsonDataFaker.generate_by_type(&1, root, opts)) + |> StreamData.fixed_list() + |> StreamData.bind(fn fixed_list -> + additional_generator = + StreamData.list_of(JsonDataFaker.generate_by_type(schema, root, opts)) + + StreamData.bind(additional_generator, fn additional -> + StreamData.constant(Enum.concat(fixed_list, additional)) + end) + end) + end + + def generate(%{"items" => inner_schema} = schema, root, _opts) + when is_map(inner_schema) do + opts = + Enum.reduce(schema, [], fn + {"minItems", min}, acc -> Keyword.put(acc, :min_length, min) + {"maxItems", max}, acc -> Keyword.put(acc, :max_length, max) + _, acc -> acc + end) + + case Map.get(schema, "uniqueItems", false) do + false -> + StreamData.list_of(JsonDataFaker.generate_by_type(inner_schema, root, opts), opts) + + true -> + inner_schema + |> JsonDataFaker.generate_by_type(root, opts) + |> StreamData.scale(fn size -> + case Keyword.get(opts, :max_length, false) do + false -> size + max -> max * 3 + end + end) + |> StreamData.uniq_list_of(opts) + end + end + + def generate(_schema, _root, _opts), do: StreamData.constant([]) +end diff --git a/lib/generator/misc.ex b/lib/generator/misc.ex new file mode 100644 index 0000000..e54a7fb --- /dev/null +++ b/lib/generator/misc.ex @@ -0,0 +1,79 @@ +defmodule JsonDataFaker.Generator.Misc do + @moduledoc false + + def generate(%{"$ref" => _} = schema, root, opts) do + schema + |> resolve(root) + |> JsonDataFaker.generate_by_type(root, opts) + end + + def generate(%{"oneOf" => oneOf}, root, opts), + do: oneOf |> Enum.map(&JsonDataFaker.generate_by_type(&1, root, opts)) |> StreamData.one_of() + + def generate(%{"anyOf" => anyOf}, root, opts), + do: anyOf |> Enum.map(&JsonDataFaker.generate_by_type(&1, root, opts)) |> StreamData.one_of() + + def generate(%{"allOf" => allOf}, root, opts) do + allOf + |> merge_all_of(root) + |> JsonDataFaker.generate_by_type(root, opts) + end + + def generate(%{"enum" => choices}, _root, _opts), do: StreamData.member_of(choices) + + def generate(%{"type" => [_ | _] = types} = schema, root, opts) do + types + |> Enum.map(fn type -> Map.put(schema, "type", type) end) + |> Enum.map(&JsonDataFaker.generate_by_type(&1, root, opts)) + |> StreamData.one_of() + end + + defp merge_all_of(all_ofs, root) do + all_of_merger_root = fn root -> &all_of_merger(&1, &2, &3, root) end + + Enum.reduce(all_ofs, %{}, fn all_of, acc -> + Map.merge(acc, resolve(all_of, root), all_of_merger_root.(root)) + end) + end + + defp all_of_merger(key, v1, v2, _root) + when key in ["minLength", "minProperties", "minimum", "maxItems"], + do: max(v1, v2) + + defp all_of_merger(key, v1, v2, _root) + when key in ["maxLength", "maxProperties", "maximum", "minItems"], + do: min(v1, v2) + + defp all_of_merger(key, v1, v2, _root) + when key in ["uniqueItems", "exclusiveMaximum", "exclusiveMinimum"], + do: v1 or v2 + + defp all_of_merger("multipleOf", v1, v2, _root) do + # TODO fix + case Integer.gcd(v1, v2) do + 1 -> v1 * v2 + _ -> max(v1, v2) + end + end + + defp all_of_merger("enum", v1, v2, _root), do: Enum.filter(v1, &(&1 in v2)) + + defp all_of_merger("required", v1, v2, _root), do: v1 |> Enum.concat(v2) |> Enum.uniq() + + defp all_of_merger(_property, %{"$ref" => _} = v1, %{"$ref" => _} = v2, root) do + f1 = resolve(v1, root) + f2 = resolve(v2, root) + all_of_merger_root = fn root -> &all_of_merger(&1, &2, &3, root) end + Map.merge(f1, f2, all_of_merger_root.(root)) + end + + defp all_of_merger(_key, m1, m2, root) when is_map(m1) and is_map(m2) do + all_of_merger_root = fn root -> &all_of_merger(&1, &2, &3, root) end + Map.merge(m1, m2, all_of_merger_root.(root)) + end + + defp all_of_merger(_key, _v1, v2, _root), do: v2 + + defp resolve(%{"$ref" => ref}, root), do: ExJsonSchema.Schema.get_fragment!(root, ref) + defp resolve(schema, _root), do: schema +end diff --git a/lib/generator/number.ex b/lib/generator/number.ex new file mode 100644 index 0000000..12fad1b --- /dev/null +++ b/lib/generator/number.ex @@ -0,0 +1,125 @@ +defmodule JsonDataFaker.Generator.Number do + @moduledoc false + + import StreamData + + def generate(%{"type" => "integer"} = schema, _root, _opts) do + generate_integer( + schema["minimum"], + schema["maximum"], + Map.get(schema, "exclusiveMinimum", false), + Map.get(schema, "exclusiveMaximum", false), + schema["multipleOf"] + ) + end + + def generate(%{"type" => "number"} = schema, _root, _opts) do + int_generator = + generate_integer( + float_min_to_int(schema["minimum"]), + float_max_to_int(schema["maximum"]), + if(float_is_int(schema["minimum"]), + do: Map.get(schema, "exclusiveMinimum", false), + else: false + ), + if(float_is_int(schema["maximum"]), + do: Map.get(schema, "exclusiveMaximum", false), + else: false + ), + schema["multipleOf"] + ) + + float_generator = + if schema["multipleOf"] != nil do + map(int_generator, &(&1 * 1.0)) + else + generate_float( + schema["minimum"], + schema["maximum"], + Map.get(schema, "exclusiveMinimum", false), + Map.get(schema, "exclusiveMaximum", false) + ) + end + + StreamData.one_of([int_generator, float_generator]) + end + + defp generate_integer(nil, nil, _, _, nil), do: integer() + + defp generate_integer(nil, nil, _, _, multipleOf), do: map(integer(), &(&1 * multipleOf)) + + defp generate_integer(min, nil, exclusive, _, nil), + do: map(positive_integer(), &(&1 - 1 + min + if(exclusive, do: 1, else: 0))) + + defp generate_integer(nil, max, _, exclusive, nil), + do: map(positive_integer(), &(max + if(exclusive, do: -1, else: 0) - (&1 - 1))) + + defp generate_integer(min, nil, exclusive, _, multipleOf) do + min = min + if(exclusive, do: 1, else: 0) + min = Integer.floor_div(min, multipleOf) + 1 + map(positive_integer(), &((&1 - 1 + min) * multipleOf)) + end + + defp generate_integer(nil, max, _, exclusive, multipleOf) do + max = max + if(exclusive, do: -1, else: 0) + max = Integer.floor_div(max, multipleOf) + map(positive_integer(), &((max - (&1 - 1)) * multipleOf)) + end + + defp generate_integer(min, max, emin, emax, nil) do + min = min + if(emin, do: 1, else: 0) + max = max + if(emax, do: -1, else: 0) + integer(min..max) + end + + defp generate_integer(min, max, emin, emax, multipleOf) do + min = min + if(emin, do: 1, else: 0) + max = max + if(emax, do: -1, else: 0) + min = Integer.floor_div(min, multipleOf) + 1 + max = Integer.floor_div(max, multipleOf) + map(integer(min..max), &(&1 * multipleOf)) + end + + defp generate_float(nil, nil, _, _), do: float() + + defp generate_float(min, nil, false, _), do: float(min: min) + + defp generate_float(min, nil, true, _), do: filter(float(min: min), &(&1 != min)) + + defp generate_float(nil, max, _, false), do: float(max: max) + + defp generate_float(nil, max, _, true), do: filter(float(max: max), &(&1 != max)) + + defp generate_float(min, max, emin, emax) do + [min: min, max: max] + |> float() + |> (&if(emin, do: filter(&1, fn val -> val != min end), else: &1)).() + |> (&if(emax, do: filter(&1, fn val -> val != max end), else: &1)).() + end + + defp float_is_int(num) when is_integer(num), do: true + defp float_is_int(num) when is_float(num), do: Float.round(num) == 1.0 * num + defp float_is_int(_), do: false + + defp float_min_to_int(nil), do: nil + defp float_min_to_int(num) when is_integer(num), do: num + + defp float_min_to_int(num) do + cond do + float_is_int(num) -> trunc(num) + num < 0 -> trunc(num) + num > 0 -> trunc(num) + 1 + end + end + + defp float_max_to_int(nil), do: nil + defp float_max_to_int(num) when is_integer(num), do: num + + defp float_max_to_int(num) do + cond do + float_is_int(num) -> trunc(num) + num < 0 -> trunc(num) - 1 + num > 0 -> trunc(num) + end + end +end diff --git a/lib/generator/object.ex b/lib/generator/object.ex new file mode 100644 index 0000000..cc0194f --- /dev/null +++ b/lib/generator/object.ex @@ -0,0 +1,40 @@ +defmodule JsonDataFaker.Generator.Object do + @moduledoc false + + def generate(%{"type" => "object", "properties" => _} = schema, root, opts) do + case Keyword.get(opts, :require_optional_properties, false) do + true -> generate_full_object(schema, root, opts) + false -> generate_object(schema, root, opts) + end + end + + def generate(%{"type" => "object"}, _root, _opts), do: StreamData.constant(%{}) + + defp generate_full_object(%{"properties" => properties}, root, opts) do + properties + |> Map.new(fn {key, inner_schema} -> + {key, JsonDataFaker.generate_by_type(inner_schema, root, opts)} + end) + |> StreamData.fixed_map() + end + + defp generate_object(%{"properties" => properties} = schema, root, opts) do + required = Map.get(schema, "required", []) + {required_props, optional_props} = Enum.split_with(properties, &(elem(&1, 0) in required)) + + [required_map, optional_map] = + Enum.map([required_props, optional_props], fn props -> + Map.new(props, fn {key, inner_schema} -> + {key, JsonDataFaker.generate_by_type(inner_schema, root, opts)} + end) + end) + + required_map + |> StreamData.fixed_map() + |> StreamData.bind(fn req_map -> + StreamData.bind(StreamData.optional_map(optional_map), fn opt_map -> + StreamData.constant(Map.merge(opt_map, req_map)) + end) + end) + end +end diff --git a/lib/generator/string.ex b/lib/generator/string.ex new file mode 100644 index 0000000..e931370 --- /dev/null +++ b/lib/generator/string.ex @@ -0,0 +1,48 @@ +defmodule JsonDataFaker.Generator.String do + @moduledoc false + + import StreamData, only: [string: 2] + + def generate(%{"format" => "date-time"}, _root, _opts), + do: stream_gen(fn -> 30 |> Faker.DateTime.backward() |> DateTime.to_iso8601() end) + + def generate(%{"format" => "uuid"}, _root, _opts), do: stream_gen(&Faker.UUID.v4/0) + def generate(%{"format" => "email"}, _root, _opts), do: stream_gen(&Faker.Internet.email/0) + + def generate(%{"format" => "hostname"}, _root, _opts), + do: stream_gen(&Faker.Internet.domain_name/0) + + def generate(%{"format" => "ipv4"}, _root, _opts), + do: stream_gen(&Faker.Internet.ip_v4_address/0) + + def generate(%{"format" => "ipv6"}, _root, _opts), + do: stream_gen(&Faker.Internet.ip_v6_address/0) + + def generate(%{"format" => "uri"}, _root, _opts), do: stream_gen(&Faker.Internet.url/0) + + def generate(%{"format" => "image_uri"}, _root, _opts) do + stream_gen(fn -> + w = Enum.random(1..4) * 400 + h = Enum.random(1..4) * 400 + "https://source.unsplash.com/random/#{w}x#{h}" + end) + end + + def generate(%{"pattern" => regex}, _root, _opts), + do: Randex.stream(Regex.compile!(regex), mod: Randex.Generator.StreamData, max_repetition: 10) + + def generate(schema, _root, _opts) do + opts = + Enum.reduce(schema, [], fn + {"minLength", min}, acc -> Keyword.put(acc, :min_length, min) + {"maxLength", max}, acc -> Keyword.put(acc, :max_length, max) + _, acc -> acc + end) + + string(:ascii, opts) + end + + defp stream_gen(fun) do + StreamData.map(StreamData.constant(nil), fn _ -> fun.() end) + end +end diff --git a/lib/json_data_faker.ex b/lib/json_data_faker.ex index f4c80ca..f08f28f 100644 --- a/lib/json_data_faker.ex +++ b/lib/json_data_faker.ex @@ -4,8 +4,11 @@ defmodule JsonDataFaker do """ import StreamData require Logger + alias ExJsonSchema.Schema + alias JsonDataFaker.Generator.{Array, Misc, Number, Object, String} + if Mix.env() == :test do defp unshrink(stream), do: stream else @@ -48,337 +51,37 @@ defmodule JsonDataFaker do def generate(_schema, _opts), do: StreamData.constant(nil) - # private functions - defp generate_by_type(%{"$ref" => _} = schema, root, opts) do - schema - |> resolve(root) - |> generate_by_type(root, opts) - end - - defp generate_by_type(%{"oneOf" => oneOf}, root, opts), - do: oneOf |> Enum.map(&generate_by_type(&1, root, opts)) |> StreamData.one_of() + @doc false - defp generate_by_type(%{"anyOf" => anyOf}, root, opts), - do: anyOf |> Enum.map(&generate_by_type(&1, root, opts)) |> StreamData.one_of() + def generate_by_type(%{"$ref" => _} = schema, root, opts), do: Misc.generate(schema, root, opts) - defp generate_by_type(%{"allOf" => allOf}, root, opts) do - allOf - |> merge_all_of(root) - |> generate_by_type(root, opts) - end + def generate_by_type(%{"oneOf" => _} = schema, root, opts), + do: Misc.generate(schema, root, opts) - defp generate_by_type(%{"enum" => choices}, _root, _opts), do: StreamData.member_of(choices) + def generate_by_type(%{"anyOf" => _} = schema, root, opts), + do: Misc.generate(schema, root, opts) - defp generate_by_type(%{"type" => [_ | _] = types} = schema, root, opts) do - types - |> Enum.map(fn type -> Map.put(schema, "type", type) end) - |> Enum.map(&generate_by_type(&1, root, opts)) - |> StreamData.one_of() - end - - defp generate_by_type(%{"type" => "boolean"}, _root, _opts), do: boolean() - - defp generate_by_type(%{"type" => "string"} = schema, _root, _opts), do: generate_string(schema) - - defp generate_by_type(%{"type" => "integer"} = schema, _root, _opts) do - generate_integer( - schema["minimum"], - schema["maximum"], - Map.get(schema, "exclusiveMinimum", false), - Map.get(schema, "exclusiveMaximum", false), - schema["multipleOf"] - ) - end + def generate_by_type(%{"allOf" => _} = schema, root, opts), + do: Misc.generate(schema, root, opts) - defp generate_by_type(%{"type" => "number"} = schema, _root, _opts) do - int_generator = - generate_integer( - float_min_to_int(schema["minimum"]), - float_max_to_int(schema["maximum"]), - if(float_is_int(schema["minimum"]), - do: Map.get(schema, "exclusiveMinimum", false), - else: false - ), - if(float_is_int(schema["maximum"]), - do: Map.get(schema, "exclusiveMaximum", false), - else: false - ), - schema["multipleOf"] - ) + def generate_by_type(%{"enum" => _} = schema, root, opts), do: Misc.generate(schema, root, opts) - float_generator = - if schema["multipleOf"] != nil do - map(int_generator, &(&1 * 1.0)) - else - generate_float( - schema["minimum"], - schema["maximum"], - Map.get(schema, "exclusiveMinimum", false), - Map.get(schema, "exclusiveMaximum", false) - ) - end + def generate_by_type(%{"type" => [_ | _]} = schema, root, opts), + do: Misc.generate(schema, root, opts) - StreamData.one_of([int_generator, float_generator]) - end - - defp generate_by_type( - %{"type" => "array", "additionalItems" => ai, "items" => [_ | _] = items}, - root, - opts - ) - when is_boolean(ai) do - items - |> Enum.map(&generate_by_type(&1, root, opts)) - |> StreamData.fixed_list() - end - - defp generate_by_type( - %{"type" => "array", "additionalItems" => schema, "items" => [_ | _] = items}, - root, - opts - ) - when is_map(schema) do - items - |> Enum.map(&generate_by_type(&1, root, opts)) - |> StreamData.fixed_list() - |> StreamData.bind(fn fixed_list -> - additional_generator = StreamData.list_of(generate_by_type(schema, root, opts)) - - StreamData.bind(additional_generator, fn additional -> - StreamData.constant(Enum.concat(fixed_list, additional)) - end) - end) - end - - defp generate_by_type(%{"type" => "array", "items" => inner_schema} = schema, root, _opts) - when is_map(inner_schema) do - opts = - Enum.reduce(schema, [], fn - {"minItems", min}, acc -> Keyword.put(acc, :min_length, min) - {"maxItems", max}, acc -> Keyword.put(acc, :max_length, max) - _, acc -> acc - end) - - case Map.get(schema, "uniqueItems", false) do - false -> - StreamData.list_of(generate_by_type(inner_schema, root, opts), opts) - - true -> - inner_schema - |> generate_by_type(root, opts) - |> StreamData.scale(fn size -> - case Keyword.get(opts, :max_length, false) do - false -> size - max -> max * 3 - end - end) - |> StreamData.uniq_list_of(opts) - end - end + def generate_by_type(%{"type" => "boolean"}, _root, _opts), do: boolean() - defp generate_by_type(%{"type" => "array"}, _root, _opts), do: StreamData.constant([]) - - defp generate_by_type(%{"type" => "object", "properties" => _} = schema, root, opts) do - case Keyword.get(opts, :require_optional_properties, false) do - true -> generate_full_object(schema, root, opts) - false -> generate_object(schema, root, opts) - end - end - - defp generate_by_type(%{"type" => "object"}, _root, _opts), do: StreamData.constant(%{}) - - defp generate_by_type(_schema, _root, _opts), do: StreamData.constant(nil) - - defp generate_full_object(%{"properties" => properties}, root, opts) do - properties - |> Map.new(fn {key, inner_schema} -> {key, generate_by_type(inner_schema, root, opts)} end) - |> StreamData.fixed_map() - end - - defp generate_object(%{"properties" => properties} = schema, root, opts) do - required = Map.get(schema, "required", []) - {required_props, optional_props} = Enum.split_with(properties, &(elem(&1, 0) in required)) - - [required_map, optional_map] = - Enum.map([required_props, optional_props], fn props -> - Map.new(props, fn {key, inner_schema} -> - {key, generate_by_type(inner_schema, root, opts)} - end) - end) - - required_map - |> StreamData.fixed_map() - |> StreamData.bind(fn req_map -> - StreamData.bind(StreamData.optional_map(optional_map), fn opt_map -> - StreamData.constant(Map.merge(opt_map, req_map)) - end) - end) - end - - defp generate_string(%{"format" => "date-time"}), - do: stream_gen(fn -> 30 |> Faker.DateTime.backward() |> DateTime.to_iso8601() end) - - defp generate_string(%{"format" => "uuid"}), do: stream_gen(&Faker.UUID.v4/0) - defp generate_string(%{"format" => "email"}), do: stream_gen(&Faker.Internet.email/0) - - defp generate_string(%{"format" => "hostname"}), - do: stream_gen(&Faker.Internet.domain_name/0) - - defp generate_string(%{"format" => "ipv4"}), do: stream_gen(&Faker.Internet.ip_v4_address/0) - defp generate_string(%{"format" => "ipv6"}), do: stream_gen(&Faker.Internet.ip_v6_address/0) - defp generate_string(%{"format" => "uri"}), do: stream_gen(&Faker.Internet.url/0) - - defp generate_string(%{"format" => "image_uri"}) do - stream_gen(fn -> - w = Enum.random(1..4) * 400 - h = Enum.random(1..4) * 400 - "https://source.unsplash.com/random/#{w}x#{h}" - end) - end - - defp generate_string(%{"pattern" => regex}), - do: Randex.stream(Regex.compile!(regex), mod: Randex.Generator.StreamData, max_repetition: 10) - - defp generate_string(schema) do - opts = - Enum.reduce(schema, [], fn - {"minLength", min}, acc -> Keyword.put(acc, :min_length, min) - {"maxLength", max}, acc -> Keyword.put(acc, :max_length, max) - _, acc -> acc - end) - - string(:ascii, opts) - end + def generate_by_type(%{"type" => "string"} = schema, root, opts), + do: String.generate(schema, root, opts) - defp generate_integer(nil, nil, _, _, nil), do: integer() + def generate_by_type(%{"type" => "array"} = schema, root, opts), + do: Array.generate(schema, root, opts) - defp generate_integer(nil, nil, _, _, multipleOf), do: map(integer(), &(&1 * multipleOf)) + def generate_by_type(%{"type" => "object"} = schema, root, opts), + do: Object.generate(schema, root, opts) - defp generate_integer(min, nil, exclusive, _, nil), - do: map(positive_integer(), &(&1 - 1 + min + if(exclusive, do: 1, else: 0))) + def generate_by_type(%{"type" => type} = schema, root, opts) when type in ["integer", "number"], + do: Number.generate(schema, root, opts) - defp generate_integer(nil, max, _, exclusive, nil), - do: map(positive_integer(), &(max + if(exclusive, do: -1, else: 0) - (&1 - 1))) - - defp generate_integer(min, nil, exclusive, _, multipleOf) do - min = min + if(exclusive, do: 1, else: 0) - min = Integer.floor_div(min, multipleOf) + 1 - map(positive_integer(), &((&1 - 1 + min) * multipleOf)) - end - - defp generate_integer(nil, max, _, exclusive, multipleOf) do - max = max + if(exclusive, do: -1, else: 0) - max = Integer.floor_div(max, multipleOf) - map(positive_integer(), &((max - (&1 - 1)) * multipleOf)) - end - - defp generate_integer(min, max, emin, emax, nil) do - min = min + if(emin, do: 1, else: 0) - max = max + if(emax, do: -1, else: 0) - integer(min..max) - end - - defp generate_integer(min, max, emin, emax, multipleOf) do - min = min + if(emin, do: 1, else: 0) - max = max + if(emax, do: -1, else: 0) - min = Integer.floor_div(min, multipleOf) + 1 - max = Integer.floor_div(max, multipleOf) - map(integer(min..max), &(&1 * multipleOf)) - end - - defp generate_float(nil, nil, _, _), do: float() - - defp generate_float(min, nil, false, _), do: float(min: min) - - defp generate_float(min, nil, true, _), do: filter(float(min: min), &(&1 != min)) - - defp generate_float(nil, max, _, false), do: float(max: max) - - defp generate_float(nil, max, _, true), do: filter(float(max: max), &(&1 != max)) - - defp generate_float(min, max, emin, emax) do - [min: min, max: max] - |> float() - |> (&if(emin, do: filter(&1, fn val -> val != min end), else: &1)).() - |> (&if(emax, do: filter(&1, fn val -> val != max end), else: &1)).() - end - - defp stream_gen(fun) do - StreamData.map(StreamData.constant(nil), fn _ -> fun.() end) - end - - defp merge_all_of(all_ofs, root) do - all_of_merger_root = fn root -> &all_of_merger(&1, &2, &3, root) end - - Enum.reduce(all_ofs, %{}, fn all_of, acc -> - Map.merge(acc, resolve(all_of, root), all_of_merger_root.(root)) - end) - end - - defp all_of_merger(key, v1, v2, _root) - when key in ["minLength", "minProperties", "minimum", "maxItems"], - do: max(v1, v2) - - defp all_of_merger(key, v1, v2, _root) - when key in ["maxLength", "maxProperties", "maximum", "minItems"], - do: min(v1, v2) - - defp all_of_merger(key, v1, v2, _root) - when key in ["uniqueItems", "exclusiveMaximum", "exclusiveMinimum"], - do: v1 or v2 - - defp all_of_merger("multipleOf", v1, v2, _root) do - # TODO fix - case Integer.gcd(v1, v2) do - 1 -> v1 * v2 - _ -> max(v1, v2) - end - end - - defp all_of_merger("enum", v1, v2, _root), do: Enum.filter(v1, &(&1 in v2)) - - defp all_of_merger("required", v1, v2, _root), do: v1 |> Enum.concat(v2) |> Enum.uniq() - - defp all_of_merger(_property, %{"$ref" => _} = v1, %{"$ref" => _} = v2, root) do - f1 = resolve(v1, root) - f2 = resolve(v2, root) - all_of_merger_root = fn root -> &all_of_merger(&1, &2, &3, root) end - Map.merge(f1, f2, all_of_merger_root.(root)) - end - - defp all_of_merger(_key, m1, m2, root) when is_map(m1) and is_map(m2) do - all_of_merger_root = fn root -> &all_of_merger(&1, &2, &3, root) end - Map.merge(m1, m2, all_of_merger_root.(root)) - end - - defp all_of_merger(_key, _v1, v2, _root), do: v2 - - defp resolve(%{"$ref" => ref}, root), do: ExJsonSchema.Schema.get_fragment!(root, ref) - defp resolve(schema, _root), do: schema - - defp float_is_int(num) when is_integer(num), do: true - defp float_is_int(num) when is_float(num), do: Float.round(num) == 1.0 * num - defp float_is_int(_), do: false - - defp float_min_to_int(nil), do: nil - defp float_min_to_int(num) when is_integer(num), do: num - - defp float_min_to_int(num) do - cond do - float_is_int(num) -> trunc(num) - num < 0 -> trunc(num) - num > 0 -> trunc(num) + 1 - end - end - - defp float_max_to_int(nil), do: nil - defp float_max_to_int(num) when is_integer(num), do: num - - defp float_max_to_int(num) do - cond do - float_is_int(num) -> trunc(num) - num < 0 -> trunc(num) - 1 - num > 0 -> trunc(num) - end - end + def generate_by_type(_schema, _root, _opts), do: StreamData.constant(nil) end From b4f770fb1758b38010348c6eccf4333fa9ddabef Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Tue, 1 Jun 2021 14:29:47 +0200 Subject: [PATCH 18/35] allOf fixes and tests --- lib/generator/misc.ex | 21 ++++--- test/json_data_faker_test.exs | 100 ++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 11 deletions(-) diff --git a/lib/generator/misc.ex b/lib/generator/misc.ex index e54a7fb..b4b64ce 100644 --- a/lib/generator/misc.ex +++ b/lib/generator/misc.ex @@ -37,24 +37,18 @@ defmodule JsonDataFaker.Generator.Misc do end defp all_of_merger(key, v1, v2, _root) - when key in ["minLength", "minProperties", "minimum", "maxItems"], + when key in ["minLength", "minProperties", "minimum", "minItems"], do: max(v1, v2) defp all_of_merger(key, v1, v2, _root) - when key in ["maxLength", "maxProperties", "maximum", "minItems"], + when key in ["maxLength", "maxProperties", "maximum", "maxItems"], do: min(v1, v2) defp all_of_merger(key, v1, v2, _root) when key in ["uniqueItems", "exclusiveMaximum", "exclusiveMinimum"], do: v1 or v2 - defp all_of_merger("multipleOf", v1, v2, _root) do - # TODO fix - case Integer.gcd(v1, v2) do - 1 -> v1 * v2 - _ -> max(v1, v2) - end - end + defp all_of_merger("multipleOf", v1, v2, _root), do: lcm(v1, v2) defp all_of_merger("enum", v1, v2, _root), do: Enum.filter(v1, &(&1 in v2)) @@ -63,8 +57,7 @@ defmodule JsonDataFaker.Generator.Misc do defp all_of_merger(_property, %{"$ref" => _} = v1, %{"$ref" => _} = v2, root) do f1 = resolve(v1, root) f2 = resolve(v2, root) - all_of_merger_root = fn root -> &all_of_merger(&1, &2, &3, root) end - Map.merge(f1, f2, all_of_merger_root.(root)) + all_of_merger(nil, f1, f2, root) end defp all_of_merger(_key, m1, m2, root) when is_map(m1) and is_map(m2) do @@ -72,8 +65,14 @@ defmodule JsonDataFaker.Generator.Misc do Map.merge(m1, m2, all_of_merger_root.(root)) end + # NOTE + # here fall also "pattern" and "format" keywords + # there is no easy way of merging them so we keep only the second value + # be aware that this can lead to generated values that are not valid against the schema defp all_of_merger(_key, _v1, v2, _root), do: v2 defp resolve(%{"$ref" => ref}, root), do: ExJsonSchema.Schema.get_fragment!(root, ref) defp resolve(schema, _root), do: schema + + defp lcm(m, n), do: trunc(m * n / Integer.gcd(m, n)) end diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index 30cd1d2..669b54e 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -301,6 +301,106 @@ defmodule JsonDataFakerTest do %{"type" => ["integer", "string"], "maximum" => 10, "minLength" => 10} ]) + property_test("allOf generation should work", [ + %{"allOf" => [%{"type" => "integer"}, %{"type" => "integer", "minimum" => 10}]}, + %{ + "allOf" => [ + %{"type" => "string", "maxLength" => 4}, + %{"type" => "string", "minLength" => 2} + ] + }, + %{ + "allOf" => [ + %{"type" => "object", "properties" => %{"foo" => %{"type" => "string"}}}, + %{ + "type" => "object", + "required" => ["bar"], + "properties" => %{"bar" => %{"type" => "boolean"}} + } + ] + } + ]) + + property_test("allOf generation with merged values should work", [ + %{ + "allOf" => [ + %{"type" => "integer", "minimum" => 12, "maximum" => 18, "multipleOf" => 3}, + %{"type" => "integer", "minimum" => 10, "maximum" => 20, "multipleOf" => 2} + ] + }, + %{ + "allOf" => [ + %{"type" => "string", "maxLength" => 4, "minLength" => 2}, + %{"type" => "string", "maxLength" => 7, "minLength" => 3} + ] + }, + %{ + "allOf" => [ + %{ + "type" => "object", + "required" => ["bar"], + "properties" => %{ + "bar" => %{"type" => "string"}, + "foo" => %{"type" => "string", "enum" => ["a", "b", "c"]} + } + }, + %{ + "type" => "object", + "required" => ["foo"], + "properties" => %{"foo" => %{"type" => "string", "enum" => ["b", "c", "d"]}} + } + ] + } + ]) + + property_test("allOf generation with refs should work", [ + %{ + "allOf" => [ + %{"$ref" => "#/components/schemas/Obj1"}, + %{ + "type" => "object", + "required" => ["foo"], + "properties" => %{"foo" => %{"type" => "string", "enum" => ["b", "c", "d"]}} + } + ], + "components" => %{ + "schemas" => %{ + "Obj1" => %{ + "type" => "object", + "required" => ["bar"], + "properties" => %{ + "bar" => %{"type" => "string"}, + "foo" => %{"type" => "string", "enum" => ["a", "b", "c"]} + } + } + } + } + }, + %{ + "allOf" => [ + %{"$ref" => "#/components/schemas/Obj1"}, + %{"$ref" => "#/components/schemas/Obj2"} + ], + "components" => %{ + "schemas" => %{ + "Obj1" => %{ + "type" => "object", + "required" => ["bar"], + "properties" => %{ + "bar" => %{"type" => "string"}, + "foo" => %{"type" => "string", "enum" => ["a", "b", "c"]} + } + }, + "Obj2" => %{ + "type" => "object", + "required" => ["foo"], + "properties" => %{"foo" => %{"type" => "string", "enum" => ["b", "c", "d"]}} + } + } + } + } + ]) + property "empty or invalid schema should return nil" do schema = %{} From 8014f5a36d23789e26511fe672e78db18e8d2c7d Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Tue, 1 Jun 2021 14:31:34 +0200 Subject: [PATCH 19/35] integer fixes when endpoints are multiple of multipleOf --- lib/generator/number.ex | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/generator/number.ex b/lib/generator/number.ex index 12fad1b..22b4dba 100644 --- a/lib/generator/number.ex +++ b/lib/generator/number.ex @@ -56,7 +56,7 @@ defmodule JsonDataFaker.Generator.Number do defp generate_integer(min, nil, exclusive, _, multipleOf) do min = min + if(exclusive, do: 1, else: 0) - min = Integer.floor_div(min, multipleOf) + 1 + min = Integer.floor_div(min, multipleOf) + if(rem(min, multipleOf) == 0, do: 0, else: 1) map(positive_integer(), &((&1 - 1 + min) * multipleOf)) end @@ -69,15 +69,25 @@ defmodule JsonDataFaker.Generator.Number do defp generate_integer(min, max, emin, emax, nil) do min = min + if(emin, do: 1, else: 0) max = max + if(emax, do: -1, else: 0) - integer(min..max) + + if min > max do + StreamData.constant(nil) + else + integer(min..max) + end end defp generate_integer(min, max, emin, emax, multipleOf) do min = min + if(emin, do: 1, else: 0) max = max + if(emax, do: -1, else: 0) - min = Integer.floor_div(min, multipleOf) + 1 + min = Integer.floor_div(min, multipleOf) + if(rem(min, multipleOf) == 0, do: 0, else: 1) max = Integer.floor_div(max, multipleOf) - map(integer(min..max), &(&1 * multipleOf)) + + if min > max do + StreamData.constant(nil) + else + map(integer(min..max), &(&1 * multipleOf)) + end end defp generate_float(nil, nil, _, _), do: float() From 87fe03f33af80dc11a7798e8d471552a5acdca26 Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Tue, 1 Jun 2021 16:16:41 +0200 Subject: [PATCH 20/35] generate patternProperties in objects --- lib/generator/object.ex | 111 ++++++++++++++++++++++++++-------- lib/generator/utils.ex | 19 ++++++ test/json_data_faker_test.exs | 45 +++++++++++--- 3 files changed, 141 insertions(+), 34 deletions(-) create mode 100644 lib/generator/utils.ex diff --git a/lib/generator/object.ex b/lib/generator/object.ex index cc0194f..199e105 100644 --- a/lib/generator/object.ex +++ b/lib/generator/object.ex @@ -1,39 +1,102 @@ defmodule JsonDataFaker.Generator.Object do @moduledoc false - def generate(%{"type" => "object", "properties" => _} = schema, root, opts) do - case Keyword.get(opts, :require_optional_properties, false) do - true -> generate_full_object(schema, root, opts) - false -> generate_object(schema, root, opts) + def generate(%{"type" => "object"} = schema, root, opts) do + required = Map.get(schema, "required", []) + + {required_props, optional_props} = + schema + |> Map.get("properties", %{}) + |> Enum.split_with(&(elem(&1, 0) in required)) + + pattern_props = schema |> Map.get("patternProperties", %{}) |> Map.to_list() + + if Keyword.get(opts, :require_optional_properties, false) do + generate_full_object(schema, required_props, optional_props, pattern_props, root, opts) + else + generate_object(schema, required_props, optional_props, pattern_props, root, opts) end end - def generate(%{"type" => "object"}, _root, _opts), do: StreamData.constant(%{}) - - defp generate_full_object(%{"properties" => properties}, root, opts) do - properties - |> Map.new(fn {key, inner_schema} -> - {key, JsonDataFaker.generate_by_type(inner_schema, root, opts)} - end) + defp generate_full_object(_schema, required_props, optional_props, pattern_props, root, opts) do + required_props + |> Enum.concat(optional_props) + |> streamdata_map_builder_args(root, opts) |> StreamData.fixed_map() + |> merge_map_generators( + pattern_properties_generator(pattern_props, required_props, optional_props, root, opts) + ) end - defp generate_object(%{"properties" => properties} = schema, root, opts) do - required = Map.get(schema, "required", []) - {required_props, optional_props} = Enum.split_with(properties, &(elem(&1, 0) in required)) - - [required_map, optional_map] = - Enum.map([required_props, optional_props], fn props -> - Map.new(props, fn {key, inner_schema} -> - {key, JsonDataFaker.generate_by_type(inner_schema, root, opts)} - end) - end) + defp generate_object(_schema, required_props, optional_props, pattern_props, root, opts) do + required_map = streamdata_map_builder_args(required_props, root, opts) + optional_map = streamdata_map_builder_args(optional_props, root, opts) required_map |> StreamData.fixed_map() - |> StreamData.bind(fn req_map -> - StreamData.bind(StreamData.optional_map(optional_map), fn opt_map -> - StreamData.constant(Map.merge(opt_map, req_map)) + |> merge_map_generators(StreamData.optional_map(optional_map)) + |> merge_map_generators( + pattern_properties_generator(pattern_props, required_props, optional_props, root, opts) + ) + end + + defp pattern_properties_generator( + [], + _required_props, + _optional_props, + _root, + _opts + ), + do: StreamData.constant(%{}) + + defp pattern_properties_generator( + pattern_properties, + required_props, + optional_props, + root, + opts + ) do + # if the generated property has the same name of a standard property of the object than + # it should be valid against the standar property schema and not against the + # patternProperty one. In order to avoid generation of invalid properties we filter out + # patternProperties with name equal to one of the standard properties + other_props_names = required_props |> Enum.concat(optional_props) |> Enum.map(&elem(&1, 0)) + + pattern_properties + |> Enum.map(fn {key_regex, schema} -> + pattern_property_generator(key_regex, schema, other_props_names, root, opts) + end) + |> StreamData.one_of() + |> StreamData.list_of() + |> StreamData.bind(&(&1 |> Map.new() |> StreamData.constant())) + end + + defp pattern_property_generator(key_regex, schema, keys_blacklist, root, opts) do + key_generator = + key_regex + |> Regex.compile!() + |> Randex.stream(mod: Randex.Generator.StreamData, max_repetition: 10) + |> StreamData.filter(&(&1 not in keys_blacklist)) + + value_generator = + if(schema == %{}, + do: JsonDataFaker.Generator.Utils.json(), + else: JsonDataFaker.generate_by_type(schema, root, opts) + ) + + StreamData.tuple({key_generator, value_generator}) + end + + defp streamdata_map_builder_args(properties, root, opts) do + Map.new(properties, fn {key, inner_schema} -> + {key, JsonDataFaker.generate_by_type(inner_schema, root, opts)} + end) + end + + defp merge_map_generators(map1_gen, map2_gen) do + StreamData.bind(map1_gen, fn map1 -> + StreamData.bind(map2_gen, fn map2 -> + StreamData.constant(Map.merge(map1, map2)) end) end) end diff --git a/lib/generator/utils.ex b/lib/generator/utils.ex new file mode 100644 index 0000000..7e4bf98 --- /dev/null +++ b/lib/generator/utils.ex @@ -0,0 +1,19 @@ +defmodule JsonDataFaker.Generator.Utils do + @moduledoc false + + def json do + simple_value = + StreamData.one_of([ + StreamData.boolean(), + StreamData.integer(), + StreamData.string(:printable), + StreamData.float() + ]) + + map_key = StreamData.string(:printable, min_length: 1) + + StreamData.tree(simple_value, fn leaf -> + StreamData.one_of([StreamData.list_of(leaf), StreamData.map_of(map_key, leaf)]) + end) + end +end diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index 669b54e..4f918fd 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -1,11 +1,11 @@ defmodule JsonDataFakerTest.Helpers do - defmacro property_test(name, schemas) do + defmacro property_test(name, schemas, opts \\ []) do quote do property unquote(name) do Enum.each(List.wrap(unquote(schemas)), fn schema -> resolved_schema = ExJsonSchema.Schema.resolve(schema) - check all(data <- JsonDataFaker.generate(schema)) do + check all(data <- JsonDataFaker.generate(schema, unquote(opts))) do assert ExJsonSchema.Validator.valid?(resolved_schema, data) end end) @@ -218,14 +218,9 @@ defmodule JsonDataFakerTest do property_test("complex object generation should work", @full_object) - property "require_optional_properties property should work" do - resolved_schema = ExJsonSchema.Schema.resolve(@full_object) - - check all(data <- JsonDataFaker.generate(@full_object, require_optional_properties: true)) do - assert ExJsonSchema.Validator.valid?(resolved_schema, data) - assert Map.has_key?(data, "status") - end - end + property_test("require_optional_properties property should work", @full_object, + require_optional_properties: true + ) property_test("array of object generation should work", %{ "items" => @complex_object, @@ -401,6 +396,36 @@ defmodule JsonDataFakerTest do } ]) + property_test("patternProperties generation should work", %{ + "patternProperties" => %{ + "^[0-9]{4}$" => %{"type" => "integer"}, + "^[a-z]{4}$" => %{"type" => "string"} + }, + "type" => "object", + "properties" => %{ + "foo" => %{"type" => "boolean"}, + "bar" => %{"type" => "array", "items" => %{"type" => "integer"}} + }, + "required" => ["foo"] + }) + + property_test( + "patternProperties generation with require_optional_properties should work", + %{ + "patternProperties" => %{ + "^[0-9]{4}$" => %{"type" => "integer"}, + "^[a-z]{4}$" => %{"type" => "string"} + }, + "type" => "object", + "properties" => %{ + "foo" => %{"type" => "boolean"}, + "bar" => %{"type" => "array", "items" => %{"type" => "integer"}} + }, + "required" => ["foo"] + }, + require_optional_properties: true + ) + property "empty or invalid schema should return nil" do schema = %{} From 011eb2dc299752855df72c75909ce871d4c8fe5a Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Tue, 1 Jun 2021 17:32:44 +0200 Subject: [PATCH 21/35] correctly handle min/maxItems also with additionalItems --- lib/generator/array.ex | 97 ++++++++++++++++++++++++++++------- test/json_data_faker_test.exs | 80 +++++++++++++++++++++++++++-- 2 files changed, 154 insertions(+), 23 deletions(-) diff --git a/lib/generator/array.ex b/lib/generator/array.ex index 45eb149..b22dc81 100644 --- a/lib/generator/array.ex +++ b/lib/generator/array.ex @@ -1,26 +1,41 @@ defmodule JsonDataFaker.Generator.Array do @moduledoc false - def generate(%{"additionalItems" => ai, "items" => [_ | _] = items}, root, opts) - when is_boolean(ai) do - items - |> Enum.map(&JsonDataFaker.generate_by_type(&1, root, opts)) - |> StreamData.fixed_list() - end + def generate( + %{"additionalItems" => false, "items" => [_ | _] = items, "minItems" => min}, + _root, + _opts + ) + when length(items) < min, + do: StreamData.constant(nil) - def generate(%{"additionalItems" => schema, "items" => [_ | _] = items}, root, opts) - when is_map(schema) do - items - |> Enum.map(&JsonDataFaker.generate_by_type(&1, root, opts)) - |> StreamData.fixed_list() - |> StreamData.bind(fn fixed_list -> - additional_generator = - StreamData.list_of(JsonDataFaker.generate_by_type(schema, root, opts)) + def generate(%{"additionalItems" => false, "items" => [_ | _] = items} = schema, root, opts) do + len = length(items) + maxItems = schema["maxItems"] + maxItems = if(not is_nil(maxItems), do: min(maxItems, len), else: len) - StreamData.bind(additional_generator, fn additional -> - StreamData.constant(Enum.concat(fixed_list, additional)) - end) - end) + generate_additional_schema( + JsonDataFaker.Generator.Utils.json(), + items, + schema["minItems"], + maxItems, + root, + opts + ) + end + + def generate(%{"additionalItems" => ai, "items" => [_ | _] = items} = schema, root, opts) do + generate_additional_schema( + if(is_boolean(ai), + do: JsonDataFaker.Generator.Utils.json(), + else: JsonDataFaker.generate_by_type(ai, root, opts) + ), + items, + schema["minItems"], + schema["maxItems"], + root, + opts + ) end def generate(%{"items" => inner_schema} = schema, root, _opts) @@ -50,4 +65,50 @@ defmodule JsonDataFaker.Generator.Array do end def generate(_schema, _root, _opts), do: StreamData.constant([]) + + defp generate_additional_schema(_additional_generator, _items, _min, 0, _root, _opts), + do: StreamData.constant([]) + + defp generate_additional_schema(_additional_generator, items, _min, max, root, opts) + when is_integer(max) and max <= length(items) do + items + |> Enum.slice(0..(max - 1)) + |> Enum.map(&JsonDataFaker.generate_by_type(&1, root, opts)) + |> StreamData.fixed_list() + end + + defp generate_additional_schema(additional_generator, items, min, max, root, opts) do + items + |> Enum.map(&JsonDataFaker.generate_by_type(&1, root, opts)) + |> StreamData.fixed_list() + |> concat_list_generators( + StreamData.list_of( + additional_generator, + list_of_opts(length(items), min, max) + ) + ) + end + + defp list_of_opts(_items_len, nil, nil), do: [max_length: 0] + defp list_of_opts(items_len, min, nil) when min <= items_len, do: [max_length: 0] + + # avoid generating too many additional items since the schema can be hard to generate + defp list_of_opts(items_len, min, nil), + do: [min_length: min - items_len, max_length: min - items_len + 2] + + defp list_of_opts(items_len, nil, max), do: [max_length: max - items_len] + + defp list_of_opts(items_len, min, max) when min <= items_len, + do: [max_length: max - items_len] + + defp list_of_opts(items_len, min, max), + do: [min_length: min - items_len, max_length: max - items_len] + + defp concat_list_generators(list1_gen, list2_gen) do + StreamData.bind(list1_gen, fn list1 -> + StreamData.bind(list2_gen, fn list2 -> + StreamData.constant(Enum.concat(list1, list2)) + end) + end) + end end diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index 4f918fd..7fe48ee 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -252,20 +252,90 @@ defmodule JsonDataFakerTest do "type" => "array", "additionalItems" => false }, + %{ + "items" => [%{"type" => "integer"}, %{"type" => "string"}], + "type" => "array", + "additionalItems" => false, + "minItems" => 2 + }, + %{ + "items" => [%{"type" => "integer"}, %{"type" => "string"}], + "type" => "array", + "additionalItems" => false, + "maxItems" => 1 + }, %{ "items" => [%{"type" => "integer"}, %{"type" => "string"}], "type" => "array", "additionalItems" => true + }, + %{ + "items" => [%{"type" => "integer"}, %{"type" => "string"}], + "type" => "array", + "additionalItems" => true, + "minItems" => 3 + }, + %{ + "items" => [%{"type" => "integer"}, %{"type" => "string"}], + "type" => "array", + "additionalItems" => true, + "maxItems" => 1 + }, + %{ + "items" => [%{"type" => "integer"}, %{"type" => "string"}], + "type" => "array", + "additionalItems" => true, + "maxItems" => 3 + }, + %{ + "items" => [%{"type" => "integer"}, %{"type" => "string"}], + "type" => "array", + "additionalItems" => true, + "maxItems" => 3, + "minItems" => 1 } ]) property_test( "array generation with additionalItems as schema and array of items should work", - %{ - "type" => "array", - "items" => [%{"type" => "integer"}, %{"type" => "string"}], - "additionalItems" => %{"type" => "object"} - } + [ + %{ + "type" => "array", + "items" => [%{"type" => "integer"}, %{"type" => "string"}], + "additionalItems" => %{"type" => "object"} + }, + %{ + "type" => "array", + "items" => [%{"type" => "integer"}, %{"type" => "string"}], + "additionalItems" => %{"type" => "object"}, + "minItems" => 1 + }, + %{ + "type" => "array", + "items" => [%{"type" => "integer"}, %{"type" => "string"}], + "additionalItems" => %{"type" => "object"}, + "minItems" => 3 + }, + %{ + "type" => "array", + "items" => [%{"type" => "integer"}, %{"type" => "string"}], + "additionalItems" => %{"type" => "object"}, + "maxItems" => 1 + }, + %{ + "type" => "array", + "items" => [%{"type" => "integer"}, %{"type" => "string"}], + "additionalItems" => %{"type" => "object"}, + "maxItems" => 3 + }, + %{ + "type" => "array", + "items" => [%{"type" => "integer"}, %{"type" => "string"}], + "additionalItems" => %{"type" => "object"}, + "maxItems" => 3, + "minItems" => 1 + } + ] ) property_test("array generation with all options should work", %{ From 7f199e2d5473b9536e8e7dd208a044ccdb2f8ec9 Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Tue, 1 Jun 2021 17:39:51 +0200 Subject: [PATCH 22/35] rearrange folders --- lib/{ => json_data_faker}/generator/array.ex | 6 ++++-- lib/{ => json_data_faker}/generator/misc.ex | 0 lib/{ => json_data_faker}/generator/number.ex | 0 lib/{ => json_data_faker}/generator/object.ex | 2 +- lib/{ => json_data_faker}/generator/string.ex | 0 lib/{generator => json_data_faker}/utils.ex | 2 +- 6 files changed, 6 insertions(+), 4 deletions(-) rename lib/{ => json_data_faker}/generator/array.ex (97%) rename lib/{ => json_data_faker}/generator/misc.ex (100%) rename lib/{ => json_data_faker}/generator/number.ex (100%) rename lib/{ => json_data_faker}/generator/object.ex (98%) rename lib/{ => json_data_faker}/generator/string.ex (100%) rename lib/{generator => json_data_faker}/utils.ex (90%) diff --git a/lib/generator/array.ex b/lib/json_data_faker/generator/array.ex similarity index 97% rename from lib/generator/array.ex rename to lib/json_data_faker/generator/array.ex index b22dc81..f25f0a7 100644 --- a/lib/generator/array.ex +++ b/lib/json_data_faker/generator/array.ex @@ -1,6 +1,8 @@ defmodule JsonDataFaker.Generator.Array do @moduledoc false + alias JsonDataFaker.Utils + def generate( %{"additionalItems" => false, "items" => [_ | _] = items, "minItems" => min}, _root, @@ -15,7 +17,7 @@ defmodule JsonDataFaker.Generator.Array do maxItems = if(not is_nil(maxItems), do: min(maxItems, len), else: len) generate_additional_schema( - JsonDataFaker.Generator.Utils.json(), + Utils.json(), items, schema["minItems"], maxItems, @@ -27,7 +29,7 @@ defmodule JsonDataFaker.Generator.Array do def generate(%{"additionalItems" => ai, "items" => [_ | _] = items} = schema, root, opts) do generate_additional_schema( if(is_boolean(ai), - do: JsonDataFaker.Generator.Utils.json(), + do: Utils.json(), else: JsonDataFaker.generate_by_type(ai, root, opts) ), items, diff --git a/lib/generator/misc.ex b/lib/json_data_faker/generator/misc.ex similarity index 100% rename from lib/generator/misc.ex rename to lib/json_data_faker/generator/misc.ex diff --git a/lib/generator/number.ex b/lib/json_data_faker/generator/number.ex similarity index 100% rename from lib/generator/number.ex rename to lib/json_data_faker/generator/number.ex diff --git a/lib/generator/object.ex b/lib/json_data_faker/generator/object.ex similarity index 98% rename from lib/generator/object.ex rename to lib/json_data_faker/generator/object.ex index 199e105..b483c06 100644 --- a/lib/generator/object.ex +++ b/lib/json_data_faker/generator/object.ex @@ -80,7 +80,7 @@ defmodule JsonDataFaker.Generator.Object do value_generator = if(schema == %{}, - do: JsonDataFaker.Generator.Utils.json(), + do: JsonDataFaker.Utils.json(), else: JsonDataFaker.generate_by_type(schema, root, opts) ) diff --git a/lib/generator/string.ex b/lib/json_data_faker/generator/string.ex similarity index 100% rename from lib/generator/string.ex rename to lib/json_data_faker/generator/string.ex diff --git a/lib/generator/utils.ex b/lib/json_data_faker/utils.ex similarity index 90% rename from lib/generator/utils.ex rename to lib/json_data_faker/utils.ex index 7e4bf98..2e0d4d3 100644 --- a/lib/generator/utils.ex +++ b/lib/json_data_faker/utils.ex @@ -1,4 +1,4 @@ -defmodule JsonDataFaker.Generator.Utils do +defmodule JsonDataFaker.Utils do @moduledoc false def json do From 3a2f7aeb32f21a1108a19bb2a3a43a17e17b426c Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Thu, 3 Jun 2021 12:31:59 +0200 Subject: [PATCH 23/35] allow to set {mod, fun} for custom format string generation --- lib/json_data_faker/generator/string.ex | 7 +++++++ test/json_data_faker_test.exs | 17 +++++++++++++++++ test/test_helper.exs | 12 ++++++++++++ 3 files changed, 36 insertions(+) diff --git a/lib/json_data_faker/generator/string.ex b/lib/json_data_faker/generator/string.ex index e931370..018856e 100644 --- a/lib/json_data_faker/generator/string.ex +++ b/lib/json_data_faker/generator/string.ex @@ -28,6 +28,13 @@ defmodule JsonDataFaker.Generator.String do end) end + def generate(%{"format" => format}, root, opts) do + case Application.fetch_env(:json_data_faker, :custom_format_generator) do + :error -> string(:ascii, []) + {:ok, {mod, fun}} -> apply(mod, fun, [format, root, opts]) + end + end + def generate(%{"pattern" => regex}, _root, _opts), do: Randex.stream(Regex.compile!(regex), mod: Randex.Generator.StreamData, max_repetition: 10) diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index 7fe48ee..93af079 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -14,6 +14,18 @@ defmodule JsonDataFakerTest.Helpers do end end +defmodule JsonDataFakerTest.CustomFormat do + def generate("foo", _root, _opts) do + StreamData.string([?a..?f]) + end + + def validate("foo", data) do + Regex.match?(~r/^[a-f]*$/, data) + end + + def validate(_, _data), do: true +end + defmodule JsonDataFakerTest do use ExUnit.Case use ExUnitProperties @@ -106,6 +118,11 @@ defmodule JsonDataFakerTest do "maxLength" => 201 }) + property_test("string generation with custom format should work", %{ + "type" => "string", + "format" => "foo" + }) + property_test("integer generation should work", %{ "type" => "integer", "minimum" => 5, diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..658e7ed 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,13 @@ +Application.put_env( + :ex_json_schema, + :custom_format_validator, + {JsonDataFakerTest.CustomFormat, :validate} +) + +Application.put_env( + :json_data_faker, + :custom_format_generator, + {JsonDataFakerTest.CustomFormat, :generate} +) + ExUnit.start() From 3b2f5ffdf73aa237e2ae96a942c1f37343c47f22 Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Thu, 3 Jun 2021 10:10:57 +0200 Subject: [PATCH 24/35] hide StreamData dialyzer warnings see https://github.com/whatyouhide/stream_data/pull/133 --- lib/json_data_faker/generator/object.ex | 3 +++ mix.exs | 3 ++- mix.lock | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/json_data_faker/generator/object.ex b/lib/json_data_faker/generator/object.ex index b483c06..8c0a08e 100644 --- a/lib/json_data_faker/generator/object.ex +++ b/lib/json_data_faker/generator/object.ex @@ -1,6 +1,9 @@ defmodule JsonDataFaker.Generator.Object do @moduledoc false + # see https://github.com/whatyouhide/stream_data/pull/133 + @dialyzer {:no_opaque, [pattern_properties_generator: 5]} + def generate(%{"type" => "object"} = schema, root, opts) do required = Map.get(schema, "required", []) diff --git a/mix.exs b/mix.exs index a04a624..7bcb81b 100644 --- a/mix.exs +++ b/mix.exs @@ -43,7 +43,8 @@ defmodule JsonDataFaker.MixProject do # dev/test deps {:ex_doc, "~> 0.23", only: :dev, runtime: false}, - {:credo, "~> 1.5", only: [:dev, :test]} + {:credo, "~> 1.5", only: [:dev, :test]}, + {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false} ] end diff --git a/mix.lock b/mix.lock index be151f6..3f511bb 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,9 @@ %{ "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, "credo": {:hex, :credo, "1.5.1", "4fe303cc828412b9d21eed4eab60914c401e71f117f40243266aafb66f30d036", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "0b219ca4dcc89e4e7bc6ae7e6539c313e738e192e10b85275fa1e82b5203ecd7"}, + "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, "earmark_parser": {:hex, :earmark_parser, "1.4.12", "b245e875ec0a311a342320da0551da407d9d2b65d98f7a9597ae078615af3449", [:mix], [], "hexpm", "711e2cc4d64abb7d566d43f54b78f7dc129308a63bc103fbd88550d2174b3160"}, + "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "ex_doc": {:hex, :ex_doc, "0.23.0", "a069bc9b0bf8efe323ecde8c0d62afc13d308b1fa3d228b65bca5cf8703a529d", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f5e2c4702468b2fd11b10d39416ddadd2fcdd173ba2a0285ebd92c39827a5a16"}, "ex_json_schema": {:hex, :ex_json_schema, "0.7.4", "09eb5b0c8184e5702bc89625a9d0c05c7a0a845d382e9f6f406a0fc1c9a8cc3f", [:mix], [], "hexpm", "45c67fa840f0d719a2b5578126dc29bcdc1f92499c0f61bcb8a3bcb5935f9684"}, "faker": {:hex, :faker, "0.16.0", "1e2cf3e8d60d44a30741fb98118fcac18b2020379c7e00d18f1a005841b2f647", [:mix], [], "hexpm", "fbcb9bf1299dff3c9dd7e50f41802bbc472ffbb84e7656394c8aa913ec315141"}, From b55c4c9b37af7f0304903ca99a4d7f0a42f99130 Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Thu, 3 Jun 2021 14:39:57 +0200 Subject: [PATCH 25/35] generate generic json if empty schema is given --- lib/json_data_faker.ex | 4 +++- lib/json_data_faker/generator/object.ex | 6 +----- lib/json_data_faker/utils.ex | 5 +++-- test/json_data_faker_test.exs | 6 ------ 4 files changed, 7 insertions(+), 14 deletions(-) diff --git a/lib/json_data_faker.ex b/lib/json_data_faker.ex index f08f28f..56560a6 100644 --- a/lib/json_data_faker.ex +++ b/lib/json_data_faker.ex @@ -83,5 +83,7 @@ defmodule JsonDataFaker do def generate_by_type(%{"type" => type} = schema, root, opts) when type in ["integer", "number"], do: Number.generate(schema, root, opts) - def generate_by_type(_schema, _root, _opts), do: StreamData.constant(nil) + def generate_by_type(%{"type" => "null"}, _root, _opts), do: StreamData.constant(nil) + + def generate_by_type(_schema, _root, _opts), do: JsonDataFaker.Utils.json() end diff --git a/lib/json_data_faker/generator/object.ex b/lib/json_data_faker/generator/object.ex index 8c0a08e..4b5e85e 100644 --- a/lib/json_data_faker/generator/object.ex +++ b/lib/json_data_faker/generator/object.ex @@ -81,11 +81,7 @@ defmodule JsonDataFaker.Generator.Object do |> Randex.stream(mod: Randex.Generator.StreamData, max_repetition: 10) |> StreamData.filter(&(&1 not in keys_blacklist)) - value_generator = - if(schema == %{}, - do: JsonDataFaker.Utils.json(), - else: JsonDataFaker.generate_by_type(schema, root, opts) - ) + value_generator = JsonDataFaker.generate_by_type(schema, root, opts) StreamData.tuple({key_generator, value_generator}) end diff --git a/lib/json_data_faker/utils.ex b/lib/json_data_faker/utils.ex index 2e0d4d3..17fbaf4 100644 --- a/lib/json_data_faker/utils.ex +++ b/lib/json_data_faker/utils.ex @@ -6,11 +6,12 @@ defmodule JsonDataFaker.Utils do StreamData.one_of([ StreamData.boolean(), StreamData.integer(), - StreamData.string(:printable), + StreamData.string(:ascii), StreamData.float() ]) - map_key = StreamData.string(:printable, min_length: 1) + key_chars = Enum.concat([?a..?z, ?A..?Z, [?-, ?_]]) + map_key = StreamData.string(key_chars, min_length: 1) StreamData.tree(simple_value, fn leaf -> StreamData.one_of([StreamData.list_of(leaf), StreamData.map_of(map_key, leaf)]) diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index 93af079..48df2d6 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -514,12 +514,6 @@ defmodule JsonDataFakerTest do ) property "empty or invalid schema should return nil" do - schema = %{} - - check all(data <- JsonDataFaker.generate(schema)) do - assert is_nil(data) - end - schema = nil check all(data <- JsonDataFaker.generate(schema)) do From e2cfee0270e39464453dece1a4dea57c2553bc04 Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Thu, 3 Jun 2021 12:12:14 +0200 Subject: [PATCH 26/35] handle minProperties and maxProperties for objects --- lib/json_data_faker/generator/object.ex | 142 +++++++++++++++++++++--- test/json_data_faker_test.exs | 76 ++++++++++++- 2 files changed, 204 insertions(+), 14 deletions(-) diff --git a/lib/json_data_faker/generator/object.ex b/lib/json_data_faker/generator/object.ex index 4b5e85e..2826edc 100644 --- a/lib/json_data_faker/generator/object.ex +++ b/lib/json_data_faker/generator/object.ex @@ -2,7 +2,21 @@ defmodule JsonDataFaker.Generator.Object do @moduledoc false # see https://github.com/whatyouhide/stream_data/pull/133 - @dialyzer {:no_opaque, [pattern_properties_generator: 5]} + @dialyzer {:no_opaque, [pattern_properties_generator: 6]} + + def generate(%{"required" => [_ | _] = req, "maxProperties" => max_prop}, _root, _opts) + when max_prop < length(req), + do: StreamData.constant(nil) + + def generate( + %{"additionalProperties" => false, "minProperties" => min_prop} = schema, + _root, + _opts + ) + when not is_map_key(schema, "patternProperties") and + (not is_map_key(schema, "properties") or + map_size(:erlang.map_get("properties", schema)) < min_prop), + do: StreamData.constant(nil) def generate(%{"type" => "object"} = schema, root, opts) do required = Map.get(schema, "required", []) @@ -14,39 +28,119 @@ defmodule JsonDataFaker.Generator.Object do pattern_props = schema |> Map.get("patternProperties", %{}) |> Map.to_list() + additional_props = Map.get(schema, "additionalProperties", %{}) + if Keyword.get(opts, :require_optional_properties, false) do - generate_full_object(schema, required_props, optional_props, pattern_props, root, opts) + generate_full_object( + schema, + required_props, + optional_props, + pattern_props, + additional_props, + root, + opts + ) else - generate_object(schema, required_props, optional_props, pattern_props, root, opts) + generate_object( + schema, + required_props, + optional_props, + pattern_props, + additional_props, + root, + opts + ) end end - defp generate_full_object(_schema, required_props, optional_props, pattern_props, root, opts) do - required_props - |> Enum.concat(optional_props) + defp generate_full_object( + schema, + required_props, + optional_props, + pattern_props, + _additional_props, + root, + opts + ) do + max_prop = schema["maxProperties"] + min_prop = schema["minProperties"] || 0 + + req_count = length(required_props) + opt_count = length(optional_props) + min_pattern_props = min_prop - req_count - opt_count + + optional_props + |> (&if(max_prop, do: Enum.take(&1, max_prop - req_count), else: &1)).() + |> Enum.concat(required_props) |> streamdata_map_builder_args(root, opts) |> StreamData.fixed_map() |> merge_map_generators( - pattern_properties_generator(pattern_props, required_props, optional_props, root, opts) + pattern_properties_generator( + pattern_props, + required_props, + optional_props, + min_pattern_props, + root, + opts + ), + schema["maxProperties"] ) end - defp generate_object(_schema, required_props, optional_props, pattern_props, root, opts) do - required_map = streamdata_map_builder_args(required_props, root, opts) + defp generate_object( + schema, + required_props, + optional_props, + pattern_props, + _additional_props, + root, + opts + ) do + max_prop = schema["maxProperties"] + min_prop = schema["minProperties"] || 0 + + req_count = length(required_props) + opt_count = length(optional_props) + + {optional_required_props, optional_props} = + if(req_count < min_prop, + do: Enum.split(optional_props, min_prop - req_count), + else: {[], optional_props} + ) + + required_map = + required_props + |> Enum.concat(optional_required_props) + |> streamdata_map_builder_args(root, opts) + optional_map = streamdata_map_builder_args(optional_props, root, opts) + min_pattern_props = min_prop - req_count - opt_count + required_map |> StreamData.fixed_map() - |> merge_map_generators(StreamData.optional_map(optional_map)) + |> merge_map_generators(StreamData.optional_map(optional_map), max_prop) |> merge_map_generators( - pattern_properties_generator(pattern_props, required_props, optional_props, root, opts) + pattern_properties_generator( + pattern_props, + required_props, + optional_props, + min_pattern_props, + root, + opts + ), + max_prop ) end + defp pattern_properties_generator(pp, rp, op, min_props, root, opts) when min_props < 0, + do: pattern_properties_generator(pp, rp, op, 0, root, opts) + defp pattern_properties_generator( [], _required_props, _optional_props, + _min_props, _root, _opts ), @@ -56,6 +150,7 @@ defmodule JsonDataFaker.Generator.Object do pattern_properties, required_props, optional_props, + min_props, root, opts ) do @@ -70,7 +165,7 @@ defmodule JsonDataFaker.Generator.Object do pattern_property_generator(key_regex, schema, other_props_names, root, opts) end) |> StreamData.one_of() - |> StreamData.list_of() + |> StreamData.list_of(min_length: min_props) |> StreamData.bind(&(&1 |> Map.new() |> StreamData.constant())) end @@ -92,11 +187,32 @@ defmodule JsonDataFaker.Generator.Object do end) end - defp merge_map_generators(map1_gen, map2_gen) do + defp merge_map_generators(map1_gen, map2_gen, nil) do StreamData.bind(map1_gen, fn map1 -> StreamData.bind(map2_gen, fn map2 -> StreamData.constant(Map.merge(map1, map2)) end) end) end + + defp merge_map_generators(map1_gen, map2_gen, max_keys) do + StreamData.bind(map1_gen, fn map1 -> + StreamData.bind(map2_gen, fn map2 -> + case max_keys - map_size(map1) do + n when n <= 0 -> + StreamData.constant(map1) + + n when n >= map_size(map2) -> + StreamData.constant(Map.merge(map1, map2)) + + n -> + map2 + |> Enum.take(n) + |> Map.new() + |> (&Map.merge(map1, &1)).() + |> StreamData.constant() + end + end) + end) + end end diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index 48df2d6..a85c1ae 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -513,7 +513,81 @@ defmodule JsonDataFakerTest do require_optional_properties: true ) - property "empty or invalid schema should return nil" do + property_test("object generation with min/maxProperties should work", [ + %{ + "properties" => %{ + "bar" => %{"items" => %{"type" => "integer"}, "type" => "array"}, + "foo" => %{"type" => "boolean"} + }, + "required" => ["foo"], + "type" => "object", + "minProperties" => 1 + }, + %{ + "properties" => %{ + "bar" => %{"items" => %{"type" => "integer"}, "type" => "array"}, + "foo" => %{"type" => "boolean"} + }, + "required" => ["foo"], + "type" => "object", + "maxProperties" => 1 + }, + %{ + "properties" => %{ + "bar" => %{"items" => %{"type" => "integer"}, "type" => "array"}, + "foo" => %{"type" => "boolean"} + }, + "required" => ["foo"], + "type" => "object", + "minProperties" => 1, + "maxProperties" => 2 + } + ]) + + property_test("object generation with min/maxProperties and patternProperties should work", [ + %{ + "patternProperties" => %{ + "^[0-9]{4}$" => %{"type" => "integer"}, + "^[a-z]{4}$" => %{"type" => "string"} + }, + "properties" => %{ + "bar" => %{"items" => %{"type" => "integer"}, "type" => "array"}, + "foo" => %{"type" => "boolean"} + }, + "required" => ["foo"], + "type" => "object", + "minProperties" => 3 + }, + %{ + "patternProperties" => %{ + "^[0-9]{4}$" => %{"type" => "integer"}, + "^[a-z]{4}$" => %{"type" => "string"} + }, + "properties" => %{ + "bar" => %{"items" => %{"type" => "integer"}, "type" => "array"}, + "foo" => %{"type" => "boolean"} + }, + "required" => ["foo"], + "type" => "object", + "maxProperties" => 1 + }, + %{ + "patternProperties" => %{ + "^[0-9]{4}$" => %{"type" => "integer"}, + "^[a-z]{4}$" => %{"type" => "string"} + }, + "properties" => %{ + "bar" => %{"items" => %{"type" => "integer"}, "type" => "array"}, + "foo" => %{"type" => "boolean"} + }, + "required" => ["foo"], + "type" => "object", + "minProperties" => 2, + "maxProperties" => 4 + } + ]) + + property "invalid schema should return nil" do schema = nil check all(data <- JsonDataFaker.generate(schema)) do From 6cb085189fb864a36a7ec4edfa884fa430acec62 Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Thu, 3 Jun 2021 16:10:34 +0200 Subject: [PATCH 27/35] handle additionalProperties --- lib/json_data_faker/generator/object.ex | 224 ++++++++++++------------ lib/json_data_faker/utils.ex | 10 +- test/json_data_faker_test.exs | 76 ++++++++ 3 files changed, 198 insertions(+), 112 deletions(-) diff --git a/lib/json_data_faker/generator/object.ex b/lib/json_data_faker/generator/object.ex index 2826edc..4baf9c6 100644 --- a/lib/json_data_faker/generator/object.ex +++ b/lib/json_data_faker/generator/object.ex @@ -30,143 +30,100 @@ defmodule JsonDataFaker.Generator.Object do additional_props = Map.get(schema, "additionalProperties", %{}) + max_prop = schema["maxProperties"] + min_prop = schema["minProperties"] || 0 + min_extra_props = min_prop - length(required_props) - length(optional_props) + + schema_info = %{ + required_props: required_props, + optional_props: optional_props, + pattern_props: pattern_props, + additional_props: additional_props, + max_prop: max_prop, + min_prop: min_prop, + min_extra_props: min_extra_props + } + if Keyword.get(opts, :require_optional_properties, false) do - generate_full_object( - schema, - required_props, - optional_props, - pattern_props, - additional_props, - root, - opts - ) + generate_full_object(schema_info, root, opts) else - generate_object( - schema, - required_props, - optional_props, - pattern_props, - additional_props, - root, - opts - ) + generate_object(schema_info, root, opts) end end - defp generate_full_object( - schema, - required_props, - optional_props, - pattern_props, - _additional_props, - root, - opts - ) do - max_prop = schema["maxProperties"] - min_prop = schema["minProperties"] || 0 - - req_count = length(required_props) - opt_count = length(optional_props) - min_pattern_props = min_prop - req_count - opt_count + defp generate_full_object(schema_info, root, opts) do + req_count = length(schema_info.required_props) - optional_props - |> (&if(max_prop, do: Enum.take(&1, max_prop - req_count), else: &1)).() - |> Enum.concat(required_props) + schema_info.optional_props + |> (&if(schema_info.max_prop, + do: Enum.take(&1, schema_info.max_prop - req_count), + else: &1 + )).() + |> Enum.concat(schema_info.required_props) |> streamdata_map_builder_args(root, opts) |> StreamData.fixed_map() - |> merge_map_generators( - pattern_properties_generator( - pattern_props, - required_props, - optional_props, - min_pattern_props, - root, - opts - ), - schema["maxProperties"] - ) + |> add_pattern_properties(schema_info, root, opts) + |> add_additonal_properties(schema_info, root, opts) end - defp generate_object( - schema, - required_props, - optional_props, - pattern_props, - _additional_props, - root, - opts - ) do - max_prop = schema["maxProperties"] - min_prop = schema["minProperties"] || 0 - - req_count = length(required_props) - opt_count = length(optional_props) + defp generate_object(schema_info, root, opts) do + req_count = length(schema_info.required_props) {optional_required_props, optional_props} = - if(req_count < min_prop, - do: Enum.split(optional_props, min_prop - req_count), - else: {[], optional_props} + if(req_count < schema_info.min_prop, + do: Enum.split(schema_info.optional_props, schema_info.min_prop - req_count), + else: {[], schema_info.optional_props} ) required_map = - required_props + schema_info.required_props |> Enum.concat(optional_required_props) |> streamdata_map_builder_args(root, opts) optional_map = streamdata_map_builder_args(optional_props, root, opts) - min_pattern_props = min_prop - req_count - opt_count - required_map |> StreamData.fixed_map() - |> merge_map_generators(StreamData.optional_map(optional_map), max_prop) - |> merge_map_generators( - pattern_properties_generator( - pattern_props, - required_props, - optional_props, - min_pattern_props, - root, - opts - ), - max_prop - ) + |> merge_map_generators(StreamData.optional_map(optional_map), schema_info.max_prop) + |> add_pattern_properties(schema_info, root, opts) + |> add_additonal_properties(schema_info, root, opts) end - defp pattern_properties_generator(pp, rp, op, min_props, root, opts) when min_props < 0, - do: pattern_properties_generator(pp, rp, op, 0, root, opts) - - defp pattern_properties_generator( - [], - _required_props, - _optional_props, - _min_props, - _root, - _opts - ), - do: StreamData.constant(%{}) - - defp pattern_properties_generator( - pattern_properties, - required_props, - optional_props, - min_props, - root, - opts - ) do + defp add_pattern_properties(generator, %{min_extra_props: mep}, _, _) when mep <= 0, + do: generator + + defp add_pattern_properties(generator, %{pattern_props: []}, _, _), do: generator + + defp add_pattern_properties(generator, schema_info, root, opts) do # if the generated property has the same name of a standard property of the object than # it should be valid against the standar property schema and not against the # patternProperty one. In order to avoid generation of invalid properties we filter out # patternProperties with name equal to one of the standard properties - other_props_names = required_props |> Enum.concat(optional_props) |> Enum.map(&elem(&1, 0)) + other_props_names = + schema_info.required_props + |> Enum.concat(schema_info.optional_props) + |> Enum.map(&elem(&1, 0)) + + pattern_generator = fn previous_map -> + ms = map_size(previous_map) + min_length = max(schema_info.min_prop - ms, 0) + + max_length = + if(is_nil(schema_info.max_prop) or schema_info.max_prop > ms + 2, + do: min_length + 2, + else: schema_info.max_prop + ) + + schema_info.pattern_props + |> Enum.map(fn {key_regex, schema} -> + pattern_property_generator(key_regex, schema, other_props_names, root, opts) + end) + |> StreamData.one_of() + |> StreamData.list_of(min_length: min_length, max_length: max_length) + |> StreamData.bind(&(&1 |> Map.new() |> StreamData.constant())) + end - pattern_properties - |> Enum.map(fn {key_regex, schema} -> - pattern_property_generator(key_regex, schema, other_props_names, root, opts) - end) - |> StreamData.one_of() - |> StreamData.list_of(min_length: min_props) - |> StreamData.bind(&(&1 |> Map.new() |> StreamData.constant())) + merge_map_generators(generator, pattern_generator, nil) end defp pattern_property_generator(key_regex, schema, keys_blacklist, root, opts) do @@ -181,6 +138,53 @@ defmodule JsonDataFaker.Generator.Object do StreamData.tuple({key_generator, value_generator}) end + defp add_additonal_properties(generator, %{min_extra_props: mep}, _, _) + when mep <= 0, + do: generator + + defp add_additonal_properties(generator, %{additional_props: false}, _, _), do: generator + + defp add_additonal_properties(generator, schema_info, root, opts) do + key_allowed? = additional_key_allowed?(schema_info) + + additional_generator = fn previous_map -> + ms = map_size(previous_map) + min_length = max(schema_info.min_prop - ms, 0) + + max_length = + if(is_nil(schema_info.max_prop) or schema_info.max_prop > ms + 2, + do: min_length + 2, + else: schema_info.max_prop + ) + + StreamData.map_of( + StreamData.filter(JsonDataFaker.Utils.json_key(), &key_allowed?.(&1)), + if(schema_info.additional_props == true, + do: JsonDataFaker.Utils.json(), + else: JsonDataFaker.generate_by_type(schema_info.additional_props, root, opts) + ), + min_length: min_length, + max_length: max_length + ) + end + + merge_map_generators(generator, additional_generator, nil) + end + + defp additional_key_allowed?(schema_info) do + fixed_keys = + schema_info.required_props + |> Enum.concat(schema_info.optional_props) + |> Enum.map(&elem(&1, 0)) + + pattern_keys = Enum.map(schema_info.pattern_props, &elem(&1, 0)) + + fn key -> + key not in fixed_keys and + not Enum.any?(pattern_keys, &(&1 |> Regex.compile!() |> Regex.match?(key))) + end + end + defp streamdata_map_builder_args(properties, root, opts) do Map.new(properties, fn {key, inner_schema} -> {key, JsonDataFaker.generate_by_type(inner_schema, root, opts)} @@ -189,6 +193,8 @@ defmodule JsonDataFaker.Generator.Object do defp merge_map_generators(map1_gen, map2_gen, nil) do StreamData.bind(map1_gen, fn map1 -> + map2_gen = if(is_function(map2_gen), do: map2_gen.(map1), else: map2_gen) + StreamData.bind(map2_gen, fn map2 -> StreamData.constant(Map.merge(map1, map2)) end) @@ -197,6 +203,8 @@ defmodule JsonDataFaker.Generator.Object do defp merge_map_generators(map1_gen, map2_gen, max_keys) do StreamData.bind(map1_gen, fn map1 -> + map2_gen = if(is_function(map2_gen), do: map2_gen.(map1), else: map2_gen) + StreamData.bind(map2_gen, fn map2 -> case max_keys - map_size(map1) do n when n <= 0 -> diff --git a/lib/json_data_faker/utils.ex b/lib/json_data_faker/utils.ex index 17fbaf4..9d5123c 100644 --- a/lib/json_data_faker/utils.ex +++ b/lib/json_data_faker/utils.ex @@ -10,11 +10,13 @@ defmodule JsonDataFaker.Utils do StreamData.float() ]) - key_chars = Enum.concat([?a..?z, ?A..?Z, [?-, ?_]]) - map_key = StreamData.string(key_chars, min_length: 1) - StreamData.tree(simple_value, fn leaf -> - StreamData.one_of([StreamData.list_of(leaf), StreamData.map_of(map_key, leaf)]) + StreamData.one_of([StreamData.list_of(leaf), StreamData.map_of(json_key(), leaf)]) end) end + + def json_key do + key_chars = Enum.concat([?a..?z, ?A..?Z, [?-, ?_]]) + StreamData.string(key_chars, min_length: 1) + end end diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index a85c1ae..38c34c4 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -587,6 +587,82 @@ defmodule JsonDataFakerTest do } ]) + property_test("additionalProperties generation should work", [ + %{ + "additionalProperties" => false, + "type" => "object", + "properties" => %{ + "foo" => %{"type" => "boolean"}, + "bar" => %{"type" => "array", "items" => %{"type" => "integer"}} + }, + "required" => ["foo"] + }, + %{ + "minProperties" => 3, + "additionalProperties" => true, + "type" => "object", + "properties" => %{ + "foo" => %{"type" => "boolean"}, + "bar" => %{"type" => "array", "items" => %{"type" => "integer"}} + }, + "required" => ["foo"] + }, + %{ + "minProperties" => 3, + "additionalProperties" => %{"type" => "integer"}, + "type" => "object", + "properties" => %{ + "foo" => %{"type" => "boolean"}, + "bar" => %{"type" => "array", "items" => %{"type" => "integer"}} + }, + "required" => ["foo"] + } + ]) + + property_test("additionalProperties generation with patternProperties should work", [ + %{ + "additionalProperties" => false, + "patternProperties" => %{ + "^[0-9]{4}$" => %{"type" => "integer"}, + "^[a-z]{4}$" => %{"type" => "string"} + }, + "type" => "object", + "properties" => %{ + "foo" => %{"type" => "boolean"}, + "bar" => %{"type" => "array", "items" => %{"type" => "integer"}} + }, + "required" => ["foo"] + }, + %{ + "minProperties" => 3, + "additionalProperties" => true, + "patternProperties" => %{ + "^[0-9]{4}$" => %{"type" => "integer"}, + "^[a-z]{4}$" => %{"type" => "string"} + }, + "type" => "object", + "properties" => %{ + "foo" => %{"type" => "boolean"}, + "bar" => %{"type" => "array", "items" => %{"type" => "integer"}} + }, + "required" => ["foo"] + }, + %{ + "minProperties" => 3, + "additionalProperties" => %{"type" => "integer"}, + "patternProperties" => %{ + "^[0-9]{4}$" => %{"type" => "integer"}, + "^[a-z]{4}$" => %{"type" => "string"} + }, + "type" => "object", + "properties" => %{ + "foo" => %{"type" => "boolean"}, + "bar" => %{"type" => "array", "items" => %{"type" => "integer"}} + }, + "required" => ["foo"] + } + ]) + property "invalid schema should return nil" do schema = nil From 8323ca7fce4236d546063e2dc5bc7ac38acb9c75 Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Thu, 3 Jun 2021 17:09:40 +0200 Subject: [PATCH 28/35] fix: set max_length when generating unique items arrays with limited values --- lib/json_data_faker/generator/array.ex | 26 +++++++++++++++++++++++++- lib/json_data_faker/generator/misc.ex | 13 ++++++------- lib/json_data_faker/utils.ex | 3 +++ 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/lib/json_data_faker/generator/array.ex b/lib/json_data_faker/generator/array.ex index f25f0a7..3f9e4b0 100644 --- a/lib/json_data_faker/generator/array.ex +++ b/lib/json_data_faker/generator/array.ex @@ -49,9 +49,33 @@ defmodule JsonDataFaker.Generator.Array do _, acc -> acc end) + opts = + inner_schema + |> Utils.schema_resolve(root) + |> case do + %{"enum" => enum} -> + Keyword.put( + opts, + :max_length, + min(Keyword.get(opts, :max_length, length(enum)), length(enum)) + ) + + %{"type" => "boolean"} -> + Keyword.put( + opts, + :max_length, + min(Keyword.get(opts, :max_length, 2), 2) + ) + + _ -> + opts + end + case Map.get(schema, "uniqueItems", false) do false -> - StreamData.list_of(JsonDataFaker.generate_by_type(inner_schema, root, opts), opts) + inner_schema + |> JsonDataFaker.generate_by_type(root, opts) + |> StreamData.list_of(opts) true -> inner_schema diff --git a/lib/json_data_faker/generator/misc.ex b/lib/json_data_faker/generator/misc.ex index b4b64ce..5209921 100644 --- a/lib/json_data_faker/generator/misc.ex +++ b/lib/json_data_faker/generator/misc.ex @@ -1,9 +1,11 @@ defmodule JsonDataFaker.Generator.Misc do @moduledoc false + alias JsonDataFaker.Utils + def generate(%{"$ref" => _} = schema, root, opts) do schema - |> resolve(root) + |> Utils.schema_resolve(root) |> JsonDataFaker.generate_by_type(root, opts) end @@ -32,7 +34,7 @@ defmodule JsonDataFaker.Generator.Misc do all_of_merger_root = fn root -> &all_of_merger(&1, &2, &3, root) end Enum.reduce(all_ofs, %{}, fn all_of, acc -> - Map.merge(acc, resolve(all_of, root), all_of_merger_root.(root)) + Map.merge(acc, Utils.schema_resolve(all_of, root), all_of_merger_root.(root)) end) end @@ -55,8 +57,8 @@ defmodule JsonDataFaker.Generator.Misc do defp all_of_merger("required", v1, v2, _root), do: v1 |> Enum.concat(v2) |> Enum.uniq() defp all_of_merger(_property, %{"$ref" => _} = v1, %{"$ref" => _} = v2, root) do - f1 = resolve(v1, root) - f2 = resolve(v2, root) + f1 = Utils.schema_resolve(v1, root) + f2 = Utils.schema_resolve(v2, root) all_of_merger(nil, f1, f2, root) end @@ -71,8 +73,5 @@ defmodule JsonDataFaker.Generator.Misc do # be aware that this can lead to generated values that are not valid against the schema defp all_of_merger(_key, _v1, v2, _root), do: v2 - defp resolve(%{"$ref" => ref}, root), do: ExJsonSchema.Schema.get_fragment!(root, ref) - defp resolve(schema, _root), do: schema - defp lcm(m, n), do: trunc(m * n / Integer.gcd(m, n)) end diff --git a/lib/json_data_faker/utils.ex b/lib/json_data_faker/utils.ex index 9d5123c..08005c3 100644 --- a/lib/json_data_faker/utils.ex +++ b/lib/json_data_faker/utils.ex @@ -19,4 +19,7 @@ defmodule JsonDataFaker.Utils do key_chars = Enum.concat([?a..?z, ?A..?Z, [?-, ?_]]) StreamData.string(key_chars, min_length: 1) end + + def schema_resolve(%{"$ref" => ref}, root), do: ExJsonSchema.Schema.get_fragment!(root, ref) + def schema_resolve(schema, _root), do: schema end From 6286c376a701698658c8136ee5666817b529c0b7 Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Thu, 3 Jun 2021 17:10:25 +0200 Subject: [PATCH 29/35] fix: correcly merge deeply nested allOf --- lib/json_data_faker/generator/misc.ex | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/json_data_faker/generator/misc.ex b/lib/json_data_faker/generator/misc.ex index 5209921..0686c39 100644 --- a/lib/json_data_faker/generator/misc.ex +++ b/lib/json_data_faker/generator/misc.ex @@ -34,7 +34,13 @@ defmodule JsonDataFaker.Generator.Misc do all_of_merger_root = fn root -> &all_of_merger(&1, &2, &3, root) end Enum.reduce(all_ofs, %{}, fn all_of, acc -> - Map.merge(acc, Utils.schema_resolve(all_of, root), all_of_merger_root.(root)) + all_of = + case Utils.schema_resolve(all_of, root) do + %{"allOf" => all_ofs} -> merge_all_of(all_ofs, root) + all_of -> all_of + end + + Map.merge(acc, all_of, all_of_merger_root.(root)) end) end From 6f60cf92cd7f417f0faa6dcd44f59cf2b3cccffc Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Fri, 4 Jun 2021 09:36:25 +0200 Subject: [PATCH 30/35] fix: generation of unique items arrays with enum of small size uses enum combination --- lib/json_data_faker/generator/array.ex | 40 +++++++++++++------------ lib/json_data_faker/generator/string.ex | 24 +++++++-------- lib/json_data_faker/utils.ex | 2 ++ mix.exs | 1 + mix.lock | 1 + test/json_data_faker_test.exs | 9 ++++++ 6 files changed, 46 insertions(+), 31 deletions(-) diff --git a/lib/json_data_faker/generator/array.ex b/lib/json_data_faker/generator/array.ex index 3f9e4b0..f48c800 100644 --- a/lib/json_data_faker/generator/array.ex +++ b/lib/json_data_faker/generator/array.ex @@ -40,6 +40,23 @@ defmodule JsonDataFaker.Generator.Array do ) end + def generate(%{"items" => %{"$ref" => _} = inner_schema} = schema, root, opts) do + schema + |> Map.put("items", Utils.schema_resolve(inner_schema, root)) + |> generate(root, opts) + end + + def generate(%{"items" => %{"enum" => enum}, "uniqueItems" => true} = schema, _root, _opts) + when length(enum) < 12 do + Utils.stream_gen(fn -> + len = length(enum) + + (schema["minItems"] || 1)..min(schema["maxItems"] || 5, len) + |> Enum.flat_map(&Combination.combine(enum, &1)) + |> Enum.random() + end) + end + def generate(%{"items" => inner_schema} = schema, root, _opts) when is_map(inner_schema) do opts = @@ -50,25 +67,10 @@ defmodule JsonDataFaker.Generator.Array do end) opts = - inner_schema - |> Utils.schema_resolve(root) - |> case do - %{"enum" => enum} -> - Keyword.put( - opts, - :max_length, - min(Keyword.get(opts, :max_length, length(enum)), length(enum)) - ) - - %{"type" => "boolean"} -> - Keyword.put( - opts, - :max_length, - min(Keyword.get(opts, :max_length, 2), 2) - ) - - _ -> - opts + case {Keyword.get(opts, :min_length), Keyword.get(opts, :max_length)} do + {nil, nil} -> Keyword.put(opts, :max_length, 5) + {minlen, nil} -> Keyword.put(opts, :max_length, minlen + 2) + _ -> opts end case Map.get(schema, "uniqueItems", false) do diff --git a/lib/json_data_faker/generator/string.ex b/lib/json_data_faker/generator/string.ex index 018856e..901a913 100644 --- a/lib/json_data_faker/generator/string.ex +++ b/lib/json_data_faker/generator/string.ex @@ -1,27 +1,31 @@ defmodule JsonDataFaker.Generator.String do @moduledoc false + alias JsonDataFaker.Utils + import StreamData, only: [string: 2] def generate(%{"format" => "date-time"}, _root, _opts), - do: stream_gen(fn -> 30 |> Faker.DateTime.backward() |> DateTime.to_iso8601() end) + do: Utils.stream_gen(fn -> 30 |> Faker.DateTime.backward() |> DateTime.to_iso8601() end) + + def generate(%{"format" => "uuid"}, _root, _opts), do: Utils.stream_gen(&Faker.UUID.v4/0) - def generate(%{"format" => "uuid"}, _root, _opts), do: stream_gen(&Faker.UUID.v4/0) - def generate(%{"format" => "email"}, _root, _opts), do: stream_gen(&Faker.Internet.email/0) + def generate(%{"format" => "email"}, _root, _opts), + do: Utils.stream_gen(&Faker.Internet.email/0) def generate(%{"format" => "hostname"}, _root, _opts), - do: stream_gen(&Faker.Internet.domain_name/0) + do: Utils.stream_gen(&Faker.Internet.domain_name/0) def generate(%{"format" => "ipv4"}, _root, _opts), - do: stream_gen(&Faker.Internet.ip_v4_address/0) + do: Utils.stream_gen(&Faker.Internet.ip_v4_address/0) def generate(%{"format" => "ipv6"}, _root, _opts), - do: stream_gen(&Faker.Internet.ip_v6_address/0) + do: Utils.stream_gen(&Faker.Internet.ip_v6_address/0) - def generate(%{"format" => "uri"}, _root, _opts), do: stream_gen(&Faker.Internet.url/0) + def generate(%{"format" => "uri"}, _root, _opts), do: Utils.stream_gen(&Faker.Internet.url/0) def generate(%{"format" => "image_uri"}, _root, _opts) do - stream_gen(fn -> + Utils.stream_gen(fn -> w = Enum.random(1..4) * 400 h = Enum.random(1..4) * 400 "https://source.unsplash.com/random/#{w}x#{h}" @@ -48,8 +52,4 @@ defmodule JsonDataFaker.Generator.String do string(:ascii, opts) end - - defp stream_gen(fun) do - StreamData.map(StreamData.constant(nil), fn _ -> fun.() end) - end end diff --git a/lib/json_data_faker/utils.ex b/lib/json_data_faker/utils.ex index 08005c3..3747b2c 100644 --- a/lib/json_data_faker/utils.ex +++ b/lib/json_data_faker/utils.ex @@ -22,4 +22,6 @@ defmodule JsonDataFaker.Utils do def schema_resolve(%{"$ref" => ref}, root), do: ExJsonSchema.Schema.get_fragment!(root, ref) def schema_resolve(schema, _root), do: schema + + def stream_gen(fun), do: StreamData.map(StreamData.constant(nil), fn _ -> fun.() end) end diff --git a/mix.exs b/mix.exs index 7bcb81b..ed4af55 100644 --- a/mix.exs +++ b/mix.exs @@ -40,6 +40,7 @@ defmodule JsonDataFaker.MixProject do {:faker, "~> 0.16"}, {:uuid, "~> 1.1"}, {:stream_data, "~> 0.5"}, + {:combination, ">= 0.0.0"}, # dev/test deps {:ex_doc, "~> 0.23", only: :dev, runtime: false}, diff --git a/mix.lock b/mix.lock index 3f511bb..f613372 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,6 @@ %{ "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, + "combination": {:hex, :combination, "0.0.3", "746aedca63d833293ec6e835aa1f34974868829b1486b1e1cb0685f0b2ae1f41", [:mix], [], "hexpm", "72b099f463df42ef7dc6371d250c7070b57b6c5902853f69deb894f79eda18ca"}, "credo": {:hex, :credo, "1.5.1", "4fe303cc828412b9d21eed4eab60914c401e71f117f40243266aafb66f30d036", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "0b219ca4dcc89e4e7bc6ae7e6539c313e738e192e10b85275fa1e82b5203ecd7"}, "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, "earmark_parser": {:hex, :earmark_parser, "1.4.12", "b245e875ec0a311a342320da0551da407d9d2b65d98f7a9597ae078615af3449", [:mix], [], "hexpm", "711e2cc4d64abb7d566d43f54b78f7dc129308a63bc103fbd88550d2174b3160"}, diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index 38c34c4..d4d44b5 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -363,6 +363,15 @@ defmodule JsonDataFakerTest do "uniqueItems" => true }) + property_test("generation of arrays with small enum of unique items should work", %{ + "type" => "array", + "uniqueItems" => true, + "items" => %{ + "type" => "string", + "enum" => ["a", "b"] + } + }) + property_test("empty objects and arrays generation should work", [ %{"type" => "object"}, %{"type" => "array"} From 0ce688573414590747890f6748f59378ae308e6f Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Fri, 4 Jun 2021 09:32:20 +0200 Subject: [PATCH 31/35] detect type of schema by checking its own specific keys when type is missing --- lib/json_data_faker.ex | 32 +++++++++++++++++++++++ lib/json_data_faker/generator/array.ex | 7 +++--- test/json_data_faker_test.exs | 35 ++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/lib/json_data_faker.ex b/lib/json_data_faker.ex index 56560a6..d050f08 100644 --- a/lib/json_data_faker.ex +++ b/lib/json_data_faker.ex @@ -15,6 +15,18 @@ defmodule JsonDataFaker do defp unshrink(stream), do: StreamData.unshrinkable(stream) end + @string_keys ["pattern", "minLength", "maxLength"] + @number_keys ["multipleOf", "maximum", "exclusiveMaximum", "minimum", "exclusiveMinimum"] + @array_keys ["additionalItems", "items", "maxItems", "minItems", "uniqueItems"] + @object_keys [ + "maxProperties", + "minProperties", + "required", + "additionalProperties", + "properties", + "patternProperties" + ] + @doc """ generate fake data with given schema. It could be a raw json schema or ExJsonSchema.Schema.Root type. @@ -85,5 +97,25 @@ defmodule JsonDataFaker do def generate_by_type(%{"type" => "null"}, _root, _opts), do: StreamData.constant(nil) + for key <- @string_keys do + def generate_by_type(schema, root, opts) when is_map_key(schema, unquote(key)), + do: schema |> Map.put("type", "string") |> String.generate(root, opts) + end + + for key <- @number_keys do + def generate_by_type(schema, root, opts) when is_map_key(schema, unquote(key)), + do: schema |> Map.put("type", "number") |> Number.generate(root, opts) + end + + for key <- @array_keys do + def generate_by_type(schema, root, opts) when is_map_key(schema, unquote(key)), + do: schema |> Map.put("type", "array") |> Array.generate(root, opts) + end + + for key <- @object_keys do + def generate_by_type(schema, root, opts) when is_map_key(schema, unquote(key)), + do: schema |> Map.put("type", "object") |> Object.generate(root, opts) + end + def generate_by_type(_schema, _root, _opts), do: JsonDataFaker.Utils.json() end diff --git a/lib/json_data_faker/generator/array.ex b/lib/json_data_faker/generator/array.ex index f48c800..cf4c273 100644 --- a/lib/json_data_faker/generator/array.ex +++ b/lib/json_data_faker/generator/array.ex @@ -57,8 +57,9 @@ defmodule JsonDataFaker.Generator.Array do end) end - def generate(%{"items" => inner_schema} = schema, root, _opts) - when is_map(inner_schema) do + def generate(schema, root, _opts) do + inner_schema = Map.get(schema, "items", %{}) + opts = Enum.reduce(schema, [], fn {"minItems", min}, acc -> Keyword.put(acc, :min_length, min) @@ -92,8 +93,6 @@ defmodule JsonDataFaker.Generator.Array do end end - def generate(_schema, _root, _opts), do: StreamData.constant([]) - defp generate_additional_schema(_additional_generator, _items, _min, 0, _root, _opts), do: StreamData.constant([]) diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index d4d44b5..0cce334 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -78,6 +78,41 @@ defmodule JsonDataFakerTest do @full_object Map.put(@complex_object, "components", @components) + @schema_keys [ + {"pattern", "[0-9]+"}, + {"minLength", 2}, + {"maxLength", 5}, + {"multipleOf", 3}, + {"maximum", 10}, + {"exclusiveMaximum", true, %{"maximum" => 3}}, + {"minimum", 5}, + {"exclusiveMinimum", false, %{"minimum" => 1}}, + {"additionalItems", true}, + {"items", %{"type" => "integer"}}, + {"maxItems", 7}, + {"minItems", 4}, + {"uniqueItems", true}, + {"maxProperties", 2}, + {"minProperties", 5}, + {"required", ["foo"], %{"properties" => %{"foo" => %{"type" => "string"}}}}, + {"additionalProperties", false}, + {"properties", %{"foo" => %{"type" => "string"}}}, + {"patternProperties", %{"[0-9]+" => %{"type" => "boolean"}}} + ] + + for tuple <- @schema_keys do + {key, map} = + case tuple do + {key, value, extra} -> {key, Map.merge(%{key => value}, extra)} + {key, value} -> {key, %{key => value}} + end + + property_test( + "generation from schema with single key #{key} should work", + unquote(Macro.escape(map)) + ) + end + property "string uuid generation should work" do schema = %{"type" => "string", "format" => "uuid"} From 758c870d7f1afcce8962bf934e7a233a9ec1cc36 Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Fri, 4 Jun 2021 09:57:44 +0200 Subject: [PATCH 32/35] handle not keyword --- lib/json_data_faker.ex | 18 ++++++------------ lib/json_data_faker/generator/misc.ex | 8 ++++++++ test/json_data_faker_test.exs | 7 +++++++ 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/lib/json_data_faker.ex b/lib/json_data_faker.ex index d050f08..42681a6 100644 --- a/lib/json_data_faker.ex +++ b/lib/json_data_faker.ex @@ -27,6 +27,8 @@ defmodule JsonDataFaker do "patternProperties" ] + @misc_keys ["$ref", "oneOf", "anyOf", "allOf", "not", "enum"] + @doc """ generate fake data with given schema. It could be a raw json schema or ExJsonSchema.Schema.Root type. @@ -65,18 +67,10 @@ defmodule JsonDataFaker do @doc false - def generate_by_type(%{"$ref" => _} = schema, root, opts), do: Misc.generate(schema, root, opts) - - def generate_by_type(%{"oneOf" => _} = schema, root, opts), - do: Misc.generate(schema, root, opts) - - def generate_by_type(%{"anyOf" => _} = schema, root, opts), - do: Misc.generate(schema, root, opts) - - def generate_by_type(%{"allOf" => _} = schema, root, opts), - do: Misc.generate(schema, root, opts) - - def generate_by_type(%{"enum" => _} = schema, root, opts), do: Misc.generate(schema, root, opts) + for key <- @misc_keys do + def generate_by_type(schema, root, opts) when is_map_key(schema, unquote(key)), + do: Misc.generate(schema, root, opts) + end def generate_by_type(%{"type" => [_ | _]} = schema, root, opts), do: Misc.generate(schema, root, opts) diff --git a/lib/json_data_faker/generator/misc.ex b/lib/json_data_faker/generator/misc.ex index 0686c39..e2f920a 100644 --- a/lib/json_data_faker/generator/misc.ex +++ b/lib/json_data_faker/generator/misc.ex @@ -21,6 +21,14 @@ defmodule JsonDataFaker.Generator.Misc do |> JsonDataFaker.generate_by_type(root, opts) end + def generate(%{"not" => not_schema} = schema, root, opts) do + schema + |> Map.delete("not") + |> JsonDataFaker.generate_by_type(root, opts) + |> StreamData.scale(&(&1 * 3)) + |> StreamData.filter(&(not ExJsonSchema.Validator.valid?(not_schema, &1))) + end + def generate(%{"enum" => choices}, _root, _opts), do: StreamData.member_of(choices) def generate(%{"type" => [_ | _] = types} = schema, root, opts) do diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index 0cce334..8f589b0 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -707,6 +707,13 @@ defmodule JsonDataFakerTest do } ]) + property_test("generation from schema with 'not' key should work", %{ + "type" => "integer", + "minimum" => 0, + "multipleOf" => 2, + "not" => %{"multipleOf" => 3} + }) + property "invalid schema should return nil" do schema = nil From 57595f772e54aa2b4452a94fe6a11800005cf001 Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Fri, 4 Jun 2021 11:51:57 +0200 Subject: [PATCH 33/35] better error handling --- lib/json_data_faker.ex | 59 +++++++++++++++++++------ lib/json_data_faker/generator/array.ex | 6 ++- lib/json_data_faker/generator/number.ex | 6 ++- lib/json_data_faker/generator/object.ex | 15 +++++-- test/json_data_faker_test.exs | 30 +++++++++---- 5 files changed, 86 insertions(+), 30 deletions(-) diff --git a/lib/json_data_faker.ex b/lib/json_data_faker.ex index 42681a6..d15ac1e 100644 --- a/lib/json_data_faker.ex +++ b/lib/json_data_faker.ex @@ -9,6 +9,14 @@ defmodule JsonDataFaker do alias JsonDataFaker.Generator.{Array, Misc, Number, Object, String} + defmodule InvalidSchemaError do + defexception [:message] + end + + defmodule GenerationError do + defexception [:message] + end + if Mix.env() == :test do defp unshrink(stream), do: stream else @@ -42,28 +50,53 @@ defmodule JsonDataFaker do ...> "required" => ["title"], ...> "type" => "object" ...>} - iex> %{"title" => _title} = JsonDataFaker.generate(schema) |> Enum.take(1) |> List.first() + iex> %{"title" => _title} = JsonDataFaker.generate!(schema) |> Enum.take(1) |> List.first() """ - def generate(schema, opts \\ []) + def generate!(schema, opts \\ []) - def generate(%Schema.Root{} = schema, opts) do - schema.schema - |> generate_by_type(schema, opts) - |> unshrink() - end + def generate!(schema, opts) when is_map(schema) do + {root, schema} = + case schema do + %Schema.Root{} -> + {schema, schema.schema} + + _ -> + root = Schema.resolve(schema) + {root, root.schema} + end - def generate(schema, opts) when is_map(schema) do schema - |> Schema.resolve() - |> generate(opts) + |> generate_by_type(root, opts) |> unshrink() rescue + e in JsonDataFaker.InvalidSchemaError -> + reraise e, __STACKTRACE__ + e -> - Logger.error("Failed to generate data. #{inspect(e)}") - StreamData.constant(nil) + %struct{} = e + + case Module.split(struct) do + ["ExJsonSchema" | _] -> + reraise JsonDataFaker.InvalidSchemaError, [message: e.message], __STACKTRACE__ + + ["StreamData" | _] -> + reraise JsonDataFaker.GenerationError, [message: e.message], __STACKTRACE__ + + _ -> + reraise e, __STACKTRACE__ + end end - def generate(_schema, _opts), do: StreamData.constant(nil) + def generate!(schema, _opts) do + msg = "invalid schema, it should be a map or a resolved ExJsonSchema, got #{inspect(schema)}" + raise JsonDataFaker.InvalidSchemaError, message: msg + end + + def generate(schema, opts \\ []) do + {:ok, generate!(schema, opts)} + rescue + e -> {:error, e.message} + end @doc false diff --git a/lib/json_data_faker/generator/array.ex b/lib/json_data_faker/generator/array.ex index cf4c273..1b96dd9 100644 --- a/lib/json_data_faker/generator/array.ex +++ b/lib/json_data_faker/generator/array.ex @@ -8,8 +8,10 @@ defmodule JsonDataFaker.Generator.Array do _root, _opts ) - when length(items) < min, - do: StreamData.constant(nil) + when length(items) < min do + msg = "array minItems greater than number of items choiches with 'additionalItems' false" + raise JsonDataFaker.InvalidSchemaError, message: msg + end def generate(%{"additionalItems" => false, "items" => [_ | _] = items} = schema, root, opts) do len = length(items) diff --git a/lib/json_data_faker/generator/number.ex b/lib/json_data_faker/generator/number.ex index 22b4dba..1c39358 100644 --- a/lib/json_data_faker/generator/number.ex +++ b/lib/json_data_faker/generator/number.ex @@ -71,7 +71,8 @@ defmodule JsonDataFaker.Generator.Number do max = max + if(emax, do: -1, else: 0) if min > max do - StreamData.constant(nil) + msg = "number/integer 'minimum' greater than corresponding 'maximum'" + raise JsonDataFaker.InvalidSchemaError, message: msg else integer(min..max) end @@ -84,7 +85,8 @@ defmodule JsonDataFaker.Generator.Number do max = Integer.floor_div(max, multipleOf) if min > max do - StreamData.constant(nil) + msg = "number/integer 'minimum' greater than corresponding 'maximum'" + raise JsonDataFaker.InvalidSchemaError, message: msg else map(integer(min..max), &(&1 * multipleOf)) end diff --git a/lib/json_data_faker/generator/object.ex b/lib/json_data_faker/generator/object.ex index 4baf9c6..dad72c8 100644 --- a/lib/json_data_faker/generator/object.ex +++ b/lib/json_data_faker/generator/object.ex @@ -5,8 +5,10 @@ defmodule JsonDataFaker.Generator.Object do @dialyzer {:no_opaque, [pattern_properties_generator: 6]} def generate(%{"required" => [_ | _] = req, "maxProperties" => max_prop}, _root, _opts) - when max_prop < length(req), - do: StreamData.constant(nil) + when max_prop < length(req) do + msg = "object 'maxProperties' lower than number of required properties" + raise JsonDataFaker.InvalidSchemaError, message: msg + end def generate( %{"additionalProperties" => false, "minProperties" => min_prop} = schema, @@ -15,8 +17,13 @@ defmodule JsonDataFaker.Generator.Object do ) when not is_map_key(schema, "patternProperties") and (not is_map_key(schema, "properties") or - map_size(:erlang.map_get("properties", schema)) < min_prop), - do: StreamData.constant(nil) + map_size(:erlang.map_get("properties", schema)) < min_prop) do + msg = + "object 'minProperties' lower than number of possible properties" <> + "without 'patternProperties' and with 'additionalProperties' false" + + raise JsonDataFaker.InvalidSchemaError, message: msg + end def generate(%{"type" => "object"} = schema, root, opts) do required = Map.get(schema, "required", []) diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index 8f589b0..472a11b 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -5,7 +5,7 @@ defmodule JsonDataFakerTest.Helpers do Enum.each(List.wrap(unquote(schemas)), fn schema -> resolved_schema = ExJsonSchema.Schema.resolve(schema) - check all(data <- JsonDataFaker.generate(schema, unquote(opts))) do + check all(data <- JsonDataFaker.generate!(schema, unquote(opts))) do assert ExJsonSchema.Validator.valid?(resolved_schema, data) end end) @@ -116,7 +116,7 @@ defmodule JsonDataFakerTest do property "string uuid generation should work" do schema = %{"type" => "string", "format" => "uuid"} - check all(data <- JsonDataFaker.generate(schema)) do + check all(data <- JsonDataFaker.generate!(schema)) do assert {:ok, _} = UUID.info(data) end end @@ -714,17 +714,29 @@ defmodule JsonDataFakerTest do "not" => %{"multipleOf" => 3} }) - property "invalid schema should return nil" do - schema = nil + test "invalid schema should return error or raise" do + assert {:error, _} = JsonDataFaker.generate(nil) + assert {:error, _} = JsonDataFaker.generate([]) + assert {:error, _} = JsonDataFaker.generate(%{"minimum" => "foo"}) - check all(data <- JsonDataFaker.generate(schema)) do - assert is_nil(data) + assert_raise JsonDataFaker.InvalidSchemaError, fn -> + JsonDataFaker.generate!(%{"minimum" => 5, "maximum" => 1}) end - schema = [] + assert_raise JsonDataFaker.InvalidSchemaError, fn -> + JsonDataFaker.generate!(%{"required" => ["a", "b"], "maxProperties" => 1}) + end + + assert_raise JsonDataFaker.InvalidSchemaError, fn -> + JsonDataFaker.generate!(%{"additionalProperties" => false, "minProperties" => 3}) + end - check all(data <- JsonDataFaker.generate(schema)) do - assert is_nil(data) + assert_raise JsonDataFaker.InvalidSchemaError, fn -> + JsonDataFaker.generate!(%{ + "additionalItems" => false, + "items" => [%{"type" => "integer"}], + "minItems" => 2 + }) end end end From 941b30234ff7cc3956b45ad6e6213b92532c1608 Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Thu, 10 Jun 2021 10:58:07 +0200 Subject: [PATCH 34/35] fix: undefined function pattern_properties_generator/6 --- lib/json_data_faker/generator/object.ex | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/json_data_faker/generator/object.ex b/lib/json_data_faker/generator/object.ex index dad72c8..09fabbf 100644 --- a/lib/json_data_faker/generator/object.ex +++ b/lib/json_data_faker/generator/object.ex @@ -1,9 +1,6 @@ defmodule JsonDataFaker.Generator.Object do @moduledoc false - # see https://github.com/whatyouhide/stream_data/pull/133 - @dialyzer {:no_opaque, [pattern_properties_generator: 6]} - def generate(%{"required" => [_ | _] = req, "maxProperties" => max_prop}, _root, _opts) when max_prop < length(req) do msg = "object 'maxProperties' lower than number of required properties" From 46304a4139635074b8b62b9d026433932096b273 Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Thu, 7 Apr 2022 14:50:41 +0200 Subject: [PATCH 35/35] fix: always generate additional and pattern properties --- lib/json_data_faker/generator/object.ex | 13 +++++-------- mix.exs | 2 +- mix.lock | 2 +- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/lib/json_data_faker/generator/object.ex b/lib/json_data_faker/generator/object.ex index 09fabbf..b5a4314 100644 --- a/lib/json_data_faker/generator/object.ex +++ b/lib/json_data_faker/generator/object.ex @@ -93,9 +93,6 @@ defmodule JsonDataFaker.Generator.Object do |> add_additonal_properties(schema_info, root, opts) end - defp add_pattern_properties(generator, %{min_extra_props: mep}, _, _) when mep <= 0, - do: generator - defp add_pattern_properties(generator, %{pattern_props: []}, _, _), do: generator defp add_pattern_properties(generator, schema_info, root, opts) do @@ -115,7 +112,7 @@ defmodule JsonDataFaker.Generator.Object do max_length = if(is_nil(schema_info.max_prop) or schema_info.max_prop > ms + 2, do: min_length + 2, - else: schema_info.max_prop + else: max(schema_info.max_prop - ms, 0) ) schema_info.pattern_props @@ -142,12 +139,12 @@ defmodule JsonDataFaker.Generator.Object do StreamData.tuple({key_generator, value_generator}) end - defp add_additonal_properties(generator, %{min_extra_props: mep}, _, _) - when mep <= 0, - do: generator - defp add_additonal_properties(generator, %{additional_props: false}, _, _), do: generator + defp add_additonal_properties(generator, %{additional_props: ap, min_extra_props: mep}, _, _) + when map_size(ap) == 0 and mep <= 0, + do: generator + defp add_additonal_properties(generator, schema_info, root, opts) do key_allowed? = additional_key_allowed?(schema_info) diff --git a/mix.exs b/mix.exs index ed4af55..49ab66a 100644 --- a/mix.exs +++ b/mix.exs @@ -37,7 +37,7 @@ defmodule JsonDataFaker.MixProject do [ {:ex_json_schema, "~> 0.7"}, {:randex, "~> 0.4.0"}, - {:faker, "~> 0.16"}, + {:faker, "~> 0.15.0"}, {:uuid, "~> 1.1"}, {:stream_data, "~> 0.5"}, {:combination, ">= 0.0.0"}, diff --git a/mix.lock b/mix.lock index f613372..576eb42 100644 --- a/mix.lock +++ b/mix.lock @@ -7,7 +7,7 @@ "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "ex_doc": {:hex, :ex_doc, "0.23.0", "a069bc9b0bf8efe323ecde8c0d62afc13d308b1fa3d228b65bca5cf8703a529d", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f5e2c4702468b2fd11b10d39416ddadd2fcdd173ba2a0285ebd92c39827a5a16"}, "ex_json_schema": {:hex, :ex_json_schema, "0.7.4", "09eb5b0c8184e5702bc89625a9d0c05c7a0a845d382e9f6f406a0fc1c9a8cc3f", [:mix], [], "hexpm", "45c67fa840f0d719a2b5578126dc29bcdc1f92499c0f61bcb8a3bcb5935f9684"}, - "faker": {:hex, :faker, "0.16.0", "1e2cf3e8d60d44a30741fb98118fcac18b2020379c7e00d18f1a005841b2f647", [:mix], [], "hexpm", "fbcb9bf1299dff3c9dd7e50f41802bbc472ffbb84e7656394c8aa913ec315141"}, + "faker": {:hex, :faker, "0.15.0", "7b91646b97aef21f4b514367ce95a177c9871fcf301336b33e931d2519343bce", [:mix], [], "hexpm", "73ce103e4dca83a147198bdf40d78b5840be520c7bd15ee5b59b48550654b932"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"},