diff --git a/lib/json_data_faker.ex b/lib/json_data_faker.ex index 663c80f..d15ac1e 100644 --- a/lib/json_data_faker.ex +++ b/lib/json_data_faker.ex @@ -4,8 +4,39 @@ defmodule JsonDataFaker do """ import StreamData require Logger + alias ExJsonSchema.Schema + 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 + 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" + ] + + @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. @@ -19,98 +50,99 @@ 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) - end + def generate!(schema, opts \\ []) + + 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) when is_map(schema) do - generate(Schema.resolve(schema)) + schema + |> generate_by_type(root, opts) + |> unshrink() rescue + e in JsonDataFaker.InvalidSchemaError -> + reraise e, __STACKTRACE__ + e -> - Logger.error("Failed to generate data. #{inspect(e)}") - nil - end + %struct{} = e - def generate(_schema), do: nil + case Module.split(struct) do + ["ExJsonSchema" | _] -> + reraise JsonDataFaker.InvalidSchemaError, [message: e.message], __STACKTRACE__ - # private functions - defp generate_by_type(%{"type" => "boolean"}) do - boolean() - end + ["StreamData" | _] -> + reraise JsonDataFaker.GenerationError, [message: e.message], __STACKTRACE__ - defp generate_by_type(%{"type" => "string"} = schema) do - generate_string(schema) + _ -> + reraise e, __STACKTRACE__ + end end - defp generate_by_type(%{"type" => "integer"} = schema) do - min = schema["minimum"] || 10 - max = schema["maximum"] || 1000 - integer(min..max) + 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 - 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) + def generate(schema, opts \\ []) do + {:ok, generate!(schema, opts)} + rescue + e -> {:error, e.message} 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() + @doc false - Map.put(acc, k, v) - end) - end) + 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 - defp generate_by_type(_schema), do: StreamData.constant(nil) + def generate_by_type(%{"type" => [_ | _]} = schema, root, opts), + do: Misc.generate(schema, root, opts) - defp generate_string(%{"format" => "date-time"}), - do: stream_gen(fn -> 30 |> Faker.DateTime.backward() |> DateTime.to_iso8601() end) + def generate_by_type(%{"type" => "boolean"}, _root, _opts), do: boolean() - defp generate_string(%{"format" => "uuid"}), do: stream_gen(&Faker.UUID.v4/0) - defp generate_string(%{"format" => "email"}), do: stream_gen(&Faker.Internet.email/0) + def generate_by_type(%{"type" => "string"} = schema, root, opts), + do: String.generate(schema, root, opts) - defp generate_string(%{"format" => "hostname"}), - do: stream_gen(&Faker.Internet.domain_name/0) + def generate_by_type(%{"type" => "array"} = schema, root, opts), + do: Array.generate(schema, root, opts) - 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) + def generate_by_type(%{"type" => "object"} = schema, root, opts), + do: Object.generate(schema, root, opts) - 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(%{"enum" => choices}), do: StreamData.member_of(choices) + def generate_by_type(%{"type" => type} = schema, root, opts) when type in ["integer", "number"], + do: Number.generate(schema, root, opts) - defp generate_string(%{"pattern" => regex}), - do: Randex.stream(Regex.compile!(regex), mod: Randex.Generator.StreamData) + def generate_by_type(%{"type" => "null"}, _root, _opts), do: StreamData.constant(nil) - defp generate_string(schema) do - min = schema["minLength"] || 0 - max = schema["maxLength"] || 1024 + 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 - stream_gen(fn -> - s = Faker.Lorem.word() + 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 - 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) + 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 - defp stream_gen(fun) do - StreamData.map(StreamData.constant(nil), fn _ -> fun.() 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 new file mode 100644 index 0000000..1b96dd9 --- /dev/null +++ b/lib/json_data_faker/generator/array.ex @@ -0,0 +1,143 @@ +defmodule JsonDataFaker.Generator.Array do + @moduledoc false + + alias JsonDataFaker.Utils + + def generate( + %{"additionalItems" => false, "items" => [_ | _] = items, "minItems" => min}, + _root, + _opts + ) + 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) + maxItems = schema["maxItems"] + maxItems = if(not is_nil(maxItems), do: min(maxItems, len), else: len) + + generate_additional_schema( + 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: Utils.json(), + else: JsonDataFaker.generate_by_type(ai, root, opts) + ), + items, + schema["minItems"], + schema["maxItems"], + root, + opts + ) + 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(schema, root, _opts) do + inner_schema = Map.get(schema, "items", %{}) + + 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) + + 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 + false -> + inner_schema + |> JsonDataFaker.generate_by_type(root, opts) + |> StreamData.list_of(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 + + 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/lib/json_data_faker/generator/misc.ex b/lib/json_data_faker/generator/misc.ex new file mode 100644 index 0000000..e2f920a --- /dev/null +++ b/lib/json_data_faker/generator/misc.ex @@ -0,0 +1,91 @@ +defmodule JsonDataFaker.Generator.Misc do + @moduledoc false + + alias JsonDataFaker.Utils + + def generate(%{"$ref" => _} = schema, root, opts) do + schema + |> Utils.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(%{"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 + 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 -> + 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 + + defp all_of_merger(key, v1, v2, _root) + 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", "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: lcm(v1, v2) + + 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 = Utils.schema_resolve(v1, root) + f2 = Utils.schema_resolve(v2, 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 + all_of_merger_root = fn root -> &all_of_merger(&1, &2, &3, root) end + 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 lcm(m, n), do: trunc(m * n / Integer.gcd(m, n)) +end diff --git a/lib/json_data_faker/generator/number.ex b/lib/json_data_faker/generator/number.ex new file mode 100644 index 0000000..1c39358 --- /dev/null +++ b/lib/json_data_faker/generator/number.ex @@ -0,0 +1,137 @@ +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) + if(rem(min, multipleOf) == 0, do: 0, else: 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) + + if min > max do + msg = "number/integer 'minimum' greater than corresponding 'maximum'" + raise JsonDataFaker.InvalidSchemaError, message: msg + 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) + if(rem(min, multipleOf) == 0, do: 0, else: 1) + max = Integer.floor_div(max, multipleOf) + + if min > max do + msg = "number/integer 'minimum' greater than corresponding 'maximum'" + raise JsonDataFaker.InvalidSchemaError, message: msg + else + map(integer(min..max), &(&1 * multipleOf)) + end + 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/json_data_faker/generator/object.ex b/lib/json_data_faker/generator/object.ex new file mode 100644 index 0000000..b5a4314 --- /dev/null +++ b/lib/json_data_faker/generator/object.ex @@ -0,0 +1,227 @@ +defmodule JsonDataFaker.Generator.Object do + @moduledoc false + + def generate(%{"required" => [_ | _] = req, "maxProperties" => max_prop}, _root, _opts) + 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, + _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 + 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", []) + + {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() + + 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_info, root, opts) + else + generate_object(schema_info, root, opts) + end + end + + defp generate_full_object(schema_info, root, opts) do + req_count = length(schema_info.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() + |> add_pattern_properties(schema_info, root, opts) + |> add_additonal_properties(schema_info, root, opts) + end + + defp generate_object(schema_info, root, opts) do + req_count = length(schema_info.required_props) + + {optional_required_props, 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 = + 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) + + required_map + |> StreamData.fixed_map() + |> 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 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 = + 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: max(schema_info.max_prop - ms, 0) + ) + + 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 + + merge_map_generators(generator, pattern_generator, nil) + 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 = JsonDataFaker.generate_by_type(schema, root, opts) + + StreamData.tuple({key_generator, value_generator}) + end + + 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) + + 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)} + end) + end + + 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) + end) + end + + 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 -> + 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/lib/json_data_faker/generator/string.ex b/lib/json_data_faker/generator/string.ex new file mode 100644 index 0000000..901a913 --- /dev/null +++ b/lib/json_data_faker/generator/string.ex @@ -0,0 +1,55 @@ +defmodule JsonDataFaker.Generator.String do + @moduledoc false + + alias JsonDataFaker.Utils + + import StreamData, only: [string: 2] + + def generate(%{"format" => "date-time"}, _root, _opts), + 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" => "email"}, _root, _opts), + do: Utils.stream_gen(&Faker.Internet.email/0) + + def generate(%{"format" => "hostname"}, _root, _opts), + do: Utils.stream_gen(&Faker.Internet.domain_name/0) + + def generate(%{"format" => "ipv4"}, _root, _opts), + do: Utils.stream_gen(&Faker.Internet.ip_v4_address/0) + + def generate(%{"format" => "ipv6"}, _root, _opts), + do: Utils.stream_gen(&Faker.Internet.ip_v6_address/0) + + def generate(%{"format" => "uri"}, _root, _opts), do: Utils.stream_gen(&Faker.Internet.url/0) + + def generate(%{"format" => "image_uri"}, _root, _opts) do + Utils.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(%{"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) + + 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 +end diff --git a/lib/json_data_faker/utils.ex b/lib/json_data_faker/utils.ex new file mode 100644 index 0000000..3747b2c --- /dev/null +++ b/lib/json_data_faker/utils.ex @@ -0,0 +1,27 @@ +defmodule JsonDataFaker.Utils do + @moduledoc false + + def json do + simple_value = + StreamData.one_of([ + StreamData.boolean(), + StreamData.integer(), + StreamData.string(:ascii), + StreamData.float() + ]) + + StreamData.tree(simple_value, fn 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 + + 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 a04a624..49ab66a 100644 --- a/mix.exs +++ b/mix.exs @@ -37,13 +37,15 @@ 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"}, # 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..576eb42 100644 --- a/mix.lock +++ b/mix.lock @@ -1,10 +1,13 @@ %{ "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"}, + "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"}, diff --git a/test/json_data_faker_test.exs b/test/json_data_faker_test.exs index c38161f..472a11b 100644 --- a/test/json_data_faker_test.exs +++ b/test/json_data_faker_test.exs @@ -1,16 +1,42 @@ +defmodule JsonDataFakerTest.Helpers 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, unquote(opts))) do + assert ExJsonSchema.Validator.valid?(resolved_schema, data) + end + end) + end + end + 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 + import JsonDataFakerTest.Helpers - alias ExJsonSchema.{Validator, Schema} doctest JsonDataFaker @complex_object %{ "properties" => %{ "body" => %{ - "maxLength" => 140, - "minLength" => 3, - "type" => "string" + "$ref" => "#/components/schemas/Body" }, "created" => %{ "format" => "date-time", @@ -38,99 +64,679 @@ defmodule JsonDataFakerTest do "type" => "object" } + @components %{ + "schemas" => %{ + "Body" => %{ + "enum" => [ + "active", + "completed" + ], + "type" => "string" + } + } + } + + @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"} - check all(data <- JsonDataFaker.generate(schema)) do + check all(data <- JsonDataFaker.generate!(schema)) do assert {:ok, _} = UUID.info(data) end 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("enum generation should work", [ + %{ + "type" => "string", + "enum" => ["active", "completed"] + }, + %{ + "type" => "integer", + "enum" => [1, 2, 7] + }, + %{ + "enum" => [[1, 2], %{"foo" => "bar"}] + } + ]) - 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("string generation with custom format should work", %{ + "type" => "string", + "format" => "foo" + }) - property "string with max / min length should work" do - schema = %{"type" => "string", "minLength" => 200, "maxLength" => 201} - resolved_schema = Schema.resolve(schema) + property_test("integer generation should work", %{ + "type" => "integer", + "minimum" => 5, + "maximum" => 20 + }) - check all(data <- JsonDataFaker.generate(schema)) do - assert Validator.valid?(resolved_schema, data) - end - end + property_test("integer generation with exclusive endpoints should work", %{ + "type" => "integer", + "minimum" => 3, + "maximum" => 7, + "exclusiveMinimum" => true, + "exclusiveMaximum" => true + }) - property "integer generation should work" do - schema = %{"type" => "integer", "minimum" => 5, "maximum" => 20} - resolved_schema = Schema.resolve(schema) + property_test("integer generation with exclusive and negative endpoints should work", %{ + "type" => "integer", + "minimum" => -7, + "maximum" => -3, + "exclusiveMinimum" => true, + "exclusiveMaximum" => true + }) - check all(data <- JsonDataFaker.generate(schema)) do - assert Validator.valid?(resolved_schema, data) - end - end + property_test("integer generation with multipleOf and min should work", %{ + "type" => "integer", + "minimum" => 5, + "multipleOf" => 3 + }) - property "complex object generation should work" do - resolved_schema = Schema.resolve(@complex_object) + property_test("integer generation with multipleOf and negative min should work", %{ + "type" => "integer", + "minimum" => -5, + "multipleOf" => 3 + }) - check all(data <- JsonDataFaker.generate(@complex_object)) do - assert Validator.valid?(resolved_schema, data) - end - end + 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 "array of object generation should work" do - schema = %{ - "items" => @complex_object, - "type" => "array" + 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 } + ) - resolved_schema = Schema.resolve(schema) + property_test("float generation should work", %{ + "type" => "number", + "minimum" => 5.24, + "maximum" => 20.33 + }) - check all(data <- JsonDataFaker.generate(schema)) do - assert Validator.valid?(resolved_schema, data) - end - end + property_test("float generation with exclusive endpoints should work", %{ + "type" => "number", + "minimum" => 3.0, + "maximum" => 7.789, + "exclusiveMinimum" => true, + "exclusiveMaximum" => true + }) - property "empty or invalid schema should return nil" do - schema = %{} + property_test("float generation with exclusive and negative endpoints should work", %{ + "type" => "number", + "minimum" => -7.245, + "maximum" => -3.0, + "exclusiveMinimum" => true, + "exclusiveMaximum" => true + }) - check all(data <- JsonDataFaker.generate(schema)) do - assert is_nil(data) - end + 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" => %{ + "foo" => %{ + "type" => "integer" + } + } + }) - schema = nil + property_test("complex object generation should work", @full_object) - check all(data <- JsonDataFaker.generate(schema)) do - assert is_nil(data) + 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, + "type" => "array", + "components" => @components + }) + + 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 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" => 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"}, + "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", %{ + "items" => %{"type" => "integer"}, + "type" => "array", + "minItems" => 5, + "maxItems" => 8, + "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"} + ]) + + 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_test("array of types generation should work", [ + %{"type" => ["integer", "null"], "minimum" => 10}, + %{"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_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_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_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_test("generation from schema with 'not' key should work", %{ + "type" => "integer", + "minimum" => 0, + "multipleOf" => 2, + "not" => %{"multipleOf" => 3} + }) + + test "invalid schema should return error or raise" do + assert {:error, _} = JsonDataFaker.generate(nil) + assert {:error, _} = JsonDataFaker.generate([]) + assert {:error, _} = JsonDataFaker.generate(%{"minimum" => "foo"}) + + assert_raise JsonDataFaker.InvalidSchemaError, fn -> + JsonDataFaker.generate!(%{"minimum" => 5, "maximum" => 1}) + end + + assert_raise JsonDataFaker.InvalidSchemaError, fn -> + JsonDataFaker.generate!(%{"required" => ["a", "b"], "maxProperties" => 1}) end - schema = [] + 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 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()