defmodule OpenApiSpex.Schemax do
@moduledoc """
Similar to `Ecto.Schema`, define Schema of Open Api Spec as DSL syntax.
## `schema` macro
To define schema, use `schema/2` macro. It must be used once in a module.
`schema/2` macro define a function named `schema/0`, which return a `%OpenApiSpex.Schema{}` struct.
The first argument of `schema/2` macro is required and it become `:title` field of the struct.
### Example
defmodule SimpleUser do
use OpenApiSpex.Schemax
@required [:id, :name]
schema "SimpleUser" do
property :id, :integer
property :name, :string
property :is_verified, :boolean
end
end
when you call the created function `schema/0`, it will show:
iex> SimpleUser.schema()
%OpenApiSpex.Schema{
title: "SimpleUser",
type: :object,
properties: %{id: %OpenApiSpex.Schema{type: :integer},
name: %OpenApiSpex.Schema{type: :string},
is_verified: %OpenApiSpex.Schema{type: :boolean}},
required: [:id, :name]
}
## `embedded_schema/2` macro
Unlike `schema/2`, `embedded_schema/2` macro can be defined multiple time in a module.
Sometimes when you have deep nested schema, it is bothering that turn every schema into modules.
In that case, you might want to temporary schemas in a module, which you can do through this macro.
When you define this macro, unlike `schema` macro, put function name into
first argument of macro instead of title.
After that, when you want to use that embedded schema, call that function name.
define both of `:schema_type` and `:required` fields are also included in do block,
unlike `schema` macro which defines them as module attributes.
### Example
defmodule ListResponse do
use OpenApiSpex.Schemax
schema "ListResponse" do
property :list, list()
end
embedded_schema :list do
property :id, :integer
property :name, :string
required [:id, :name]
end
end
"""
alias OpenApiSpex.Schema
alias OpenApiSpex.Schemax.Parser
defmacro __using__(_) do
quote do
import OpenApiSpex.Schemax, only: [schema: 1, schema: 2, embedded_schema: 2, wrapper: 2]
alias OpenApiSpex.Schema
@required []
@schema_type :object
end
end
@doc """
"""
defmacro schema(title \\ nil, do: block) do
fields = Parser.parse_body(block)
{properties, rest_fields} = extract_properties(fields)
title =
if title,
do: title,
else: __CALLER__.module |> Module.split() |> List.last()
quote do
def schema do
properties_map =
unquote(properties)
|> OpenApiSpex.Schemax.maybe_properties_map()
struct(
Schema,
[
title: unquote(title),
type: @schema_type,
properties: properties_map,
required: @required
] ++
unquote(rest_fields)
)
end
end
end
defmacro embedded_schema(function_name, do: block) do
unless is_atom(function_name) do
raise ArgumentError, "1st argument must be an atom, which represent function name"
end
fields = Parser.parse_body(block)
{properties, rest_fields} = extract_properties(fields)
quote do
def unquote(function_name)() do
properties_map =
unquote(properties)
|> OpenApiSpex.Schemax.maybe_properties_map()
struct(Schema, [properties: properties_map, type: :object] ++ unquote(rest_fields))
end
end
end
defp extract_properties(fields) do
{properties, rest_fields} =
fields
|> Enum.split_with(fn
{:property, _} -> true
_ -> false
end)
properties = build_properties(properties)
rest_fields = build_rest_fields(rest_fields)
{properties, rest_fields}
end
defp build_properties(properties) do
properties
|> Enum.map(fn
{:property, {name, schema_fields}} when is_list(schema_fields) ->
{name, schema_fields |> Enum.map(fn {k, v} -> {to_camel_case(k), v} end)}
{:property, {name, schema_fields, item_type}} ->
{name,
[items: item_schema(item_type)] ++
Enum.map(schema_fields, fn {k, v} -> {to_camel_case(k), v} end)}
{:property, {name, stuff}} ->
{name, stuff}
end)
end
defp build_rest_fields(fields) do
fields
|> Enum.map(fn
{:items, s} ->
{:items, item_schema(s)}
otherwise ->
otherwise
end)
end
def maybe_properties_map([]), do: nil
def maybe_properties_map(properties) do
properties
|> Map.new(fn
{k, kwlist} when is_list(kwlist) -> {k, struct(Schema, kwlist)}
other -> other
end)
end
@known_types [:integer, :string, :boolean, :number, :object]
defp item_schema(type) when type in @known_types do
Macro.escape(%Schema{type: type})
end
defp item_schema(schema), do: schema
defp to_camel_case(field) when is_atom(field), do: to_camel_case("#{field}")
defp to_camel_case(<<f::utf8, _::binary>> = str) do
<<_::utf8, rest::binary>> = Macro.camelize(str)
String.to_existing_atom(<<f::utf8, rest::binary>>)
end
@doc """
Wrapper function for convenient.
For example, if there is a User schema, it can make a response like `%{"user" => %User{...}}`.
NOTE: This is not recommended. please use `embedded_schema/2` instead.
"""
@spec wrapper(module() | struct(), atom()) :: Schema.t()
def wrapper(schema, field_name) when is_atom(field_name) do
%Schema{
type: :object,
properties: %{field_name => schema},
required: [field_name]
}
end
end