Skip to content

Instantly share code, notes, and snippets.

@trbngr
Last active August 27, 2021 05:06
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save trbngr/439cd16fba1bf87ee6eae4e29a7285fd to your computer and use it in GitHub Desktop.
Save trbngr/439cd16fba1bf87ee6eae4e29a7285fd to your computer and use it in GitHub Desktop.
Absinthe GraphQL Query to JSON Schema

docker run -p 8080:8080 --pull always livebook/livebook

Click Import, paste: https://gist.githubusercontent.com/trbngr/439cd16fba1bf87ee6eae4e29a7285fd/raw/300a10de650d72f40b0cab5069828a7bfa21f393/absinthe_json_schema.livemd

Absinthe to JSON Schema

Deps

Mix.install([
  {:jason, "~> 1.2"},
  {:absinthe, "~> 1.6"},

  # Not required for generating json schema. Only used to validate data.
  {:json_xema, "~> 0.6.0"}
])

Schema

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

AbsintheJsonSchema module

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

Create JSON Schema

query = """
query User {
  user(id: "abc"){
    id
    name
    friends: pets {
      name
      type
    }
  }
}
"""

json_schema = AbsintheJsonSchema.create(ApiSchema, query, title: "User Schema", id: "user")

Serialize Schema

AbsintheJsonSchema.to_json(json_schema)

Validate Data

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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment