Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F115360
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
23 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/lib/open_api_spex.ex b/lib/open_api_spex.ex
index bbae919..f57c3dc 100644
--- a/lib/open_api_spex.ex
+++ b/lib/open_api_spex.ex
@@ -1,217 +1,225 @@
defmodule OpenApiSpex do
@moduledoc """
Provides the entry-points for defining schemas, validating and casting.
"""
alias OpenApiSpex.{
+ Components,
OpenApi,
Operation,
Operation2,
Reference,
Schema,
SchemaException,
SchemaResolver
}
alias OpenApiSpex.Cast.Error
@doc """
Adds schemas to the api spec from the modules specified in the Operations.
Eg, if the response schema for an operation is defined with:
responses: %{
200 => Operation.response("User", "application/json", UserResponse)
}
Then the `UserResponse.schema()` function will be called to load the schema, and
a `Reference` to the loaded schema will be used in the operation response.
See `OpenApiSpex.schema` macro for a convenient syntax for defining schema modules.
"""
@spec resolve_schema_modules(OpenApi.t()) :: OpenApi.t()
def resolve_schema_modules(spec = %OpenApi{}) do
SchemaResolver.resolve_schema_modules(spec)
end
def cast_and_validate(
spec = %OpenApi{},
operation = %Operation{},
conn = %Plug.Conn{},
content_type \\ nil
) do
Operation2.cast(operation, conn, content_type, spec.components)
end
@doc """
Cast params to conform to a `OpenApiSpex.Schema`.
See `OpenApiSpex.Schema.cast/3` for additional examples and details.
"""
@spec cast(OpenApi.t(), Schema.t() | Reference.t(), any) :: {:ok, any} | {:error, String.t()}
def cast(spec = %OpenApi{}, schema = %Schema{}, params) do
Schema.cast(schema, params, spec.components.schemas)
end
def cast(spec = %OpenApi{}, schema = %Reference{}, params) do
Schema.cast(schema, params, spec.components.schemas)
end
@doc """
Cast all params in `Plug.Conn` to conform to the schemas for `OpenApiSpex.Operation`.
Returns `{:ok, Plug.Conn.t}` with `params` and `body_params` fields updated if successful,
or `{:error, reason}` if casting fails.
`content_type` may optionally be supplied to select the `requestBody` schema.
"""
@spec cast(OpenApi.t(), Operation.t(), Plug.Conn.t(), content_type | nil) ::
{:ok, Plug.Conn.t()} | {:error, String.t()}
when content_type: String.t()
def cast(spec = %OpenApi{}, operation = %Operation{}, conn = %Plug.Conn{}, content_type \\ nil) do
Operation.cast(operation, conn, content_type, spec.components.schemas)
end
@doc """
Validate params against `OpenApiSpex.Schema`.
See `OpenApiSpex.Schema.validate/3` for examples of error messages.
"""
@spec validate(OpenApi.t(), Schema.t() | Reference.t(), any) :: :ok | {:error, String.t()}
def validate(spec = %OpenApi{}, schema = %Schema{}, params) do
Schema.validate(schema, params, spec.components.schemas)
end
def validate(spec = %OpenApi{}, schema = %Reference{}, params) do
Schema.validate(schema, params, spec.components.schemas)
end
@doc """
Validate all params in `Plug.Conn` against `OpenApiSpex.Operation` `parameter` and `requestBody` schemas.
`content_type` may be optionally supplied to select the `requestBody` schema.
"""
@spec validate(OpenApi.t(), Operation.t(), Plug.Conn.t(), content_type | nil) ::
:ok | {:error, String.t()}
when content_type: String.t()
def validate(
spec = %OpenApi{},
operation = %Operation{},
conn = %Plug.Conn{},
content_type \\ nil
) do
Operation.validate(operation, conn, content_type, spec.components.schemas)
end
def path_to_string(%Error{} = error) do
Error.path_to_string(error)
end
@doc """
Declares a struct based `OpenApiSpex.Schema`
- defines the schema/0 callback
- ensures the schema is linked to the module by "x-struct" extension property
- defines a struct with keys matching the schema properties
- defines a @type `t` for the struct
- derives a `Jason.Encoder` and/or `Poison.Encoder` for the struct
See `OpenApiSpex.Schema` for additional examples and details.
## Example
require OpenApiSpex
defmodule User do
OpenApiSpex.schema %{
title: "User",
description: "A user of the app",
type: :object,
properties: %{
id: %Schema{type: :integer, description: "User ID"},
name: %Schema{type: :string, description: "User name", pattern: ~r/[a-zA-Z][a-zA-Z0-9_]+/},
email: %Schema{type: :string, description: "Email address", format: :email},
inserted_at: %Schema{type: :string, description: "Creation timestamp", format: :'date-time'},
updated_at: %Schema{type: :string, description: "Update timestamp", format: :'date-time'}
},
required: [:name, :email],
example: %{
"id" => 123,
"name" => "Joe User",
"email" => "joe@gmail.com",
"inserted_at" => "2017-09-12T12:34:55Z",
"updated_at" => "2017-09-13T10:11:12Z"
}
}
end
"""
defmacro schema(body) do
quote do
@compile {:report_warnings, false}
@behaviour OpenApiSpex.Schema
@schema struct(
OpenApiSpex.Schema,
Map.put(Map.delete(unquote(body), :__struct__), :"x-struct", __MODULE__)
)
def schema, do: @schema
@derive Enum.filter([Poison.Encoder, Jason.Encoder], &Code.ensure_loaded?/1)
defstruct Schema.properties(@schema)
@type t :: %__MODULE__{}
Map.from_struct(@schema) |> OpenApiSpex.validate_compiled_schema()
end
end
@doc """
Validate the compiled schema's properties to ensure the schema is not improperly
defined. Only errors which would cause a given schema to _always_ fail should be
raised here.
"""
def validate_compiled_schema(schema) do
Enum.each(schema, fn prop_and_val ->
:ok = validate_compiled_schema(prop_and_val, schema)
end)
end
def validate_compiled_schema({_, %Schema{} = schema}, _parent) do
validate_compiled_schema(schema)
end
@doc """
Used for validating the schema at compile time, otherwise we're forced
to raise errors for improperly defined schemas at runtime.
"""
def validate_compiled_schema({:discriminator, %{propertyName: property, mapping: _}}, %{
anyOf: schemas
})
when is_list(schemas) do
Enum.each(schemas, fn schema ->
case schema do
%Schema{title: title} when is_binary(title) -> :ok
_ -> error!(:discriminator_schema_missing_title, schema, property_name: property)
end
end)
end
def validate_compiled_schema({:discriminator, %{propertyName: _, mapping: _}}, schema) do
case {schema.anyOf, schema.allOf, schema.oneOf} do
{nil, nil, nil} ->
error!(:discriminator_missing_composite_key, schema)
_ ->
:ok
end
end
def validate_compiled_schema({_property, _value}, _schema), do: :ok
@doc """
Raises compile time errors for improperly defined schemas.
"""
@spec error!(atom(), Schema.t(), keyword()) :: no_return()
@spec error!(atom(), Schema.t()) :: no_return()
def error!(error, schema, details \\ []) do
raise SchemaException, %{error: error, schema: schema, details: details}
end
+
+ @doc """
+ Resolve a schema or reference to a schema.
+ """
+ @spec resolve_schema(Schema.t() | Reference.t(), Components.schemas_map()) :: Schema.t()
+ def resolve_schema(%Schema{} = schema, _), do: schema
+ def resolve_schema(%Reference{} = ref, schemas), do: Reference.resolve_schema(ref, schemas)
end
diff --git a/lib/open_api_spex/cast/all_of.ex b/lib/open_api_spex/cast/all_of.ex
index 2a38690..169d031 100644
--- a/lib/open_api_spex/cast/all_of.ex
+++ b/lib/open_api_spex/cast/all_of.ex
@@ -1,20 +1,23 @@
defmodule OpenApiSpex.Cast.AllOf do
@moduledoc false
alias OpenApiSpex.Cast
def cast(ctx),
do: cast_all_of(ctx, nil)
defp cast_all_of(%{schema: %{type: _, allOf: [schema | remaining]}} = ctx, result) do
- with {:ok, value} <- Cast.cast(%{ctx | schema: schema}) do
+ schema = OpenApiSpex.resolve_schema(schema, ctx.schemas)
+ relaxed_schema = %{schema | additionalProperties: true}
+
+ with {:ok, value} <- Cast.cast(%{ctx | schema: relaxed_schema}) do
new_schema = %{ctx.schema | allOf: remaining}
cast_all_of(%{ctx | schema: new_schema}, result || {:ok, value})
else
_ -> Cast.error(ctx, {:all_of, to_string(schema.title || schema.type)})
end
end
defp cast_all_of(_, {:ok, result}) do
{:ok, result}
end
end
diff --git a/lib/open_api_spex/cast/any_of.ex b/lib/open_api_spex/cast/any_of.ex
index fd19883..a0cc0b6 100644
--- a/lib/open_api_spex/cast/any_of.ex
+++ b/lib/open_api_spex/cast/any_of.ex
@@ -1,42 +1,44 @@
defmodule OpenApiSpex.Cast.AnyOf do
@moduledoc false
alias OpenApiSpex.Cast
def cast(ctx, failed_schemas \\ [])
def cast(%_{schema: %{type: _, anyOf: []}} = ctx, failed_schemas) do
- Cast.error(ctx, {:any_of, error_message(failed_schemas)})
+ Cast.error(ctx, {:any_of, error_message(failed_schemas, ctx.schemas)})
end
def cast(
%{schema: %{type: _, anyOf: [schema | schemas]}} = ctx,
failed_schemas
) do
with {:ok, value} <- Cast.cast(%{ctx | schema: schema}) do
{:ok, value}
else
_ ->
new_schema = %{ctx.schema | anyOf: schemas}
cast(%{ctx | schema: new_schema}, [schema | failed_schemas])
end
end
## Private functions
- defp error_message([]) do
+ defp error_message([], _) do
"[] (no schemas provided)"
end
- defp error_message(failed_schemas) do
+ defp error_message(failed_schemas, schemas) do
for schema <- failed_schemas do
+ schema = OpenApiSpex.resolve_schema(schema, schemas)
+
case schema do
%{title: title, type: type} when not is_nil(title) ->
"Schema(title: #{inspect(title)}, type: #{inspect(type)})"
%{type: type} ->
"Schema(type: #{inspect(type)})"
end
end
|> Enum.join(", ")
end
end
diff --git a/lib/open_api_spex/cast/object.ex b/lib/open_api_spex/cast/object.ex
index 45b7bcc..9c83a93 100644
--- a/lib/open_api_spex/cast/object.ex
+++ b/lib/open_api_spex/cast/object.ex
@@ -1,123 +1,128 @@
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 <- check_min_properties(ctx),
{:ok, value} <- cast_properties(%{ctx | schema: schema_properties}) do
ctx = to_struct(%{ctx | value: value})
{:ok, ctx}
end
end
+ # When additionalProperties is true, extra properties are allowed in input
+ defp check_unrecognized_properties(%{schema: %{additionalProperties: true}}, _expected_keys) do
+ :ok
+ 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 check_min_properties(%{schema: %{minProperties: min_properties}} = ctx)
when is_integer(min_properties) do
count = ctx.value |> Map.keys() |> length()
if count < min_properties do
Cast.error(ctx, {:min_properties, min_properties, count})
else
:ok
end
end
defp check_min_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/one_of.ex b/lib/open_api_spex/cast/one_of.ex
index 5306f73..b5a7623 100644
--- a/lib/open_api_spex/cast/one_of.ex
+++ b/lib/open_api_spex/cast/one_of.ex
@@ -1,46 +1,48 @@
defmodule OpenApiSpex.Cast.OneOf do
@moduledoc false
alias OpenApiSpex.Cast
def cast(%_{schema: %{type: _, oneOf: []}} = ctx) do
error(ctx, [])
end
def cast(%{schema: %{type: _, oneOf: schemas}} = ctx) do
castable_schemas =
Enum.reduce(schemas, {[], 0}, fn schema, {results, count} ->
+ schema = OpenApiSpex.resolve_schema(schema, ctx.schemas)
+
case Cast.cast(%{ctx | schema: %{schema | anyOf: nil}}) do
{:ok, value} -> {[{:ok, value, schema} | results], count + 1}
_ -> {results, count}
end
end)
case castable_schemas do
{[{:ok, value, _schema}], 1} -> {:ok, value}
{failed_schemas, _count} -> error(ctx, failed_schemas)
end
end
## Private functions
defp error(ctx, failed_schemas) do
Cast.error(ctx, {:one_of, error_message(failed_schemas)})
end
defp error_message([]) do
"[] (no schemas provided)"
end
defp error_message(failed_schemas) do
for {:ok, _value, schema} <- failed_schemas do
case schema do
%{title: title, type: type} when not is_nil(title) ->
"Schema(title: #{inspect(title)}, type: #{inspect(type)})"
%{type: type} ->
"Schema(type: #{inspect(type)})"
end
end
|> Enum.join(", ")
end
end
diff --git a/lib/open_api_spex/components.ex b/lib/open_api_spex/components.ex
index 2617f52..8a87878 100644
--- a/lib/open_api_spex/components.ex
+++ b/lib/open_api_spex/components.ex
@@ -1,40 +1,51 @@
defmodule OpenApiSpex.Components do
@moduledoc """
Defines the `OpenApiSpex.Components.t` type.
"""
alias OpenApiSpex.{
- Schema, Reference, Response, Parameter, Example,
- RequestBody, Header, SecurityScheme, Link, Callback,
+ Schema,
+ Reference,
+ Response,
+ Parameter,
+ Example,
+ RequestBody,
+ Header,
+ SecurityScheme,
+ Link,
+ Callback,
Components
}
+
defstruct [
:schemas,
:responses,
:parameters,
:examples,
:requestBodies,
:headers,
:securitySchemes,
:links,
- :callbacks,
+ :callbacks
]
+ @type schemas_map :: %{String.t() => Schema.t() | Reference.t()}
+
@typedoc """
[Components Object](https://swagger.io/specification/#componentsObject)
Holds a set of reusable objects for different aspects of the OAS.
All objects defined within the components object will have no effect on the API unless
they are explicitly referenced from properties outside the components object.
"""
@type t :: %Components{
- schemas: %{String.t => Schema.t | Reference.t} | nil,
- responses: %{String.t => Response.t | Reference.t} | nil,
- parameters: %{String.t => Parameter.t | Reference.t} | nil,
- examples: %{String.t => Example.t | Reference.t} | nil,
- requestBodies: %{String.t => RequestBody.t | Reference.t} | nil,
- headers: %{String.t => Header.t | Reference.t} | nil,
- securitySchemes: %{String.t => SecurityScheme.t | Reference.t} | nil,
- links: %{String.t => Link.t | Reference.t} | nil,
- callbacks: %{String.t => Callback.t | Reference.t} | nil
- }
+ schemas: schemas_map | nil,
+ responses: %{String.t() => Response.t() | Reference.t()} | nil,
+ parameters: %{String.t() => Parameter.t() | Reference.t()} | nil,
+ examples: %{String.t() => Example.t() | Reference.t()} | nil,
+ requestBodies: %{String.t() => RequestBody.t() | Reference.t()} | nil,
+ headers: %{String.t() => Header.t() | Reference.t()} | nil,
+ securitySchemes: %{String.t() => SecurityScheme.t() | Reference.t()} | nil,
+ links: %{String.t() => Link.t() | Reference.t()} | nil,
+ callbacks: %{String.t() => Callback.t() | Reference.t()} | nil
+ }
end
diff --git a/lib/open_api_spex/reference.ex b/lib/open_api_spex/reference.ex
index b7ec073..d1069a2 100644
--- a/lib/open_api_spex/reference.ex
+++ b/lib/open_api_spex/reference.ex
@@ -1,38 +1,40 @@
defmodule OpenApiSpex.Reference do
@moduledoc """
Defines the `OpenApiSpex.Reference.t` type.
"""
- alias OpenApiSpex.Reference
+ alias OpenApiSpex.{Components, Reference}
@enforce_keys :"$ref"
defstruct [
:"$ref"
]
@typedoc """
[Reference Object](https://swagger.io/specification/#referenceObject)
A simple object to allow referencing other components in the specification, internally and externally.
The Reference Object is defined by JSON Reference and follows the same structure, behavior and rules.
"""
@type t :: %Reference{
- "$ref": String.t
- }
+ "$ref": String.t()
+ }
@doc """
Resolve a `Reference` to the `Schema` it refers to.
## Examples
iex> alias OpenApiSpex.{Reference, Schema}
...> schemas = %{"user" => %Schema{title: "user", type: :object}}
...> Reference.resolve_schema(%Reference{"$ref": "#/components/schemas/user"}, schemas)
%OpenApiSpex.Schema{type: :object, title: "user"}
"""
- @spec resolve_schema(Reference.t, %{String.t => Schema.t}) :: Schema.t | nil
- def resolve_schema(%Reference{"$ref": "#/components/schemas/" <> name}, schemas), do: schemas[name]
+ @spec resolve_schema(Reference.t(), Components.schema_map()) :: Schema.t() | nil
+ def resolve_schema(%Reference{"$ref": "#/components/schemas/" <> name}, schemas),
+ do: schemas[name]
- @spec resolve_parameter(Reference.t, %{String.t => Parameter.t}) :: Parameter.t | nil
- def resolve_parameter(%Reference{"$ref": "#/components/parameters/" <> name}, parameters), do: parameters[name]
+ @spec resolve_parameter(Reference.t(), %{String.t() => Parameter.t()}) :: Parameter.t() | nil
+ def resolve_parameter(%Reference{"$ref": "#/components/parameters/" <> name}, parameters),
+ do: parameters[name]
end
diff --git a/test/cast/all_of_test.exs b/test/cast/all_of_test.exs
index 8a00b35..236a4ab 100644
--- a/test/cast/all_of_test.exs
+++ b/test/cast/all_of_test.exs
@@ -1,30 +1,36 @@
defmodule OpenApiSpex.CastAllOfTest do
use ExUnit.Case
alias OpenApiSpex.{Cast, Schema}
alias OpenApiSpex.Cast.{Error, AllOf}
+ alias OpenApiSpex.Test.Assertions2
defp cast(ctx), do: AllOf.cast(struct(Cast, ctx))
describe "cast/1" do
test "allOf" do
schema = %Schema{allOf: [%Schema{type: :integer}, %Schema{type: :string}]}
assert {:ok, 1} = cast(value: "1", schema: schema)
end
test "allOf, uncastable schema" do
schema = %Schema{allOf: [%Schema{type: :integer}, %Schema{type: :string}]}
assert {:error, [error]} = cast(value: [:whoops], schema: schema)
assert Error.message(error) ==
"Failed to cast value as integer. Value must be castable using `allOf` schemas listed."
schema_with_title = %Schema{allOf: [%Schema{title: "Age", type: :integer}]}
assert {:error, [error_with_schema_title]} =
cast(value: [:nopes], schema: schema_with_title)
assert Error.message(error_with_schema_title) ==
"Failed to cast value as Age. Value must be castable using `allOf` schemas listed."
end
+
+ test "a more sophisticated example" do
+ dog = %{"bark" => "woof", "pet_type" => "Dog"}
+ Assertions2.assert_schema(dog, "Dog", OpenApiSpexTest.ApiSpec.spec())
+ end
end
end
diff --git a/test/cast/one_of_test.exs b/test/cast/one_of_test.exs
index 284d72c..381e933 100644
--- a/test/cast/one_of_test.exs
+++ b/test/cast/one_of_test.exs
@@ -1,31 +1,37 @@
defmodule OpenApiSpex.CastOneOfTest do
use ExUnit.Case
alias OpenApiSpex.{Cast, Schema}
alias OpenApiSpex.Cast.{Error, OneOf}
+ alias OpenApiSpex.Test.Assertions2
defp cast(ctx), do: OneOf.cast(struct(Cast, ctx))
describe "cast/1" do
test "oneOf" do
schema = %Schema{oneOf: [%Schema{type: :integer}, %Schema{type: :string}]}
assert {:ok, "hello"} = cast(value: "hello", schema: schema)
end
test "oneOf, more than one matching schema" do
schema = %Schema{oneOf: [%Schema{type: :integer}, %Schema{type: :string}]}
assert {:error, [error]} = cast(value: "1", schema: schema)
assert error.reason == :one_of
assert Error.message(error) ==
"Failed to cast value to one of: Schema(type: :string), Schema(type: :integer)"
end
test "oneOf, no castable schema" do
schema = %Schema{oneOf: [%Schema{type: :string}]}
assert {:error, [error]} = cast(value: 1, schema: schema)
assert error.reason == :one_of
assert Error.message(error) == "Failed to cast value to one of: [] (no schemas provided)"
end
+
+ test "a more sophisticated case" do
+ dog = %{"bark" => "woof", "pet_type" => "Dog"}
+ Assertions2.assert_schema(dog, "CatOrDog", OpenApiSpexTest.ApiSpec.spec())
+ end
end
end
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Thu, Nov 28, 12:24 AM (1 d, 18 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
40775
Default Alt Text
(23 KB)
Attached To
Mode
R22 open_api_spex
Attached
Detach File
Event Timeline
Log In to Comment