Xema and JsonXema - Schema validatiors

JsonXema is a JSON Schema validator with support for draft 04, 06, and 07. JsonXema based on elixir schema validator Xema.

https://github.com/hrzndhrn/json_xema

Xema is a schema validator inspired by JSON Schema. In Xema schemas can be created in Elixir and any data structure can be validated.

https://github.com/hrzndhrn/xema

Feedback, critic, improvements and PRs are very welcome, thanks!

10 Likes

I’d be interested in seeing a side-by-side comparison between Xema and Ecto’s validation.

5 Likes

The Xema library was updated to 0.8.0 and works now with inlined references to speed up validation. That also affects JsonXema see benchmark.

Xema also supports now custom validators. There is an example on the examples page in the online docs.

2 Likes

Thanks for working on this! I wanted to try JSON Schema with my new personal project, and was disappointed to see the top Google result is a few specifications out-of-date. Glad I searched here on the forum!

3 Likes

Hello @repromancer . You are welcome. I am happy if you find the lib helpful. Feel free to report everything you like or don’t like. In the next version, I will improve the error messages for validation errors. For now, it can be hard in big schemas to figure where the error in the data is.

Will do!

Xema 0.9.0 is released. The new version comes with some breaking changes, see changelog.

New features

  • convert data with Xema.cast
  • nice error messages

Example:

iex> defmodule Person do
...>   use Xema
...>
...>   xema do
...>     map(
...>       keys: :atoms,
...>       properties: %{
...>         name: string(),
...>         age: integer(minimum: 0),
...>         fav: atom(enum: [:erlan, :elixir, :js, :rust, :go])
...>       }
...>     )
...>   end
...>
...>   def new(args), do: cast!(args)
...> end
iex>
iex> person = Person.new(name: "Joe", age: 24, fav: :elixir) 
%{
  age: 24,
  fav: :elixir,
  name: "Joe"
}
iex> json = person |> Jason.encode!() |> Jason.decode()
%{
  "age" => 24,
  "fav" => "elixir",
  "name" => "Joe"
}
iex> Person.cast!(json)
%{
  age: 24,
  fav: :elixir,
  name: "Joe"
}
iex> {:error, error} = Person.validate(%{name: 42, age: -1, fav: :php})
iex> Exception.message(error) 
"""
Value -1 is less than minimum value of 0, at [:age].
Value :php is not defined in enum, at [:fav].
Expected :string, got 42, at [:name].\
"""

More examples can be found at section Cast and Examples - Cast JSON.

4 Likes

The new release of Xema adds the macros field and required to create struct schemas.

iex> defmodule StructA do
...>   use Xema
...>
...>   xema do
...>     field :foo, :integer, minimum: 0
...>   end
...> end
...>
...> defmodule StructB do
...>   use Xema
...>
...>   xema do
...>     field :a, :string, min_length: 3
...>     field :b, StructA
...>     required [:a]
...>   end
...> end
...>
...> data = StructB.cast!(a: "abc", b: %{foo: 5})
...> data.a
"abc"
iex> Map.from_struct(data.b)
%{foo: 5}

A more extensive example can be found at Examples > Struct

2 Likes

@Marcus is there a way to generate docs from a xema struct? Similar to a typespec or what is done in NimbleOptions.

1 Like

Currently there is no way to generate docs from the schemas/structs with Xema. But it would be nice to have a feature like NimbleOptions.docs/2. I will put it on my todo list.

1 Like

@Marcus awesome tool! I was wondering if there are some config settings to make ElixirLS Dialyzer happy?

defmodule Radixir.Schema.Gateway do
  use Xema, multi: true

  xema :derive_account_identifier do
    map(
      keys: :atoms,
      properties: %{
        network_identifier:
          map(
            keys: :atoms,
            properties: %{
              network: {:string, min_length: 1}
            },
            required: [:network]
          ),
        public_key:
          map(
            keys: :atoms,
            properties: %{
              hex: {:string, min_length: 66, max_length: 66, pattern: "^[a-fA-F0-9]+$"}
            },
            required: [:hex]
          )
      },
      required: [:network_identifier, :public_key]
    )
  end
end

when i remove , multi: true it makes it happy :confused:

I am happy that you like Xema. I will take a look to the dialyzer issue. Thanks for reporting. If you like you can crate an issue on GitHub.

2 Likes

I have made some fixes. The attribute @default true to mark a schema as default is now deprecated. That is not a part of your example but a part of the problem. Now a default schema is marked with the :default option.

defmoudle MyApp.Schemas do
  use Xemae, multi: true, default: :s1

  xema :s1 do
    ...
  end

  xema :s2 do
    ...
  end
end

I still have to adjust some things before I release a new version. If you like you can test the fix/refactoring with the GitHub main brach: {:xema, git: "https://github.com/hrzndhrn/xema"}.

2 Likes

@Marcus your library is :fire:

I’m wondering, how would you build a form in Phoenix for schemas that are not known ahead of time? I’m thinking something flexible like json-editor

Hello @egze thanks for the :fire: and sorry for the late response.

The JSON-Editor could be a good example to show how xema works together with phoenix-forms.

At the moment I have no idea for the form but a simplified first approach for the schema could be:

defmodule Form do
  use Xema

  @str map(properties: %{t: [const: "str"], v: :string})
  @int map(properties: %{t: [const: "int"], v: :integer})
  @obj map(
         properties: %{
           t: [const: "obj"],
           v:
             map(
               property_names: string(min_length: 1),
               pattern_properties: %{".*" => ref("#")}
             )
         }
       )
  @arr list(items: ref("#"))

  xema do
    one_of([@str, @int, @arr, @obj])
  end
end

That would work for:

iex> Form.valid?(%{t: "str", v: "Hinz"})
true
iex> Form.valid?(%{
  t: "obj",
  v: %{
    foo: %{t: "int", v: 55},
    bar: [%{t: "str", v: "Hinz"}, %{t: "str", v: "Kunz"}]
  }
})

In case of invalid data the error reason is a little bit hard to handle.

iex> Form.validate(%{
  t: "obj",
  v: %{
    foo: %{t: "int", v: 55},
    bar: [%{t: "str", v: "Hinz"}, %{t: "str", v: 88}]
  }
})
{:error,
 %Xema.ValidationError{
   message: nil,
   reason: %{
     one_of: {:error,
      [
        %{
          properties: %{
            t: %{const: "str", value: "obj"},
            v: %{
              type: :string,
              value: %{bar: [%{t: "str", v: "Hinz"}, %{t: "str", v: 88}], foo: %{t: "int", v: 55}}
            }
          }
        },
        %{
          properties: %{
            t: %{const: "int", value: "obj"},
            v: %{
              type: :integer,
              value: %{bar: [%{t: "str", v: "Hinz"}, %{t: "str", v: 88}], foo: %{t: "int", v: 55}}
            }
          }
        },
        %{
          type: :list,
          value: %{
            t: "obj",
            v: %{bar: [%{t: "str", v: "Hinz"}, %{t: "str", v: 88}], foo: %{t: "int", v: 55}}
          }
        },
        %{
          properties: %{
            v: %{
              properties: %{
                bar: %{
                  one_of: {:error,
                   [
                     %{type: :map, value: [%{t: "str", v: "Hinz"}, %{t: "str", v: 88}]},
                     %{type: :map, value: [%{t: "str", v: "Hinz"}, %{t: "str", v: 88}]},
                     %{
                       items: %{
                         1 => %{
                           one_of: {:error,
                            [
                              %{properties: %{v: %{type: :string, value: 88}}},
                              %{properties: %{t: %{const: "int", value: "str"}}},
                              %{type: :list, value: %{t: "str", v: 88}},
                              %{
                                properties: %{
                                  t: %{const: "obj", value: "str"},
                                  v: %{type: :map, value: 88}
                                }
                              }
                            ]},
                           value: %{t: "str", v: 88}
                         }
                       }
                     },
                     %{type: :map, value: [%{t: "str", v: "Hinz"}, %{t: "str", v: 88}]}
                   ]},
                  value: [%{t: "str", v: "Hinz"}, %{t: "str", v: 88}]
                }
              }
            }
          }
        }
      ]},
     value: %{
       t: "obj",
       v: %{bar: [%{t: "str", v: "Hinz"}, %{t: "str", v: 88}], foo: %{t: "int", v: 55}}
     }
   }
 }}

I think this could be improved in xema.