Page MenuHomePhorge

No OneTemporary

Size
63 KB
Referenced Files
None
Subscribers
None
diff --git a/lib/open_api_spex/cast.ex b/lib/open_api_spex/cast.ex
index f84d807..8c143d1 100644
--- a/lib/open_api_spex/cast.ex
+++ b/lib/open_api_spex/cast.ex
@@ -1,84 +1,98 @@
defmodule OpenApiSpex.Cast do
- alias OpenApiSpex.Reference
+ alias OpenApiSpex.{Reference, Schema}
alias OpenApiSpex.Cast.{Array, Error, Object, Primitive, String}
+ @type schema_or_reference :: Schema.t() | Reference.t()
+ @type t :: %__MODULE__{
+ value: term(),
+ schema: schema_or_reference | nil,
+ schemas: map(),
+ path: [atom() | String.t() | integer()],
+ key: atom() | nil,
+ index: integer,
+ errors: [Error.t()]
+ }
+
defstruct value: nil,
schema: nil,
schemas: %{},
path: [],
key: nil,
index: 0,
errors: []
+ @spec cast(schema_or_reference | nil, term(), map()) :: {:ok, term()} | {:error, [Error.t()]}
def cast(schema, value, schemas) do
ctx = %__MODULE__{schema: schema, value: value, schemas: schemas}
cast(ctx)
end
+ @spec cast(t()) :: {:ok, term()} | {:error, [Error.t()]}
+
# nil schema
def cast(%__MODULE__{value: value, schema: nil}),
do: {:ok, value}
def cast(%__MODULE__{schema: %Reference{}} = ctx) do
schema = Reference.resolve_schema(ctx.schema, ctx.schemas)
cast(%{ctx | schema: schema})
end
# nullable: true
def cast(%__MODULE__{value: nil, schema: %{nullable: true}}) do
{:ok, nil}
end
# nullable: false
def cast(%__MODULE__{value: nil} = ctx) do
error(ctx, {:null_value})
end
# Enum
def cast(%__MODULE__{schema: %{enum: []}} = ctx) do
- cast(%{ctx | enum: nil})
+ cast(%{ctx | schema: %{ctx.schema | enum: nil}})
end
# Enum
def cast(%__MODULE__{schema: %{enum: enum}} = ctx) when is_list(enum) do
with {:ok, value} <- cast(%{ctx | schema: %{ctx.schema | enum: nil}}) do
if value in enum do
{:ok, value}
else
error(ctx, {:invalid_enum})
end
end
end
## Specific types
def cast(%__MODULE__{schema: %{type: :boolean}} = ctx),
do: Primitive.cast_boolean(ctx)
def cast(%__MODULE__{schema: %{type: :integer}} = ctx),
do: Primitive.cast_integer(ctx)
def cast(%__MODULE__{schema: %{type: :number}} = ctx),
do: Primitive.cast_number(ctx)
def cast(%__MODULE__{schema: %{type: :string}} = ctx),
do: String.cast(ctx)
def cast(%__MODULE__{schema: %{type: :array}} = ctx),
do: Array.cast(ctx)
def cast(%__MODULE__{schema: %{type: :object}} = ctx),
do: Object.cast(ctx)
def cast(%__MODULE__{schema: %{type: _other}} = ctx),
do: error(ctx, {:invalid_schema_type})
def cast(%{} = ctx), do: cast(struct(__MODULE__, ctx))
def cast(ctx) when is_list(ctx), do: cast(struct(__MODULE__, ctx))
# Add an error
def error(ctx, error_args) do
error = Error.new(ctx, error_args)
{:error, [error | ctx.errors]}
end
end
diff --git a/lib/open_api_spex/cast/error.ex b/lib/open_api_spex/cast/error.ex
index 0604070..58c5980 100644
--- a/lib/open_api_spex/cast/error.ex
+++ b/lib/open_api_spex/cast/error.ex
@@ -1,147 +1,154 @@
defmodule OpenApiSpex.Cast.Error do
alias OpenApiSpex.TermType
defstruct reason: nil,
value: nil,
format: nil,
type: nil,
name: nil,
path: [],
length: 0,
meta: %{}
def new(ctx, {:invalid_schema_type}) do
%__MODULE__{reason: :invalid_schema_type, type: ctx.schema.type}
|> add_context_fields(ctx)
end
def new(ctx, {:null_value}) do
type = ctx.schema && ctx.schema.type
%__MODULE__{reason: :null_value, type: type}
|> add_context_fields(ctx)
end
def new(ctx, {:min_length, length}) do
%__MODULE__{reason: :min_length, length: length}
|> add_context_fields(ctx)
end
def new(ctx, {:invalid_type, type}) do
%__MODULE__{reason: :invalid_type, type: type}
|> add_context_fields(ctx)
end
def new(ctx, {:invalid_format, format}) do
%__MODULE__{reason: :invalid_format, format: format}
|> add_context_fields(ctx)
end
def new(ctx, {:invalid_enum}) do
%__MODULE__{reason: :invalid_enum}
|> add_context_fields(ctx)
end
def new(ctx, {:unexpected_field, name}) do
%__MODULE__{reason: :unexpected_field, name: name}
|> add_context_fields(ctx)
end
def new(ctx, {:missing_field, name}) do
%__MODULE__{reason: :missing_field, name: name}
|> add_context_fields(ctx)
end
def new(ctx, {:max_properties, max_properties, property_count}) do
%__MODULE__{
reason: :max_properties,
meta: %{max_properties: max_properties, property_count: property_count}
}
|> add_context_fields(ctx)
end
def message(%{reason: :invalid_schema_type, type: type}) do
"Invalid schema.type. Got: #{inspect(type)}"
end
def message(%{reason: :null_value} = error) do
case error.type do
nil -> "null value"
type -> "null value where #{type} expected"
end
end
def message(%{reason: :min_length, length: length}) do
"String length is smaller than minLength: #{length}"
end
def message(%{reason: :invalid_type, type: type, value: value}) do
"Invalid #{type}. Got: #{TermType.type(value)}"
end
def message(%{reason: :invalid_format, format: format}) do
"Invalid format. Expected #{inspect(format)}"
end
def message(%{reason: :invalid_enum}) do
"Invalid value for enum"
end
def message(%{reason: :polymorphic_failed, type: polymorphic_type}) do
"Failed to cast to any schema in #{polymorphic_type}"
end
def message(%{reason: :unexpected_field, name: name}) do
"Unexpected field: #{safe_string(name)}"
end
def message(%{reason: :no_value_required_for_discriminator, name: field}) do
"No value for required disciminator property: #{field}"
end
def message(%{reason: :unknown_schema, name: name}) do
"Unknown schema: #{name}"
end
def message(%{reason: :missing_field, name: name}) do
"Missing field: #{name}"
end
def message(%{reason: :max_properties, meta: meta}) do
"Object property count #{meta.property_count} is greater than maxProperties: #{
meta.max_properties
}"
end
def message_with_path(error) do
prepend_path(error, message(error))
end
def path_to_string(%{path: path} = _error) do
- "/" <> (path |> Enum.map(&to_string/1) |> Path.join())
+ path =
+ if path == [] do
+ ""
+ else
+ path |> Enum.map(&to_string/1) |> Path.join()
+ end
+
+ "/" <> path
end
defp add_context_fields(error, ctx) do
%{error | path: Enum.reverse(ctx.path), value: ctx.value}
end
defp prepend_path(error, message) do
path =
case error.path do
[] -> "#"
_ -> "#" <> path_to_string(error)
end
path <> ": " <> message
end
defp safe_string(string) do
to_string(string) |> String.slice(0..39)
end
end
defimpl String.Chars, for: OpenApiSpex.Cast.Error do
def to_string(error) do
OpenApiSpex.Cast.Error.message(error)
end
end
diff --git a/lib/open_api_spex/cast/object.ex b/lib/open_api_spex/cast/object.ex
index 0bbafdf..820ef16 100644
--- a/lib/open_api_spex/cast/object.ex
+++ b/lib/open_api_spex/cast/object.ex
@@ -1,108 +1,109 @@
defmodule OpenApiSpex.Cast.Object do
@moduledoc false
alias OpenApiSpex.Cast
alias OpenApiSpex.Cast.Error
def cast(%{value: value} = ctx) when not is_map(value) do
Cast.error(ctx, {:invalid_type, :object})
end
def cast(%{value: value, schema: %{properties: nil}}) do
{:ok, value}
end
def cast(%{value: value, schema: schema} = ctx) do
schema_properties = schema.properties || %{}
with :ok <- check_unrecognized_properties(ctx, schema_properties),
value = cast_atom_keys(value, schema_properties),
ctx = %{ctx | value: value},
:ok <- check_required_fields(ctx, schema),
:ok <- check_max_properties(ctx),
{:ok, value} <- cast_properties(%{ctx | schema: schema_properties}) do
ctx = to_struct(%{ctx | value: value})
{:ok, ctx}
end
end
defp check_unrecognized_properties(%{value: value} = ctx, expected_keys) do
input_keys = value |> Map.keys() |> Enum.map(&to_string/1)
schema_keys = expected_keys |> Map.keys() |> Enum.map(&to_string/1)
extra_keys = input_keys -- schema_keys
if extra_keys == [] do
:ok
else
[name | _] = extra_keys
+ ctx = %{ctx | path: [name | ctx.path]}
Cast.error(ctx, {:unexpected_field, name})
end
end
defp check_required_fields(%{value: input_map} = ctx, schema) do
required = schema.required || []
input_keys = Map.keys(input_map)
missing_keys = required -- input_keys
if missing_keys == [] do
:ok
else
errors =
Enum.map(missing_keys, fn key ->
ctx = %{ctx | path: [key | ctx.path]}
Error.new(ctx, {:missing_field, key})
end)
{:error, ctx.errors ++ errors}
end
end
defp check_max_properties(%{schema: %{maxProperties: max_properties}} = ctx)
when is_integer(max_properties) do
count = ctx.value |> Map.keys() |> length()
if count > max_properties do
Cast.error(ctx, {:max_properties, max_properties, count})
else
:ok
end
end
defp check_max_properties(_ctx), do: :ok
defp cast_atom_keys(input_map, properties) do
Enum.reduce(properties, %{}, fn {key, _}, output ->
string_key = to_string(key)
case input_map do
%{^key => value} -> Map.put(output, key, value)
%{^string_key => value} -> Map.put(output, key, value)
_ -> output
end
end)
end
defp cast_properties(%{value: object, schema: schema_properties} = ctx) do
Enum.reduce(object, {:ok, %{}}, fn
{key, value}, {:ok, output} ->
cast_property(%{ctx | key: key, value: value, schema: schema_properties}, output)
_, error ->
error
end)
end
defp cast_property(%{key: key, schema: schema_properties} = ctx, output) do
prop_schema = Map.get(schema_properties, key)
path = [key | ctx.path]
with {:ok, value} <- Cast.cast(%{ctx | path: path, schema: prop_schema}) do
{:ok, Map.put(output, key, value)}
end
end
defp to_struct(%{value: value = %_{}}), do: value
defp to_struct(%{value: value, schema: %{"x-struct": nil}}), do: value
defp to_struct(%{value: value, schema: %{"x-struct": module}}),
do: struct(module, value)
end
diff --git a/lib/open_api_spex/cast_parameters.ex b/lib/open_api_spex/cast_parameters.ex
new file mode 100644
index 0000000..b6587dd
--- /dev/null
+++ b/lib/open_api_spex/cast_parameters.ex
@@ -0,0 +1,37 @@
+defmodule OpenApiSpex.CastParameters do
+ @moduledoc false
+ alias OpenApiSpex.{Cast, Operation, Parameter, Schema}
+ alias OpenApiSpex.Cast.{Error, Object}
+ alias Plug.Conn
+
+ @spec cast(Plug.Conn.t(), Operation.t(), Schema.schemas()) ::
+ {:error, [Error.t()]} | {:ok, Conn.t()}
+ def cast(conn, operation, schemas) do
+ # Taken together as a set, operation parameters are similar to an object schema type.
+ # Convert parameters to an object schema, then delegate to `Cast.Object.cast/1`
+
+ properties =
+ operation.parameters
+ |> Enum.map(fn parameter -> {parameter.name, Parameter.schema(parameter)} end)
+ |> Map.new()
+
+ required =
+ operation.parameters
+ |> Enum.filter(& &1.required)
+ |> Enum.map(& &1.name)
+
+ object_schema = %Schema{
+ type: :object,
+ properties: properties,
+ required: required
+ }
+
+ params = Map.merge(conn.path_params, conn.query_params)
+
+ ctx = %Cast{value: params, schema: object_schema, schemas: schemas}
+
+ with {:ok, params} <- Object.cast(ctx) do
+ {:ok, %{conn | params: params}}
+ end
+ end
+end
diff --git a/lib/open_api_spex/operation.ex b/lib/open_api_spex/operation.ex
index 4a0a7cf..7dccd04 100644
--- a/lib/open_api_spex/operation.ex
+++ b/lib/open_api_spex/operation.ex
@@ -1,223 +1,221 @@
defmodule OpenApiSpex.Operation do
@moduledoc """
Defines the `OpenApiSpex.Operation.t` type.
"""
alias OpenApiSpex.{
Callback,
ExternalDocumentation,
MediaType,
Operation,
Parameter,
Reference,
RequestBody,
Response,
Responses,
Schema,
SecurityRequirement,
Server,
}
@enforce_keys :responses
- defstruct [
- :tags,
- :summary,
- :description,
- :externalDocs,
- :operationId,
- :parameters,
- :requestBody,
- :responses,
- :callbacks,
- :deprecated,
- :security,
- :servers
- ]
+ defstruct tags: [],
+ summary: nil,
+ description: nil,
+ externalDocs: nil,
+ operationId: nil,
+ parameters: [],
+ requestBody: nil,
+ responses: nil,
+ callbacks: %{},
+ deprecated: false,
+ security: [],
+ servers: []
@typedoc """
[Operation Object](https://swagger.io/specification/#operationObject)
Describes a single API operation on a path.
"""
@type t :: %__MODULE__{
- tags: [String.t] | nil,
- summary: String.t | nil,
- description: String.t | nil,
- externalDocs: ExternalDocumentation.t | nil,
- operationId: String.t | nil,
- parameters: [Parameter.t | Reference.t] | nil,
- requestBody: RequestBody.t | Reference.t | nil,
- responses: Responses.t,
- callbacks: %{String.t => Callback.t | Reference.t} | nil,
- deprecated: boolean | nil,
- security: [SecurityRequirement.t] | nil,
- servers: [Server.t] | nil
- }
+ tags: [String.t()],
+ summary: String.t() | nil,
+ description: String.t() | nil,
+ externalDocs: ExternalDocumentation.t() | nil,
+ operationId: String.t() | nil,
+ parameters: [Parameter.t() | Reference.t()],
+ requestBody: RequestBody.t() | Reference.t() | nil,
+ responses: Responses.t(),
+ callbacks: %{String.t() => Callback.t() | Reference.t()},
+ deprecated: boolean,
+ security: [SecurityRequirement.t()],
+ servers: [Server.t()]
+ }
@doc """
Constructs an Operation struct from the plug and opts specified in the given route
"""
@spec from_route(PathItem.route) :: t
def from_route(route) do
from_plug(route.plug, route.opts)
end
@doc """
Constructs an Operation struct from plug module and opts
"""
@spec from_plug(module, any) :: t
def from_plug(plug, opts) do
plug.open_api_operation(opts)
end
@doc """
Shorthand for constructing a Parameter name, location, type, description and optional examples
"""
@spec parameter(atom, Parameter.location, Reference.t | Schema.t | atom, String.t, keyword) :: Parameter.t
def parameter(name, location, type, description, opts \\ []) do
params =
[name: name, in: location, description: description, required: location == :path]
|> Keyword.merge(opts)
Parameter
|> struct(params)
|> Parameter.put_schema(type)
end
@doc """
Shorthand for constructing a RequestBody with description, media_type, schema and optional examples
"""
@spec request_body(String.t, String.t, (Schema.t | Reference.t | module), keyword) :: RequestBody.t
def request_body(description, media_type, schema_ref, opts \\ []) do
%RequestBody{
description: description,
content: %{
media_type => %MediaType{
schema: schema_ref,
example: opts[:example],
examples: opts[:examples]
}
},
required: opts[:required] || false
}
end
@doc """
Shorthand for constructing a Response with description, media_type, schema and optional examples
"""
@spec response(String.t, String.t, (Schema.t | Reference.t | module), keyword) :: Response.t
def response(description, media_type, schema_ref, opts \\ []) do
%Response{
description: description,
content: %{
media_type => %MediaType {
schema: schema_ref,
example: opts[:example],
examples: opts[:examples]
}
}
}
end
@doc """
Cast params to the types defined by the schemas of the operation parameters and requestBody
"""
@spec cast(Operation.t, Conn.t, String.t | nil, %{String.t => Schema.t}) :: {:ok, Plug.Conn.t} | {:error, String.t}
def cast(operation = %Operation{}, conn = %Plug.Conn{}, content_type, schemas) do
parameters = Enum.filter(operation.parameters || [], fn p -> Map.has_key?(conn.params, Atom.to_string(p.name)) end)
with :ok <- check_query_params_defined(conn, operation.parameters),
{:ok, parameter_values} <- cast_parameters(parameters, conn.params, schemas),
{:ok, body} <- cast_request_body(operation.requestBody, conn.body_params, content_type, schemas) do
{:ok, %{conn | params: parameter_values, body_params: body}}
end
end
@spec check_query_params_defined(Conn.t, list | nil) :: :ok | {:error, String.t}
defp check_query_params_defined(%Plug.Conn{} = conn, defined_params) when is_nil(defined_params) do
case conn.query_params do
%{} -> :ok
_ -> {:error, "No query parameters defined for this operation"}
end
end
defp check_query_params_defined(%Plug.Conn{} = conn, defined_params) when is_list(defined_params) do
defined_query_params = for param <- defined_params, param.in == :query, into: MapSet.new(), do: to_string(param.name)
case validate_parameter_keys(Map.keys(conn.query_params), defined_query_params) do
{:error, param} -> {:error, "Undefined query parameter: #{inspect(param)}"}
:ok -> :ok
end
end
@spec validate_parameter_keys([String.t], MapSet.t) :: {:error, String.t} | :ok
defp validate_parameter_keys([], _defined_params), do: :ok
defp validate_parameter_keys([param|params], defined_params) do
case MapSet.member?(defined_params, param) do
false -> {:error, param}
_ -> validate_parameter_keys(params, defined_params)
end
end
@spec cast_parameters([Parameter.t], map, %{String.t => Schema.t}) :: {:ok, map} | {:error, String.t}
defp cast_parameters([], _params, _schemas), do: {:ok, %{}}
defp cast_parameters([p | rest], params = %{}, schemas) do
with {:ok, cast_val} <- Schema.cast(Parameter.schema(p), params[Atom.to_string(p.name)], schemas),
{:ok, cast_tail} <- cast_parameters(rest, params, schemas) do
{:ok, Map.put_new(cast_tail, p.name, cast_val)}
end
end
@spec cast_request_body(RequestBody.t | nil, map, String.t | nil, %{String.t => Schema.t}) :: {:ok, map} | {:error, String.t}
defp cast_request_body(nil, _, _, _), do: {:ok, %{}}
defp cast_request_body(%RequestBody{content: content}, params, content_type, schemas) do
schema = content[content_type].schema
Schema.cast(schema, params, schemas)
end
@doc """
Validate params against the schemas of the operation parameters and requestBody
"""
@spec validate(Operation.t, Conn.t, String.t | nil, %{String.t => Schema.t}) :: :ok | {:error, String.t}
def validate(operation = %Operation{}, conn = %Plug.Conn{}, content_type, schemas) do
with :ok <- validate_required_parameters(operation.parameters || [], conn.params),
parameters <- Enum.filter(operation.parameters || [], &Map.has_key?(conn.params, &1.name)),
:ok <- validate_parameter_schemas(parameters, conn.params, schemas),
:ok <- validate_body_schema(operation.requestBody, conn.body_params, content_type, schemas) do
:ok
end
end
@spec validate_required_parameters([Parameter.t], map) :: :ok | {:error, String.t}
defp validate_required_parameters(parameter_list, params = %{}) do
required =
parameter_list
|> Stream.filter(fn parameter -> parameter.required end)
|> Enum.map(fn parameter -> parameter.name end)
missing = required -- Map.keys(params)
case missing do
[] -> :ok
_ -> {:error, "Missing required parameters: #{inspect(missing)}"}
end
end
@spec validate_parameter_schemas([Parameter.t()], map, %{String.t() => Schema.t()}) ::
:ok | {:error, String.t()}
defp validate_parameter_schemas([], %{} = _params, _schemas), do: :ok
defp validate_parameter_schemas([p | rest], %{} = params, schemas) do
{:ok, parameter_value} = Map.fetch(params, p.name)
with :ok <- Schema.validate(Parameter.schema(p), parameter_value, schemas) do
validate_parameter_schemas(rest, params, schemas)
end
end
@spec validate_body_schema(RequestBody.t | nil, map, String.t | nil, %{String.t => Schema.t}) :: :ok | {:error, String.t}
defp validate_body_schema(nil, _, _, _), do: :ok
defp validate_body_schema(%RequestBody{required: false}, params, _content_type, _schemas) when map_size(params) == 0 do
:ok
end
defp validate_body_schema(%RequestBody{content: content}, params, content_type, schemas) do
content
|> Map.get(content_type)
|> Map.get(:schema)
|> Schema.validate(params, schemas)
end
end
diff --git a/lib/open_api_spex/operation2.ex b/lib/open_api_spex/operation2.ex
index 5c542fa..f8cdef6 100644
--- a/lib/open_api_spex/operation2.ex
+++ b/lib/open_api_spex/operation2.ex
@@ -1,93 +1,38 @@
defmodule OpenApiSpex.Operation2 do
@moduledoc """
Defines the `OpenApiSpex.Operation.t` type.
"""
alias OpenApiSpex.{
Cast,
+ CastParameters,
Operation,
- Parameter,
RequestBody,
Schema
}
- def cast(operation = %Operation{}, conn = %Plug.Conn{}, content_type, schemas) do
- parameters =
- Enum.filter(operation.parameters || [], fn p ->
- Map.has_key?(conn.params, Atom.to_string(p.name))
- end)
+ alias OpenApiSpex.Cast.Error
+ alias Plug.Conn
- with :ok <- check_query_params_defined(conn, operation.parameters),
- {:ok, parameter_values} <- cast_parameters(parameters, conn.params, schemas),
- conn = %{conn | params: parameter_values},
- parameters =
- Enum.filter(operation.parameters || [], &Map.has_key?(conn.params, &1.name)),
- :ok <- validate_parameter_schemas(parameters, conn.params, schemas),
+ @spec cast(Operation.t(), Conn.t(), String.t() | nil, Schema.schemas()) ::
+ {:error, [Error.t()]} | {:ok, Conn.t()}
+ def cast(operation = %Operation{}, conn = %Conn{}, content_type, schemas) do
+ with {:ok, conn} <- cast_parameters(conn, operation, schemas),
{:ok, body} <-
cast_request_body(operation.requestBody, conn.body_params, content_type, schemas) do
- {:ok, %{conn | params: parameter_values, body_params: body}}
+ {:ok, %{conn | body_params: body}}
end
end
- @spec check_query_params_defined(Conn.t(), list | nil) :: :ok | {:error, String.t()}
- defp check_query_params_defined(%Plug.Conn{}, nil = _defined_params) do
- :ok
- end
-
- defp check_query_params_defined(%Plug.Conn{} = conn, defined_params)
- when is_list(defined_params) do
- defined_query_params =
- for param <- defined_params,
- param.in == :query,
- into: MapSet.new(),
- do: to_string(param.name)
-
- case validate_parameter_keys(Map.keys(conn.query_params), defined_query_params) do
- {:error, param} -> {:error, "Undefined query parameter: #{inspect(param)}"}
- :ok -> :ok
- end
- end
-
- @spec validate_parameter_keys([String.t()], MapSet.t()) :: {:error, String.t()} | :ok
- defp validate_parameter_keys([], _defined_params), do: :ok
-
- defp validate_parameter_keys([param | params], defined_params) do
- case MapSet.member?(defined_params, param) do
- false -> {:error, param}
- _ -> validate_parameter_keys(params, defined_params)
- end
- end
-
- @spec cast_parameters([Parameter.t()], map, %{String.t() => Schema.t()}) ::
- {:ok, map} | {:error, String.t()}
- defp cast_parameters([], _params, _schemas), do: {:ok, %{}}
+ ## Private functions
- defp cast_parameters([p | rest], params = %{}, schemas) do
- with {:ok, cast_val} <-
- Cast.cast(Parameter.schema(p), params[Atom.to_string(p.name)], schemas),
- {:ok, cast_tail} <- cast_parameters(rest, params, schemas) do
- {:ok, Map.put_new(cast_tail, p.name, cast_val)}
- end
+ defp cast_parameters(conn, operation, schemas) do
+ CastParameters.cast(conn, operation, schemas)
end
- @spec cast_request_body(RequestBody.t() | nil, map, String.t() | nil, %{
- String.t() => Schema.t()
- }) :: {:ok, map} | {:error, String.t()}
defp cast_request_body(nil, _, _, _), do: {:ok, %{}}
defp cast_request_body(%RequestBody{content: content}, params, content_type, schemas) do
schema = content[content_type].schema
Cast.cast(schema, params, schemas)
end
-
- @spec validate_parameter_schemas([Parameter.t()], map, %{String.t() => Schema.t()}) ::
- :ok | {:error, String.t()}
- defp validate_parameter_schemas([], %{} = _params, _schemas), do: :ok
-
- defp validate_parameter_schemas([p | rest], %{} = params, schemas) do
- {:ok, parameter_value} = Map.fetch(params, p.name)
-
- with {:ok, _value} <- Cast.cast(Parameter.schema(p), parameter_value, schemas) do
- validate_parameter_schemas(rest, params, schemas)
- end
- end
end
diff --git a/lib/open_api_spex/schema.ex b/lib/open_api_spex/schema.ex
index 8a397c8..d192881 100644
--- a/lib/open_api_spex/schema.ex
+++ b/lib/open_api_spex/schema.ex
@@ -1,704 +1,709 @@
defmodule OpenApiSpex.Schema do
@moduledoc """
Defines the `OpenApiSpex.Schema.t` type and operations for casting and validating against a schema.
The `OpenApiSpex.schema` macro can be used to declare schemas with an associated struct and `Poison.Encoder`.
## Examples
defmodule MyApp.Schemas do
defmodule EmailString do
@behaviour OpenApiSpex.Schema
def schema do
%OpenApiSpex.Schema {
title: "EmailString",
type: :string,
format: :email
}
end
end
defmodule Person do
require OpenApiSpex
alias OpenApiSpex.{Reference, Schema}
OpenApiSpex.schema(%{
type: :object,
required: [:name],
properties: %{
name: %Schema{type: :string},
address: %Reference{"$ref": "#components/schemas/Address"},
age: %Schema{type: :integer, format: :int32, minimum: 0}
}
})
end
defmodule StringDictionary do
@behaviour OpenApiSpex.Schema
def schema() do
%OpenApiSpex.Schema{
type: :object,
additionalProperties: %{
type: :string
}
}
end
end
defmodule Pet do
require OpenApiSpex
alias OpenApiSpex.{Schema, Discriminator}
OpenApiSpex.schema(%{
title: "Pet",
type: :object,
discriminator: %Discriminator{
propertyName: "petType"
},
properties: %{
name: %Schema{type: :string},
petType: %Schema{type: :string}
},
required: [:name, :petType]
})
end
defmodule Cat do
require OpenApiSpex
alias OpenApiSpex.Schema
OpenApiSpex.schema(%{
title: "Cat",
type: :object,
description: "A representation of a cat. Note that `Cat` will be used as the discriminator value.",
allOf: [
Pet,
%Schema{
type: :object,
properties: %{
huntingSkill: %Schema{
type: :string,
description: "The measured skill for hunting",
default: "lazy",
enum: ["clueless", "lazy", "adventurous", "aggresive"]
}
},
required: [:huntingSkill]
}
]
})
end
defmodule Dog do
require OpenApiSpex
alias OpenApiSpex.Schema
OpenApiSpex.schema(%{
type: :object,
title: "Dog",
description: "A representation of a dog. Note that `Dog` will be used as the discriminator value.",
allOf: [
Pet,
%Schema {
type: :object,
properties: %{
packSize: %Schema{
type: :integer,
format: :int32,
description: "the size of the pack the dog is from",
default: 0,
minimum: 0
}
},
required: [
:packSize
]
}
]
})
end
end
"""
alias OpenApiSpex.{
Schema, Reference, Discriminator, Xml, ExternalDocumentation
}
@doc """
A module implementing the `OpenApiSpex.Schema` behaviour should export a `schema/0` function
that produces an `OpenApiSpex.Schema` struct.
"""
@callback schema() :: t
defstruct [
:title,
:multipleOf,
:maximum,
:exclusiveMaximum,
:minimum,
:exclusiveMinimum,
:maxLength,
:minLength,
:pattern,
:maxItems,
:minItems,
:uniqueItems,
:maxProperties,
:minProperties,
:required,
:enum,
:type,
:allOf,
:oneOf,
:anyOf,
:not,
:items,
:properties,
:additionalProperties,
:description,
:format,
:default,
:nullable,
:discriminator,
:readOnly,
:writeOnly,
:xml,
:externalDocs,
:example,
:deprecated,
:"x-struct"
]
@typedoc """
[Schema Object](https://swagger.io/specification/#schemaObject)
The Schema Object allows the definition of input and output data types.
These types can be objects, but also primitives and arrays.
This object is an extended subset of the JSON Schema Specification Wright Draft 00.
## Example
alias OpenApiSpex.Schema
%Schema{
title: "User",
type: :object,
properties: %{
id: %Schema{type: :integer, minimum: 1},
name: %Schema{type: :string, pattern: "[a-zA-Z][a-zA-Z0-9_]+"},
email: %Scheam{type: :string, format: :email},
last_login: %Schema{type: :string, format: :"date-time"}
},
required: [:name, :email],
example: %{
"name" => "joe",
"email" => "joe@gmail.com"
}
}
"""
@type t :: %__MODULE__{
title: String.t | nil,
multipleOf: number | nil,
maximum: number | nil,
exclusiveMaximum: boolean | nil,
minimum: number | nil,
exclusiveMinimum: boolean | nil,
maxLength: integer | nil,
minLength: integer | nil,
pattern: String.t | Regex.t | nil,
maxItems: integer | nil,
minItems: integer | nil,
uniqueItems: boolean | nil,
maxProperties: integer | nil,
minProperties: integer | nil,
required: [atom] | nil,
enum: [String.t] | nil,
type: data_type | nil,
allOf: [Schema.t | Reference.t | module] | nil,
oneOf: [Schema.t | Reference.t | module] | nil,
anyOf: [Schema.t | Reference.t | module] | nil,
not: Schema.t | Reference.t | module | nil,
items: Schema.t | Reference.t | module | nil,
properties: %{atom => Schema.t | Reference.t | module} | nil,
additionalProperties: boolean | Schema.t | Reference.t | module | nil,
description: String.t | nil,
format: String.t | atom | nil,
default: any,
nullable: boolean | nil,
discriminator: Discriminator.t | nil,
readOnly: boolean | nil,
writeOnly: boolean | nil,
xml: Xml.t | nil,
externalDocs: ExternalDocumentation.t | nil,
example: any,
deprecated: boolean | nil,
"x-struct": module | nil
}
@typedoc """
The basic data types supported by openapi.
[Reference](https://swagger.io/docs/specification/data-models/data-types/)
"""
@type data_type :: :string | :number | :integer | :boolean | :array | :object
+ @typedoc """
+ Global schemas lookup by name.
+ """
+ @type schemas :: %{String.t() => t()}
+
@doc """
Cast a simple value to the elixir type defined by a schema.
By default, object types are cast to maps, however if the "x-struct" attribute is set in the schema,
the result will be constructed as an instance of the given struct type.
## Examples
iex> OpenApiSpex.Schema.cast(%Schema{type: :integer}, "123", %{})
{:ok, 123}
iex> {:ok, dt = %DateTime{}} = OpenApiSpex.Schema.cast(%Schema{type: :string, format: :"date-time"}, "2018-04-02T13:44:55Z", %{})
...> dt |> DateTime.to_iso8601()
"2018-04-02T13:44:55Z"
## Casting Polymorphic Schemas
Schemas using `discriminator`, `allOf`, `oneOf`, `anyOf` are cast using the following rules:
- If a `discriminator` is present, cast the properties defined in the base schema, then
cast the result using the schema identified by the discriminator. To avoid infinite recursion,
the discriminator is only dereferenced if the discriminator property has not already been cast.
- Cast the properties using each schema listing in `allOf`. When a property is defined in
multiple `allOf` schemas, it will be cast using the first schema listed containing the property.
- Cast the value using each schema listed in `oneOf`, stopping as soon as a sucessful cast is made.
- Cast the value using each schema listed in `anyOf`, stopping as soon as a succesful cast is made.
"""
def cast(%Schema{type: :boolean}, value, _schemas) when is_boolean(value), do: {:ok, value}
def cast(%Schema{type: :boolean}, value, _schemas) when is_binary(value) do
case value do
"true" -> {:ok, true}
"false" -> {:ok, false}
_ -> {:error, "Invalid boolean: #{inspect(value)}"}
end
end
def cast(%Schema{type: :integer}, value, _schemas) when is_integer(value), do: {:ok, value}
def cast(%Schema{type: :integer}, value, _schemas) when is_binary(value) do
case Integer.parse(value) do
{i, ""} -> {:ok, i}
_ -> {:error, :bad_integer}
end
end
def cast(%Schema{type: :number}, value, _schemas) when is_number(value), do: {:ok, value}
def cast(%Schema{type: :number}, value, _schemas) when is_binary(value) do
case Float.parse(value) do
{x, ""} -> {:ok, x}
_ -> {:error, :bad_float}
end
end
def cast(%Schema{type: :string, format: :"date-time"}, value, _schemas) when is_binary(value) do
case DateTime.from_iso8601(value) do
{:ok, datetime = %DateTime{}, _offset} -> {:ok, datetime}
error = {:error, _reason} -> error
end
end
def cast(%Schema{type: :string, format: :date}, value, _schemas) when is_binary(value) do
case Date.from_iso8601(value) do
{:ok, date = %Date{}} -> {:ok, date}
error = {:error, _reason} -> error
end
end
def cast(%Schema{type: :string}, value, _schemas) when is_binary(value), do: {:ok, value}
def cast(%Schema{type: :array, items: nil}, value, _schemas) when is_list(value), do: {:ok, value}
def cast(%Schema{type: :array}, [], _schemas), do: {:ok, []}
def cast(schema = %Schema{type: :array, items: items_schema}, [x | rest], schemas) do
with {:ok, x_cast} <- cast(items_schema, x, schemas),
{:ok, rest_cast} <- cast(schema, rest, schemas) do
{:ok, [x_cast | rest_cast]}
end
end
def cast(%Schema{type: :array}, value, _schemas) when not is_list(value) do
{:error, "Invalid array: #{inspect(value)}"}
end
def cast(schema = %Schema{type: :object, discriminator: discriminator = %{}}, value = %{}, schemas) do
discriminator_property = String.to_existing_atom(discriminator.propertyName)
already_cast? =
if Map.has_key?(value, discriminator_property) do
{:error, :already_cast}
else
:ok
end
with :ok <- already_cast?,
{:ok, partial_cast} <- cast(%Schema{type: :object, properties: schema.properties}, value, schemas),
{:ok, derived_schema} <- Discriminator.resolve(discriminator, value, schemas),
{:ok, result} <- cast(derived_schema, partial_cast, schemas) do
{:ok, make_struct(result, schema)}
else
{:error, :already_cast} -> {:ok, value}
{:error, reason} -> {:error, reason}
end
end
def cast(schema = %Schema{type: :object, allOf: [first | rest]}, value = %{}, schemas) do
with {:ok, cast_first} <- cast(first, value, schemas),
{:ok, result} <- cast(%{schema | allOf: rest}, cast_first, schemas) do
{:ok, result}
else
{:error, reason} -> {:error, reason}
end
end
def cast(schema = %Schema{type: :object, allOf: []}, value = %{}, schemas) do
cast(%{schema | allOf: nil}, value, schemas)
end
def cast(schema = %Schema{oneOf: [first | rest]}, value, schemas) do
case cast(first, value, schemas) do
{:ok, result} ->
cast(%{schema | oneOf: nil}, result, schemas)
{:error, _reason} ->
cast(%{schema | oneOf: rest}, value, schemas)
end
end
def cast(%Schema{oneOf: []}, _value, _schemas) do
{:error, "Failed to cast to any schema in oneOf"}
end
def cast(schema = %Schema{anyOf: [first | rest]}, value, schemas) do
case cast(first, value, schemas) do
{:ok, result} ->
cast(%{schema | anyOf: nil}, result, schemas)
{:error, _reason} ->
cast(%{schema | anyOf: rest}, value, schemas)
end
end
def cast(%Schema{anyOf: []}, _value, _schemas) do
{:error, "Failed to cast to any schema in anyOf"}
end
def cast(schema = %Schema{type: :object}, value, schemas) when is_map(value) do
schema = %{schema | properties: schema.properties || %{}}
{regular_properties, others} =
value
|> no_struct()
|> Enum.split_with(fn {k, _v} -> is_binary(k) end)
with {:ok, props} <- cast_properties(schema, regular_properties, schemas) do
result = Map.new(others ++ props) |> make_struct(schema)
{:ok, result}
end
end
def cast(ref = %Reference{}, val, schemas), do: cast(Reference.resolve_schema(ref, schemas), val, schemas)
def cast(_additionalProperties = false, val, _schemas) do
{:error, "Unexpected field with value #{inspect(val)}"}
end
def cast(_additionalProperties, val, _schemas), do: {:ok, val}
defp make_struct(val = %_{}, _), do: val
defp make_struct(val, %{"x-struct": nil}), do: val
defp make_struct(val, %{"x-struct": mod}) do
Enum.reduce(val, struct(mod), fn {k, v}, acc ->
Map.put(acc, k, v)
end)
end
defp no_struct(val), do: Map.delete(val, :__struct__)
@spec cast_properties(Schema.t, list, %{String.t => Schema.t}) :: {:ok, list} | {:error, String.t}
defp cast_properties(%Schema{}, [], _schemas), do: {:ok, []}
defp cast_properties(object_schema = %Schema{}, [{key, value} | rest], schemas) do
{name, schema} = Enum.find(
object_schema.properties,
{key, object_schema.additionalProperties},
fn {name, _schema} -> to_string(name) == to_string(key) end)
with {:ok, new_value} <- cast(schema, value, schemas),
{:ok, cast_tail} <- cast_properties(object_schema, rest, schemas) do
{:ok, [{name, new_value} | cast_tail]}
end
end
@doc ~S"""
Validate a value against a Schema.
This expects that the value has already been `cast` to the appropriate data type.
## Examples
iex> OpenApiSpex.Schema.validate(%OpenApiSpex.Schema{type: :integer, minimum: 5}, 3, %{})
{:error, "#: 3 is smaller than minimum 5"}
iex> OpenApiSpex.Schema.validate(%OpenApiSpex.Schema{type: :string, pattern: "(.*)@(.*)"}, "joe@gmail.com", %{})
:ok
iex> OpenApiSpex.Schema.validate(%OpenApiSpex.Schema{type: :string, pattern: "(.*)@(.*)"}, "joegmail.com", %{})
{:error, "#: Value \"joegmail.com\" does not match pattern: (.*)@(.*)"}
"""
@spec validate(Schema.t | Reference.t, any, %{String.t => Schema.t | Reference.t}) :: :ok | {:error, String.t}
def validate(schema, val, schemas), do: validate(schema, val, "#", schemas)
@spec validate(Schema.t | Reference.t, any, String.t, %{String.t => Schema.t | Reference.t}) :: :ok | {:error, String.t}
def validate(ref = %Reference{}, val, path, schemas), do: validate(Reference.resolve_schema(ref, schemas), val, path, schemas)
def validate(%Schema{nullable: true}, nil, _path, _schemas), do: :ok
def validate(%Schema{type: type}, nil, path, _schemas) when not is_nil(type) do
{:error, "#{path}: null value where #{type} expected"}
end
def validate(schema = %Schema{anyOf: valid_schemas}, value, path, schemas) when is_list(valid_schemas) do
if Enum.any?(valid_schemas, &validate(&1, value, path, schemas) == :ok) do
validate(%{schema | anyOf: nil}, value, path, schemas)
else
{:error, "#{path}: Failed to validate against any schema"}
end
end
def validate(schema = %Schema{oneOf: valid_schemas}, value, path, schemas) when is_list(valid_schemas) do
case Enum.count(valid_schemas, &validate(&1, value, path, schemas) == :ok) do
1 -> validate(%{schema | oneOf: nil}, value, path, schemas)
0 -> {:error, "#{path}: Failed to validate against any schema"}
other -> {:error, "#{path}: Validated against #{other} schemas when only one expected"}
end
end
def validate(schema = %Schema{allOf: required_schemas}, value, path, schemas) when is_list(required_schemas) do
required_schemas
|> Enum.map(&validate(&1, value, path, schemas))
|> Enum.reject(& &1 == :ok)
|> Enum.map(fn {:error, msg} -> msg end)
|> case do
[] -> validate(%{schema | allOf: nil}, value, path, schemas)
errors -> {:error, Enum.join(errors, "\n")}
end
end
def validate(schema = %Schema{not: not_schema}, value, path, schemas) when not is_nil(not_schema) do
case validate(not_schema, value, path, schemas) do
{:error, _} -> validate(%{schema | not: nil}, value, path, schemas)
:ok -> {:error, "#{path}: Value is valid for schema given in `not`"}
end
end
def validate(%Schema{enum: options = [_ | _]}, value, path, _schemas) do
case Enum.member?(options, value) do
true -> :ok
_ ->
{:error, "#{path}: Value not in enum: #{inspect(value)}"}
end
end
def validate(schema = %Schema{type: :integer}, value, path, _schemas) when is_integer(value) do
validate_number_types(schema, value, path)
end
def validate(schema = %Schema{type: :number}, value, path, _schemas) when is_number(value) do
validate_number_types(schema, value, path)
end
def validate(schema = %Schema{type: :string}, value, path, _schemas) when is_binary(value) do
validate_string_types(schema, value, path)
end
def validate(%Schema{type: :string, format: :"date-time"}, %DateTime{}, _path, _schemas) do
:ok
end
def validate(%Schema{type: expected_type}, %DateTime{}, path, _schemas) do
{:error, "#{path}: invalid type DateTime where #{expected_type} expected"}
end
def validate(%Schema{type: :string, format: :date}, %Date{}, _path, _schemas) do
:ok
end
def validate(%Schema{type: expected_type}, %Date{}, path, _schemas) do
{:error,
"#{path}: invalid type Date where #{expected_type} expected"}
end
def validate(%Schema{type: :boolean}, value, _path, _schemas) when is_boolean(value), do: :ok
def validate(schema = %Schema{type: :array}, value, path, schemas) when is_list(value) do
with :ok <- validate_max_items(schema, value, path),
:ok <- validate_min_items(schema, value, path),
:ok <- validate_unique_items(schema, value, path),
:ok <- validate_array_items(schema, value, {path, 0}, schemas) do
:ok
end
end
def validate(schema = %Schema{type: :object}, value = %{}, path, schemas) do
schema = %{schema | properties: schema.properties || %{}, required: schema.required || []}
with :ok <- validate_required_properties(schema, value, path),
:ok <- validate_max_properties(schema, value, path),
:ok <- validate_min_properties(schema, value, path),
:ok <- validate_object_properties(schema.properties, MapSet.new(schema.required), value, path, schemas) do
:ok
end
end
def validate(%Schema{type: nil}, _value, _path, _schemas) do
# polymorphic schemas will terminate here after validating against anyOf/oneOf/allOf/not
:ok
end
def validate(%Schema{type: expected_type}, value, path, _schemas) when not is_nil(expected_type) do
{:error,
"#{path}: invalid type #{term_type(value)} where #{expected_type} expected"}
end
@spec term_type(term) :: data_type | nil | String.t()
defp term_type(v) when is_list(v), do: :array
defp term_type(v) when is_map(v), do: :object
defp term_type(v) when is_binary(v), do: :string
defp term_type(v) when is_boolean(v), do: :boolean
defp term_type(v) when is_integer(v), do: :integer
defp term_type(v) when is_number(v), do: :number
defp term_type(v) when is_nil(v), do: nil
defp term_type(v), do: inspect(v)
@spec validate_number_types(Schema.t(), number, String.t()) :: :ok | {:error, String.t()}
defp validate_number_types(schema, value, path) do
with :ok <- validate_multiple(schema, value, path),
:ok <- validate_maximum(schema, value, path),
:ok <- validate_minimum(schema, value, path) do
:ok
end
end
@spec validate_string_types(Schema.t(), String.t(), String.t()) :: :ok | {:error, String.t()}
defp validate_string_types(schema, value, path) do
with :ok <- validate_max_length(schema, value, path),
:ok <- validate_min_length(schema, value, path),
:ok <- validate_pattern(schema, value, path) do
:ok
end
end
@spec validate_multiple(Schema.t(), number, String.t()) :: :ok | {:error, String.t()}
defp validate_multiple(%{multipleOf: nil}, _, _), do: :ok
defp validate_multiple(%{multipleOf: n}, value, _) when (round(value / n) * n == value), do: :ok
defp validate_multiple(%{multipleOf: n}, value, path), do: {:error, "#{path}: #{value} is not a multiple of #{n}"}
@spec validate_maximum(Schema.t, number, String.t) :: :ok | {:error, String.t}
defp validate_maximum(%{maximum: nil}, _val, _path), do: :ok
defp validate_maximum(%{maximum: n, exclusiveMaximum: true}, value, _path) when value < n, do: :ok
defp validate_maximum(%{maximum: n}, value, _path) when value <= n, do: :ok
defp validate_maximum(%{maximum: n}, value, path), do: {:error, "#{path}: #{value} is larger than maximum #{n}"}
@spec validate_minimum(Schema.t, number, String.t) :: :ok | {:error, String.t}
defp validate_minimum(%{minimum: nil}, _val, _path), do: :ok
defp validate_minimum(%{minimum: n, exclusiveMinimum: true}, value, _path) when value > n, do: :ok
defp validate_minimum(%{minimum: n}, value, _path) when value >= n, do: :ok
defp validate_minimum(%{minimum: n}, value, path), do: {:error, "#{path}: #{value} is smaller than minimum #{n}"}
@spec validate_max_length(Schema.t, String.t, String.t) :: :ok | {:error, String.t}
defp validate_max_length(%{maxLength: nil}, _val, _path), do: :ok
defp validate_max_length(%{maxLength: n}, value, path) do
case String.length(value) <= n do
true -> :ok
_ -> {:error, "#{path}: String length is larger than maxLength: #{n}"}
end
end
@spec validate_min_length(Schema.t, String.t, String.t) :: :ok | {:error, String.t}
defp validate_min_length(%{minLength: nil}, _val, _path), do: :ok
defp validate_min_length(%{minLength: n}, value, path) do
case String.length(value) >= n do
true -> :ok
_ -> {:error, "#{path}: String length is smaller than minLength: #{n}"}
end
end
@spec validate_pattern(Schema.t, String.t, String.t) :: :ok | {:error, String.t}
defp validate_pattern(%{pattern: nil}, _val, _path), do: :ok
defp validate_pattern(schema = %{pattern: regex}, val, path) when is_binary(regex) do
with {:ok, regex} <- Regex.compile(regex) do
validate_pattern(%{schema | pattern: regex}, val, path)
end
end
defp validate_pattern(%{pattern: regex = %Regex{}}, val, path) do
case Regex.match?(regex, val) do
true -> :ok
_ -> {:error, "#{path}: Value #{inspect(val)} does not match pattern: #{regex.source}"}
end
end
@spec validate_max_items(Schema.t, list, String.t) :: :ok | {:error, String.t}
defp validate_max_items(%Schema{maxItems: nil}, _val, _path), do: :ok
defp validate_max_items(%Schema{maxItems: n}, value, _path) when length(value) <= n, do: :ok
defp validate_max_items(%Schema{maxItems: n}, value, path) do
{:error, "#{path}: Array length #{length(value)} is larger than maxItems: #{n}"}
end
@spec validate_min_items(Schema.t, list, String.t) :: :ok | {:error, String.t}
defp validate_min_items(%Schema{minItems: nil}, _val, _path), do: :ok
defp validate_min_items(%Schema{minItems: n}, value, _path) when length(value) >= n, do: :ok
defp validate_min_items(%Schema{minItems: n}, value, path) do
{:error, "#{path}: Array length #{length(value)} is smaller than minItems: #{n}"}
end
@spec validate_unique_items(Schema.t, list, String.t) :: :ok | {:error, String.t}
defp validate_unique_items(%Schema{uniqueItems: true}, value, path) do
unique_size =
value
|> MapSet.new()
|> MapSet.size()
case unique_size == length(value) do
true -> :ok
_ -> {:error, "#{path}: Array items must be unique"}
end
end
defp validate_unique_items(_schema, _value, _path), do: :ok
@spec validate_array_items(Schema.t, list, {String.t, integer}, %{String.t => Schema.t}) :: :ok | {:error, String.t}
defp validate_array_items(%Schema{type: :array, items: nil}, value, _path, _schemas) when is_list(value), do: :ok
defp validate_array_items(%Schema{type: :array}, [], _path, _schemas), do: :ok
defp validate_array_items(schema = %Schema{type: :array, items: item_schema}, [x | rest], {path, index}, schemas) do
with :ok <- validate(item_schema, x, "#{path}/#{index}", schemas) do
validate_array_items(schema, rest, {path, index + 1}, schemas)
end
end
@spec validate_required_properties(Schema.t, %{}, String.t) :: :ok | {:error, String.t}
defp validate_required_properties(%Schema{type: :object, required: nil}, _val, _path), do: :ok
defp validate_required_properties(%Schema{type: :object, required: required}, value = %{}, path) do
missing = required -- Map.keys(value)
case missing do
[] -> :ok
_ -> {:error, "#{path}: Missing required properties: #{inspect(missing)}"}
end
end
@spec validate_max_properties(Schema.t, %{}, String.t) :: :ok | {:error, String.t}
defp validate_max_properties(%Schema{type: :object, maxProperties: nil}, _val, _path), do: :ok
defp validate_max_properties(%Schema{type: :object, maxProperties: n}, val, _path) when map_size(val) <= n, do: :ok
defp validate_max_properties(%Schema{type: :object, maxProperties: n}, val, path) do
{:error, "#{path}: Object property count #{map_size(val)} is greater than maxProperties: #{n}"}
end
@spec validate_min_properties(Schema.t, %{}, String.t) :: :ok | {:error, String.t}
defp validate_min_properties(%Schema{type: :object, minProperties: nil}, _val, _path), do: :ok
defp validate_min_properties(%Schema{type: :object, minProperties: n}, val, _path) when map_size(val) >= n, do: :ok
defp validate_min_properties(%Schema{type: :object, minProperties: n}, val, path) do
{:error, "#{path}: Object property count #{map_size(val)} is less than minProperties: #{n}"}
end
@spec validate_object_properties(Enumerable.t, MapSet.t, %{}, String.t, %{String.t => Schema.t | Reference.t}) :: :ok | {:error, String.t}
defp validate_object_properties(properties = %{}, required, value = %{}, path, schemas = %{}) do
properties
|> Enum.filter(fn {name, _schema} -> Map.has_key?(value, name) end)
|> validate_object_properties(required, value, path, schemas)
end
defp validate_object_properties([], _required, _val, _path, _schemas), do: :ok
defp validate_object_properties([{name, schema} | rest], required, value = %{}, path, schemas = %{}) do
property_required = MapSet.member?(required, name)
property_value = Map.get(value, name)
property_path = "#{path}/#{name}"
with :ok <- validate_object_property(schema, property_required, property_value, property_path, schemas),
:ok <- validate_object_properties(rest, required, value, path, schemas) do
:ok
end
end
defp validate_object_property(_schema, false, nil, _path, _schemas), do: :ok
defp validate_object_property(schema, _required, value, path, schemas) do
validate(schema, value, path, schemas)
end
@doc """
Get the names of all properties definied for a schema.
Includes all properties directly defined in the schema, and all schemas
included in the `allOf` list.
"""
def properties(schema = %Schema{type: :object, properties: properties = %{}}) do
Map.keys(properties) ++ properties(%{schema | properties: nil})
end
def properties(%Schema{allOf: schemas}) when is_list(schemas) do
Enum.flat_map(schemas, &properties/1) |> Enum.uniq()
end
def properties(schema_module) when is_atom(schema_module) do
properties(schema_module.schema())
end
def properties(_), do: []
end
diff --git a/test/cast/error_test.exs b/test/cast/error_test.exs
new file mode 100644
index 0000000..dfe35e8
--- /dev/null
+++ b/test/cast/error_test.exs
@@ -0,0 +1,11 @@
+defmodule OpenApiSpex.Cast.ErrorTest do
+ use ExUnit.Case
+ alias OpenApiSpex.Cast.Error
+
+ describe "path_to_string/1" do
+ test "with empty path" do
+ error = %Error{path: []}
+ assert Error.path_to_string(error) == "/"
+ end
+ end
+end
diff --git a/test/cast/object_test.exs b/test/cast/object_test.exs
index 7f89298..d6d01cf 100644
--- a/test/cast/object_test.exs
+++ b/test/cast/object_test.exs
@@ -1,125 +1,137 @@
defmodule OpenApiSpex.ObjectTest do
use ExUnit.Case
alias OpenApiSpex.{Cast, Schema}
alias OpenApiSpex.Cast.{Object, Error}
defp cast(ctx), do: Object.cast(struct(Cast, ctx))
describe "cast/3" do
test "when input is not an object" do
schema = %Schema{type: :object}
assert {:error, [error]} = cast(value: ["hello"], schema: schema)
assert %Error{} = error
assert error.reason == :invalid_type
assert error.value == ["hello"]
end
test "input map can have atom keys" do
schema = %Schema{type: :object}
assert {:ok, map} = cast(value: %{one: "one"}, schema: schema)
assert map == %{one: "one"}
end
test "converting string keys to atom keys when properties are defined" do
schema = %Schema{
type: :object,
properties: %{
one: nil
}
}
assert {:ok, map} = cast(value: %{"one" => "one"}, schema: schema)
assert map == %{one: "one"}
end
test "properties:nil, given unknown input property" do
schema = %Schema{type: :object}
assert cast(value: %{}, schema: schema) == {:ok, %{}}
assert cast(value: %{"unknown" => "hello"}, schema: schema) ==
{:ok, %{"unknown" => "hello"}}
end
test "with empty schema properties, given unknown input property" do
schema = %Schema{type: :object, properties: %{}}
assert cast(value: %{}, schema: schema) == {:ok, %{}}
assert {:error, [error]} = cast(value: %{"unknown" => "hello"}, schema: schema)
assert %Error{} = error
end
test "with schema properties set, given known input property" do
schema = %Schema{
type: :object,
properties: %{age: nil}
}
assert cast(value: %{}, schema: schema) == {:ok, %{}}
assert cast(value: %{"age" => "hello"}, schema: schema) == {:ok, %{age: "hello"}}
end
+ test "unexpected field" do
+ schema = %Schema{
+ type: :object,
+ properties: %{}
+ }
+
+ assert {:error, [error]} = cast(value: %{foo: "foo"}, schema: schema)
+ assert %Error{} = error
+ assert error.reason == :unexpected_field
+ assert error.path == ["foo"]
+ end
+
test "required fields" do
schema = %Schema{
type: :object,
properties: %{age: nil, name: nil},
required: [:age, :name]
}
assert {:error, [error, error2]} = cast(value: %{}, schema: schema)
assert %Error{} = error
assert error.reason == :missing_field
assert error.name == :age
assert error.path == [:age]
assert error2.reason == :missing_field
assert error2.name == :name
assert error2.path == [:name]
end
test "cast property against schema" do
schema = %Schema{
type: :object,
properties: %{age: %Schema{type: :integer}}
}
assert cast(value: %{}, schema: schema) == {:ok, %{}}
assert {:error, [error]} = cast(value: %{"age" => "hello"}, schema: schema)
assert %Error{} = error
assert error.reason == :invalid_type
assert error.path == [:age]
end
defmodule User do
defstruct [:name]
end
test "optionally casts to struct" do
schema = %Schema{
type: :object,
"x-struct": User,
properties: %{
name: %Schema{type: :string}
}
}
assert {:ok, user} = cast(value: %{"name" => "Name"}, schema: schema)
assert user == %User{name: "Name"}
end
test "validates maxProperties" do
schema = %Schema{
type: :object,
properties: %{
one: nil,
two: nil
},
maxProperties: 1
}
assert {:error, [error]} = cast(value: %{one: "one", two: "two"}, schema: schema)
assert %Error{} = error
assert error.reason == :max_properties
assert {:ok, _} = cast(value: %{one: "one"}, schema: schema)
end
end
end
diff --git a/test/operation2_test.exs b/test/operation2_test.exs
new file mode 100644
index 0000000..b8c8ace
--- /dev/null
+++ b/test/operation2_test.exs
@@ -0,0 +1,187 @@
+defmodule OpenApiSpex.Operation2Test do
+ use ExUnit.Case
+ alias OpenApiSpex.{Operation, Operation2, Schema}
+ alias OpenApiSpex.Cast.Error
+
+ defmodule SchemaFixtures do
+ @user %Schema{
+ type: :object,
+ properties: %{
+ user: %Schema{
+ type: :object,
+ properties: %{
+ email: %Schema{type: :string}
+ }
+ }
+ }
+ }
+ @user_list %Schema{
+ type: :array,
+ items: @user
+ }
+ @schemas %{"User" => @user, "UserList" => @user_list}
+
+ def user, do: @user
+ def user_list, do: @user_list
+ def schemas, do: @schemas
+ end
+
+ defmodule OperationFixtures do
+ @user_index %Operation{
+ operationId: "UserController.index",
+ parameters: [
+ Operation.parameter(:name, :query, :string, "Filter by user name"),
+ Operation.parameter(:age, :query, :integer, "User's age")
+ ],
+ responses: %{
+ 200 => Operation.response("User", "application/json", SchemaFixtures.user())
+ }
+ }
+
+ def user_index, do: @user_index
+
+ @create_user %Operation{
+ operationId: "UserController.create",
+ parameters: [
+ Operation.parameter(:name, :query, :string, "Filter by user name")
+ ],
+ requestBody:
+ Operation.request_body("request body", "application/json", SchemaFixtures.user(),
+ required: true
+ ),
+ responses: %{
+ 200 => Operation.response("User list", "application/json", SchemaFixtures.user_list())
+ }
+ }
+
+ def create_user, do: @create_user
+ end
+
+ defmodule SpecModule do
+ def spec do
+ paths = %{
+ "/users" => %{
+ "post" => OperationFixtures.create_user()
+ }
+ }
+
+ %OpenApiSpex.OpenApi{
+ info: nil,
+ paths: paths,
+ components: %{
+ schemas: SchemaFixtures.schemas()
+ }
+ }
+ end
+ end
+
+ defmodule RenderError do
+ def init(_) do
+ nil
+ end
+
+ def call(_conn, _errors) do
+ raise "should not have errors"
+ end
+ end
+
+ describe "cast/4" do
+ test "cast request body" do
+ conn = create_conn(%{"user" => %{"email" => "foo@bar.com"}})
+
+ assert {:ok, conn} =
+ Operation2.cast(
+ OperationFixtures.create_user(),
+ conn,
+ "application/json",
+ SchemaFixtures.schemas()
+ )
+
+ assert %Plug.Conn{} = conn
+ end
+
+ test "cast request body - invalid data type" do
+ conn = create_conn(%{"user" => %{"email" => 123}})
+
+ assert {:error, errors} =
+ Operation2.cast(
+ OperationFixtures.create_user(),
+ conn,
+ "application/json",
+ SchemaFixtures.schemas()
+ )
+
+ assert [error] = errors
+ assert %Error{} = error
+ assert error.reason == :invalid_type
+ end
+
+ test "validate undefined query param name" do
+ query_params = %{"unknown" => "asdf"}
+
+ assert {:error, [error]} = do_index_cast(query_params)
+
+ assert %Error{} = error
+ assert error.reason == :unexpected_field
+ assert error.name == "unknown"
+ assert error.path == ["unknown"]
+ end
+
+ test "validate invalid data type for query param" do
+ query_params = %{"age" => "asdf"}
+ assert {:error, [error]} = do_index_cast(query_params)
+ assert %Error{} = error
+ assert error.reason == :invalid_type
+ assert error.type == :integer
+ assert error.value == "asdf"
+ end
+
+ test "validate missing required query param" do
+ parameter =
+ Operation.parameter(:name, :query, :string, "Filter by user name", required: true)
+
+ operation = %{OperationFixtures.user_index() | parameters: [parameter]}
+
+ assert {:error, [error]} = do_index_cast(%{}, operation: operation)
+ assert %Error{} = error
+ assert error.reason == :missing_field
+ assert error.name == :name
+ end
+
+ defp do_index_cast(query_params, opts \\ []) do
+ conn =
+ :get
+ |> Plug.Test.conn("/api/users?" <> URI.encode_query(query_params))
+ |> Plug.Conn.put_req_header("content-type", "application/json")
+ |> Plug.Conn.fetch_query_params()
+ |> build_params()
+
+ operation = opts[:operation] || OperationFixtures.user_index()
+
+ Operation2.cast(
+ operation,
+ conn,
+ "application/json",
+ SchemaFixtures.schemas()
+ )
+ end
+
+ defp create_conn(body_params) do
+ :post
+ |> Plug.Test.conn("/api/users")
+ |> Plug.Conn.put_req_header("content-type", "application/json")
+ |> Plug.Conn.fetch_query_params()
+ |> Map.put(:body_params, body_params)
+ |> build_params()
+ end
+
+ defp build_params(conn) do
+ params =
+ conn.path_params
+ |> Map.merge(conn.query_params)
+ |> Map.merge(conn.body_params)
+
+ %{conn | params: params}
+ end
+ end
+end

File Metadata

Mime Type
text/x-diff
Expires
Sat, Nov 30, 2:27 PM (1 d, 16 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
41442
Default Alt Text
(63 KB)

Event Timeline