Skip to content

Commit f260cd7

Browse files
committed
Support validations too
1 parent 1bf60aa commit f260cd7

2 files changed

Lines changed: 294 additions & 2 deletions

File tree

lib/schemecto.ex

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,15 @@ defmodule Schemecto do
145145
}
146146
147147
"""
148-
def to_json_schema(%Ecto.Changeset{types: types, required: required}) do
148+
def to_json_schema(%Ecto.Changeset{types: types, required: required, validations: validations}) do
149149
properties =
150150
Map.new(types, fn {field, type} ->
151-
{to_string(field), type_to_json_schema(type)}
151+
schema =
152+
validations
153+
|> Keyword.get_values(field)
154+
|> Enum.reduce(type_to_json_schema(type), &apply_validation/2)
155+
156+
{to_string(field), schema}
152157
end)
153158

154159
result = %{
@@ -225,4 +230,75 @@ defmodule Schemecto do
225230
defp do_type_to_json_schema(unknown) do
226231
raise ArgumentError, "unknown type given to to_json_schema: #{inspect(unknown)}"
227232
end
233+
234+
# Validation appliers for each type
235+
236+
defp apply_validation({:format, regex}, schema) do
237+
unless schema["type"] == "string" do
238+
raise ArgumentError, "validate_format can only be applied to string fields"
239+
end
240+
241+
Map.put(schema, "pattern", Regex.source(regex))
242+
end
243+
244+
defp apply_validation({:inclusion, values}, schema) when is_list(values) do
245+
Map.put(schema, "enum", values)
246+
end
247+
248+
defp apply_validation({:inclusion, first..last//1}, schema) do
249+
schema
250+
|> Map.put("minimum", first)
251+
|> Map.put("maximum", last)
252+
end
253+
254+
defp apply_validation({:length, opts}, schema) do
255+
case schema["type"] do
256+
"string" -> apply_string_length(schema, opts)
257+
"array" -> apply_array_length(schema, opts)
258+
"object" -> apply_object_length(schema, opts)
259+
_ -> schema
260+
end
261+
end
262+
263+
defp apply_validation({:number, opts}, schema) do
264+
Enum.reduce(opts, schema, fn
265+
{:greater_than, val}, acc -> Map.put(acc, "exclusiveMinimum", val)
266+
{:less_than, val}, acc -> Map.put(acc, "exclusiveMaximum", val)
267+
{:greater_than_or_equal_to, val}, acc -> Map.put(acc, "minimum", val)
268+
{:less_than_or_equal_to, val}, acc -> Map.put(acc, "maximum", val)
269+
{:equal_to, val}, acc -> Map.put(acc, "const", val)
270+
_unknown, acc -> acc
271+
end)
272+
end
273+
274+
defp apply_validation({:subset, values}, schema) do
275+
update_in(schema["items"], fn items ->
276+
Map.put(items || %{}, "enum", values)
277+
end)
278+
end
279+
280+
defp apply_validation(_unknown, schema), do: schema
281+
282+
defp maybe_put(schema, _key, nil), do: schema
283+
defp maybe_put(schema, key, value), do: Map.put(schema, key, value)
284+
285+
# Type-specific length handlers
286+
287+
defp apply_string_length(schema, opts) do
288+
schema
289+
|> maybe_put("minLength", opts[:is] || opts[:min])
290+
|> maybe_put("maxLength", opts[:is] || opts[:max])
291+
end
292+
293+
defp apply_array_length(schema, opts) do
294+
schema
295+
|> maybe_put("minItems", opts[:is] || opts[:min])
296+
|> maybe_put("maxItems", opts[:is] || opts[:max])
297+
end
298+
299+
defp apply_object_length(schema, opts) do
300+
schema
301+
|> maybe_put("minProperties", opts[:is] || opts[:min])
302+
|> maybe_put("maxProperties", opts[:is] || opts[:max])
303+
end
228304
end

test/schemecto_test.exs

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,222 @@ defmodule SchemectoTest do
580580
}
581581
}
582582
end
583+
end
584+
585+
describe "to_json_schema + validations" do
586+
test "extracts format validation to pattern" do
587+
types = %{email: :string}
588+
589+
changeset =
590+
Schemecto.new(types)
591+
|> Ecto.Changeset.validate_format(:email, ~r/@/)
592+
593+
result = Schemecto.to_json_schema(changeset)
594+
595+
assert result == %{
596+
"type" => "object",
597+
"properties" => %{
598+
"email" => %{"type" => "string", "pattern" => "@"}
599+
}
600+
}
601+
end
602+
603+
test "extracts inclusion validation to enum for lists" do
604+
types = %{status: :string}
605+
606+
changeset =
607+
Schemecto.new(types)
608+
|> Ecto.Changeset.validate_inclusion(:status, ["pending", "active", "completed"])
609+
610+
result = Schemecto.to_json_schema(changeset)
611+
612+
assert result == %{
613+
"type" => "object",
614+
"properties" => %{
615+
"status" => %{"type" => "string", "enum" => ["pending", "active", "completed"]}
616+
}
617+
}
618+
end
619+
620+
test "extracts inclusion validation with range to min/max" do
621+
types = %{age: :integer}
622+
623+
changeset =
624+
Schemecto.new(types)
625+
|> Ecto.Changeset.validate_inclusion(:age, 0..120)
626+
627+
result = Schemecto.to_json_schema(changeset)
628+
629+
assert result == %{
630+
"type" => "object",
631+
"properties" => %{
632+
"age" => %{"type" => "integer", "minimum" => 0, "maximum" => 120}
633+
}
634+
}
635+
end
636+
637+
test "extracts length validation for strings with min/max" do
638+
types = %{title: :string}
639+
640+
changeset =
641+
Schemecto.new(types)
642+
|> Ecto.Changeset.validate_length(:title, min: 1, max: 100)
643+
644+
result = Schemecto.to_json_schema(changeset)
645+
646+
assert result == %{
647+
"type" => "object",
648+
"properties" => %{
649+
"title" => %{"type" => "string", "minLength" => 1, "maxLength" => 100}
650+
}
651+
}
652+
end
653+
654+
test "extracts length validation for strings with is" do
655+
types = %{code: :string}
656+
657+
changeset =
658+
Schemecto.new(types)
659+
|> Ecto.Changeset.validate_length(:code, is: 6)
660+
661+
result = Schemecto.to_json_schema(changeset)
662+
663+
assert result == %{
664+
"type" => "object",
665+
"properties" => %{
666+
"code" => %{"type" => "string", "minLength" => 6, "maxLength" => 6}
667+
}
668+
}
669+
end
670+
671+
test "extracts length validation for arrays" do
672+
types = %{tags: {:array, :string}}
673+
674+
changeset =
675+
Schemecto.new(types)
676+
|> Ecto.Changeset.validate_length(:tags, min: 1, max: 5)
677+
678+
result = Schemecto.to_json_schema(changeset)
679+
680+
assert result == %{
681+
"type" => "object",
682+
"properties" => %{
683+
"tags" => %{
684+
"type" => "array",
685+
"items" => %{"type" => "string"},
686+
"minItems" => 1,
687+
"maxItems" => 5
688+
}
689+
}
690+
}
691+
end
692+
693+
test "extracts number validations" do
694+
types = %{age: :integer}
695+
696+
changeset =
697+
Schemecto.new(types)
698+
|> Ecto.Changeset.validate_number(:age, greater_than_or_equal_to: 0, less_than: 150)
699+
700+
result = Schemecto.to_json_schema(changeset)
701+
702+
assert result == %{
703+
"type" => "object",
704+
"properties" => %{
705+
"age" => %{"type" => "integer", "minimum" => 0, "exclusiveMaximum" => 150}
706+
}
707+
}
708+
end
709+
710+
test "extracts subset validation for array items" do
711+
types = %{tags: {:array, :string}}
712+
713+
changeset =
714+
Schemecto.new(types)
715+
|> Ecto.Changeset.validate_subset(:tags, ["elixir", "erlang", "ecto"])
716+
717+
result = Schemecto.to_json_schema(changeset)
718+
719+
assert result == %{
720+
"type" => "object",
721+
"properties" => %{
722+
"tags" => %{
723+
"type" => "array",
724+
"items" => %{"type" => "string", "enum" => ["elixir", "erlang", "ecto"]}
725+
}
726+
}
727+
}
728+
end
729+
730+
test "handles multiple validations on same field" do
731+
types = %{title: :string}
732+
733+
changeset =
734+
Schemecto.new(types)
735+
|> Ecto.Changeset.validate_length(:title, min: 1)
736+
|> Ecto.Changeset.validate_length(:title, max: 100)
737+
|> Ecto.Changeset.validate_format(:title, ~r/^[A-Z]/)
738+
739+
result = Schemecto.to_json_schema(changeset)
740+
741+
assert result == %{
742+
"type" => "object",
743+
"properties" => %{
744+
"title" => %{
745+
"type" => "string",
746+
"minLength" => 1,
747+
"maxLength" => 100,
748+
"pattern" => "^[A-Z]"
749+
}
750+
}
751+
}
752+
end
753+
754+
test "handles nested validations in one/many" do
755+
address_types = %{street: :string, zip: :string}
756+
757+
validate_address = fn changeset, params ->
758+
changeset
759+
|> Ecto.Changeset.cast(params, [:street, :zip])
760+
|> Ecto.Changeset.validate_required([:street])
761+
|> Ecto.Changeset.validate_length(:zip, is: 5)
762+
end
763+
764+
types = %{
765+
name: :string,
766+
address: Schemecto.one(address_types, with: validate_address)
767+
}
768+
769+
changeset = Schemecto.new(types)
770+
result = Schemecto.to_json_schema(changeset)
771+
772+
assert result == %{
773+
"type" => "object",
774+
"properties" => %{
775+
"name" => %{"type" => "string"},
776+
"address" => %{
777+
"type" => "object",
778+
"properties" => %{
779+
"street" => %{"type" => "string"},
780+
"zip" => %{"type" => "string", "minLength" => 5, "maxLength" => 5}
781+
},
782+
"required" => ["street"]
783+
}
784+
}
785+
}
786+
end
787+
788+
test "raises when format validation on non-string field" do
789+
types = %{age: :integer}
790+
791+
changeset =
792+
Schemecto.new(types)
793+
|> Ecto.Changeset.validate_format(:age, ~r/\d+/)
794+
795+
assert_raise ArgumentError, "validate_format can only be applied to string fields", fn ->
796+
Schemecto.to_json_schema(changeset)
797+
end
798+
end
583799

584800
test "raises error for unknown type" do
585801
types = %{name: :unknown_type}

0 commit comments

Comments
 (0)