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

385 lines
11 KiB
Elixir

defmodule WandererAppWeb.OpenAPISpecAnalyzer do
@moduledoc """
Utilities for analyzing and reporting on OpenAPI specifications.
This module provides tools for:
- Loading and caching API specifications
- Analyzing spec coverage
- Detecting schema changes
- Generating test reports
"""
@doc """
Loads and caches the API specification.
"""
def load_spec(force_reload \\ false) do
cache_key = :wanderer_api_spec
if force_reload do
# Check if key exists before attempting to erase
case :persistent_term.get(cache_key, :not_found) do
:not_found -> :ok
_ -> :persistent_term.erase(cache_key)
end
end
case :persistent_term.get(cache_key, nil) do
nil ->
spec = WandererAppWeb.ApiSpec.spec()
:persistent_term.put(cache_key, spec)
spec
spec ->
spec
end
end
@doc """
Analyzes the API specification and returns comprehensive statistics.
"""
def analyze_spec(spec \\ nil) do
spec = spec || load_spec()
%{
info: analyze_info(spec),
paths: analyze_paths(spec),
operations: analyze_operations(spec),
schemas: analyze_schemas(spec),
security: analyze_security(spec),
coverage: calculate_coverage(spec)
}
end
@doc """
Generates a markdown report of the API specification.
"""
def generate_report(spec \\ nil) do
spec = spec || load_spec()
analysis = analyze_spec(spec)
"""
# OpenAPI Specification Analysis Report
## API Information
- **Title**: #{spec.info.title}
- **Version**: #{spec.info.version}
- **Description**: #{spec.info.description || "N/A"}
## Paths Summary
- **Total Paths**: #{analysis.paths.total}
- **Operations**: #{analysis.operations.total}
- **Deprecated**: #{analysis.operations.deprecated}
## Operations by Method
#{format_method_breakdown(analysis.operations.by_method)}
## Schema Coverage
- **Total Schemas**: #{analysis.schemas.total}
- **Request Schemas**: #{analysis.schemas.request_schemas}
- **Response Schemas**: #{analysis.schemas.response_schemas}
- **Shared Schemas**: #{analysis.schemas.shared_schemas}
## Security
- **Security Schemes**: #{length(analysis.security.schemes)}
- **Protected Operations**: #{analysis.security.protected_operations}
- **Public Operations**: #{analysis.security.public_operations}
## Test Coverage Recommendations
#{format_coverage_recommendations(analysis.coverage)}
"""
end
@doc """
Lists all operations that need contract tests.
"""
def operations_needing_tests(spec \\ nil) do
spec = spec || load_spec()
all_operations = list_all_operations(spec)
# In a real implementation, we'd check which operations already have tests
# For now, return all operations
all_operations
end
@doc """
Compares two API specifications to detect changes.
"""
def compare_specs(old_spec, new_spec) do
%{
added_paths: find_added_paths(old_spec, new_spec),
removed_paths: find_removed_paths(old_spec, new_spec),
added_operations: find_added_operations(old_spec, new_spec),
removed_operations: find_removed_operations(old_spec, new_spec),
schema_changes: find_schema_changes(old_spec, new_spec),
breaking_changes: detect_breaking_changes(old_spec, new_spec)
}
end
# Private analysis functions
defp analyze_info(spec) do
%{
title: spec.info.title,
version: spec.info.version,
description: spec.info.description
}
end
defp analyze_paths(spec) do
paths = Map.keys(spec.paths || %{})
%{
total: length(paths),
by_prefix: group_by_prefix(paths)
}
end
defp analyze_operations(spec) do
operations = list_all_operations(spec)
%{
total: length(operations),
deprecated: Enum.count(operations, & &1.deprecated),
by_method:
Enum.group_by(operations, & &1.method) |> Map.new(fn {k, v} -> {k, length(v)} end),
with_request_body: Enum.count(operations, & &1.has_request_body),
documented: Enum.count(operations, &(&1.summary != nil))
}
end
defp analyze_schemas(spec) do
schemas = spec.components[:schemas] || %{}
schema_names = Map.keys(schemas)
# Categorize schemas based on naming patterns
request_schemas = Enum.filter(schema_names, &String.contains?(&1, "Request"))
response_schemas = Enum.filter(schema_names, &String.contains?(&1, "Response"))
shared_schemas = schema_names -- request_schemas -- response_schemas
%{
total: length(schema_names),
request_schemas: length(request_schemas),
response_schemas: length(response_schemas),
shared_schemas: length(shared_schemas),
by_type: categorize_schemas(schemas)
}
end
defp analyze_security(spec) do
schemes = spec.components[:security_schemes] || %{}
operations = list_all_operations(spec)
protected =
Enum.count(operations, fn op ->
op.security != nil && op.security != []
end)
%{
schemes: Map.keys(schemes),
protected_operations: protected,
public_operations: length(operations) - protected
}
end
defp calculate_coverage(spec) do
operations = list_all_operations(spec)
%{
# Would need to check for examples
operations_with_examples: 0,
operations_with_all_responses:
Enum.count(operations, fn op ->
responses = Map.keys(op.responses || %{})
# Should have at least success and error responses
length(responses) >= 2
end),
# Would need to check schemas for examples
schemas_with_examples: 0,
total_operations: length(operations)
}
end
defp list_all_operations(spec) do
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,
security: operation[:security],
parameters: operation[:parameters] || [],
has_request_body: Map.has_key?(operation, :request_body),
responses: operation[:responses] || %{}
}
end)
end)
end
defp group_by_prefix(paths) do
paths
|> Enum.group_by(fn path ->
case String.split(path, "/", parts: 4) do
["", "api", prefix | _] -> prefix
_ -> "other"
end
end)
|> Map.new(fn {k, v} -> {k, length(v)} end)
end
defp categorize_schemas(schemas) do
Enum.reduce(schemas, %{}, fn {_name, schema}, acc ->
type = determine_schema_type(schema)
Map.update(acc, type, 1, &(&1 + 1))
end)
end
defp determine_schema_type(schema) do
cond do
schema.type == :object -> :object
schema.type == :array -> :array
schema.type == :string && schema.enum != nil -> :enum
true -> schema.type || :unknown
end
end
defp format_method_breakdown(by_method) do
[:get, :post, :put, :patch, :delete]
|> Enum.map(fn method ->
count = Map.get(by_method, method, 0)
"- **#{String.upcase(to_string(method))}**: #{count}"
end)
|> Enum.join("\n")
end
defp format_coverage_recommendations(coverage) do
total = coverage.total_operations
with_responses = coverage.operations_with_all_responses
"""
- Total operations: #{total}
- Operations with comprehensive responses: #{with_responses}
- Coverage percentage: #{round(with_responses / total * 100)}%
Recommendations:
- Ensure all operations have at least success (2xx) and error (4xx) responses
- Add examples to schemas for better documentation
- Consider adding 5xx responses for server error scenarios
"""
end
# Comparison functions
defp find_added_paths(old_spec, new_spec) do
old_paths = MapSet.new(Map.keys(old_spec.paths || %{}))
new_paths = MapSet.new(Map.keys(new_spec.paths || %{}))
MapSet.difference(new_paths, old_paths) |> MapSet.to_list()
end
defp find_removed_paths(old_spec, new_spec) do
old_paths = MapSet.new(Map.keys(old_spec.paths || %{}))
new_paths = MapSet.new(Map.keys(new_spec.paths || %{}))
MapSet.difference(old_paths, new_paths) |> MapSet.to_list()
end
defp find_added_operations(old_spec, new_spec) do
old_ops = list_all_operations(old_spec) |> Enum.map(& &1.operation_id) |> MapSet.new()
new_ops = list_all_operations(new_spec) |> Enum.map(& &1.operation_id) |> MapSet.new()
MapSet.difference(new_ops, old_ops) |> MapSet.to_list()
end
defp find_removed_operations(old_spec, new_spec) do
old_ops = list_all_operations(old_spec) |> Enum.map(& &1.operation_id) |> MapSet.new()
new_ops = list_all_operations(new_spec) |> Enum.map(& &1.operation_id) |> MapSet.new()
MapSet.difference(old_ops, new_ops) |> MapSet.to_list()
end
defp find_schema_changes(old_spec, new_spec) do
old_schemas = old_spec.components[:schemas] || %{}
new_schemas = new_spec.components[:schemas] || %{}
%{
added:
MapSet.difference(MapSet.new(Map.keys(new_schemas)), MapSet.new(Map.keys(old_schemas)))
|> MapSet.to_list(),
removed:
MapSet.difference(MapSet.new(Map.keys(old_schemas)), MapSet.new(Map.keys(new_schemas)))
|> MapSet.to_list(),
modified: find_modified_schemas(old_schemas, new_schemas)
}
end
defp find_modified_schemas(old_schemas, new_schemas) do
Enum.reduce(old_schemas, [], fn {name, old_schema}, acc ->
case Map.get(new_schemas, name) do
nil ->
acc
new_schema ->
if schemas_differ?(old_schema, new_schema) do
[name | acc]
else
acc
end
end
end)
end
defp schemas_differ?(old_schema, new_schema) do
deep_schema_comparison(old_schema, new_schema)
end
# Comprehensive schema comparison that checks for semantic differences
defp deep_schema_comparison(old_schema, new_schema) when old_schema == new_schema, do: false
defp deep_schema_comparison(old_schema, new_schema)
when is_map(old_schema) and is_map(new_schema) do
old_keys = Map.keys(old_schema) |> MapSet.new()
new_keys = Map.keys(new_schema) |> MapSet.new()
# Check for added/removed keys
keys_differ = not MapSet.equal?(old_keys, new_keys)
# Check for value differences in common keys
common_keys = MapSet.intersection(old_keys, new_keys)
values_differ =
Enum.any?(common_keys, fn key ->
deep_schema_comparison(Map.get(old_schema, key), Map.get(new_schema, key))
end)
keys_differ or values_differ
end
defp deep_schema_comparison(old_schema, new_schema)
when is_list(old_schema) and is_list(new_schema) do
length(old_schema) != length(new_schema) or
Enum.zip(old_schema, new_schema)
|> Enum.any?(fn {old_item, new_item} -> deep_schema_comparison(old_item, new_item) end)
end
defp deep_schema_comparison(_old_schema, _new_schema), do: true
defp detect_breaking_changes(old_spec, new_spec) do
%{
removed_paths: find_removed_paths(old_spec, new_spec),
removed_operations: find_removed_operations(old_spec, new_spec),
# Would need to implement
removed_required_params: [],
# Would need to implement
removed_schema_fields: [],
# Would need to implement
narrowed_types: []
}
end
end