Packages

An elixir library for Phoenix that generates Swagger documentation and performs parameter validation.

Current section

Files

Jump to
valspec lib valspec.ex
Raw

lib/valspec.ex

defmodule Valspec do
@moduledoc """
# Options:
* `tags`- the Swagger tags for the module. Example: [tags: ["V3 Clients Api"]].
* `default_response_schema`- defines a default response schema for use in Swagger docs
* `default_callback_schema`- defines a default callback schema for use in Swagger docs (Callbacks)
"""
require OpenApiSpex
@schema_keys Map.keys(%OpenApiSpex.Schema{})
defmacro __using__(opts) do
quote do
use OpenApiSpex.ControllerSpecs
import Goal
import Valspec
if opts_tags = Keyword.get(unquote(opts), :tags) do
tags(opts_tags)
end
default_response_schema = Keyword.get(unquote(opts), :default_response_schema, nil)
Module.put_attribute(__MODULE__, :default_response_schema, default_response_schema)
default_callback_schema = Keyword.get(unquote(opts), :default_callback_schema, nil)
Module.put_attribute(__MODULE__, :default_callback_schema, default_callback_schema)
end
end
@doc """
Generates both a Goal validation struct and Swagger schema for a create controller action.
Example:
valspec_create :create_client,
summary: "Creates a client",
response_schema: MyAppWeb.Response do
required(:name, :string, example: "Client 1")
required(:type, :enum, values: [:vendor, :customer])
required(:account_id, :uuid, example: "82b557d1-c529-4aff-b0fb-55df16add1b5")
end
# Options:
* `summary`- a summary of what the post request does.
* `response_schema`- a response schema defined with the valspec_schema macro.
* `callback_schemas`- a list of tuples with the name of a callback to a schema. Example:
`
[{"Create User Webhook", MyApp.User.WebhookSchema}, {"Create Account Webhook", MyApp.User.WehookSchema}]
`
"""
@spec valspec_create(name :: String.t(), opts :: Keyword.t(), block :: any()) :: Macro.t()
defmacro valspec_create(name, opts, do: expression) do
quote do
valspec_custom(
unquote(name),
:create,
unquote(opts),
do: unquote(expression)
)
end
end
@doc """
Generates both a Goal validation struct and Swagger schema for an update controller action.
Example:
valspec_update :update_client,
summary: "Updates a client",
response_schema: MyAppWeb.Response do
optional(:client_id, :uuid, example: "82b557d1-c529-4aff-b0fb-55df16add1b5")
optional(:location_id, :string,
format: "uuid",
example: "b388a4da-a3d6-42d4-8a98-4149f0007e6b",
description: "the location_id used to do stuff..."
)
end
# Options:
* `summary`- a summary of what the post request does.
* `response_schema`- a response schema defined with the valspec_schema macro.
* `callback_schemas`- a list of tuples with the name of a callback to a schema. Example:
`
[{"Update User Webhook", MyApp.User.WebhookSchema}, {"Update Account Webhook", MyApp.User.WehookSchema}]
`
"""
@spec valspec_update(name :: String.t(), opts :: Keyword.t(), block :: any()) :: Macro.t()
defmacro valspec_update(name, opts, do: expression) do
quote do
valspec_custom(
unquote(name),
:update,
unquote(opts),
do: unquote(expression)
)
end
end
@doc """
Generates both a Goal validation struct and Swagger schema for an index controller action.
Example:
valspec_index(
summary: "Lists all clients",
response_schema: MyAppWeb.IndexResponse,
)
# Options:
* `summary`- a summary of what the post request does.
* `response_schema`- a response struct defined with the valspec_schema macro.
"""
@spec valspec_index(opts :: Keyword.t()) :: Macro.t()
defmacro valspec_index(opts) do
quote do
require OpenApiSpex
summary = Keyword.get(unquote(opts), :summary, "")
swagger_opts =
[summary: summary, type: :object]
|> maybe_add_response_schema(unquote(opts), @default_response_schema)
operation(:index, swagger_opts)
end
end
@doc """
Generates both a Goal validation struct and Swagger schema for a show controller action.
Example:
valspec_show(
summary: "Show a single client",
response_schema: MyAppApi.V3.Client.JSON.IndexResponse
)
# Options:
* `summary`- a summary of what the post request does.
* `response_schema`- a response struct defined with the valspec_schema macro.
"""
defmacro valspec_show(opts) do
quote do
require OpenApiSpex
summary = Keyword.get(unquote(opts), :summary, "")
swagger_opts =
[summary: summary, type: :object]
|> maybe_add_response_schema(unquote(opts), @default_response_schema)
operation(:show, swagger_opts)
end
end
@doc """
Generates both a Goal validation struct and Swagger schema for a delete controller action.
## Example:
valspec_delete(
summary: "Deletes a client",
response_schema: MyAppWeb.Response
)
## Options:
* `summary` - Swagger endpoint summary
* `response_schema` - The schema module to generating the response schema
* `callback_schemas`- a list of tuples with the name of a callback to a schema. Example:
`
[{"Delete User Webhook", MyApp.User.WebhookSchema}, {"Delete Account Webhook", MyApp.User.WehookSchema}]
`
"""
@spec valspec_delete(opts :: Keyword.t()) :: Macro.t()
defmacro valspec_delete(opts) do
quote do
valspec_custom(:delete, unquote(opts))
end
end
@doc """
## Example 1:
valspec_custom(:my_action, summary: "Hello world")
## Example 2:
valspec_custom
:my_validation_name
:my_action,
summary: "Hello world" do
required(:id, :string)
optional(:name, :string)
optional(:location_id, :string,
format: "uuid",
example: "b388a4da-a3d6-42d4-8a98-4149f0007e6b",
description: "the location_id of something..."
)
end
## Options:
* `summary` - Swagger endpoint summary
* `response_schema` - The schema module to generating the response schema
"""
@spec valspec_custom(
name :: atom(),
action :: atom(),
opts :: Keyword.t(),
block :: any()
) :: Macro.t()
defmacro valspec_custom(action, opts) do
quote do
require OpenApiSpex
summary = Keyword.get(unquote(opts), :summary, "")
swagger_opts =
[summary: summary, type: :object]
|> maybe_add_response_schema(unquote(opts), @default_response_schema)
|> maybe_add_callback_schemas(unquote(opts), @default_callback_schema)
operation(unquote(action), swagger_opts)
end
end
defmacro valspec_custom(name, action, opts, do: expression) do
new_module_name = generate_schema_module_name(__CALLER__.module, name)
quote do
require OpenApiSpex
# Goal...
defparams unquote(name) do
unquote(expression)
end
unquote(generate_new_schema_module(Macro.escape(new_module_name), expression))
summary = Keyword.get(unquote(opts), :summary, "")
request_body =
{"", "application/json", apply(unquote(Macro.escape(new_module_name)), :schema, [])}
swagger_opts =
[summary: summary, type: :object, request_body: request_body]
|> maybe_add_response_schema(unquote(opts), @default_response_schema)
|> maybe_add_callback_schemas(unquote(opts), @default_callback_schema)
operation(unquote(action), swagger_opts)
def valspec_validate(name, params) do
validate_params(
unquote(__CALLER__.module).schema(name),
params
)
end
end
end
@doc """
Generates both an embedded_schema and a Swagger schema.
## Example:
valspec_schema do
field :id, :uuid, example: "82b557d1-0000-0000-0000-55df16add1b5"
field :name, :string, example: "opts world"
field :type, :enum, values: [:msp, :idn, :facility, :custom]
field :account_id, :uuid
field :location_id, :uuid
field :updated_at, :utc_datetime
end
"""
@spec valspec_schema(do_block :: any()) :: Macro.t()
defmacro valspec_schema(do: expression) do
quote do
use Ecto.Schema
@primary_key false
@derive {Jason.Encoder, except: []}
embedded_schema do
unquote(generate_embedded_schema(expression))
end
# Gets invoked within macros like valspec_create/3
def __schema__ do
unquote(Macro.escape(generate_swagger_schema(expression)))
# A single embedded_schema 'field' macro (field, embeds_one, embeds_many, etc.) may not
# be passed in with a __block__ attribute, which is where we normally add the :type, and
# :properties map needed to correctly generate the schema within generate_swagger_schema/1...
|> then(fn maybe_schema ->
if Map.has_key?(maybe_schema, :properties) do
maybe_schema
else
%{:type => :object, :properties => maybe_schema}
end
end)
|> OpenApiSpex.build_schema()
end
end
end
# ------ PRIVATE -----
def generate_swagger_schema({:__block__, _, contents}) do
%{
type: :object,
properties:
Enum.reduce(contents, %{}, fn function, acc ->
Map.merge(acc, generate_swagger_schema(function))
end)
}
end
# Support embedded_schema definitions within Swagger
def generate_swagger_schema({:embeds_many, _lines, [field, {:__aliases__, _, _} = als]}) do
schema = expand_nested_module_alias(als, Macro.Env.location(__ENV__))
opts = [type: :array, items: schema.__schema__()]
%{field => struct(OpenApiSpex.Schema, opts)}
end
def generate_swagger_schema({:embeds_one, _lines, [field, {:__aliases__, _, _} = als]}) do
schema = expand_nested_module_alias(als, Macro.Env.location(__ENV__))
%{field => schema.__schema__()}
end
# Support Goals :uuid type within Swagger
def generate_swagger_schema({maybe_required, _lines, [field, :uuid, opts]}) do
opts = maybe_update_opt_value_for_ast(opts, :example)
opts =
Enum.into(opts, %{})
|> Map.take(@schema_keys)
|> Map.put(:type, :string)
|> Map.put(:format, "uuid")
|> Map.put(:required, maybe_required == :required)
%{field => struct(OpenApiSpex.Schema, opts)}
end
def generate_swagger_schema({maybe_required, _lines, [field, :uuid]}) do
opts =
%{}
|> Map.put(:type, :string)
|> Map.put(:format, "uuid")
|> Map.put(:required, maybe_required == :required)
%{field => struct(OpenApiSpex.Schema, opts)}
end
# Support Ectos :utc_datetime type within Swagger
def generate_swagger_schema({maybe_required, _lines, [field, :utc_datetime, opts]}) do
opts =
opts
# date-time notation as defined by RFC 3339, section 5.6, for example, 2017-07-21T17:32:28Z
|> Keyword.put_new(:example, "2024-08-12T21:00:39")
|> maybe_update_opt_value_for_ast(:example)
# ensure example is always a string
|> Keyword.update!(:example, fn example -> to_string(example) end)
|> Enum.into(%{})
|> Map.take(@schema_keys)
|> Map.put(:type, :string)
|> Map.put(:format, "date-time")
|> Map.put(:required, maybe_required == :required)
%{field => struct(OpenApiSpex.Schema, opts)}
end
def generate_swagger_schema({maybe_required, _lines, [field, :utc_datetime]}) do
opts =
%{}
|> Map.put(:type, :string)
|> Map.put(:format, "date-time")
# date-time notation as defined by RFC 3339, section 5.6, for example, 2017-07-21T17:32:28Z
|> Map.put(:example, "2024-08-12T21:00:39")
|> Map.put(:required, maybe_required == :required)
%{field => struct(OpenApiSpex.Schema, opts)}
end
# Support Ectos :naive_datetime type within Swagger
def generate_swagger_schema({maybe_required, _lines, [field, :naive_datetime, opts]}) do
opts =
opts
# date-time notation as defined by RFC 3339, section 5.6, for example, 2017-07-21T17:32:28Z
|> Keyword.put_new(:example, "2024-08-12T21:00:39")
|> maybe_update_opt_value_for_ast(:example)
# ensure example is always a string
|> Keyword.update!(:example, fn example -> to_string(example) end)
|> Enum.into(%{})
|> Map.take(@schema_keys)
|> Map.put(:type, :string)
|> Map.put(:format, "date-time")
|> Map.put(:required, maybe_required == :required)
%{field => struct(OpenApiSpex.Schema, opts)}
end
def generate_swagger_schema({maybe_required, _lines, [field, :naive_datetime]}) do
opts =
%{}
|> Map.put(:type, :string)
|> Map.put(:format, "date-time")
# date-time notation as defined by RFC 3339, section 5.6, for example, 2017-07-21T17:32:28Z
|> Map.put(:example, "2024-08-12T21:00:39")
|> Map.put(:required, maybe_required == :required)
%{field => struct(OpenApiSpex.Schema, opts)}
end
# Support Ectos :date type within Swagger
def generate_swagger_schema({maybe_required, _lines, [field, :date, opts]}) do
opts = maybe_update_opt_value_for_ast(opts, :example)
opts =
opts
|> then(fn opts ->
if Keyword.has_key?(opts, :example) do
Keyword.replace(opts, :example, to_string(opts[:example]))
else
opts
end
end)
|> Keyword.replace(:example, to_string(opts[:example] || ""))
|> Enum.into(%{})
|> Map.take(@schema_keys)
|> Map.put(:type, :string)
|> Map.put(:format, "date")
|> Map.put(:required, maybe_required == :required)
%{field => struct(OpenApiSpex.Schema, opts)}
end
def generate_swagger_schema({maybe_required, _lines, [field, :date]}) do
opts =
%{}
|> Map.put(:type, :string)
|> Map.put(:format, "date")
|> Map.put(:required, maybe_required == :required)
%{field => struct(OpenApiSpex.Schema, opts)}
end
# Support Goals :enum type within Swagger
def generate_swagger_schema({maybe_required, _lines, [field, :enum, opts]}) do
opts = maybe_update_opt_value_for_ast(opts, :values)
opts =
opts
|> Keyword.put_new(:enum, Enum.map(opts[:values], &to_string/1))
|> Enum.into(%{})
|> Map.take(@schema_keys)
|> Map.put(:type, :string)
|> Map.put(:required, maybe_required == :required)
%{field => struct(OpenApiSpex.Schema, opts)}
end
# Support Goals :decimal type within Swagger
def generate_swagger_schema({maybe_required, _lines, [field, :decimal]}) do
opts = %{type: :number, format: "double", required: maybe_required == :required}
%{field => struct(OpenApiSpex.Schema, opts)}
end
def generate_swagger_schema({maybe_required, _lines, [field, :decimal, opts]}) do
opts =
opts
|> maybe_update_opt_value_for_ast(:example)
|> maybe_update_opt_value_for_ast(:minimum)
|> maybe_update_opt_value_for_ast(:maximum)
|> then(fn opts ->
example = Keyword.get(opts, :example)
minimum = Keyword.get(opts, :minimum)
maximum = Keyword.get(opts, :maximum)
[example, minimum, maximum]
|> Enum.filter(& &1)
|> Enum.reduce(opts, fn value, acc ->
case value do
%Decimal{} ->
raise """
Do not use Decimal module with valspec. Value: #{inspect(value)}
"""
_ ->
nil
end
acc
end)
end)
|> Enum.into(%{})
|> Map.take(@schema_keys)
|> Map.put(:type, :number)
|> Map.put(:format, "double")
|> Map.put(:required, maybe_required == :required)
%{field => struct(OpenApiSpex.Schema, opts)}
end
# Support Goals {:array, type} type within Swagger
def generate_swagger_schema({maybe_required, _lines, [field, {:array, subtype}, opts]}) do
# Case where subtype == :map
if do_block = Keyword.get(opts, :do) do
nested_schema = generate_swagger_schema(do_block)
nested_schema =
if Map.has_key?(nested_schema, :properties),
do: nested_schema,
else: %{properties: nested_schema}
opts = [
type: :array,
items: %OpenApiSpex.Schema{
type: :object,
properties: nested_schema.properties,
required: maybe_required == :required
}
]
%{field => struct(OpenApiSpex.Schema, opts)}
else
if subtype not in [:string, :integer], do: "Unsuported subtype #{inspect(subtype)} in array"
base_opts = maybe_update_opt_value_for_ast(opts, :example)
opts =
[
type: :array,
items: %OpenApiSpex.Schema{type: subtype, required: maybe_required == :required}
]
|> Keyword.merge(base_opts)
|> Enum.into(%{})
|> Map.take(@schema_keys)
%{field => struct(OpenApiSpex.Schema, opts)}
end
end
def generate_swagger_schema({maybe_required, _lines, [field, {:array, subtype}]}) do
if subtype not in [:string, :integer], do: "Unsuported subtype #{inspect(subtype)} in array"
opts =
[
type: :array,
items: %OpenApiSpex.Schema{type: subtype, required: maybe_required == :required}
]
|> Enum.into(%{})
|> Map.take(@schema_keys)
%{field => struct(OpenApiSpex.Schema, opts)}
end
# Support Goals :map type within Swagger
def generate_swagger_schema({maybe_required, _lines, [field, :map, opts]}) do
# Case where subtype == :map
if do_block = Keyword.get(opts, :do) do
nested_schema = generate_swagger_schema(do_block)
opts = [
type: :object,
properties: nested_schema.properties,
required: maybe_required == :required
]
%{field => struct(OpenApiSpex.Schema, opts)}
else
%{field => struct(OpenApiSpex.Schema, type: :object, required: maybe_required == :required)}
end
end
def generate_swagger_schema({maybe_required, _lines, [field, type]}) do
opts = %{type: type, required: maybe_required == :required}
%{field => struct(OpenApiSpex.Schema, opts)}
end
# maybe translate Goal string w/ includes into Swagger enum
def generate_swagger_schema({maybe_required, _lines, [field, :string, opts]}) do
if Keyword.has_key?(opts, :included) do
# handle possible included passed as AST during compile
{updated_included, _} = Code.eval_quoted(opts[:included])
opts = Keyword.put(opts, :values, updated_included)
generate_swagger_schema({maybe_required, nil, [field, :enum, opts]})
else
opts = maybe_update_opt_value_for_ast(opts, :example)
opts =
Enum.into(opts, %{})
|> Map.take(@schema_keys)
|> Map.put(:type, :string)
|> Map.put(:required, maybe_required == :required)
%{field => struct(OpenApiSpex.Schema, opts)}
end
end
def generate_swagger_schema({maybe_required, _lines, [field, type, opts]}) do
opts =
Enum.into(opts, %{})
|> Map.take(@schema_keys)
|> Map.put(:type, type)
opts = if maybe_required == :required, do: Map.put(opts, :required, true), else: opts
%{field => struct(OpenApiSpex.Schema, opts)}
end
def generate_swagger_schema({_type, _lines, _contents}) do
%{}
end
defp generate_embedded_schema({:__block__, _lines, contents}) do
Enum.reduce(contents, [], fn function, acc ->
[generate_embedded_schema(function) | acc]
end)
end
# translate swagger uuid -> ecto string
defp generate_embedded_schema({:field, lines, [field, :uuid]}),
do: {:field, lines, [field, :string]}
defp generate_embedded_schema({:field, lines, [field, :uuid, _opts]}),
do: {:field, lines, [field, :string]}
# translate swagger enum -> ecto string
defp generate_embedded_schema({:field, lines, [field, :enum]}),
do: {:field, lines, [field, :string]}
defp generate_embedded_schema({:field, lines, [field, :enum, _opts]}),
do: {:field, lines, [field, :string]}
defp generate_embedded_schema({:field, lines, [field, type]}),
do: {:field, lines, [field, type]}
defp generate_embedded_schema({:field, lines, [field, type, _opts]}),
do: {:field, lines, [field, type]}
defp generate_embedded_schema(anything), do: anything
# inspired by how ecto schema handles aliases within a schema.
defp expand_nested_module_alias({:__aliases__, _, [Elixir, _ | _] = alias}, _env),
do: Module.concat(alias)
defp expand_nested_module_alias({:__aliases__, _, [h | t]}, _env) when is_atom(h),
do: Module.concat([h | t])
defp expand_nested_module_alias(other, _env), do: other
def add_callback_schema_for(callback_schema_module) do
schema = callback_schema_module.__schema__()
req_body = %OpenApiSpex.Operation{
requestBody: %OpenApiSpex.RequestBody{
content: %{
"application/json" => %OpenApiSpex.MediaType{
schema: schema
}
}
},
responses: %{
200 => %OpenApiSpex.Response{
description: "Your server returns this code if it accepts the callback"
}
}
}
%{
"#{schema.title}" => %{
"" => %OpenApiSpex.PathItem{post: req_body}
}
}
end
defp maybe_update_opt_value_for_ast(opts, key) do
if value = Keyword.get(opts, key) do
# handle possible value passed as AST during compile
{updated_value, _} = Code.eval_quoted(value)
Keyword.replace(opts, key, updated_value)
else
opts
end
end
@doc """
Creates AST to generate a new swagger schema module.
The AST gets unquoted inside a `valspec_` macro.
Currently, used to generate a new Swagger Request Schema module.
"""
def generate_new_schema_module(module, expr) do
quote do
defmodule unquote(module) do
def schema do
unquote(Valspec.generate_swagger_schema(expr) |> Macro.escape())
# A single schema 'field' macro (field, embeds_one, embeds_many, etc.) may not
# be passed in with a __block__ attribute, which is where we normally add the :type, and
# :properties map needed to correctly generate the schema within generate_swagger_schema/1...
|> then(fn maybe_schema ->
if Map.has_key?(maybe_schema, :properties) do
maybe_schema
else
%{:type => :object, :properties => maybe_schema}
end
end)
|> OpenApiSpex.build_schema()
end
end
end
end
@doc """
Helper to generate a new module name for the Swagger schema based on the current
module the macro is being expanded in.
"""
def generate_schema_module_name(module, name) do
module
|> Module.concat(Schemas)
|> Module.concat(Macro.camelize(Atom.to_string(name)))
|> Macro.escape()
end
@doc """
Helper to possibly add the response schema opt to the Swagger operation
"""
def maybe_add_response_schema(base_opts, opts, default \\ nil) do
if response_schema = Keyword.get(opts, :response_schema) || default do
Keyword.merge(base_opts,
responses: %{201 => {"", "application/json", response_schema.__schema__()}}
)
else
base_opts
end
end
@doc """
Helper to possibly add the callback schemas opt to the Swagger operation
"""
def maybe_add_callback_schemas(base_opts, opts, default \\ nil) do
if callback_schema_modules = Keyword.get(opts, :callback_schemas) || default do
callbacks =
[callback_schema_modules]
|> List.flatten()
|> Enum.reduce(%{}, fn callback_schema_module, acc ->
val = add_callback_schema_for(callback_schema_module)
Map.merge(acc, val)
end)
Keyword.merge(base_opts, callbacks: callbacks)
else
base_opts
end
end
end