Files
wanderer/lib/wanderer_app_web/api_router/introspection.ex
2025-07-16 23:17:47 +00:00

401 lines
9.8 KiB
Elixir

defmodule WandererAppWeb.ApiRouter.Introspection do
@moduledoc """
Route introspection and documentation generation for the API.
This module provides utilities for analyzing routes, generating
documentation, and creating OpenAPI specifications.
"""
alias WandererAppWeb.{ApiRoutes, ApiRouter.RouteSpec}
@doc """
List all routes for a specific version or all versions.
"""
def list_routes(version \\ nil) do
case version do
nil -> ApiRoutes.table()
v -> %{v => ApiRoutes.routes_for_version(v)}
end
end
@doc """
Find route information by version, method, and path.
"""
def route_info(version, method, path) do
routes = ApiRoutes.routes_for_version(version)
method_atom = method |> String.downcase() |> String.to_atom()
normalized_path = normalize_path(path)
Enum.find(routes, fn route_spec ->
route_spec.verb == method_atom and
path_matches?(normalized_path, route_spec.path)
end)
end
@doc """
Get all routes that match a specific controller.
"""
def routes_for_controller(controller, version \\ nil) do
routes =
case version do
nil ->
ApiRoutes.table()
|> Enum.flat_map(fn {_v, routes} -> routes end)
v ->
ApiRoutes.routes_for_version(v)
end
Enum.filter(routes, fn route_spec ->
route_spec.controller == controller
end)
end
@doc """
Get all routes that support a specific feature.
"""
def routes_with_feature(feature, version \\ nil) do
routes =
case version do
nil ->
ApiRoutes.table()
|> Enum.flat_map(fn {_v, routes} -> routes end)
v ->
ApiRoutes.routes_for_version(v)
end
Enum.filter(routes, fn route_spec ->
feature in route_spec.features
end)
end
@doc """
Generate OpenAPI 3.0 specification for a version.
"""
def generate_openapi_spec(version) do
routes = ApiRoutes.routes_for_version(version)
%{
openapi: "3.0.0",
info: %{
title: "Wanderer API",
version: version,
description: "EVE Online mapping tool API",
contact: %{
name: "Wanderer Support",
url: "https://docs.wanderer.com"
}
},
servers: [
%{
url: "/api/v#{version}",
description: "API v#{version} endpoint"
}
],
paths: generate_paths(routes),
components: %{
securitySchemes: %{
bearerAuth: %{
type: "http",
scheme: "bearer",
bearerFormat: "JWT"
}
}
}
}
end
@doc """
Generate a simple route summary for documentation.
"""
def generate_route_summary(version \\ "1") do
routes = ApiRoutes.routes_for_version(version)
Enum.group_by(routes, fn route_spec ->
# Group by controller for better organization
route_spec.controller
|> Module.split()
|> List.last()
|> String.replace("Controller", "")
end)
|> Enum.map(fn {controller_name, controller_routes} ->
%{
controller: controller_name,
routes: Enum.map(controller_routes, &route_to_summary/1)
}
end)
end
@doc """
Validate route definitions and return any issues.
"""
def validate_routes(version \\ nil) do
routes =
case version do
nil ->
ApiRoutes.table()
|> Enum.flat_map(fn {v, routes} ->
Enum.map(routes, fn route -> {v, route} end)
end)
v ->
ApiRoutes.routes_for_version(v)
|> Enum.map(fn route -> {v, route} end)
end
Enum.reduce(routes, [], fn {v, route_spec}, errors ->
case RouteSpec.validate(route_spec) do
{:ok, _} -> errors
{:error, error} -> [{v, route_spec, error} | errors]
end
end)
end
@doc """
Find duplicate routes (same method and path).
"""
def find_duplicate_routes(version \\ nil) do
routes =
case version do
nil ->
ApiRoutes.table()
|> Enum.flat_map(fn {v, routes} ->
Enum.map(routes, fn route -> {v, route} end)
end)
v ->
ApiRoutes.routes_for_version(v)
|> Enum.map(fn route -> {v, route} end)
end
routes
|> Enum.group_by(fn {_v, route_spec} ->
{route_spec.verb, normalize_path_for_comparison(route_spec.path)}
end)
|> Enum.filter(fn {_key, group} -> length(group) > 1 end)
|> Enum.map(fn {key, duplicates} ->
%{
route_signature: key,
duplicates: duplicates
}
end)
end
# Private helper functions
defp normalize_path(path) when is_list(path), do: path
defp normalize_path(path) when is_binary(path) do
path
|> String.split("/")
|> Enum.reject(&(&1 == ""))
end
defp normalize_path_for_comparison(path) do
# Replace parameter atoms with a standard placeholder for comparison
Enum.map(path, fn
segment when is_atom(segment) -> ":param"
segment -> segment
end)
end
defp path_matches?(request_path, route_path) when length(request_path) != length(route_path) do
false
end
defp path_matches?([], []), do: true
defp path_matches?([req_segment | req_rest], [route_segment | route_rest])
when is_binary(route_segment) do
req_segment == route_segment and path_matches?(req_rest, route_rest)
end
defp path_matches?([_req_segment | req_rest], [route_segment | route_rest])
when is_atom(route_segment) do
# Route segment is a parameter (atom), so it matches any request segment
path_matches?(req_rest, route_rest)
end
defp path_matches?(_, _), do: false
defp generate_paths(routes) do
routes
|> Enum.group_by(&openapi_path_key/1)
|> Enum.reduce(%{}, fn {path_key, path_routes}, acc ->
operations =
Enum.reduce(path_routes, %{}, fn route_spec, ops ->
method_key = Atom.to_string(route_spec.verb)
operation = generate_operation(route_spec)
Map.put(ops, method_key, operation)
end)
Map.put(acc, path_key, operations)
end)
end
defp openapi_path_key(route_spec) do
"/" <>
Enum.join(
Enum.map(route_spec.path, fn
segment when is_atom(segment) -> "{#{segment}}"
segment -> segment
end),
"/"
)
end
defp generate_operation(route_spec) do
metadata = route_spec.metadata
%{
summary: Map.get(metadata, :description, ""),
operationId: "#{route_spec.controller}_#{route_spec.action}",
tags: [extract_tag_from_controller(route_spec.controller)],
parameters: generate_parameters(route_spec),
responses: generate_responses(route_spec),
security: if(Map.get(metadata, :auth_required, false), do: [%{bearerAuth: []}], else: [])
}
end
defp extract_tag_from_controller(controller) do
controller
|> Module.split()
|> List.last()
|> String.replace(~r/(API)?Controller$/, "")
end
defp generate_parameters(route_spec) do
# Extract path parameters
path_params =
route_spec.path
|> Enum.filter(&is_atom/1)
|> Enum.map(fn param ->
%{
name: Atom.to_string(param),
in: "path",
required: true,
schema: %{type: "string"}
}
end)
# Add query parameters based on features
query_params =
route_spec.features
|> Enum.flat_map(&feature_to_parameters/1)
path_params ++ query_params
end
defp feature_to_parameters("filtering") do
[
%{
name: "filter",
in: "query",
required: false,
schema: %{type: "object"},
description: "Filter parameters"
}
]
end
defp feature_to_parameters("sorting") do
[
%{
name: "sort",
in: "query",
required: false,
schema: %{type: "string"},
description: "Sort fields (comma-separated)"
}
]
end
defp feature_to_parameters("pagination") do
[
%{
name: "page[number]",
in: "query",
required: false,
schema: %{type: "integer", minimum: 1},
description: "Page number"
},
%{
name: "page[size]",
in: "query",
required: false,
schema: %{type: "integer", minimum: 1, maximum: 100},
description: "Page size"
}
]
end
defp feature_to_parameters("includes") do
[
%{
name: "include",
in: "query",
required: false,
schema: %{type: "string"},
description: "Related resources to include (comma-separated)"
}
]
end
defp feature_to_parameters("sparse_fieldsets") do
[
%{
name: "fields",
in: "query",
required: false,
schema: %{type: "object"},
description: "Sparse fieldsets"
}
]
end
defp feature_to_parameters(_), do: []
defp generate_responses(route_spec) do
metadata = route_spec.metadata
success_status = Map.get(metadata, :success_status, 200)
content_type = Map.get(metadata, :content_type, "application/vnd.api+json")
responses = %{
to_string(success_status) => %{
description: "Success",
content: %{
content_type => %{
schema: %{type: "object"}
}
}
}
}
# Add error responses
if Map.get(metadata, :auth_required, false) do
Map.put(responses, "401", %{
description: "Unauthorized",
content: %{
"application/json" => %{
schema: %{type: "object"}
}
}
})
else
responses
end
end
defp route_to_summary(route_spec) do
%{
method: String.upcase(Atom.to_string(route_spec.verb)),
path: "/" <> Enum.join(route_spec.path, "/"),
action: route_spec.action,
features: route_spec.features,
auth_required: get_in(route_spec.metadata, [:auth_required]),
description: get_in(route_spec.metadata, [:description])
}
end
end