Files
wanderer/test/support/openapi_contract_helpers.ex
2025-07-09 01:47:24 -04:00

331 lines
9.7 KiB
Elixir

defmodule WandererAppWeb.OpenAPIContractHelpers do
@moduledoc """
Enhanced helpers for comprehensive OpenAPI contract testing.
Provides utilities for:
- Response schema validation
- Request schema validation
- Operation lookup and validation
- Parameter validation
- Error response validation
- Schema evolution tracking
"""
import ExUnit.Assertions
alias OpenApiSpex.{Cast, Parameter, RequestBody, Response, Schema}
@doc """
Validates an HTTP response against its OpenAPI schema.
## Examples
assert_response_schema(conn, 200, "MapSystemResponse")
assert_response_schema(conn, 201, "CreateMapSystemResponse", operation_id: "createMapSystem")
"""
def assert_response_schema(conn, status_code, schema_name, opts \\ []) do
operation_id = opts[:operation_id] || infer_operation_id(conn)
spec = opts[:spec] || api_spec()
with {:ok, operation} <- get_operation(spec, operation_id),
{:ok, response_spec} <- get_response_spec(operation, status_code),
{:ok, schema} <- get_response_schema(response_spec, schema_name, spec) do
response_data = Jason.decode!(conn.resp_body)
case Cast.cast(schema, response_data, spec) do
{:ok, _} ->
:ok
{:error, errors} ->
flunk("""
Response schema validation failed for #{operation_id} (#{status_code}):
Expected schema: #{schema_name}
Response data: #{inspect(response_data, pretty: true)}
Errors:
#{format_errors(errors)}
""")
end
else
{:error, reason} -> flunk("Contract validation setup failed: #{reason}")
end
end
@doc """
Validates a request body against its OpenAPI schema.
## Examples
assert_request_schema(params, "createMapSystem")
assert_request_schema(params, "updateMapSystem", content_type: "application/json")
"""
def assert_request_schema(params, operation_id, opts \\ []) do
content_type = opts[:content_type] || "application/json"
spec = opts[:spec] || api_spec()
with {:ok, operation} <- get_operation(spec, operation_id),
{:ok, request_body} <- get_request_body(operation),
{:ok, schema} <- get_request_schema(request_body, content_type, spec) do
case Cast.cast(schema, params, spec) do
{:ok, _} ->
:ok
{:error, errors} ->
flunk("""
Request schema validation failed for #{operation_id}:
Request data: #{inspect(params, pretty: true)}
Errors:
#{format_errors(errors)}
""")
end
else
{:error, reason} -> flunk("Contract validation setup failed: #{reason}")
end
end
@doc """
Validates request parameters (path, query, header) against OpenAPI spec.
## Examples
assert_parameters(%{id: "123", sort: "name"}, "getMapSystems")
"""
def assert_parameters(params, operation_id, opts \\ []) do
spec = opts[:spec] || api_spec()
with {:ok, operation} <- get_operation(spec, operation_id) do
Enum.each(operation.parameters || [], fn param ->
validate_parameter(param, params, spec)
end)
else
{:error, reason} -> flunk("Parameter validation setup failed: #{reason}")
end
end
@doc """
Validates that an error response conforms to the standard error schema.
"""
def assert_error_response(conn, expected_status) do
assert conn.status == expected_status
response = Jason.decode!(conn.resp_body)
assert Map.has_key?(response, "error")
assert is_binary(response["error"])
# Validate against error schema if defined
assert_response_schema(conn, expected_status, "ErrorResponse")
end
@doc """
Gets all operations defined in the API spec.
"""
def list_operations(spec \\ nil) do
spec = spec || api_spec()
Enum.flat_map(spec.paths, fn {path, path_item} ->
path_item
|> Map.from_struct()
|> Enum.filter(fn {method, _} -> method in [:get, :post, :put, :patch, :delete] end)
|> Enum.map(fn {method, operation} ->
%{
path: path,
method: method,
operation_id: operation.operation_id,
summary: operation.summary,
deprecated: operation.deprecated || false,
parameters: length(operation.parameters || []),
has_request_body: operation.request_body != nil,
responses: Map.keys(operation.responses || %{})
}
end)
end)
end
@doc """
Validates that all operations have required documentation.
"""
def assert_operations_documented(spec \\ nil) do
spec = spec || api_spec()
operations = list_operations(spec)
Enum.each(operations, fn op ->
assert op.operation_id != nil,
"Operation #{op.method} #{op.path} missing operation_id"
assert op.summary != nil,
"Operation #{op.operation_id} missing summary"
assert map_size(op.responses) > 0,
"Operation #{op.operation_id} has no documented responses"
end)
end
@doc """
Gets the API specification.
"""
def api_spec do
WandererAppWeb.ApiSpec.spec()
end
# Private helpers
defp get_operation(spec, operation_id) do
operation =
spec.paths
|> Enum.flat_map(fn {_path, path_item} ->
path_item
|> Map.from_struct()
|> Enum.filter(fn {method, _} -> method in [:get, :post, :put, :patch, :delete] end)
|> Enum.map(fn {_method, op} -> op end)
end)
|> Enum.find(&(&1.operation_id == operation_id))
case operation do
nil -> {:error, "Operation '#{operation_id}' not found"}
op -> {:ok, op}
end
end
defp get_response_spec(%{responses: responses}, status_code) when is_map(responses) do
status_key = to_string(status_code)
case Map.get(responses, status_key) || Map.get(responses, "default") do
nil -> {:error, "No response defined for status #{status_code}"}
response -> {:ok, response}
end
end
defp get_response_spec(_, _), do: {:error, "No responses defined"}
defp get_response_schema(%Response{content: content}, schema_name, spec) when is_map(content) do
# Usually we want application/json
case Map.get(content, "application/json") do
%{schema: schema} -> resolve_schema(schema, schema_name, spec)
_ -> {:error, "No JSON response schema defined"}
end
end
defp get_response_schema(_, _, _), do: {:error, "No response content defined"}
defp get_request_body(%{request_body: nil}), do: {:error, "No request body defined"}
defp get_request_body(%{request_body: body}), do: {:ok, body}
defp get_request_body(_), do: {:error, "No request body defined"}
defp get_request_schema(%RequestBody{content: content}, content_type, spec)
when is_map(content) do
case Map.get(content, content_type) do
%{schema: schema} -> resolve_schema(schema, nil, spec)
_ -> {:error, "No schema for content type #{content_type}"}
end
end
defp get_request_schema(_, _, _), do: {:error, "No request content defined"}
defp resolve_schema(%{"$ref": ref}, _name, spec) do
# Handle component references like "#/components/schemas/MapSystem"
case String.split(ref, "/") do
["#", "components", "schemas", schema_name] ->
case get_schema_from_components(schema_name, spec) do
nil -> {:error, "Schema #{schema_name} not found"}
schema -> {:ok, schema}
end
_ ->
{:error, "Invalid schema reference: #{ref}"}
end
end
defp resolve_schema(schema, _name, _spec) when is_map(schema) do
# Direct schema definition
{:ok, struct(Schema, schema)}
end
defp resolve_schema(_, name, spec) when is_binary(name) do
# Try to find by name in components
case get_schema_from_components(name, spec) do
nil -> {:error, "Schema #{name} not found"}
schema -> {:ok, schema}
end
end
defp get_schema_from_components(name, spec) do
case spec.components do
%{schemas: schemas} when is_map(schemas) ->
Map.get(schemas, name)
_ ->
nil
end
end
defp validate_parameter(%Parameter{} = param, values, spec) do
param_name = param.name
value = get_parameter_value(param, values)
if param.required && value == nil do
flunk("Required parameter '#{param_name}' is missing")
end
if value != nil && param.schema do
case Cast.cast(param.schema, value, spec) do
{:ok, _} ->
:ok
{:error, errors} ->
flunk("""
Parameter '#{param_name}' validation failed:
Value: #{inspect(value)}
Errors: #{format_errors(errors)}
""")
end
end
end
defp get_parameter_value(%Parameter{in: :path, name: name}, values) do
Map.get(values, String.to_atom(name)) || Map.get(values, name)
end
defp get_parameter_value(%Parameter{in: :query, name: name}, values) do
Map.get(values, String.to_atom(name)) || Map.get(values, name)
end
defp get_parameter_value(%Parameter{in: :header, name: name}, values) do
Map.get(values, String.to_atom(name)) || Map.get(values, name)
end
defp infer_operation_id(conn) do
# Try to infer from controller action
case conn.private do
%{phoenix_controller: controller, phoenix_action: action} ->
controller_name =
controller
|> Module.split()
|> List.last()
|> String.replace("Controller", "")
|> Macro.underscore()
"#{controller_name}_#{action}"
_ ->
nil
end
end
defp format_errors(errors) when is_list(errors) do
errors
|> Enum.map(&format_error/1)
|> Enum.join("\n")
end
defp format_errors(error), do: format_error(error)
defp format_error(%Cast.Error{} = error) do
path = error.path |> Enum.join(".")
" - #{error.reason} at path: #{path}"
end
defp format_error(error), do: " - #{inspect(error)}"
end