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

651 lines
18 KiB
Elixir

defmodule WandererAppWeb.OpenAPISchemaEvolution do
@moduledoc """
Tools for detecting and tracking OpenAPI schema evolution.
This module helps identify breaking changes in API specifications
and generates migration guides when schemas evolve.
"""
# alias WandererAppWeb.OpenAPISpecAnalyzer # Currently unused
@breaking_change_types [
:removed_endpoint,
:removed_operation,
:removed_required_field,
:removed_enum_value,
:type_narrowing,
:removed_response_code,
:required_field_added,
:parameter_location_changed
]
@doc """
Detects breaking changes between two API specifications.
"""
def detect_breaking_changes(old_spec, new_spec) do
%{
endpoints: analyze_endpoint_changes(old_spec, new_spec),
operations: analyze_operation_changes(old_spec, new_spec),
schemas: analyze_schema_changes(old_spec, new_spec),
parameters: analyze_parameter_changes(old_spec, new_spec),
responses: analyze_response_changes(old_spec, new_spec)
}
|> identify_breaking_changes()
end
@doc """
Generates a changelog between two specifications.
"""
def generate_changelog(old_spec, new_spec, options \\ []) do
changes = detect_all_changes(old_spec, new_spec)
version = options[:version] || new_spec.info.version
"""
# API Changelog - Version #{version}
#{format_breaking_changes(changes.breaking)}
#{format_deprecations(changes.deprecations)}
#{format_additions(changes.additions)}
#{format_modifications(changes.modifications)}
"""
end
@doc """
Validates that a new spec is backwards compatible with an old spec.
"""
def validate_backwards_compatibility(old_spec, new_spec) do
breaking_changes = detect_breaking_changes(old_spec, new_spec)
case count_breaking_changes(breaking_changes) do
0 ->
{:ok, "No breaking changes detected"}
count ->
{:error, format_validation_errors(breaking_changes, count)}
end
end
@doc """
Generates a migration guide for breaking changes.
"""
def generate_migration_guide(old_spec, new_spec) do
breaking_changes = detect_breaking_changes(old_spec, new_spec)
"""
# API Migration Guide
## Overview
This guide helps you migrate from API version #{old_spec.info.version} to #{new_spec.info.version}.
## Breaking Changes
#{format_migration_steps(breaking_changes)}
## Recommended Migration Order
#{format_migration_order(breaking_changes)}
"""
end
# Private functions
defp analyze_endpoint_changes(old_spec, new_spec) do
old_paths = Map.keys(old_spec.paths || %{})
new_paths = Map.keys(new_spec.paths || %{})
%{
removed: old_paths -- new_paths,
added: new_paths -- old_paths,
modified: find_modified_endpoints(old_spec, new_spec)
}
end
defp analyze_operation_changes(old_spec, new_spec) do
old_ops = extract_all_operations(old_spec)
new_ops = extract_all_operations(new_spec)
old_op_ids = Map.keys(old_ops)
new_op_ids = Map.keys(new_ops)
%{
removed: old_op_ids -- new_op_ids,
added: new_op_ids -- old_op_ids,
modified: find_modified_operations(old_ops, new_ops)
}
end
defp analyze_schema_changes(old_spec, new_spec) do
old_schemas = old_spec.components[:schemas] || %{}
new_schemas = new_spec.components[:schemas] || %{}
old_names = Map.keys(old_schemas)
new_names = Map.keys(new_schemas)
modified =
Enum.reduce(old_names, [], fn name, acc ->
case Map.get(new_schemas, name) do
nil ->
acc
new_schema ->
old_schema = Map.get(old_schemas, name)
changes = compare_schemas(old_schema, new_schema)
if changes != [] do
[{name, changes} | acc]
else
acc
end
end
end)
%{
removed: old_names -- new_names,
added: new_names -- old_names,
modified: modified
}
end
defp analyze_parameter_changes(old_spec, new_spec) do
old_ops = extract_all_operations(old_spec)
new_ops = extract_all_operations(new_spec)
Enum.reduce(old_ops, [], fn {op_id, old_op}, acc ->
case Map.get(new_ops, op_id) do
nil ->
acc
new_op ->
param_changes =
compare_parameters(
old_op.parameters || [],
new_op.parameters || []
)
if param_changes != %{} do
[{op_id, param_changes} | acc]
else
acc
end
end
end)
end
defp analyze_response_changes(old_spec, new_spec) do
old_ops = extract_all_operations(old_spec)
new_ops = extract_all_operations(new_spec)
Enum.reduce(old_ops, [], fn {op_id, old_op}, acc ->
case Map.get(new_ops, op_id) do
nil ->
acc
new_op ->
response_changes =
compare_responses(
old_op.responses || %{},
new_op.responses || %{}
)
if response_changes != %{} do
[{op_id, response_changes} | acc]
else
acc
end
end
end)
end
defp extract_all_operations(spec) do
Enum.reduce(spec.paths || %{}, %{}, fn {path, path_item}, acc ->
path_item
|> Map.from_struct()
|> Enum.filter(fn {method, _} -> method in [:get, :post, :put, :patch, :delete] end)
|> Enum.reduce(acc, fn {method, operation}, inner_acc ->
op_id = operation[:operation_id] || "#{method}_#{path}"
Map.put(inner_acc, op_id, Map.put(operation, :_path, path))
end)
end)
end
defp find_modified_endpoints(old_spec, new_spec) do
common_paths =
MapSet.intersection(
MapSet.new(Map.keys(old_spec.paths || %{})),
MapSet.new(Map.keys(new_spec.paths || %{}))
)
Enum.reduce(common_paths, [], fn path, acc ->
old_item = Map.get(old_spec.paths, path)
new_item = Map.get(new_spec.paths, path)
if path_item_modified?(old_item, new_item) do
[path | acc]
else
acc
end
end)
end
defp find_modified_operations(old_ops, new_ops) do
common_ids =
MapSet.intersection(
MapSet.new(Map.keys(old_ops)),
MapSet.new(Map.keys(new_ops))
)
Enum.reduce(common_ids, [], fn op_id, acc ->
old_op = Map.get(old_ops, op_id)
new_op = Map.get(new_ops, op_id)
if operation_modified?(old_op, new_op) do
[{op_id, describe_operation_changes(old_op, new_op)} | acc]
else
acc
end
end)
end
defp compare_schemas(old_schema, new_schema) do
changes = []
# Check type changes
changes =
if old_schema.type != new_schema.type do
[{:type_changed, old_schema.type, new_schema.type} | changes]
else
changes
end
# Check required fields
old_required = MapSet.new(old_schema[:required] || [])
new_required = MapSet.new(new_schema[:required] || [])
removed_required = MapSet.difference(old_required, new_required) |> MapSet.to_list()
added_required = MapSet.difference(new_required, old_required) |> MapSet.to_list()
changes2 =
if removed_required != [] do
[{:required_fields_removed, removed_required} | changes]
else
changes
end
changes3 =
if added_required != [] do
[{:required_fields_added, added_required} | changes2]
else
changes2
end
# Check properties (for object schemas)
if old_schema.type == :object && new_schema.type == :object do
old_props = Map.keys(old_schema[:properties] || %{})
new_props = Map.keys(new_schema[:properties] || %{})
removed_props = old_props -- new_props
if removed_props != [] do
[{:properties_removed, removed_props} | changes3]
else
changes3
end
else
changes3
end
end
defp compare_parameters(old_params, new_params) do
old_by_name = Enum.group_by(old_params, & &1.name)
new_by_name = Enum.group_by(new_params, & &1.name)
removed = Map.keys(old_by_name) -- Map.keys(new_by_name)
added = Map.keys(new_by_name) -- Map.keys(old_by_name)
modified =
Enum.reduce(old_by_name, [], fn {name, [old_param]}, acc ->
case Map.get(new_by_name, name) do
nil ->
acc
[new_param] ->
if parameter_modified?(old_param, new_param) do
[{name, describe_parameter_changes(old_param, new_param)} | acc]
else
acc
end
end
end)
%{
removed: removed,
added: added,
modified: modified
}
end
defp compare_responses(old_responses, new_responses) do
old_codes = Map.keys(old_responses)
new_codes = Map.keys(new_responses)
removed = old_codes -- new_codes
added = new_codes -- old_codes
%{
removed: removed,
added: added
}
end
defp path_item_modified?(old_item, new_item) do
# Simple comparison - could be more sophisticated
old_item != new_item
end
defp operation_modified?(old_op, new_op) do
# Check various aspects that might have changed
old_op[:deprecated] != new_op[:deprecated] ||
old_op[:security] != new_op[:security] ||
length(old_op[:parameters] || []) != length(new_op[:parameters] || []) ||
Map.keys(old_op[:responses] || %{}) != Map.keys(new_op[:responses] || %{})
end
defp parameter_modified?(old_param, new_param) do
old_param.required != new_param.required ||
old_param.in != new_param.in ||
old_param.schema != new_param.schema
end
defp describe_operation_changes(old_op, new_op) do
changes = []
changes =
if old_op[:deprecated] != new_op[:deprecated] do
[{:deprecated, new_op[:deprecated]} | changes]
else
changes
end
changes =
if old_op[:security] != new_op[:security] do
[{:security_changed, old_op[:security], new_op[:security]} | changes]
else
changes
end
changes
end
defp describe_parameter_changes(old_param, new_param) do
changes = []
changes =
if old_param.required != new_param.required do
[{:required_changed, old_param.required, new_param.required} | changes]
else
changes
end
changes =
if old_param.in != new_param.in do
[{:location_changed, old_param.in, new_param.in} | changes]
else
changes
end
changes
end
defp identify_breaking_changes(all_changes) do
breaking = []
# Removed endpoints are breaking
breaking =
breaking ++
Enum.map(all_changes.endpoints.removed, fn path ->
%{type: :removed_endpoint, path: path}
end)
# Removed operations are breaking
breaking =
breaking ++
Enum.map(all_changes.operations.removed, fn op_id ->
%{type: :removed_operation, operation_id: op_id}
end)
# Analyze schema changes for breaking changes
breaking =
breaking ++
Enum.flat_map(all_changes.schemas.modified, fn {schema_name, changes} ->
Enum.flat_map(changes, fn
{:required_fields_added, fields} ->
Enum.map(fields, fn field ->
%{type: :required_field_added, schema: schema_name, field: field}
end)
{:properties_removed, props} ->
Enum.map(props, fn prop ->
%{type: :removed_field, schema: schema_name, field: prop}
end)
_ ->
[]
end)
end)
# Parameter removals are breaking
breaking =
breaking ++
Enum.flat_map(all_changes.parameters, fn {op_id, param_changes} ->
Enum.map(param_changes.removed, fn param_name ->
%{type: :removed_parameter, operation_id: op_id, parameter: param_name}
end)
end)
# Response removals might be breaking
breaking =
breaking ++
Enum.flat_map(all_changes.responses, fn {op_id, response_changes} ->
Enum.flat_map(response_changes.removed, fn status_code ->
# Only 2xx removals are typically breaking
if String.starts_with?(to_string(status_code), "2") do
[%{type: :removed_response_code, operation_id: op_id, status_code: status_code}]
else
[]
end
end)
end)
breaking
end
defp detect_all_changes(old_spec, new_spec) do
breaking_changes = detect_breaking_changes(old_spec, new_spec)
%{
breaking: breaking_changes,
deprecations: detect_deprecations(old_spec, new_spec),
additions: detect_additions(old_spec, new_spec),
modifications: detect_modifications(old_spec, new_spec)
}
end
defp detect_deprecations(_old_spec, new_spec) do
new_ops = extract_all_operations(new_spec)
Enum.reduce(new_ops, [], fn {op_id, op}, acc ->
if op[:deprecated] == true do
[%{operation_id: op_id, path: op[:_path]} | acc]
else
acc
end
end)
end
defp detect_additions(old_spec, new_spec) do
%{
endpoints: Map.keys(new_spec.paths || %{}) -- Map.keys(old_spec.paths || %{}),
operations:
extract_all_operations(new_spec)
|> Map.keys()
|> Kernel.--(extract_all_operations(old_spec) |> Map.keys()),
schemas:
Map.keys(new_spec.components[:schemas] || %{}) --
Map.keys(old_spec.components[:schemas] || %{})
}
end
defp detect_modifications(_old_spec, _new_spec) do
# This would include non-breaking modifications
[]
end
defp count_breaking_changes(breaking_changes) when is_list(breaking_changes) do
length(breaking_changes)
end
defp count_breaking_changes(breaking_changes) when is_map(breaking_changes) do
breaking_changes
|> Map.values()
|> Enum.reduce(0, fn changes, acc ->
cond do
is_list(changes) -> acc + length(changes)
is_map(changes) -> acc + map_size(changes)
true -> acc
end
end)
end
defp format_breaking_changes([]), do: "## Breaking Changes\n\nNo breaking changes detected! ✅"
defp format_breaking_changes(changes) do
"""
## ⚠️ Breaking Changes
#{Enum.map_join(changes, "\n", &format_breaking_change/1)}
"""
end
defp format_breaking_change(%{type: :removed_endpoint, path: path}) do
"- **Removed endpoint**: `#{path}`"
end
defp format_breaking_change(%{type: :removed_operation, operation_id: op_id}) do
"- **Removed operation**: `#{op_id}`"
end
defp format_breaking_change(%{type: :required_field_added, schema: schema, field: field}) do
"- **New required field**: `#{field}` added to schema `#{schema}`"
end
defp format_breaking_change(%{type: :removed_field, schema: schema, field: field}) do
"- **Removed field**: `#{field}` removed from schema `#{schema}`"
end
defp format_breaking_change(change) do
"- **Change**: #{inspect(change)}"
end
defp format_deprecations([]), do: "## Deprecations\n\nNo new deprecations."
defp format_deprecations(deprecations) do
"""
## Deprecations
#{Enum.map_join(deprecations, "\n", fn dep -> "- Operation `#{dep.operation_id}` at `#{dep.path}` is now deprecated" end)}
"""
end
defp format_additions(%{endpoints: [], operations: [], schemas: []}),
do: "## Additions\n\nNo new additions."
defp format_additions(additions) do
"""
## Additions
### New Endpoints
#{format_list(additions.endpoints, "No new endpoints")}
### New Operations
#{format_list(additions.operations, "No new operations")}
### New Schemas
#{format_list(additions.schemas, "No new schemas")}
"""
end
defp format_modifications([]), do: "## Other Modifications\n\nNo other modifications."
defp format_modifications(mods) do
"""
## Other Modifications
#{Enum.map_join(mods, "\n", &format_modification/1)}
"""
end
defp format_modification(mod), do: "- #{inspect(mod)}"
defp format_list([], empty_message), do: empty_message
defp format_list(items, _), do: Enum.map_join(items, "\n", fn item -> "- `#{item}`" end)
defp format_validation_errors(breaking_changes, count) do
"""
API specification is not backwards compatible!
Found #{count} breaking change(s):
#{Enum.map_join(breaking_changes, "\n", &format_breaking_change/1)}
To proceed with these breaking changes, increment the API major version.
"""
end
defp format_migration_steps(breaking_changes) when is_list(breaking_changes) do
if breaking_changes == [] do
"No breaking changes requiring migration."
else
Enum.map_join(breaking_changes, "\n\n", &format_migration_step/1)
end
end
defp format_migration_step(%{type: :removed_endpoint, path: path}) do
"""
### Removed Endpoint: `#{path}`
**Action Required**: Update your code to use alternative endpoints or remove calls to this endpoint.
"""
end
defp format_migration_step(%{type: :required_field_added, schema: schema, field: field}) do
"""
### New Required Field: `#{field}` in `#{schema}`
**Action Required**: Update all requests that create or update `#{schema}` to include the `#{field}` field.
"""
end
defp format_migration_step(change) do
"""
### Change: #{inspect(change.type)}
**Action Required**: Review and update affected code.
Details: #{inspect(change)}
"""
end
defp format_migration_order(breaking_changes) when is_list(breaking_changes) do
if breaking_changes == [] do
"No specific migration order required."
else
"""
1. Update request payloads for new required fields
2. Update response handling for removed fields
3. Replace calls to removed endpoints
4. Update parameter usage for changed parameters
"""
end
end
end