defmodule WandererAppWeb.Plugs.ApiVersioning do @moduledoc """ API versioning middleware that handles version negotiation and routing. This plug provides: - Version detection from URL path, headers, or parameters - Version validation and compatibility checking - Deprecation warnings and migration notices - Default version handling - Version-specific feature flags """ import Plug.Conn alias WandererApp.SecurityAudit alias WandererApp.Audit.RequestContext @supported_versions ["1"] @default_version "1" @deprecated_versions [] @minimum_version "1" @maximum_version "1" # Version detection methods (in order of precedence) @version_methods [:path, :header, :query_param, :default] def init(opts) do opts |> Keyword.put_new(:supported_versions, @supported_versions) |> Keyword.put_new(:default_version, @default_version) |> Keyword.put_new(:deprecated_versions, @deprecated_versions) |> Keyword.put_new(:minimum_version, @minimum_version) |> Keyword.put_new(:maximum_version, @maximum_version) |> Keyword.put_new(:version_methods, @version_methods) |> Keyword.put_new(:deprecation_warnings, true) |> Keyword.put_new(:strict_versioning, false) end def call(conn, opts) do start_time = System.monotonic_time(:millisecond) # Fetch query params if they haven't been fetched yet conn = if conn.query_params == %Plug.Conn.Unfetched{} do Plug.Conn.fetch_query_params(conn) else conn end case detect_api_version(conn, opts) do {:ok, version, method} -> conn = conn |> assign(:api_version, version) |> assign(:version_method, method) # Validate version and handle errors case validate_version(conn, version, opts) do %{halted: true} = halted_conn -> halted_conn validated_conn -> validated_conn |> add_version_headers(version) |> handle_deprecation_warnings(version, opts) |> log_version_usage(version, method, start_time) end {:error, reason} -> handle_version_error(conn, reason, opts) end end # Version detection defp detect_api_version(conn, opts) do methods = Keyword.get(opts, :version_methods, @version_methods) default_version = Keyword.get(opts, :default_version, @default_version) Enum.reduce_while(methods, {:error, :no_version_found}, fn method, _acc -> case detect_version_by_method(conn, method, opts) do {:ok, version} -> {:halt, {:ok, version, method}} {:error, _} -> {:cont, {:error, :no_version_found}} end end) |> case do {:error, :no_version_found} -> {:ok, default_version, :default} result -> result end end defp detect_version_by_method(conn, :path, _opts) do case conn.path_info do ["api", "v" <> version | _] -> {:ok, version} ["api", version | _] when version in ["1"] -> {:ok, version} _ -> {:error, :no_path_version} end end defp detect_version_by_method(conn, :header, _opts) do case get_req_header(conn, "api-version") do [version] -> {:ok, version} [] -> # Try Accept header with versioning case get_req_header(conn, "accept") do [accept_header] -> cond do String.starts_with?(accept_header, "application/vnd.wanderer.v") and String.ends_with?(accept_header, "+json") -> version = accept_header |> String.replace_prefix("application/vnd.wanderer.v", "") |> String.replace_suffix("+json", "") {:ok, version} String.starts_with?(accept_header, "application/json; version=") -> version = String.replace_prefix(accept_header, "application/json; version=", "") {:ok, version} true -> {:error, :no_header_version} end _ -> {:error, :no_header_version} end end end defp detect_version_by_method(conn, :query_param, _opts) do case conn.query_params["version"] || conn.query_params["api_version"] do nil -> {:error, :no_query_version} version -> {:ok, version} end end defp detect_version_by_method(_conn, :default, opts) do default_version = Keyword.get(opts, :default_version, @default_version) {:ok, default_version} end # Version validation defp validate_version(conn, version, opts) do supported_versions = Keyword.get(opts, :supported_versions, @supported_versions) minimum_version = Keyword.get(opts, :minimum_version, @minimum_version) maximum_version = Keyword.get(opts, :maximum_version, @maximum_version) strict_versioning = Keyword.get(opts, :strict_versioning, false) cond do version in supported_versions -> conn strict_versioning -> conn |> send_version_error(400, "Unsupported API version", %{ requested: version, supported: supported_versions, minimum: minimum_version, maximum: maximum_version }) |> halt() version_too_old?(version, minimum_version) -> conn |> send_version_error(410, "API version no longer supported", %{ requested: version, minimum_supported: minimum_version, upgrade_required: true }) |> halt() version_too_new?(version, maximum_version) -> # Gracefully handle newer versions by falling back to latest supported latest_version = maximum_version conn |> assign(:api_version, latest_version) |> put_resp_header("api-version-fallback", "true") |> put_resp_header("api-version-requested", version) |> put_resp_header("api-version-used", latest_version) true -> # Unknown version format, use default default_version = Keyword.get(opts, :default_version, @default_version) conn |> assign(:api_version, default_version) |> put_resp_header("api-version-warning", "unknown-version") end end defp version_too_old?(requested, minimum) do compare_versions(requested, minimum) == :lt end defp version_too_new?(requested, maximum) do compare_versions(requested, maximum) == :gt end defp compare_versions(v1, v2) do v1_parts = String.split(v1, ".") |> Enum.map(&String.to_integer/1) v2_parts = String.split(v2, ".") |> Enum.map(&String.to_integer/1) case Version.compare( Version.parse!("#{Enum.join(v1_parts, ".")}.0"), Version.parse!("#{Enum.join(v2_parts, ".")}.0") ) do :eq -> :eq :gt -> :gt :lt -> :lt end rescue _ -> # If version comparison fails, treat as equal :eq end # Version headers defp add_version_headers(conn, version) do conn |> put_resp_header("api-version", version) |> put_resp_header("api-supported-versions", Enum.join(@supported_versions, ", ")) |> put_resp_header("api-deprecation-info", get_deprecation_info(version)) end defp get_deprecation_info(version) do if version in @deprecated_versions do "deprecated; upgrade-by=2025-12-31; link=https://docs.wanderer.com/api/migration" else "false" end end # Deprecation warnings defp handle_deprecation_warnings(conn, version, opts) do deprecated_versions = Keyword.get(opts, :deprecated_versions, @deprecated_versions) show_warnings = Keyword.get(opts, :deprecation_warnings, true) if version in deprecated_versions and show_warnings do conn |> put_resp_header("warning", build_deprecation_warning(version)) |> log_deprecation_usage(version) else conn end end defp build_deprecation_warning(version) do "299 wanderer-api \"API version #{version} is deprecated. Please upgrade to version #{@default_version}. See https://docs.wanderer.com/api/migration for details.\"" end defp log_deprecation_usage(conn, version) do user_id = get_user_id(conn) request_details = RequestContext.build_request_details(conn) SecurityAudit.log_event( :deprecated_api_usage, user_id, Map.put(request_details, :version, version) ) conn end # Version-specific routing support def version_supports_feature?(version, feature) do case {version, feature} do # Version 1 features (consolidated all previous features) {v, :basic_crud} when v in ["1"] -> true {v, :pagination} when v in ["1"] -> true {v, :filtering} when v in ["1"] -> true {v, :sorting} when v in ["1"] -> true {v, :sparse_fieldsets} when v in ["1"] -> true {v, :includes} when v in ["1"] -> true {v, :bulk_operations} when v in ["1"] -> true {v, :webhooks} when v in ["1"] -> true {v, :real_time_events} when v in ["1"] -> true # Future features (not yet implemented) {_v, :graphql} -> false {_v, :subscriptions} -> false _ -> false end end def get_version_config(version) do %{ "1" => %{ features: [ :basic_crud, :pagination, :filtering, :sorting, :sparse_fieldsets, :includes, :bulk_operations, :webhooks, :real_time_events ], max_page_size: 500, default_page_size: 50, supports_includes: true, supports_sparse_fields: true } }[version] || get_version_config(@default_version) end # Error handling defp handle_version_error(conn, reason, _opts) do request_details = RequestContext.build_request_details(conn) SecurityAudit.log_event( :api_version_error, get_user_id(conn), request_details |> Map.put(:reason, reason) |> Map.put(:headers, get_version_headers(conn)) ) conn |> send_version_error(400, "Invalid API version", %{ reason: reason, supported_versions: @supported_versions, default_version: @default_version }) |> halt() end defp send_version_error(conn, status, message, details) do error_response = %{ error: message, status: status, details: details, supported_versions: @supported_versions, documentation: "https://docs.wanderer.com/api/versioning", timestamp: DateTime.utc_now() } conn |> put_status(status) |> put_resp_content_type("application/json") |> send_resp(status, Jason.encode!(error_response)) end # Logging and metrics defp log_version_usage(conn, version, method, start_time) do end_time = System.monotonic_time(:millisecond) duration = end_time - start_time # Emit telemetry for version usage :telemetry.execute( [:wanderer_app, :api_versioning], %{duration: duration, count: 1}, %{ version: version, method: method, path: conn.request_path, user_id: get_user_id(conn) } ) conn end # Helper functions defp get_user_id(conn) do case conn.assigns[:current_user] do %{id: user_id} -> user_id _ -> nil end end defp get_version_headers(conn) do %{ "api-version" => get_req_header(conn, "api-version"), "accept" => get_req_header(conn, "accept"), "user-agent" => get_req_header(conn, "user-agent") } end # Public API for checking version compatibility def compatible_version?(requested_version, minimum_version \\ @minimum_version) do compare_versions(requested_version, minimum_version) != :lt end def get_migration_path(from_version, to_version \\ @default_version) do %{ from: from_version, to: to_version, breaking_changes: get_breaking_changes(from_version, to_version), migration_guide: "https://docs.wanderer.com/api/migration/#{from_version}-to-#{to_version}", estimated_effort: estimate_migration_effort(from_version, to_version) } end defp get_breaking_changes(from_version, to_version) do %{ {"1.0", "1"} => [ "All API endpoints now use /api/v1/ prefix", "Pagination parameters changed from page/per_page to page[number]/page[size]", "Error response format updated to JSON:API spec", "Date fields now return ISO 8601 format", "Relationship URLs moved to links object", "All features (filtering, sorting, includes, bulk operations) are now available" ], {"1.1", "1"} => [ "All API endpoints now use /api/v1/ prefix", "Relationship URLs moved to links object", "All features (includes, bulk operations, webhooks) are now available" ], {"1.2", "1"} => [ "All API endpoints now use /api/v1/ prefix", "Version consolidated - no functional changes" ] }[{from_version, to_version}] || [] end defp estimate_migration_effort(from_version, to_version) do case {from_version, to_version} do {"1.0", "1"} -> "high" {"1.1", "1"} -> "medium" {"1.2", "1"} -> "low" _ -> "unknown" end end end