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

239 lines
7.0 KiB
Elixir

defmodule WandererAppWeb.ApiRouter do
@moduledoc """
Enhanced version-aware API router with structured route definitions.
This module provides:
- Consolidated v1 API routing
- Performance optimizations with compiled route patterns
- Enhanced error handling with suggestions
- Deprecation warnings and sunset date support
- Feature flag support per route
- Automatic JSON:API compliance
"""
use Phoenix.Router
import WandererAppWeb.ApiRouterHelpers
alias WandererAppWeb.{ApiRoutes, ApiRouter.RouteSpec}
require Logger
def init(opts), do: opts
def call(conn, _opts) do
version = conn.assigns[:api_version] || "1"
with {:ok, route_spec} <- find_matching_route(conn, version),
conn <- add_deprecation_warnings(conn, version),
conn <- add_version_features(conn, route_spec.features, version),
params <- extract_path_params(conn.path_info, route_spec.path) do
route_to_controller(conn, route_spec.controller, route_spec.action, params)
else
{:error, :route_not_found} ->
send_enhanced_not_found_error(conn, version)
{:error, :version_not_found} ->
send_version_not_found_error(conn, version)
{:error, reason} ->
send_routing_error(conn, reason)
end
end
# Get compiled routes - use runtime compilation for now for simplicity
defp get_compiled_routes do
Enum.map(ApiRoutes.table(), fn {version, routes} ->
compiled_routes = Enum.map(routes, &compile_route_pattern/1)
{version, compiled_routes}
end)
|> Map.new()
end
defp compile_route_pattern(%RouteSpec{} = route_spec) do
# Pre-compile regex patterns for dynamic segments if needed
# For now, we'll keep the simple atom-based matching
route_spec
end
defp find_matching_route(conn, version) do
compiled_routes = get_compiled_routes()
case Map.get(compiled_routes, version) do
nil ->
{:error, :version_not_found}
routes ->
case Enum.find(routes, &match_route?(conn, &1)) do
nil -> {:error, :route_not_found}
route_spec -> {:ok, route_spec}
end
end
end
defp match_route?(%Plug.Conn{method: method, path_info: path_info}, %RouteSpec{
verb: verb,
path: route_path
}) do
verb_atom = method |> String.downcase() |> String.to_atom()
verb_atom == verb and path_match?(path_info, route_path)
end
# Enhanced path matching with better performance
defp path_match?(path_segments, route_segments) do
path_match_recursive(path_segments, route_segments)
end
defp path_match_recursive([], []), do: true
defp path_match_recursive([h | t], [s | rest]) when is_binary(s) do
h == s and path_match_recursive(t, rest)
end
defp path_match_recursive([_h | t], [s | rest]) when is_atom(s) do
path_match_recursive(t, rest)
end
defp path_match_recursive(_, _), do: false
defp extract_path_params(path_info, route_path) do
Enum.zip(route_path, path_info)
|> Enum.filter(fn {segment, _value} -> is_atom(segment) end)
|> Map.new(fn {segment, value} -> {Atom.to_string(segment), value} end)
end
defp add_deprecation_warnings(conn, version) do
if ApiRoutes.deprecated?(version) do
sunset_date = ApiRoutes.sunset_date(version)
conn
|> put_resp_header("deprecation", "true")
|> put_resp_header("sunset", (sunset_date && Date.to_iso8601(sunset_date)) || "")
|> put_resp_header("link", "</api/v1>; rel=\"successor-version\"")
else
conn
end
end
defp add_version_features(conn, features, version) do
# Add feature flags based on route capabilities
conn =
Enum.reduce(features, conn, fn feature, acc ->
assign(acc, :"supports_#{feature}", true)
end)
conn
|> assign(:api_features, features)
|> assign(:api_version, version)
end
defp send_enhanced_not_found_error(conn, version) do
available_versions = ApiRoutes.available_versions()
suggested_routes = find_similar_routes(conn.path_info, version)
error_response = %{
error: %{
code: "ROUTE_NOT_FOUND",
message: "The requested route is not available in version #{version}",
details: %{
requested_path: "/" <> Enum.join(conn.path_info, "/"),
requested_method: conn.method,
requested_version: version,
available_versions: available_versions,
suggested_routes: suggested_routes
}
}
}
conn
|> put_status(404)
|> put_resp_content_type("application/json")
|> send_resp(404, Jason.encode!(error_response))
|> halt()
end
defp send_version_not_found_error(conn, version) do
available_versions = ApiRoutes.available_versions()
error_response = %{
error: %{
code: "VERSION_NOT_FOUND",
message: "API version #{version} is not supported",
details: %{
requested_version: version,
available_versions: available_versions,
upgrade_guide: "https://docs.wanderer.com/api/migration"
}
}
}
conn
|> put_status(404)
|> put_resp_content_type("application/json")
|> send_resp(404, Jason.encode!(error_response))
|> halt()
end
defp find_similar_routes(path_info, version) do
# Find routes with similar paths in current or other versions
all_routes = ApiRoutes.table()
Enum.flat_map(all_routes, fn {v, routes} ->
Enum.filter(routes, fn route_spec ->
similarity_score(path_info, route_spec.path) > 0.7
end)
|> Enum.map(fn route_spec ->
%{
version: v,
method: String.upcase(Atom.to_string(route_spec.verb)),
path: "/" <> Enum.join(route_spec.path, "/"),
description: get_in(route_spec.metadata, [:description])
}
end)
end)
|> Enum.take(3)
end
defp similarity_score(path1, path2) do
# Simple Jaccard similarity for path segments
set1 = MapSet.new(path1)
set2 = MapSet.new(path2)
intersection_size = MapSet.intersection(set1, set2) |> MapSet.size()
union_size = MapSet.union(set1, set2) |> MapSet.size()
if union_size == 0, do: 0, else: intersection_size / union_size
end
defp send_routing_error(conn, reason) do
Logger.error("API routing error: #{inspect(reason)}")
error_response = %{
error: %{
code: "ROUTING_ERROR",
message: "Internal routing error"
}
}
conn
|> put_status(500)
|> put_resp_content_type("application/json")
|> send_resp(500, Jason.encode!(error_response))
|> halt()
end
# Helper function to route to controller with path params
defp route_to_controller(conn, controller, action, path_params) do
conn = %{conn | params: Map.merge(conn.params, path_params)}
# Handle the different parameter names used by existing controllers
conn =
case path_params do
%{"map_id" => map_id} ->
%{conn | params: Map.put(conn.params, "map_identifier", map_id)}
_ ->
conn
end
controller.call(conn, controller.init(action))
end
end