diff --git a/.gitignore b/.gitignore --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ tmp temp .dexter* +docs/superpowers diff --git a/CHANGELOG.md b/CHANGELOG.md --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,9 @@ - Optional `:telemetry` instrumentation via `Atex.Telemetry`. Add `{:telemetry, "~> 1.0"}` to your deps to receive events from XRPC requests, identity resolution, OAuth flows, and service auth validation. See `Atex.Telemetry` for the full event catalogue. +- New `:user_agent` config key under `config :atex`. When set, all outgoing XRPC + requests include a `User-Agent` header of `" (atex/)"`. + Defaults to `"atex/"`. See `Atex.Config.user_agent/0`. ### Fixed diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## Feature map -- [x] ATProto strings +- [x] atproto strings - [x] `at://` links - [x] TIDs - [x] NSIDs diff --git a/lib/atex/config.ex b/lib/atex/config.ex --- a/lib/atex/config.ex +++ b/lib/atex/config.ex @@ -8,13 +8,17 @@ config :atex, plc_directory_url: "https://plc.directory", - service_did: "did:web:my-service.example" + service_did: "did:web:my-service.example", + user_agent: "my-app/1.0.0" - `:plc_directory_url` - Base URL for the did:plc directory server. Defaults to `"https://plc.directory"`. - `:service_did` - The DID of this service, used as the expected `aud` claim when validating incoming inter-service auth JWTs via `Atex.XRPC.Router`. Required when using `Atex.XRPC.Router` with auth enabled. + - `:user_agent` - Custom User-Agent prefix for outgoing XRPC requests. When + set, the `User-Agent` header becomes `" (atex/)"`. + Defaults to `"atex/"`. """ @doc """ @@ -34,4 +38,31 @@ """ @spec service_did :: String.t() | nil def service_did, do: Application.get_env(:atex, :service_did) + + @doc """ + Returns the `User-Agent` header value for outgoing XRPC requests. + + Reads `:user_agent` from the `:atex` application environment. When set, the + atex library version is appended in parentheses. When unset, returns only + the atex version. + + ## Examples + + # Default (no :user_agent configured): + # => "atex/" + + # With `config :atex, user_agent: "my-app/1.0.0"`: + # => "my-app/1.0.0 (atex/)" + + """ + @spec user_agent :: String.t() + def user_agent do + version = to_string(Application.spec(:atex, :vsn)) + atex_ua = "atex/#{version}" + + case Application.get_env(:atex, :user_agent) do + nil -> atex_ua + custom -> "#{custom} (#{atex_ua})" + end + end end diff --git a/lib/atex/xrpc.ex b/lib/atex/xrpc.ex --- a/lib/atex/xrpc.ex +++ b/lib/atex/xrpc.ex @@ -164,6 +164,7 @@ def unauthed_get(endpoint, name, opts \\ []) do (opts ++ [method: :get, url: url(endpoint, name)]) |> Req.new() + |> attach_user_agent() |> Atex.Telemetry.attach_req_plugin(client_type: :unauthed) |> Req.request() end @@ -176,6 +177,7 @@ def unauthed_post(endpoint, name, opts \\ []) do (opts ++ [method: :post, url: url(endpoint, name)]) |> Req.new() + |> attach_user_agent() |> Atex.Telemetry.attach_req_plugin(client_type: :unauthed) |> Req.request() end @@ -190,6 +192,27 @@ """ @spec url(String.t(), String.t()) :: String.t() def url(endpoint, resource) when is_binary(endpoint), do: "#{endpoint}/xrpc/#{resource}" + + # TODO: if cross-cutting request concerns (user-agent, auth, telemetry) accumulate + # further, consider a shared build_request/2 that all clients use as their starting point. + @doc """ + Attach the `User-Agent` header to a `Req.Request`. + + Sets the `user-agent` header based on the `:user_agent` config key (see + `Atex.Config.user_agent/0`). All built-in XRPC clients call this when + building requests. Custom `Atex.XRPC.Client` implementations should call + this too. + + ## Example + + Req.new(method: :get, url: url) + |> Atex.XRPC.attach_user_agent() + |> Atex.Telemetry.attach_req_plugin(client_type: :login) + |> Req.request() + """ + @spec attach_user_agent(Req.Request.t()) :: Req.Request.t() + def attach_user_agent(req), + do: Req.Request.put_header(req, "user-agent", Atex.Config.user_agent()) @spec put_params(keyword(), struct()) :: keyword() defp put_params(keyword, %{params: params}), diff --git a/lib/atex/xrpc/login_client.ex b/lib/atex/xrpc/login_client.ex --- a/lib/atex/xrpc/login_client.ex +++ b/lib/atex/xrpc/login_client.ex @@ -86,6 +86,7 @@ fn -> request = Req.new(method: :post, url: XRPC.url(endpoint, "com.atproto.server.refreshSession")) + |> Atex.XRPC.attach_user_agent() |> put_auth(refresh_token) result = @@ -121,6 +122,7 @@ request = opts |> Req.new() + |> Atex.XRPC.attach_user_agent() |> put_auth(client.access_token) |> Atex.Telemetry.attach_req_plugin(client_type: :login) diff --git a/lib/atex/xrpc/oauth_client.ex b/lib/atex/xrpc/oauth_client.ex --- a/lib/atex/xrpc/oauth_client.ex +++ b/lib/atex/xrpc/oauth_client.ex @@ -237,6 +237,7 @@ opts |> Keyword.put(:url, url) |> Req.new() + |> Atex.XRPC.attach_user_agent() |> Req.Request.put_header("authorization", "DPoP #{session.access_token}") |> Atex.Telemetry.attach_req_plugin(client_type: :oauth) diff --git a/lib/atex/xrpc/service_auth_client.ex b/lib/atex/xrpc/service_auth_client.ex --- a/lib/atex/xrpc/service_auth_client.ex +++ b/lib/atex/xrpc/service_auth_client.ex @@ -54,6 +54,7 @@ req = opts |> Req.new() + |> Atex.XRPC.attach_user_agent() |> put_auth(client.token) |> Atex.Telemetry.attach_req_plugin(client_type: :service_auth) diff --git a/lib/atex/xrpc/unauthed_client.ex b/lib/atex/xrpc/unauthed_client.ex --- a/lib/atex/xrpc/unauthed_client.ex +++ b/lib/atex/xrpc/unauthed_client.ex @@ -27,6 +27,7 @@ def get(%__MODULE__{endpoint: endpoint} = client, resource, opts \\ []) do (opts ++ [method: :get, url: Atex.XRPC.url(endpoint, resource)]) |> Req.new() + |> Atex.XRPC.attach_user_agent() |> Atex.Telemetry.attach_req_plugin(client_type: :unauthed) |> Req.request() |> case do @@ -39,6 +40,7 @@ def post(%__MODULE__{endpoint: endpoint} = client, resource, opts \\ []) do (opts ++ [method: :post, url: Atex.XRPC.url(endpoint, resource)]) |> Req.new() + |> Atex.XRPC.attach_user_agent() |> Atex.Telemetry.attach_req_plugin(client_type: :unauthed) |> Req.request() |> case do diff --git a/test/atex/xrpc/user_agent_test.exs b/test/atex/xrpc/user_agent_test.exs new file mode 100644 --- /dev/null +++ b/test/atex/xrpc/user_agent_test.exs @@ -0,0 +1,103 @@ +defmodule Atex.XRPC.UserAgentTest do + use ExUnit.Case, async: false + + # Captures the user-agent header sent by a request and sends it to the test process. + defmodule CaptureUAPlug do + @moduledoc false + import Plug.Conn + def init(opts), do: opts + def call(conn, _opts) do + send(self(), {:user_agent, get_req_header(conn, "user-agent")}) + send_resp(conn, 200, Jason.encode!(%{})) + end + end + + defp version, do: to_string(Application.spec(:atex, :vsn)) + + describe "Atex.Config.user_agent/0" do + test "returns atex/ when :user_agent not configured" do + assert Atex.Config.user_agent() == "atex/#{version()}" + end + + test "returns custom ua with atex suffix when :user_agent is configured" do + Application.put_env(:atex, :user_agent, "my-app/1.0.0") + on_exit(fn -> Application.delete_env(:atex, :user_agent) end) + + assert Atex.Config.user_agent() == "my-app/1.0.0 (atex/#{version()})" + end + end + + describe "Atex.XRPC.attach_user_agent/1" do + test "sets user-agent header to default" do + expected_ua = "atex/#{version()}" + + req = + Req.new( + method: :get, + url: "http://example.com/xrpc/com.example.test", + plug: CaptureUAPlug + ) + |> Atex.XRPC.attach_user_agent() + + {:ok, _} = Req.request(req) + + assert_receive {:user_agent, [^expected_ua]} + end + + test "sets user-agent header to configured value" do + expected_ua = "my-app/1.0.0 (atex/#{version()})" + Application.put_env(:atex, :user_agent, "my-app/1.0.0") + on_exit(fn -> Application.delete_env(:atex, :user_agent) end) + + req = + Req.new( + method: :get, + url: "http://example.com/xrpc/com.example.test", + plug: CaptureUAPlug + ) + |> Atex.XRPC.attach_user_agent() + + {:ok, _} = Req.request(req) + + assert_receive {:user_agent, [^expected_ua]} + end + end + + describe "UnauthedClient" do + test "sends default user-agent" do + expected_ua = "atex/#{version()}" + client = Atex.XRPC.UnauthedClient.new("http://example.com") + Atex.XRPC.get(client, "com.example.test", plug: CaptureUAPlug) + assert_receive {:user_agent, [^expected_ua]} + end + + test "sends configured user-agent" do + expected_ua = "my-app/1.0.0 (atex/#{version()})" + Application.put_env(:atex, :user_agent, "my-app/1.0.0") + on_exit(fn -> Application.delete_env(:atex, :user_agent) end) + + client = Atex.XRPC.UnauthedClient.new("http://example.com") + Atex.XRPC.get(client, "com.example.test", plug: CaptureUAPlug) + assert_receive {:user_agent, [^expected_ua]} + end + end + + describe "LoginClient" do + test "sends default user-agent" do + expected_ua = "atex/#{version()}" + client = Atex.XRPC.LoginClient.new("http://example.com", "fake-access-token", nil) + Atex.XRPC.get(client, "com.example.test", plug: CaptureUAPlug) + assert_receive {:user_agent, [^expected_ua]} + end + + test "sends configured user-agent" do + expected_ua = "my-app/1.0.0 (atex/#{version()})" + Application.put_env(:atex, :user_agent, "my-app/1.0.0") + on_exit(fn -> Application.delete_env(:atex, :user_agent) end) + + client = Atex.XRPC.LoginClient.new("http://example.com", "fake-access-token", nil) + Atex.XRPC.get(client, "com.example.test", plug: CaptureUAPlug) + assert_receive {:user_agent, [^expected_ua]} + end + end +end