mirror of
https://github.com/wanderer-industries/wanderer
synced 2026-04-30 22:40:30 +00:00
651 lines
18 KiB
Elixir
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
|