Skip to content

Commit

Permalink
feat: add assert_operation_response, assert_raw_schema (#545)
Browse files Browse the repository at this point in the history
* feat: add assert_operation_response, assert_raw_schema

* make assert_operation_response pipeable

* fix return type

* automagically infer operationId in assertion

* don't need to resolve a %Schema{}

* ignore 204s

* use OpenApiSpex.OpenApi.json_encoder()

* rename test to match fn

* reorganize json_encoder check per feedback

* update json_encoder message for :jason or :poison

* use a regex to match json content types in validate_operation_response

* 💅 feedback - types, error message, module attrib for regex

* add doc for content_type_from_header

* remove no_return from spec
  • Loading branch information
msutkowski authored Aug 23, 2023
1 parent 6edb86d commit 7a0309a
Show file tree
Hide file tree
Showing 8 changed files with 277 additions and 27 deletions.
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

0 comments on commit 7a0309a

Please sign in to comment.