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(<> = str) do <<_::utf8, rest::binary>> = Macro.camelize(str) String.to_existing_atom(<>) 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