Files
wanderer/API_ROUTING_IMPROVEMENTS.md
T
2025-07-16 23:17:47 +00:00

532 lines
16 KiB
Markdown

# API Routing System Improvements
## Overview
This document outlines recommended improvements to the existing API routing system, building upon the current version-based routing approach with enhanced structure, performance, and maintainability.
## Current Architecture Analysis
**Strengths:**
- Clear version-based route organization
- Centralized route definitions
- Feature-based capability tracking
- Clean separation of concerns
**Areas for Improvement:**
- Limited metadata for routes
- Basic error handling
- No performance optimizations
- Missing deprecation support
- Minimal testing structure
## Recommended Improvements
### 1. Enhanced Route Definition Structure
Replace tuple-based definitions with structured specs:
```elixir
# lib/wanderer_app_web/api_router/route_spec.ex
defmodule WandererAppWeb.ApiRouter.RouteSpec do
@type t :: %__MODULE__{
verb: atom(),
path: [String.t() | atom()],
controller: module(),
action: atom(),
features: [String.t()],
metadata: map()
}
@enforce_keys [:verb, :path, :controller, :action]
defstruct [
:verb,
:path,
:controller,
:action,
features: [],
metadata: %{}
]
end
```
Updated route definitions:
```elixir
# lib/wanderer_app_web/api_router/routes.ex
defmodule WandererAppWeb.ApiRoutes do
alias WandererAppWeb.ApiRouter.RouteSpec
@route_definitions %{
"1" => [
%RouteSpec{
verb: :get,
path: ~w(api v1 maps),
controller: MapAPIController,
action: :index_v1,
features: ~w(filtering sorting pagination includes),
metadata: %{
auth_required: false,
rate_limit: :standard,
success_status: 200,
content_type: "application/vnd.api+json",
description: "List all maps with full feature set"
}
},
%RouteSpec{
verb: :get,
path: ~w(api v1 maps :id),
controller: MapAPIController,
action: :show_v1,
features: ~w(sparse_fieldsets),
metadata: %{
auth_required: false,
rate_limit: :standard,
success_status: 200,
content_type: "application/vnd.api+json",
description: "Show a specific map"
}
},
%RouteSpec{
verb: :post,
path: ~w(api v1 maps),
controller: MapAPIController,
action: :create_v1,
features: [],
metadata: %{
auth_required: true,
rate_limit: :strict,
success_status: 201,
error_status: 422,
content_type: "application/vnd.api+json",
description: "Create a new map"
}
},
%RouteSpec{
verb: :put,
path: ~w(api v1 maps :id),
controller: MapAPIController,
action: :update_v1,
features: [],
metadata: %{
auth_required: true,
rate_limit: :standard,
success_status: 200,
content_type: "application/vnd.api+json",
description: "Update an existing map"
}
},
%RouteSpec{
verb: :delete,
path: ~w(api v1 maps :id),
controller: MapAPIController,
action: :delete_v1,
features: [],
metadata: %{
auth_required: true,
rate_limit: :standard,
success_status: 204,
content_type: "application/vnd.api+json",
description: "Delete a map"
}
},
%RouteSpec{
verb: :post,
path: ~w(api v1 maps :id duplicate),
controller: MapAPIController,
action: :duplicate_v1,
features: [],
metadata: %{
auth_required: true,
rate_limit: :strict,
success_status: 201,
content_type: "application/vnd.api+json",
description: "Duplicate an existing map"
}
},
%RouteSpec{
verb: :get,
path: ~w(api v1 characters),
controller: CharactersAPIController,
action: :index_v1,
features: ~w(filtering sorting pagination),
metadata: %{
auth_required: true,
rate_limit: :standard,
success_status: 200,
content_type: "application/vnd.api+json",
description: "List user characters"
}
},
%RouteSpec{
verb: :get,
path: ~w(api v1 characters :id),
controller: CharactersAPIController,
action: :show_v1,
features: [],
metadata: %{
auth_required: true,
rate_limit: :standard,
success_status: 200,
content_type: "application/vnd.api+json",
description: "Show a specific character"
}
}
]
}
@deprecated_versions []
@sunset_dates %{}
def table, do: @route_definitions
def deprecated_versions, do: @deprecated_versions
def sunset_date(version), do: Map.get(@sunset_dates, version)
end
```
### 2. Enhanced Dispatcher Implementation
```elixir
# lib/wanderer_app_web/api_router.ex
defmodule WandererAppWeb.ApiRouter do
use Phoenix.Router
import WandererAppWeb.ApiRouterHelpers
alias WandererAppWeb.{ApiRoutes, ApiRouter.RouteSpec}
require Logger
# Compile route patterns at module load time for performance
@compiled_routes compile_all_routes()
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, reason} ->
send_routing_error(conn, reason)
end
end
# Compile route patterns for faster matching
defp compile_all_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
pattern = create_match_pattern(route_spec.path)
%{route_spec | metadata: Map.put(route_spec.metadata, :compiled_pattern, pattern)}
end
defp find_matching_route(conn, version) do
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 version in ApiRoutes.deprecated_versions() 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/versions>; rel=\"successor-version\"")
else
conn
end
end
defp send_enhanced_not_found_error(conn, version) do
available_versions = Map.keys(ApiRoutes.table())
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")
|> json(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: 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)}")
conn
|> put_status(500)
|> json(%{error: %{code: "ROUTING_ERROR", message: "Internal routing error"}})
|> halt()
end
# Existing helper functions remain the same
defp route_to_controller(conn, controller, action, params) do
conn = %{conn | params: Map.merge(conn.params, params)}
controller.call(conn, action)
end
defp add_version_features(conn, features, version) do
assign(conn, :api_features, features)
|> assign(:api_version, version)
end
end
```
### 3. Route Introspection and Documentation
```elixir
# lib/wanderer_app_web/api_router/introspection.ex
defmodule WandererAppWeb.ApiRouter.Introspection do
alias WandererAppWeb.ApiRoutes
def list_routes(version \\ nil) do
case version do
nil -> ApiRoutes.table()
v -> Map.get(ApiRoutes.table(), v, [])
end
end
def route_info(version, method, path) do
routes = list_routes(version)
Enum.find(routes, fn route_spec ->
route_spec.verb == method and
normalize_path(route_spec.path) == normalize_path(path)
end)
end
def generate_openapi_spec(version) do
routes = list_routes(version)
%{
openapi: "3.0.0",
info: %{
title: "Wanderer API",
version: version
},
paths: generate_paths(routes)
}
end
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 generate_paths(routes) do
Enum.reduce(routes, %{}, fn route_spec, acc ->
path_key = "/" <> Enum.join(route_spec.path, "/")
method_key = Atom.to_string(route_spec.verb)
operation = %{
summary: get_in(route_spec.metadata, [:description]),
responses: generate_responses(route_spec)
}
put_in(acc, [path_key, method_key], operation)
end)
end
defp generate_responses(route_spec) do
success_status = get_in(route_spec.metadata, [:success_status]) || 200
%{
to_string(success_status) => %{
description: "Success",
content: %{
get_in(route_spec.metadata, [:content_type]) || "application/json" => %{}
}
}
}
end
end
```
### 4. Testing Strategy
```elixir
# test/wanderer_app_web/api_router_test.exs
defmodule WandererAppWeb.ApiRouterTest do
use WandererAppWeb.ConnCase
alias WandererAppWeb.ApiRouter
describe "route matching" do
test "matches exact routes correctly" do
conn = build_conn(:get, "/api/maps")
|> assign(:api_version, "1.0")
# Test that the route matches and calls correct controller
assert %{controller: MapAPIController, action: :index_v1_0} =
extract_route_info(conn)
end
test "handles dynamic segments" do
conn = build_conn(:get, "/api/maps/123")
|> assign(:api_version, "1.0")
result = extract_route_info(conn)
assert result.params["id"] == "123"
end
test "returns 404 for non-existent routes" do
conn = build_conn(:get, "/api/nonexistent")
|> assign(:api_version, "1.0")
|> ApiRouter.call([])
assert conn.status == 404
assert %{"error" => %{"code" => "ROUTE_NOT_FOUND"}} = json_response(conn, 404)
end
end
describe "version handling" do
test "adds deprecation headers for deprecated versions" do
conn = build_conn(:get, "/api/maps")
|> assign(:api_version, "1.0")
|> ApiRouter.call([])
assert get_resp_header(conn, "deprecation") == ["true"]
end
test "suggests alternative routes" do
conn = build_conn(:get, "/api/maps/unknown-action")
|> assign(:api_version, "1.0")
|> ApiRouter.call([])
response = json_response(conn, 404)
assert is_list(response["error"]["details"]["suggested_routes"])
end
end
describe "feature flags" do
test "adds correct features for version" do
conn = build_conn(:get, "/api/maps")
|> assign(:api_version, "1.1")
|> ApiRouter.call([])
expected_features = ~w(filtering sorting pagination)
assert conn.assigns.api_features == expected_features
end
end
# Helper to extract route info without calling controller
defp extract_route_info(conn) do
# Mock implementation that returns route matching info
# without actually calling the controller
end
end
```
### 5. Performance Optimizations
1. **Compile-time route compilation**: Pre-compile route patterns during module loading
2. **Route caching**: Cache frequently accessed route information
3. **Pattern matching optimization**: Use more efficient matching algorithms for large route sets
4. **Lazy loading**: Load route definitions only when needed
### 6. Migration Strategy
1. **Phase 1**: Implement RouteSpec structure alongside existing tuples
2. **Phase 2**: Update route definitions to use new structure
3. **Phase 3**: Enhance dispatcher with new features
4. **Phase 4**: Add introspection and testing improvements
5. **Phase 5**: Remove old tuple-based system
## Benefits
- **Better Performance**: Compiled route patterns and optimized matching
- **Enhanced Error Handling**: Detailed error responses with suggestions
- **Version Management**: Built-in deprecation and sunset date support
- **Documentation**: Automatic OpenAPI spec generation
- **Testing**: Comprehensive test coverage for routing logic
- **Maintainability**: Structured, type-safe route definitions
## Implementation Notes
- All changes are backward compatible during migration
- Performance improvements are measurable with large route sets
- Error responses follow JSON:API error specification
- Introspection capabilities enable automatic API documentation
- Testing strategy covers edge cases and version transitions