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

16 KiB

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

1. Enhanced Route Definition Structure

Replace tuple-based definitions with structured specs:

# 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:

# 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

# 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

# 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

# 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