docker run -p 8080:8080 --pull always livebook/livebook
Click Import, paste: https://gist.githubusercontent.com/trbngr/439cd16fba1bf87ee6eae4e29a7285fd/raw/300a10de650d72f40b0cab5069828a7bfa21f393/absinthe_json_schema.livemd
Mix.install([
{:jason, "~> 1.2"},
{:absinthe, "~> 1.6"},
# Not required for generating json schema. Only used to validate data.
{:json_xema, "~> 0.6.0"}
])
defmodule ApiSchema.Types do
use Absinthe.Schema.Notation
object :user do
field(:id, :id)
field(:name, :string)
field(:pets, list_of(:pet))
end
enum :pet_type do
value(:dog)
value(:cat)
value(:bearded_dragon)
end
object :pet do
field(:name, :string)
field(:type, :pet_type)
end
object :user_queries do
field(:user, :user)
end
end
defmodule ApiSchema do
use Absinthe.Schema
import_types(ApiSchema.Types)
query do
import_fields(:user_queries)
end
end
defmodule AbsintheJsonSchema do
alias Absinthe.{Pipeline, Phase, Schema}
alias Absinthe.Type.Enum, as: AbsintheEnum
alias Absinthe.Type.{Interface, List, Object, Scalar}
defstruct __schema: nil,
id: nil,
schema: "http://json-schema.org/draft-07/schema#",
title: nil,
properties: %{},
required: [],
definitions: %{}
def create(schema, query, opts) do
id = Keyword.fetch!(opts, :id)
title = Keyword.fetch!(opts, :title)
with {:ok, [operation]} <- parse_query(schema, query) do
env = %__MODULE__{
__schema: schema,
title: title,
id: "https://product.company.org/#{id}.schema.json"
}
populate_schema(operation, env)
end
end
def to_json(%__MODULE__{} = schema) do
import Jason.Helpers
Jason.encode!(
json_map(
"$schema": schema.schema,
"$id": schema.id,
additionalProperties: false,
properties: schema.properties,
required: schema.required,
definitions: schema.definitions
)
)
end
defp populate_schema(%{selections: selections}, env) do
Enum.reduce(selections, env, &consume_selection/2)
end
defp consume_selection(%{alias: alias, name: name} = selection, env) do
property_name = alias || name
{type, type_name} = lookup_type(env, selection)
env = create_definition(type, selection, env)
%{required: required, properties: properties} = env
%{
env
| required: [property_name | required],
properties: Map.put(properties, property_name, type_name)
}
end
defp create_definition(%{name: name} = field, %{selections: selections}, env)
when is_struct(field, Object) or is_struct(field, Interface) do
children = %{
properties: %{},
required: [],
definitions: %{}
}
next =
Enum.reduce(selections, children, fn selection, acc ->
%{properties: properties, required: required, definitions: definitions} =
consume_selection(selection, env)
%{
acc
| required: required ++ acc.required,
definitions: Map.merge(acc.definitions, definitions),
properties: Map.merge(acc.properties, properties)
}
end)
definitions =
Map.put(next[:definitions], name, %{
type: "object",
title: name,
additionalProperties: false,
properties: next[:properties],
required: next[:required]
})
%{
env
| definitions: Map.merge(env.definitions, definitions)
}
end
defp create_definition(%AbsintheEnum{name: name, values: values}, __selection, env) do
names = values |> Map.values() |> Enum.map(& &1.name)
%{
env
| definitions: Map.put(env.definitions, name, %{type: "string", enum: names})
}
end
defp create_definition(_field, _selection, env), do: env
defp lookup_type(%{__schema: schema}, %{schema_node: %{type: node_type}}) do
schema
|> Schema.lookup_type(node_type)
|> simple_type_info()
|> final_type_info(node_type)
end
defp simple_type_info(%Object{name: name} = type),
do: {type, %{"$ref" => "#/definitions/" <> name}}
defp simple_type_info(%Interface{name: name} = type),
do: {type, %{"$ref" => "#/definitions/" <> name}}
defp simple_type_info(%AbsintheEnum{name: name} = type),
do: {type, %{"$ref" => "#/definitions/" <> name}}
defp simple_type_info(%Scalar{identifier: :id} = type), do: {type, "string"}
defp simple_type_info(%Scalar{identifier: :string} = type), do: {type, "string"}
defp simple_type_info(%Scalar{identifier: :boolean} = type), do: {type, "boolean"}
defp simple_type_info(%Scalar{identifier: :float} = type), do: {type, "string"}
defp simple_type_info(%Scalar{identifier: :decimal} = type), do: {type, "string"}
defp simple_type_info(%Scalar{identifier: :integer} = type), do: {type, "number"}
defp simple_type_info(%Scalar{identifier: :date} = type), do: {type, "date"}
defp simple_type_info(%Scalar{identifier: :datetime} = type), do: {type, "date-time"}
defp simple_type_info(%Scalar{identifier: :currency} = type), do: {type, "string"}
defp final_type_info({type, schema_type}, %List{}) do
{type, %{type: "array", items: schema_type}}
end
defp final_type_info({type, %{"$ref" => _} = schema_type}, _) do
{type, %{"oneOf" => [schema_type, %{type: "null"}]}}
end
defp final_type_info({type, format}, _) when format in ["date", "date-time"] do
{type, %{"oneOf" => [%{type: "string", format: format}, %{type: "null"}]}}
end
defp final_type_info({type, schema_type}, _) do
{type, %{type: [schema_type, "null"]}}
end
defp parse_query(schema, query) do
pipeline =
schema
|> Pipeline.for_document()
|> Pipeline.before(Phase.Document.Validation.Result)
with {:ok, %{operations: operations}, _phases} <- Absinthe.Pipeline.run(query, pipeline) do
{:ok, operations}
end
end
end
query = """
query User {
user(id: "abc"){
id
name
friends: pets {
name
type
}
}
}
"""
json_schema = AbsintheJsonSchema.create(ApiSchema, query, title: "User Schema", id: "user")
AbsintheJsonSchema.to_json(json_schema)
valid_data =
Jason.decode!("""
{
"user": {
"id": "abc",
"name": "chris",
"friends": [
{
"name": "Maize",
"type": "DOG"
},
{
"name": "Jake",
"type": "DOG"
},
{
"name": "Spike",
"type": "BEARDED_DRAGON"
}
]
}
}
""")
schema = json_schema |> AbsintheJsonSchema.to_json() |> Jason.decode!() |> JsonXema.new()
schema |> JsonXema.validate(valid_data) |> IO.inspect(label: "valid data")
invalid_data =
Jason.decode!("""
{
"user": {
"id": "abc",
"name": "chris",
"friends": [
{
"name": "Stompy",
"type": "SNAKE"
}
]
}
}
""")
JsonXema.validate(schema, invalid_data)