Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 3 additions & 15 deletions lib/open_api_spex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ defmodule OpenApiSpex do
SchemaResolver
}

alias OpenApiSpex.Cast.Error
alias OpenApiSpex.Cast.{Error, Utils}

@doc """
Adds schemas to the api spec from the modules specified in the Operations.
Expand Down Expand Up @@ -93,22 +93,10 @@ defmodule OpenApiSpex do
content_type \\ nil,
opts \\ []
) do
content_type = content_type || content_type_from_header(conn)
content_type = content_type || Utils.content_type_from_header(conn)
Operation2.cast(spec, operation, conn, content_type, opts)
end

defp content_type_from_header(conn = %Plug.Conn{}) do
case Plug.Conn.get_req_header(conn, "content-type") do
[header_value | _] ->
header_value
|> String.split(";")
|> List.first()

_ ->
nil
end
end

@doc """
Cast params to conform to a `OpenApiSpex.Schema`.

Expand Down Expand Up @@ -406,7 +394,7 @@ defmodule OpenApiSpex do
Resolve a schema or reference to a schema.
"""
@spec resolve_schema(Schema.t() | Reference.t() | module, Components.schemas_map()) ::
Schema.t()
Schema.t() | nil
def resolve_schema(%Schema{} = schema, _), do: schema
def resolve_schema(%Reference{} = ref, schemas), do: Reference.resolve_schema(ref, schemas)

Expand Down
39 changes: 39 additions & 0 deletions lib/open_api_spex/cast/utils.ex
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,43 @@ defmodule OpenApiSpex.Cast.Utils do
end

def check_required_fields(_ctx, _acc), do: :ok

@doc """
Retrieves the content type from the request header of the given connection.

## Parameters:

- `conn`: The connection from which the content type should be retrieved. Must be an instance of `Plug.Conn`.

## Returns:

- If the content type is found: Returns the main content type as a string. For example, for the header "application/json; charset=utf-8", it would return "application/json".
- If the content type is not found or is not set: Returns `nil`.

## Examples:

iex> content_type_from_header(%Plug.Conn{req_headers: [{"content-type", "application/json; charset=utf-8"}]})
"application/json"

iex> content_type_from_header(%Plug.Conn{req_headers: []})
nil

## Notes:

- The function only retrieves the main content type and does not consider any additional parameters that may be set in the `content-type` header.
- If multiple `content-type` headers are found, the function will only return the value of the first one.

"""
@spec content_type_from_header(Plug.Conn.t()) :: String.t() | nil
def content_type_from_header(conn = %Plug.Conn{}) do
case Plug.Conn.get_req_header(conn, "content-type") do
[header_value | _] ->
header_value
|> String.split(";")
|> List.first()

_ ->
nil
end
end
end
16 changes: 15 additions & 1 deletion lib/open_api_spex/operation2.ex
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,26 @@ defmodule OpenApiSpex.Operation2 do
components,
opts
) do
{:ok, conn |> cast_conn(body) |> maybe_replace_body(body, replace_params)}
{:ok,
conn
|> cast_conn(body)
|> maybe_replace_body(body, replace_params)
|> put_operation_id(operation)}
end
end

## Private functions

defp put_operation_id(conn, operation) do
private_data =
conn
|> Map.get(:private)
|> Map.get(:open_api_spex, %{})
|> Map.put(:operation_id, operation.operationId)

Plug.Conn.put_private(conn, :open_api_spex, private_data)
end

defp cast_conn(conn, body) do
private_data =
conn
Expand Down
8 changes: 2 additions & 6 deletions lib/open_api_spex/plug/cast.ex
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ defmodule OpenApiSpex.Plug.Cast do

@behaviour Plug

alias OpenApiSpex.Cast.Utils
alias OpenApiSpex.Plug.PutApiSpec
alias Plug.Conn

@impl Plug
@deprecated "Use OpenApiSpex.Plug.CastAndValidate instead"
Expand All @@ -64,11 +64,7 @@ defmodule OpenApiSpex.Plug.Cast do
{spec, operation_lookup} = PutApiSpec.get_spec_and_operation_lookup(conn)
operation = operation_lookup[operation_id]

content_type =
Conn.get_req_header(conn, "content-type")
|> Enum.at(0, "")
|> String.split(";")
|> Enum.at(0)
content_type = Utils.content_type_from_header(conn)

# credo:disable-for-next-line
case apply(OpenApiSpex, :cast, [spec, operation, conn, content_type]) do
Expand Down
2 changes: 1 addition & 1 deletion lib/open_api_spex/plug/render_spec.ex
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ defmodule OpenApiSpex.Plug.RenderSpec do
|> Plug.Conn.send_resp(200, @json_encoder.encode!(spec))
end
else
IO.warn("No JSON encoder found. Please add :json or :poison in your mix dependencies.")
IO.warn("No JSON encoder found. Please add :jason or :poison in your mix dependencies.")

@impl Plug
def call(conn, _opts), do: conn
Expand Down
120 changes: 118 additions & 2 deletions lib/open_api_spex/test/test_assertions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ defmodule OpenApiSpex.TestAssertions do
Defines helpers for testing API responses and examples against API spec schemas.
"""
import ExUnit.Assertions
alias OpenApiSpex.Cast.Error
alias OpenApiSpex.{Cast, OpenApi}
alias OpenApiSpex.Reference
alias OpenApiSpex.Cast.{Error, Utils}
alias OpenApiSpex.{Cast, Components, OpenApi, Operation, Schema}
alias OpenApiSpex.Plug.PutApiSpec

@dialyzer {:no_match, assert_schema: 3}

@json_content_regex ~r/^application\/.*json.*$/

@doc """
Asserts that `value` conforms to the schema with title `schema_title` in `api_spec`.
"""
Expand All @@ -30,6 +34,45 @@ defmodule OpenApiSpex.TestAssertions do
assert_schema(cast_context)
end

@doc """
Asserts that `value` conforms to the schema or reference definition.
"""
@spec assert_raw_schema(term, Schema.t() | Reference.t(), OpenApi.t() | %{}) :: term | no_return
def assert_raw_schema(value, schema, spec \\ %{})

def assert_raw_schema(value, schema = %Schema{}, spec) do
schemas = get_or_default_schemas(spec)

cast_context = %Cast{
value: value,
schema: schema,
schemas: schemas
}

assert_schema(cast_context)
end

def assert_raw_schema(value, schema = %Reference{}, spec) do
schemas = get_or_default_schemas(spec)
resolved_schema = OpenApiSpex.resolve_schema(schema, schemas)

if is_nil(resolved_schema) do
flunk("Schema: #{inspect(schema)} not found in #{inspect(spec)}")
end

cast_context = %Cast{
value: value,
schema: resolved_schema,
schemas: schemas
}

assert_schema(cast_context)
end

@spec get_or_default_schemas(OpenApi.t() | %{}) :: Components.schemas_map() | %{}
defp get_or_default_schemas(api_spec = %OpenApi{}), do: api_spec.components.schemas || %{}
defp get_or_default_schemas(input), do: input

@doc """
Asserts that `value` conforms to the schema in the given `%Cast{}` context.
"""
Expand Down Expand Up @@ -75,4 +118,77 @@ defmodule OpenApiSpex.TestAssertions do
def assert_request_schema(value, schema_title, api_spec = %OpenApi{}) do
assert_schema(value, schema_title, api_spec, :write)
end

@doc """
Asserts that the response body conforms to the response schema for the operation with id `operation_id`.
"""
@spec assert_operation_response(Plug.Conn.t(), String.t() | nil) :: Plug.Conn.t()
def assert_operation_response(conn, operation_id \\ nil)

# No need to check for a schema if the response is empty
def assert_operation_response(conn, _operation_id) when conn.status == 204, do: conn

def assert_operation_response(conn, operation_id) do
{spec, operation_lookup} = PutApiSpec.get_spec_and_operation_lookup(conn)

operation_id = operation_id || conn.private.open_api_spex.operation_id

case operation_lookup[operation_id] do
nil ->
flunk(
"Failed to resolve schema. Unable to find a response for operation_id: #{operation_id} for response status code: #{conn.status}"
)

operation ->
validate_operation_response(conn, operation, spec)
end

conn
end

if OpenApiSpex.OpenApi.json_encoder() do
@spec validate_operation_response(
Plug.Conn.t(),
Operation.t(),
OpenApi.t()
) ::
term | no_return
defp validate_operation_response(conn, %Operation{operationId: operation_id} = operation, spec) do
content_type = Utils.content_type_from_header(conn)

resolved_schema =
get_in(operation, [
Access.key!(:responses),
Access.key!(conn.status),
Access.key!(:content),
content_type,
Access.key!(:schema)
])

if is_nil(resolved_schema) do
flunk(
"Failed to resolve schema! Unable to find a response for operation_id: #{operation_id} for response status code: #{conn.status} and content type #{content_type}"
)
end

body =
if String.match?(content_type, @json_content_regex) do
OpenApiSpex.OpenApi.json_encoder().decode!(conn.resp_body)
else
conn.resp_body
end

assert_raw_schema(
body,
resolved_schema,
spec
)
end
else
defp validate_operation_response(_conn, _operation, _spec) do
flunk(
"Unable to use assert_operation_response unless a json encoder is configured. Please add :jason or :poison in your mix dependencies."
)
end
end
end
6 changes: 4 additions & 2 deletions test/support/pet_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ defmodule OpenApiSpexTest.PetController do
],
responses: [
ok: {"Pet", "application/json", Schemas.PetResponse}
]
],
operation_id: "showPetById"
def show(conn, %{id: _id}) do
json(conn, %Schemas.PetResponse{
data: %Schemas.Dog{
Expand All @@ -36,7 +37,8 @@ defmodule OpenApiSpexTest.PetController do
@doc """
Get a list of pets.
"""
@doc responses: [ok: {"Pet list", "application/json", Schemas.PetsResponse}]
@doc responses: [ok: {"Pet list", "application/json", Schemas.PetsResponse}],
operation_id: "listPets"
def index(conn, _params) do
json(conn, %Schemas.PetsResponse{
data: [
Expand Down
Loading