Page MenuHomePhorge

No OneTemporary

Size
34 KB
Referenced Files
None
Subscribers
None
diff --git a/README.md b/README.md
index 0102857..efaed8e 100644
--- a/README.md
+++ b/README.md
@@ -1,199 +1,210 @@
# Open API Spex
Add Open API Specification 3 (formerly swagger) to Plug applications.
## Installation
The package can be installed by adding `open_api_spex` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:open_api_spex, github: "mbuhot/open_api_spex"}
]
end
```
## Generating an API spec
Start by adding an `ApiSpec` module to your application.
```elixir
defmodule MyApp.ApiSpec do
alias OpenApiSpex.{OpenApi, Server, Info, Paths}
def spec do
%OpenApi{
servers: [
# Populate the Server info from a phoenix endpoint
Server.from_endpoint(MyAppWeb.Endpoint, otp_app: :my_app)
],
info: %Info{
title: "My App",
version: "1.0"
},
# populate the paths from a phoenix router
paths: Paths.from_router(MyAppWeb.Router)
}
|> OpenApiSpex.resolve_schema_modules() # discover request/response schemas from path specs
end
end
```
For each plug (controller) that will handle api requests, add an `open_api_operation` callback.
It will be passed the plug opts that were declared in the router, this will be the action for a phoenix controller.
```elixir
defmodule MyApp.UserController do
alias OpenApiSpex.Operation
@spec open_api_operation(any) :: Operation.t
def open_api_operation(action), do: apply(__MODULE__, :"#{action}_operation", [])
@spec show_operation() :: Operation.t
def show_operation() do
%Operation{
tags: ["users"],
summary: "Show user",
description: "Show a user by ID",
operationId: "UserController.show",
parameters: [
Operation.parameter(:id, :path, :integer, "User ID", example: 123)
],
responses: %{
200 => Operation.response("User", "application/json", Schemas.UserResponse)
}
}
end
def show(conn, %{"id" => id}) do
{:ok, user} = MyApp.Users.find_by_id(id)
json(conn, 200, user)
end
end
```
Declare the JSON schemas for request/response bodies in a `Schemas` module:
```elixir
defmodule MyApp.Schemas do
alias OpenApiSpex.Schema
defmodule User do
def schema do
%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"},
email: %Schema{type: :string, description: "Email address", format: :email},
inserted_at: %Schema{type: :string, description: "Creation timestamp", format: :datetime},
updated_at: %Schema{type: :string, description: "Update timestamp", format: :datetime}
}
}
end
end
defmodule UserResponse do
def schema do
%Schema{
title: "UserResponse",
description: "Response schema for single user",
type: :object,
properties: %{
data: User
}
}
end
end
end
```
Now you can create a mix task to write the swagger file to disk:
```elixir
defmodule Mix.Tasks.MyApp.OpenApiSpec do
def run([output_file]) do
json =
MyApp.ApiSpec.spec()
|> Poison.encode!(pretty: true)
:ok = File.write!(output_file, json)
end
end
```
Generate the file with: `mix myapp.openapispec spec.json`
## Serving the API Spec from a Controller
To serve the API spec from your application, first add the `OpenApiSpex.Plug.PutApiSpec` plug somewhere in the pipeline.
```elixir
pipeline :api do
plug OpenApiSpex.Plug.PutApiSpec, module: MyApp.ApiSpec
end
```
Now the spec will be available for use in downstream plugs.
The `OpenApiSpex.Plug.RenderSpec` plug will render the spec as JSON:
```elixir
scope "/api" do
pipe_through :api
resources "/users", MyApp.UserController, only: [:create, :index, :show]
get "/openapi", OpenApiSpex.Plug.RenderSpec, []
end
```
## Use the API Spec to cast params
Add the `OpenApiSpex.Plug.Cast` plug to a controller to cast the request parameters to elixir types defined by the operation schema.
```elixir
plug OpenApiSpex.Plug.Cast, operation_id: "UserController.show"
```
The `operation_id` can be inferred when used from a Phoenix controller from the contents of `conn.private`.
```elixir
defmodule MyApp.UserController do
use MyAppWeb, :controller
alias OpenApiSpex.Operation
plug OpenApiSpex.Plug.Cast
def open_api_operation(action) do
apply(__MODULE__, "#{action}_operation", [])
end
def create_operation do
import Operation
%Operation{
tags: ["users"],
summary: "Create user",
description: "Create a user",
operationId: "UserController.create",
parameters: [],
requestBody: request_body("The user attributes", "application/json", Schemas.UserRequest),
responses: %{
201 => response("User", "application/json", Schemas.UserResponse)
}
}
end
def create(conn, %{user: %{name: name, email: email, birthday: birthday = %Date{}}}) do
# params will have atom keys with values cast to standard elixir types
end
end
```
-TODO: SwaggerUI 3.0
-TODO: Request Validation
+## Use the API Spec to validate Requests
+
+Add both the `Cast` and `Validate` plugs to your controller / plug:
+
+```elixir
+plug OpenApiSpex.Plug.Cast
+plug OpenApiSpex.Plug.Validate
+```
+
+Now the client will receive a 422 response whenever the request fails to meet the validation rules from the api spec.
+
+
+TODO: SwaggerUI 3.0
TODO: Validating examples in the spec
TODO: Validating responses in tests
diff --git a/lib/open_api_spex.ex b/lib/open_api_spex.ex
index f4cc5a5..387c12c 100644
--- a/lib/open_api_spex.ex
+++ b/lib/open_api_spex.ex
@@ -1,40 +1,86 @@
defmodule OpenApiSpex do
- alias OpenApiSpex.{OpenApi, RequestBody, Schema, SchemaResolver}
- alias Plug.Conn
+ alias OpenApiSpex.{OpenApi, Operation, Parameter, RequestBody, Schema, SchemaResolver}
@moduledoc """
"""
def resolve_schema_modules(spec = %OpenApi{}) do
SchemaResolver.resolve_schema_modules(spec)
end
- def cast_parameters(conn = %Conn{}, operation_id) do
- operation = conn.private.open_api_spex.operation_lookup[operation_id]
- spec = conn.private.open_api_spex.spec
+ def cast_parameters(spec = %OpenApi{}, operation = %Operation{}, params = %{}, content_type \\ nil) do
schemas = spec.components.schemas
- params =
+ parameters_result =
operation.parameters
- |> Enum.filter(fn parameter -> Map.has_key?(conn.params, Atom.to_string(parameter.name)) end)
- |> Enum.map(fn %{schema: schema, name: name} -> {name, Schema.cast(schema, conn.params[name], schemas)} end)
+ |> Stream.filter(fn parameter -> Map.has_key?(params, Atom.to_string(parameter.name)) end)
+ |> Stream.map(fn parameter -> %{name: parameter.name, schema: Parameter.schema(parameter)} end)
+ |> Stream.map(fn %{schema: schema, name: name} -> {name, Schema.cast(schema, params[name], schemas)} end)
|> Enum.reduce({:ok, %{}}, fn
{name, {:ok, val}}, {:ok, acc} -> {:ok, Map.put(acc, name, val)}
_, {:error, reason} -> {:error, reason}
{_name, {:error, reason}}, _ -> {:error, reason}
end)
- body = case operation.requestBody do
- nil -> {:ok, %{}}
- %RequestBody{content: content} ->
- [content_type] = Conn.get_req_header(conn, "content-type")
- schema = content[content_type].schema
- Schema.cast(schema, conn.params, spec.components.schemas)
- end
+ body_result =
+ case operation.requestBody do
+ nil -> {:ok, %{}}
+ %RequestBody{content: content} ->
+ schema = content[content_type].schema
+ Schema.cast(schema, params, spec.components.schemas)
+ end
- with {:ok, cast_params} <- params,
- {:ok, cast_body} <- body do
+ with {:ok, cast_params} <- parameters_result,
+ {:ok, cast_body} <- body_result do
params = Map.merge(cast_params, cast_body)
- {:ok, %{conn | params: params}}
+ {:ok, params}
end
end
+
+ def validate_parameters(spec = %OpenApi{}, operation = %Operation{}, params = %{}, content_type \\ nil) do
+ schemas = spec.components.schemas
+
+ with :ok <- validate_required_parameters(operation.parameters, params),
+ {:ok, remaining} <- validate_parameter_schemas(operation.parameters, params, schemas),
+ :ok <- validate_body_schema(operation.requestBody, remaining, content_type, schemas) do
+ :ok
+ end
+ end
+
+ def 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
+
+ def validate_parameter_schemas(parameter_list, params, schemas) do
+ errors =
+ parameter_list
+ |> Stream.filter(fn parameter -> Map.has_key?(params, parameter.name) end)
+ |> Stream.map(fn parameter -> Parameter.schema(parameter) end)
+ |> Stream.map(fn schema -> Schema.validate(schema, params, schemas) end)
+ |> Enum.filter(fn result -> result != :ok end)
+
+ case errors do
+ [] -> {:ok, Map.drop(params, Enum.map(parameter_list, fn p -> p.name end)) }
+ _ -> {:error, "Parameter validation errors: #{inspect(errors)}"}
+ end
+ end
+
+ def validate_body_schema(nil, _, _, _), do: :ok
+ def validate_body_schema(%RequestBody{required: false}, params, _content_type, _schemas) when map_size(params) == 0 do
+ :ok
+ end
+ def 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/open_api.ex b/lib/open_api_spex/open_api.ex
index 0322d26..72a0107 100644
--- a/lib/open_api_spex/open_api.ex
+++ b/lib/open_api_spex/open_api.ex
@@ -1,55 +1,56 @@
defmodule OpenApiSpex.OpenApi do
alias OpenApiSpex.{
Info, Server, Paths, Components,
SecurityRequirement, Tag, ExternalDocumentation,
OpenApi
}
defstruct [
:info,
:servers,
:paths,
:components,
:security,
:tags,
:externalDocs,
openapi: "3.0",
]
@type t :: %OpenApi{
openapi: String.t,
info: Info.t,
servers: [Server.t],
paths: Paths.t,
components: Components.t,
security: [SecurityRequirement.t],
tags: [Tag.t],
externalDocs: ExternalDocumentation.t
}
defimpl Poison.Encoder do
def encode(api_spec = %OpenApi{}, options) do
api_spec
|> to_json()
|> Poison.Encoder.encode(options)
end
+ defp to_json(%Regex{source: source}), do: source
defp to_json(value = %{__struct__: _}) do
value
|> Map.from_struct()
|> to_json()
end
defp to_json(value) when is_map(value) do
value
- |> Enum.map(fn {k,v} -> {to_string(k), to_json(v)} end)
- |> Enum.filter(fn {_, nil} -> false; _ -> true end)
+ |> Stream.map(fn {k,v} -> {to_string(k), to_json(v)} end)
+ |> Stream.filter(fn {_, nil} -> false; _ -> true end)
|> Enum.into(%{})
end
defp to_json(value) when is_list(value) do
Enum.map(value, &to_json/1)
end
- defp to_json(nil) do nil end
- defp to_json(true) do true end
- defp to_json(false) do false end
- defp to_json(value) when is_atom(value) do to_string(value) end
- defp to_json(value) do value end
+ defp to_json(nil), do: nil
+ defp to_json(true), do: true
+ defp to_json(false), do: false
+ defp to_json(value) when is_atom(value), do: to_string(value)
+ defp to_json(value), do: value
end
end
\ No newline at end of file
diff --git a/lib/open_api_spex/parameter.ex b/lib/open_api_spex/parameter.ex
index 4d195bd..0cce4ce 100644
--- a/lib/open_api_spex/parameter.ex
+++ b/lib/open_api_spex/parameter.ex
@@ -1,50 +1,58 @@
defmodule OpenApiSpex.Parameter do
alias OpenApiSpex.{
Schema, Reference, Example, MediaType, Parameter
}
defstruct [
:name,
:in,
:description,
:required,
:deprecated,
:allowEmptyValue,
:style,
:explode,
:allowReserved,
:schema,
:example,
:examples,
:content,
]
@type style :: :matrix | :label | :form | :simple | :spaceDelimited | :pipeDelimited | :deep
@type t :: %__MODULE__{
name: String.t,
in: :query | :header | :path | :cookie,
description: String.t,
required: boolean,
deprecated: boolean,
allowEmptyValue: boolean,
style: style,
explode: boolean,
allowReserved: boolean,
schema: Schema.t | Reference.t,
example: any,
examples: %{String.t => Example.t | Reference.t},
content: %{String.t => MediaType.t}
}
@doc """
Sets the schema for a parameter from a simple type, reference or Schema
"""
@spec put_schema(t, Reference.t | Schema.t | atom | String.t) :: t
def put_schema(parameter = %Parameter{}, type = %Reference{}) do
%{parameter | schema: type}
end
def put_schema(parameter = %Parameter{}, type = %Schema{}) do
%{parameter | schema: type}
end
def put_schema(parameter = %Parameter{}, type) when is_binary(type) or is_atom(type) do
%{parameter | schema: %Schema{type: type}}
end
+
+ def schema(%Parameter{schema: schema = %{}}) do
+ schema
+ end
+ def schema(%Parameter{content: content = %{}}) do
+ {_type, %MediaType{schema: schema}} = Enum.at(content, 0)
+ schema
+ end
end
diff --git a/lib/open_api_spex/plug/cast.ex b/lib/open_api_spex/plug/cast.ex
index 7daab11..7bb6b63 100644
--- a/lib/open_api_spex/plug/cast.ex
+++ b/lib/open_api_spex/plug/cast.ex
@@ -1,15 +1,24 @@
defmodule OpenApiSpex.Plug.Cast do
+ alias Plug.Conn
+
def init(opts), do: opts
- def call(conn, operation_id: operation_id) do
- case OpenApiSpex.cast_parameters(conn, operation_id) do
- {:ok, conn} -> conn
+
+ def call(conn = %{private: %{open_api_spex: private_data}}, operation_id: operation_id) do
+ spec = private_data.spec
+ operation = private_data.operation_lookup[operation_id]
+ content_type = Conn.get_req_header(conn, "content-type") |> Enum.at(0)
+ private_data = Map.put(private_data, :operation_id, operation_id)
+ conn = Conn.put_private(conn, :open_api_spex, private_data)
+
+ case OpenApiSpex.cast_parameters(spec, operation, conn.params, content_type) do
+ {:ok, params} -> %{conn | params: params}
{:error, reason} ->
conn
|> Plug.Conn.send_resp(422, "#{reason}")
|> Plug.Conn.halt()
end
end
def call(conn = %{private: %{phoenix_controller: controller, phoenix_action: action}}, _opts) do
call(conn, operation_id: controller.open_api_operation(action).operationId)
end
end
\ No newline at end of file
diff --git a/lib/open_api_spex/plug/validate.ex b/lib/open_api_spex/plug/validate.ex
new file mode 100644
index 0000000..a6ee703
--- /dev/null
+++ b/lib/open_api_spex/plug/validate.ex
@@ -0,0 +1,21 @@
+defmodule OpenApiSpex.Plug.Validate do
+ alias Plug.Conn
+
+ def init(opts), do: opts
+ def call(conn, _opts) do
+ spec = conn.private.open_api_spex.spec
+ operation_id = conn.private.open_api_spex.operation_id
+ operation_lookup = conn.private.open_api_spex.operation_lookup
+ operation = operation_lookup[operation_id]
+ content_type = Conn.get_req_header(conn, "content-type") |> Enum.at(0)
+
+ with :ok <- OpenApiSpex.validate_parameters(spec, operation, conn.params, content_type) do
+ conn
+ else
+ {:error, reason} ->
+ conn
+ |> Conn.send_resp(422, "#{reason}")
+ |> Conn.halt()
+ end
+ end
+end
\ No newline at end of file
diff --git a/lib/open_api_spex/request_body.ex b/lib/open_api_spex/request_body.ex
index 0bf2255..134b899 100644
--- a/lib/open_api_spex/request_body.ex
+++ b/lib/open_api_spex/request_body.ex
@@ -1,13 +1,13 @@
defmodule OpenApiSpex.RequestBody do
alias OpenApiSpex.MediaType
defstruct [
:description,
:content,
- :required
+ required: false
]
@type t :: %__MODULE__{
description: String.t,
content: %{String.t => MediaType.t},
required: boolean
}
end
\ No newline at end of file
diff --git a/lib/open_api_spex/schema.ex b/lib/open_api_spex/schema.ex
index 1617be3..4470776 100644
--- a/lib/open_api_spex/schema.ex
+++ b/lib/open_api_spex/schema.ex
@@ -1,131 +1,292 @@
defmodule OpenApiSpex.Schema do
alias OpenApiSpex.{
Schema, Reference, Discriminator, Xml, ExternalDocumentation
}
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
]
@type t :: %__MODULE__{
title: String.t,
multipleOf: number,
maximum: number,
- exclusiveMaximum: number,
+ exclusiveMaximum: boolean,
minimum: number,
- exclusiveMinimum: number,
+ exclusiveMinimum: boolean,
maxLength: integer,
minLength: integer,
pattern: String.t,
maxItems: integer,
minItems: integer,
uniqueItems: boolean,
maxProperties: integer,
minProperties: integer,
required: [String.t],
enum: [String.t],
type: String.t,
allOf: [Schema.t | Reference.t],
oneOf: [Schema.t | Reference.t],
anyOf: [Schema.t | Reference.t],
not: Schema.t | Reference.t,
items: Schema.t | Reference.t,
properties: %{String.t => Schema.t | Reference.t},
additionalProperties: boolean | Schema.t | Reference.t,
description: String.t,
format: String.t,
default: any,
nullable: boolean,
discriminator: Discriminator.t,
readOnly: boolean,
writeOnly: boolean,
xml: Xml.t,
externalDocs: ExternalDocumentation.t,
example: any,
deprecated: boolean
}
+ defp resolve_schema(schema = %Schema{}, _), do: schema
+ defp resolve_schema(%Reference{"$ref": "#/components/schemas/" <> name}, schemas), do: schemas[name]
+
+ 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" -> true
+ "false" -> 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
+ case cast(items_schema, x, schemas) do
+ {:ok, x_cast} -> [x_cast | cast(schema, rest, schemas)]
+ error -> error
+ end
+ end
def cast(%Schema{type: :object, properties: properties}, value, schemas) when is_map(value) do
properties
|> Stream.filter(fn {name, _} -> Map.has_key?(value, name) || Map.has_key?(value, Atom.to_string(name)) end)
|> Stream.map(fn {name, schema} -> {name, resolve_schema(schema, schemas)} end)
|> Stream.map(fn {name, schema} -> {name, schema, Map.get(value, name, value[Atom.to_string(name)])} end)
|> Stream.map(fn {name, schema, property_val} -> cast_property(name, schema, property_val, schemas) end)
|> Enum.reduce({:ok, %{}}, fn
_, {:error, reason} -> {:error, reason}
{:error, reason}, _ -> {:error, reason}
{:ok, {name, property_val}}, {:ok, acc} -> {:ok, Map.put(acc, name, property_val)}
end)
end
def cast(ref = %Reference{}, val, schemas), do: cast(resolve_schema(ref, schemas), val, schemas)
- defp resolve_schema(schema = %Schema{}, _), do: schema
- defp resolve_schema(%Reference{"$ref": "#/components/schemas/" <> name}, schemas), do: schemas[name]
-
defp cast_property(name, schema, value, schemas) do
casted = cast(schema, value, schemas)
case casted do
{:ok, new_value} -> {:ok, {name, new_value}}
{:error, reason} -> {:error, reason}
end
end
+
+ def validate(ref = %Reference{}, val, schemas), do: validate(resolve_schema(ref, schemas), val, schemas)
+ def validate(schema = %Schema{type: type}, value, _schemas) when type in [:integer, :number] do
+ with :ok <- validate_multiple(schema, value),
+ :ok <- validate_maximum(schema, value),
+ :ok <- validate_minimum(schema, value) do
+ :ok
+ end
+ end
+ def validate(schema = %Schema{type: :string}, value, _schemas) do
+ with :ok <- validate_max_length(schema, value),
+ :ok <- validate_min_length(schema, value),
+ :ok <- validate_pattern(schema, value) do
+ :ok
+ end
+ end
+ def validate(%Schema{type: :boolean}, value, _schemas) do
+ case is_boolean(value) do
+ true -> :ok
+ _ -> {:error, "Invalid boolean: #{inspect(value)}"}
+ end
+ end
+ def validate(schema = %Schema{type: :array}, value, schemas) do
+ with :ok <- validate_max_items(schema, value),
+ :ok <- validate_min_items(schema, value),
+ :ok <- validate_unique_items(schema, value),
+ :ok <- validate_array_items(schema, value, schemas) do
+ :ok
+ end
+ end
+ def validate(schema = %Schema{type: :object, properties: properties}, value, schemas) do
+ with :ok <- validate_required_properties(schema, value),
+ :ok <- validate_max_properties(schema, value),
+ :ok <- validate_min_properties(schema, value),
+ :ok <- validate_object_properties(properties, value, schemas) do
+ :ok
+ end
+ end
+
+ def validate_multiple(%{multipleOf: nil}, _), do: :ok
+ def validate_multiple(%{multipleOf: n}, value) when (round(value / n) * n == value), do: :ok
+ def validate_multiple(%{multipleOf: n}, value), do: {:error, "#{value} is not a multiple of #{n}"}
+
+ def validate_maximum(%{maximum: nil}, _), do: :ok
+ def validate_maximum(%{maximum: n, exclusiveMaximum: true}, value) when value < n, do: :ok
+ def validate_maximum(%{maximum: n}, value) when value <= n, do: :ok
+ def validate_maximum(%{maximum: n}, value), do: {:error, "#{value} is larger than maximum #{n}"}
+
+ def validate_minimum(%{minimum: nil}, _), do: :ok
+ def validate_minimum(%{minimum: n, exclusiveMinimum: true}, value) when value > n, do: :ok
+ def validate_minimum(%{minimum: n}, value) when value >= n, do: :ok
+ def validate_minimum(%{minimum: n}, value), do: {:error, "#{value} is smaller than minimum #{n}"}
+
+ def validate_max_length(%{maxLength: nil}, _), do: :ok
+ def validate_max_length(%{maxLength: n}, value) do
+ case String.length(value) <= n do
+ true -> :ok
+ _ -> {:error, "String length is larger than maxLength: #{n}"}
+ end
+ end
+
+ def validate_min_length(%{minLength: nil}, _), do: :ok
+ def validate_min_length(%{minLength: n}, value) do
+ case String.length(value) >= n do
+ true -> :ok
+ _ -> {:error, "String length is smaller than minLength: #{n}"}
+ end
+ end
+
+ def validate_pattern(%{pattern: nil}, _), do: :ok
+ def validate_pattern(schema = %{pattern: regex}, val) when is_binary(regex) do
+ validate_pattern(%{schema | pattern: Regex.compile(regex)}, val)
+ end
+ def validate_pattern(%{pattern: regex = %Regex{}}, val) do
+ case Regex.match?(regex, val) do
+ true -> :ok
+ _ -> {:error, "Value does not match pattern: #{regex.source}"}
+ end
+ end
+
+ def validate_max_items(%Schema{maxItems: nil}, _), do: :ok
+ def validate_max_items(%Schema{maxItems: n}, value) when length(value) <= n, do: :ok
+ def validate_max_items(%Schema{maxItems: n}, value) do
+ {:error, "Array length #{length(value)} is larger than maxItems: #{n}"}
+ end
+
+ def validate_min_items(%Schema{minItems: nil}, _), do: :ok
+ def validate_min_items(%Schema{minItems: n}, value) when length(value) >= n, do: :ok
+ def validate_min_items(%Schema{minItems: n}, value) do
+ {:error, "Array length #{length(value)} is smaller than minItems: #{n}"}
+ end
+
+ def validate_unique_items(%Schema{uniqueItems: true}, value) do
+ unique_size =
+ value
+ |> MapSet.new()
+ |> MapSet.size()
+
+ case unique_size == length(value) do
+ true -> :ok
+ _ -> {:error, "Array items must be unique"}
+ end
+ end
+
+ def validate_array_items(%Schema{type: :array, items: nil}, value, _schemas) when is_list(value), do: {:ok, value}
+ def validate_array_items(%Schema{type: :array}, [], _schemas), do: {:ok, []}
+ def validate_array_items(schema = %Schema{type: :array, items: item_schema}, [x | rest], schemas) do
+ with :ok <- validate(item_schema, x, schemas) do
+ validate(schema, rest, schemas)
+ end
+ end
+
+ def validate_required_properties(%Schema{type: :object, required: nil}, _), do: :ok
+ def validate_required_properties(%Schema{type: :object, required: required}, value) do
+ missing = required -- Map.keys(value)
+ case missing do
+ [] -> :ok
+ _ -> {:error, "Missing required properties: #{inspect(missing)}"}
+ end
+ end
+
+ def validate_max_properties(%Schema{type: :object, maxProperties: nil}, _), do: :ok
+ def validate_max_properties(%Schema{type: :object, maxProperties: n}, val) when map_size(val) <= n, do: :ok
+ def validate_max_properties(%Schema{type: :object, maxProperties: n}, val) do
+ {:error, "Object property count #{map_size(val)} is greater than maxProperties: #{n}"}
+ end
+
+ def validate_min_properties(%Schema{type: :object, minProperties: nil}, _), do: :ok
+ def validate_min_properties(%Schema{type: :object, minProperties: n}, val) when map_size(val) >= n, do: :ok
+ def validate_min_properties(%Schema{type: :object, minProperties: n}, val) do
+ {:error, "Object property count #{map_size(val)} is less than minProperties: #{n}"}
+ end
+
+ def validate_object_properties(properties = %{}, value, schemas) do
+ properties
+ |> Enum.filter(fn {name, _schema} -> Map.has_key?(value, name) end)
+ |> validate_object_properties(value, schemas)
+ end
+ def validate_object_properties([], _, _), do: :ok
+ def validate_object_properties([{name, schema} | rest], value, schemas) do
+ case validate(schema, value[name], schemas) do
+ :ok -> validate_object_properties(rest, value, schemas)
+ error -> error
+ end
+ end
end
\ No newline at end of file
diff --git a/test/open_api_spex_test.exs b/test/open_api_spex_test.exs
index 387ed3c..7e24f47 100644
--- a/test/open_api_spex_test.exs
+++ b/test/open_api_spex_test.exs
@@ -1,37 +1,57 @@
defmodule OpenApiSpexTest do
use ExUnit.Case
alias OpenApiSpexTest.ApiSpec
describe "OpenApi" do
test "compete" do
spec = ApiSpec.spec()
assert spec
end
- test "asdfafd" do
+ test "Valid Request" do
request_body = %{
"user" => %{
"id" => 123,
"name" => "asdf",
"email" => "foo@bar.com",
"updated_at" => "2017-09-12T14:44:55Z"
}
}
conn =
:post
|> Plug.Test.conn("/api/users", Poison.encode!(request_body))
|> Plug.Conn.put_req_header("content-type", "application/json")
conn = OpenApiSpexTest.Router.call(conn, [])
assert conn.params == %{
user: %{
id: 123,
name: "asdf",
email: "foo@bar.com",
updated_at: ~N[2017-09-12T14:44:55Z] |> DateTime.from_naive!("Etc/UTC")
}
}
end
+
+ test "Invalid Request" do
+ request_body = %{
+ "user" => %{
+ "id" => 123,
+ "name" => "*1234",
+ "email" => "foo@bar.com",
+ "updated_at" => "2017-09-12T14:44:55Z"
+ }
+ }
+
+ conn =
+ :post
+ |> Plug.Test.conn("/api/users", Poison.encode!(request_body))
+ |> Plug.Conn.put_req_header("content-type", "application/json")
+
+ conn = OpenApiSpexTest.Router.call(conn, [])
+ assert conn.status == 422
+ assert conn.resp_body == "Value does not match pattern: [a-zA-Z][a-zA-Z0-9_]+"
+ end
end
end
\ No newline at end of file
diff --git a/test/support/router.ex b/test/support/router.ex
index 1ae80fa..947fc6a 100644
--- a/test/support/router.ex
+++ b/test/support/router.ex
@@ -1,14 +1,18 @@
defmodule OpenApiSpexTest.Router do
use Phoenix.Router
+ alias Plug.Parsers
+ alias OpenApiSpexTest.UserController
+ alias OpenApiSpex.Plug.{PutApiSpec, RenderSpec}
pipeline :api do
- plug OpenApiSpex.Plug.PutApiSpec, module: OpenApiSpexTest.ApiSpec
- plug Plug.Parsers, parsers: [:json], pass: ["text/*"], json_decoder: Poison
+ plug :accepts, ["json"]
+ plug PutApiSpec, module: OpenApiSpexTest.ApiSpec
+ plug Parsers, parsers: [:json], pass: ["text/*"], json_decoder: Poison
end
scope "/api" do
pipe_through :api
- resources "/users", OpenApiSpexTest.UserController, only: [:create, :index, :show]
- get "/openapi", OpenApiSpex.Plug.RenderSpec, []
+ resources "/users", UserController, only: [:create, :index, :show]
+ get "/openapi", RenderSpec, []
end
end
\ No newline at end of file
diff --git a/test/support/schemas.ex b/test/support/schemas.ex
index 19e542f..9a839cf 100644
--- a/test/support/schemas.ex
+++ b/test/support/schemas.ex
@@ -1,59 +1,59 @@
defmodule OpenApiSpexTest.Schemas do
alias OpenApiSpex.Schema
defmodule User do
def schema do
%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"},
+ 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'}
}
}
end
end
defmodule UserRequest do
def schema do
%Schema{
title: "UserRequest",
description: "POST body for creating a user",
type: :object,
properties: %{
user: User
}
}
end
end
defmodule UserResponse do
def schema do
%Schema{
title: "UserResponse",
description: "Response schema for single user",
type: :object,
properties: %{
data: User
}
}
end
end
defmodule UsersResponse do
def schema do
%Schema{
title: "UsersReponse",
description: "Response schema for multiple users",
type: :object,
properties: %{
data: %Schema{description: "The users details", type: :array, items: User}
}
}
end
end
end
\ No newline at end of file
diff --git a/test/support/user_controller.ex b/test/support/user_controller.ex
index 69c5ee9..175771e 100644
--- a/test/support/user_controller.ex
+++ b/test/support/user_controller.ex
@@ -1,69 +1,70 @@
defmodule OpenApiSpexTest.UserController do
use Phoenix.Controller
alias OpenApiSpex.Operation
alias OpenApiSpexTest.Schemas
alias Plug.Conn
plug OpenApiSpex.Plug.Cast
+ plug OpenApiSpex.Plug.Validate
def open_api_operation(action) do
apply(__MODULE__, :"#{action}_operation", [])
end
def show_operation() do
import Operation
%Operation{
tags: ["users"],
summary: "Show user",
description: "Show a user by ID",
operationId: "UserController.show",
parameters: [
parameter(:id, :path, :integer, "User ID", example: 123)
],
responses: %{
200 => response("User", "application/json", Schemas.UserResponse)
}
}
end
def show(conn, _params) do
conn
|> Conn.send_resp(200, "HELLO")
end
def index_operation() do
import Operation
%Operation{
tags: ["users"],
summary: "List users",
description: "List all useres",
operationId: "UserController.index",
parameters: [],
responses: %{
200 => response("User List Response", "application/json", Schemas.UsersResponse)
}
}
end
def index(conn, _params) do
conn
|> Conn.send_resp(200, "HELLO")
end
def create_operation() do
import Operation
%Operation{
tags: ["users"],
summary: "Create user",
description: "Create a user",
operationId: "UserController.create",
parameters: [],
requestBody: request_body("The user attributes", "application/json", Schemas.UserRequest),
responses: %{
201 => response("User", "application/json", Schemas.UserResponse)
}
}
end
def create(conn, _params) do
conn
|> Conn.send_resp(201, "DONE")
end
end
\ No newline at end of file

File Metadata

Mime Type
text/x-diff
Expires
Sat, Nov 23, 7:39 AM (1 d, 5 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
38900
Default Alt Text
(34 KB)

Event Timeline