From a41d59d35974e623e9e890ac99eceb6d201f3978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Wed, 17 Dec 2025 23:37:25 +0100 Subject: [PATCH 1/6] Use OAuth --- config/config.exs | 3 + config/runtime.exs | 2 + lib/hexdocs/hexpm/impl.ex | 14 +- lib/hexdocs/http.ex | 1 + lib/hexdocs/oauth.ex | 186 +++++++++++++++ lib/hexdocs/plug.ex | 234 ++++++++++++++----- test/hexdocs/oauth_test.exs | 154 +++++++++++++ test/hexdocs/plug_test.exs | 444 ++++++++++++++++++++++-------------- 8 files changed, 806 insertions(+), 232 deletions(-) create mode 100644 lib/hexdocs/oauth.ex create mode 100644 test/hexdocs/oauth_test.exs diff --git a/config/config.exs b/config/config.exs index d926540..e200cf0 100644 --- a/config/config.exs +++ b/config/config.exs @@ -4,6 +4,9 @@ config :hexdocs, port: "4002", hexpm_url: "http://localhost:4000", hexpm_secret: "2cd6d09334d4b00a2be4d532342b799b", + # OAuth client credentials for hexpm integration + oauth_client_id: "hexdocs", + oauth_client_secret: "dev_secret_for_testing", typesense_url: "http://localhost:8108", typesense_api_key: "hexdocs", typesense_collection: "hexdocs", diff --git a/config/runtime.exs b/config/runtime.exs index 4fe0a02..9eeac79 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -5,6 +5,8 @@ if config_env() == :prod do port: System.fetch_env!("HEXDOCS_PORT"), hexpm_url: System.fetch_env!("HEXDOCS_HEXPM_URL"), hexpm_secret: System.fetch_env!("HEXDOCS_HEXPM_SECRET"), + oauth_client_id: System.fetch_env!("HEXDOCS_OAUTH_CLIENT_ID"), + oauth_client_secret: System.fetch_env!("HEXDOCS_OAUTH_CLIENT_SECRET"), typesense_url: System.fetch_env!("HEXDOCS_TYPESENSE_URL"), typesense_api_key: System.fetch_env!("HEXDOCS_TYPESENSE_API_KEY"), typesense_collection: System.fetch_env!("HEXDOCS_TYPESENSE_COLLECTION"), diff --git a/lib/hexdocs/hexpm/impl.ex b/lib/hexdocs/hexpm/impl.ex index b47c556..98c4c96 100644 --- a/lib/hexdocs/hexpm/impl.ex +++ b/lib/hexdocs/hexpm/impl.ex @@ -56,10 +56,20 @@ defmodule Hexdocs.Hexpm.Impl do Application.get_env(:hexdocs, :hexpm_url) <> path end - defp headers(key) do + defp headers(key_or_token) do + # Support both legacy API keys and OAuth Bearer tokens + # OAuth tokens are JWTs that start with "eyJ" (base64 of '{"') + # Legacy API keys are shorter hex strings + authorization = + if String.starts_with?(key_or_token, "eyJ") do + "Bearer #{key_or_token}" + else + key_or_token + end + [ {"accept", "application/json"}, - {"authorization", key} + {"authorization", authorization} ] end end diff --git a/lib/hexdocs/http.ex b/lib/hexdocs/http.ex index 687dfb2..3148a1d 100644 --- a/lib/hexdocs/http.ex +++ b/lib/hexdocs/http.ex @@ -25,6 +25,7 @@ defmodule Hexdocs.HTTP do def post(url, headers, body, opts \\ []) do :hackney.post(url, headers, body, opts) + |> read_response() end def delete(url, headers, opts \\ []) do diff --git a/lib/hexdocs/oauth.ex b/lib/hexdocs/oauth.ex new file mode 100644 index 0000000..bfb77b2 --- /dev/null +++ b/lib/hexdocs/oauth.ex @@ -0,0 +1,186 @@ +defmodule Hexdocs.OAuth do + @moduledoc """ + OAuth 2.0 Authorization Code with PKCE client for hexdocs. + + This module implements the OAuth 2.0 Authorization Code flow with PKCE (Proof Key for + Code Exchange) as defined in RFC 7636. It can be used by any application integrating + with hexpm's OAuth infrastructure. + + ## Flow + + 1. Generate code_verifier and code_challenge using `generate_code_verifier/0` and + `generate_code_challenge/1` + 2. Build authorization URL with `authorization_url/1` and redirect user + 3. After user authorizes, exchange the code for tokens with `exchange_code/3` + 4. Use `refresh_token/2` to get new access tokens before expiration + """ + + @doc """ + Generate a cryptographically random code_verifier for PKCE. + + Returns a 43-character URL-safe base64 string (32 random bytes encoded). + """ + def generate_code_verifier do + :crypto.strong_rand_bytes(32) + |> Base.url_encode64(padding: false) + end + + @doc """ + Generate code_challenge from code_verifier using S256 method. + + Computes SHA-256 hash of the verifier and base64url encodes it. + """ + def generate_code_challenge(verifier) do + :crypto.hash(:sha256, verifier) + |> Base.url_encode64(padding: false) + end + + @doc """ + Generate a random state parameter for CSRF protection. + """ + def generate_state do + :crypto.strong_rand_bytes(16) + |> Base.url_encode64(padding: false) + end + + @doc """ + Build the OAuth authorization URL with PKCE parameters. + + ## Options (all required) + + * `:hexpm_url` - Base URL of hexpm (e.g., "https://hex.pm") + * `:client_id` - OAuth client ID + * `:redirect_uri` - URI to redirect to after authorization + * `:scope` - Space-separated scopes to request + * `:state` - Random state for CSRF protection + * `:code_challenge` - PKCE code challenge + + """ + def authorization_url(opts) do + hexpm_url = Keyword.fetch!(opts, :hexpm_url) + client_id = Keyword.fetch!(opts, :client_id) + redirect_uri = Keyword.fetch!(opts, :redirect_uri) + scope = Keyword.fetch!(opts, :scope) + state = Keyword.fetch!(opts, :state) + code_challenge = Keyword.fetch!(opts, :code_challenge) + + query = + URI.encode_query(%{ + "response_type" => "code", + "client_id" => client_id, + "redirect_uri" => redirect_uri, + "scope" => scope, + "state" => state, + "code_challenge" => code_challenge, + "code_challenge_method" => "S256" + }) + + "#{hexpm_url}/oauth/authorize?#{query}" + end + + @doc """ + Exchange an authorization code for access and refresh tokens. + + ## Parameters + + * `code` - The authorization code received from the callback + * `code_verifier` - The original code_verifier generated before authorization + * `opts` - Keyword list with: + * `:hexpm_url` - Base URL of hexpm + * `:client_id` - OAuth client ID + * `:client_secret` - OAuth client secret + * `:redirect_uri` - The same redirect_uri used in authorization + + ## Returns + + * `{:ok, tokens}` - Map with "access_token", "refresh_token", "expires_in", etc. + * `{:error, reason}` - Error tuple with status code and error response + """ + def exchange_code(code, code_verifier, opts) do + hexpm_url = Keyword.fetch!(opts, :hexpm_url) + client_id = Keyword.fetch!(opts, :client_id) + client_secret = Keyword.fetch!(opts, :client_secret) + redirect_uri = Keyword.fetch!(opts, :redirect_uri) + + body = + Jason.encode!(%{ + "grant_type" => "authorization_code", + "code" => code, + "redirect_uri" => redirect_uri, + "client_id" => client_id, + "client_secret" => client_secret, + "code_verifier" => code_verifier + }) + + url = "#{hexpm_url}/api/oauth/token" + headers = [{"content-type", "application/json"}] + + case Hexdocs.HTTP.post(url, headers, body) do + {:ok, status, _headers, response_body} when status in 200..299 -> + {:ok, Jason.decode!(response_body)} + + {:ok, status, _headers, response_body} -> + {:error, {status, Jason.decode!(response_body)}} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Refresh an access token using a refresh token. + + ## Parameters + + * `refresh_token` - The refresh token from a previous token response + * `opts` - Keyword list with: + * `:hexpm_url` - Base URL of hexpm + * `:client_id` - OAuth client ID + * `:client_secret` - OAuth client secret + + ## Returns + + * `{:ok, tokens}` - Map with new "access_token", "refresh_token", "expires_in", etc. + * `{:error, reason}` - Error tuple + """ + def refresh_token(refresh_token, opts) do + hexpm_url = Keyword.fetch!(opts, :hexpm_url) + client_id = Keyword.fetch!(opts, :client_id) + client_secret = Keyword.fetch!(opts, :client_secret) + + body = + Jason.encode!(%{ + "grant_type" => "refresh_token", + "refresh_token" => refresh_token, + "client_id" => client_id, + "client_secret" => client_secret + }) + + url = "#{hexpm_url}/api/oauth/token" + headers = [{"content-type", "application/json"}] + + case Hexdocs.HTTP.post(url, headers, body) do + {:ok, status, _headers, response_body} when status in 200..299 -> + {:ok, Jason.decode!(response_body)} + + {:ok, status, _headers, response_body} -> + {:error, {status, Jason.decode!(response_body)}} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Get the OAuth configuration from application environment. + + Returns a keyword list with all OAuth settings needed for API calls. + """ + def config do + [ + hexpm_url: Application.get_env(:hexdocs, :hexpm_url), + client_id: Application.get_env(:hexdocs, :oauth_client_id), + client_secret: Application.get_env(:hexdocs, :oauth_client_secret) + ] + end +end diff --git a/lib/hexdocs/plug.ex b/lib/hexdocs/plug.ex index 24e94ca..b939288 100644 --- a/lib/hexdocs/plug.ex +++ b/lib/hexdocs/plug.ex @@ -3,9 +3,8 @@ defmodule Hexdocs.Plug do use Plug.ErrorHandler require Logger - @key_html_fresh_time 60 - @key_asset_fresh_time 120 - @key_lifetime 60 * 60 * 24 * 29 + # OAuth token refresh buffer - refresh token 5 minutes before expiry + @token_refresh_buffer 5 * 60 use Sentry.PlugCapture @@ -43,7 +42,10 @@ defmodule Hexdocs.Plug do key: "_hexdocs_key", signing_salt: {Application, :get_env, [:hexdocs, :session_signing_salt]}, encryption_salt: {Application, :get_env, [:hexdocs, :session_encryption_salt]}, - max_age: 60 * 60 * 24 * 30 + max_age: 60 * 60 * 24 * 30, + secure: Mix.env() == :prod, + http_only: true, + same_site: "Lax" ) plug(:put_secret_key_base) @@ -62,91 +64,205 @@ defmodule Hexdocs.Plug do !subdomain -> send_resp(conn, 400, "") - key = conn.query_params["key"] -> - update_key(conn, key) + # OAuth callback - exchange code for tokens + conn.request_path == "/oauth/callback" -> + handle_oauth_callback(conn, subdomain) - key = get_session(conn, "key") -> - try_serve_page(conn, subdomain, key) + # OAuth access token in session + access_token = get_session(conn, "access_token") -> + try_serve_page_oauth(conn, subdomain, access_token) true -> - redirect_hexpm(conn, subdomain) + redirect_oauth(conn, subdomain) end end - defp try_serve_page(conn, organization, key) do - created_at = get_session(conn, "key_created_at") - refreshed_at = get_session(conn, "key_refreshed_at") + defp redirect_oauth(conn, organization) do + code_verifier = Hexdocs.OAuth.generate_code_verifier() + code_challenge = Hexdocs.OAuth.generate_code_challenge(code_verifier) + state = Hexdocs.OAuth.generate_state() - if key_live?(created_at) do - if key_fresh?(refreshed_at, conn.path_info) do - serve_page(conn, organization) - else - serve_if_valid(conn, organization, key) - end - else - redirect_hexpm(conn, organization) + redirect_uri = build_oauth_redirect_uri(conn, organization) + + url = + Hexdocs.OAuth.authorization_url( + hexpm_url: Application.get_env(:hexdocs, :hexpm_url), + client_id: Application.get_env(:hexdocs, :oauth_client_id), + redirect_uri: redirect_uri, + scope: "docs:#{organization}", + state: state, + code_challenge: code_challenge + ) + + conn + |> put_session("oauth_code_verifier", code_verifier) + |> put_session("oauth_state", state) + |> put_session("oauth_return_path", conn.request_path) + |> redirect(url) + end + + defp build_oauth_redirect_uri(_conn, organization) do + scheme = if Mix.env() == :prod, do: "https", else: "http" + host = Application.get_env(:hexdocs, :host) + "#{scheme}://#{organization}.#{host}/oauth/callback" + end + + defp handle_oauth_callback(conn, organization) do + code = conn.query_params["code"] + state = conn.query_params["state"] + error = conn.query_params["error"] + stored_state = get_session(conn, "oauth_state") + code_verifier = get_session(conn, "oauth_code_verifier") + return_path = get_session(conn, "oauth_return_path") || "/" + + cond do + error -> + # User denied authorization or other OAuth error + error_description = conn.query_params["error_description"] || error + send_resp(conn, 403, Hexdocs.Templates.auth_error(reason: error_description)) + + is_nil(state) or state != stored_state -> + send_resp(conn, 403, Hexdocs.Templates.auth_error(reason: "Invalid OAuth state")) + + is_nil(code) -> + send_resp(conn, 400, Hexdocs.Templates.auth_error(reason: "Missing authorization code")) + + true -> + exchange_oauth_code(conn, code, code_verifier, organization, return_path) end end - defp redirect_hexpm(conn, organization) do - hexpm_url = Application.get_env(:hexdocs, :hexpm_url) - url = "#{hexpm_url}/login?hexdocs=#{organization}&return=#{conn.request_path}" - redirect(conn, url) + defp exchange_oauth_code(conn, code, code_verifier, organization, return_path) do + redirect_uri = build_oauth_redirect_uri(conn, organization) + + opts = + Hexdocs.OAuth.config() + |> Keyword.put(:redirect_uri, redirect_uri) + + case Hexdocs.OAuth.exchange_code(code, code_verifier, opts) do + {:ok, tokens} -> + conn + |> delete_session("oauth_code_verifier") + |> delete_session("oauth_state") + |> delete_session("oauth_return_path") + |> store_oauth_tokens(tokens) + |> redirect(return_path) + + {:error, {_status, %{"error_description" => description}}} -> + send_resp(conn, 403, Hexdocs.Templates.auth_error(reason: description)) + + {:error, {_status, %{"error" => error}}} -> + send_resp(conn, 403, Hexdocs.Templates.auth_error(reason: error)) + + {:error, reason} -> + Logger.error("OAuth code exchange failed: #{inspect(reason)}") + send_resp(conn, 500, Hexdocs.Templates.auth_error(reason: "Authentication failed")) + end end - defp subdomain(host) do - app_host = Application.get_env(:hexdocs, :host) + defp store_oauth_tokens(conn, tokens) do + now = NaiveDateTime.utc_now() + expires_in = tokens["expires_in"] || 1800 + expires_at = NaiveDateTime.add(now, expires_in, :second) - case String.split(host, ".", parts: 2) do - [subdomain, ^app_host] -> subdomain - _ -> nil + conn + |> put_session("access_token", tokens["access_token"]) + |> put_session("refresh_token", tokens["refresh_token"]) + |> put_session("token_expires_at", expires_at) + |> put_session("token_created_at", now) + end + + defp try_serve_page_oauth(conn, organization, access_token) do + expires_at = get_session(conn, "token_expires_at") + refresh_token = get_session(conn, "refresh_token") + + cond do + # Token needs refresh + token_needs_refresh?(expires_at) and refresh_token -> + case refresh_oauth_token(conn, refresh_token, organization) do + {:ok, conn, new_access_token} -> + serve_if_valid_oauth(conn, organization, new_access_token) + + {:error, _reason} -> + # Refresh failed, re-authenticate + redirect_oauth(conn, organization) + end + + # Token expired and no refresh token + token_expired?(expires_at) -> + redirect_oauth(conn, organization) + + # Token is valid, serve the page + true -> + serve_if_valid_oauth(conn, organization, access_token) end end - defp key_fresh?(timestamp, path_info) do - file = List.last(path_info) - lifetime = file_lifetime(file) - NaiveDateTime.diff(NaiveDateTime.utc_now(), timestamp) <= lifetime + defp token_needs_refresh?(nil), do: true + + defp token_needs_refresh?(expires_at) do + now = NaiveDateTime.utc_now() + diff = NaiveDateTime.diff(expires_at, now) + diff <= @token_refresh_buffer + end + + defp token_expired?(nil), do: true + + defp token_expired?(expires_at) do + NaiveDateTime.compare(NaiveDateTime.utc_now(), expires_at) == :gt end - defp key_live?(timestamp) do - NaiveDateTime.diff(NaiveDateTime.utc_now(), timestamp) <= @key_lifetime + defp refresh_oauth_token(conn, refresh_token, _organization) do + opts = Hexdocs.OAuth.config() + + case Hexdocs.OAuth.refresh_token(refresh_token, opts) do + {:ok, tokens} -> + conn = store_oauth_tokens(conn, tokens) + {:ok, conn, tokens["access_token"]} + + {:error, reason} -> + Logger.warning("OAuth token refresh failed: #{inspect(reason)}") + {:error, reason} + end end - defp serve_if_valid(conn, organization, key) do - case Hexdocs.Hexpm.verify_key(key, organization) do + defp serve_if_valid_oauth(conn, organization, access_token) do + case Hexdocs.Hexpm.verify_key(access_token, organization) do :ok -> - conn - |> put_session("key_refreshed_at", NaiveDateTime.utc_now()) - |> serve_page(organization) + serve_page(conn, organization) :refresh -> - redirect_hexpm(conn, organization) + # Token was rejected, try to refresh or re-authenticate + refresh_token = get_session(conn, "refresh_token") + + if refresh_token do + case refresh_oauth_token(conn, refresh_token, organization) do + {:ok, conn, new_access_token} -> + # Retry verification with new token + case Hexdocs.Hexpm.verify_key(new_access_token, organization) do + :ok -> serve_page(conn, organization) + _ -> redirect_oauth(conn, organization) + end + + {:error, _} -> + redirect_oauth(conn, organization) + end + else + redirect_oauth(conn, organization) + end {:error, message} -> send_resp(conn, 403, Hexdocs.Templates.auth_error(reason: message)) end end - defp file_lifetime(file) do - if Path.extname(file || "") in ["", ".html"] do - @key_html_fresh_time - else - @key_asset_fresh_time - end - end - - defp update_key(conn, key) do - now = NaiveDateTime.utc_now() - - params = Map.delete(conn.query_params, "key") - path = conn.request_path <> Plug.Conn.Query.encode(params) + defp subdomain(host) do + app_host = Application.get_env(:hexdocs, :host) - conn - |> put_session("key", key) - |> put_session("key_refreshed_at", now) - |> put_session("key_created_at", now) - |> redirect(path) + case String.split(host, ".", parts: 2) do + [subdomain, ^app_host] -> subdomain + _ -> nil + end end defp serve_page(conn, organization) do diff --git a/test/hexdocs/oauth_test.exs b/test/hexdocs/oauth_test.exs new file mode 100644 index 0000000..f511be1 --- /dev/null +++ b/test/hexdocs/oauth_test.exs @@ -0,0 +1,154 @@ +defmodule Hexdocs.OAuthTest do + use ExUnit.Case, async: true + + alias Hexdocs.OAuth + + describe "generate_code_verifier/0" do + test "generates a non-empty string" do + verifier = OAuth.generate_code_verifier() + + assert is_binary(verifier) + assert String.length(verifier) > 0 + end + + test "generates unique values" do + verifier1 = OAuth.generate_code_verifier() + verifier2 = OAuth.generate_code_verifier() + + assert verifier1 != verifier2 + end + + test "generates URL-safe base64 encoded string" do + verifier = OAuth.generate_code_verifier() + + # Should not contain URL-unsafe characters + refute String.contains?(verifier, "+") + refute String.contains?(verifier, "/") + refute String.contains?(verifier, "=") + end + + test "generates 43-character string (32 bytes base64url encoded)" do + verifier = OAuth.generate_code_verifier() + + # 32 bytes base64url encoded without padding = 43 characters + assert String.length(verifier) == 43 + end + end + + describe "generate_code_challenge/1" do + test "generates a non-empty string" do + verifier = OAuth.generate_code_verifier() + challenge = OAuth.generate_code_challenge(verifier) + + assert is_binary(challenge) + assert String.length(challenge) > 0 + end + + test "generates URL-safe base64 encoded string" do + verifier = OAuth.generate_code_verifier() + challenge = OAuth.generate_code_challenge(verifier) + + refute String.contains?(challenge, "+") + refute String.contains?(challenge, "/") + refute String.contains?(challenge, "=") + end + + test "produces consistent output for same input" do + verifier = OAuth.generate_code_verifier() + challenge1 = OAuth.generate_code_challenge(verifier) + challenge2 = OAuth.generate_code_challenge(verifier) + + assert challenge1 == challenge2 + end + + test "produces different output for different inputs" do + verifier1 = OAuth.generate_code_verifier() + verifier2 = OAuth.generate_code_verifier() + + challenge1 = OAuth.generate_code_challenge(verifier1) + challenge2 = OAuth.generate_code_challenge(verifier2) + + assert challenge1 != challenge2 + end + + test "produces correct SHA-256 hash" do + # Known test vector + verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + + expected_challenge = + :crypto.hash(:sha256, verifier) + |> Base.url_encode64(padding: false) + + assert OAuth.generate_code_challenge(verifier) == expected_challenge + end + end + + describe "generate_state/0" do + test "generates a non-empty string" do + state = OAuth.generate_state() + + assert is_binary(state) + assert String.length(state) > 0 + end + + test "generates unique values" do + state1 = OAuth.generate_state() + state2 = OAuth.generate_state() + + assert state1 != state2 + end + end + + describe "authorization_url/1" do + test "builds correct authorization URL" do + url = + OAuth.authorization_url( + hexpm_url: "https://hex.pm", + client_id: "hexdocs", + redirect_uri: "https://acme.hexdocs.pm/oauth/callback", + scope: "docs:acme", + state: "random_state", + code_challenge: "challenge123" + ) + + assert String.starts_with?(url, "https://hex.pm/oauth/authorize?") + + uri = URI.parse(url) + query = URI.decode_query(uri.query) + + assert query["response_type"] == "code" + assert query["client_id"] == "hexdocs" + assert query["redirect_uri"] == "https://acme.hexdocs.pm/oauth/callback" + assert query["scope"] == "docs:acme" + assert query["state"] == "random_state" + assert query["code_challenge"] == "challenge123" + assert query["code_challenge_method"] == "S256" + end + + test "properly encodes special characters in parameters" do + url = + OAuth.authorization_url( + hexpm_url: "https://hex.pm", + client_id: "client with spaces", + redirect_uri: "https://example.com/callback?foo=bar", + scope: "docs:org", + state: "state&with=special", + code_challenge: "abc123" + ) + + # URL should be properly encoded + assert String.contains?(url, "client+with+spaces") or + String.contains?(url, "client%20with%20spaces") + end + end + + describe "config/0" do + test "returns keyword list with expected keys" do + config = OAuth.config() + + assert Keyword.has_key?(config, :hexpm_url) + assert Keyword.has_key?(config, :client_id) + assert Keyword.has_key?(config, :client_secret) + end + end +end diff --git a/test/hexdocs/plug_test.exs b/test/hexdocs/plug_test.exs index 39809b7..b04cca0 100644 --- a/test/hexdocs/plug_test.exs +++ b/test/hexdocs/plug_test.exs @@ -14,182 +14,284 @@ defmodule Hexdocs.PlugTest do assert conn.status == 400 end - test "redirect to hexpm with no session and no key" do - conn = conn(:get, "http://plugtest.localhost:5002/foo") |> call() - assert conn.status == 302 - - assert get_resp_header(conn, "location") == - ["http://localhost:5000/login?hexdocs=plugtest&return=/foo"] - end - - test "handle no path" do - conn = conn(:get, "http://plugtest.localhost:5002/") |> call() - assert conn.status == 302 - - assert get_resp_header(conn, "location") == - ["http://localhost:5000/login?hexdocs=plugtest&return=/"] - end - - test "update session and redirect when key is set" do - conn = conn(:get, "http://plugtest.localhost:5002/foo?key=abc") |> call() - assert conn.status == 302 - assert get_resp_header(conn, "location") == ["/foo"] - - assert get_session(conn, "key") == "abc" - assert recent?(get_session(conn, "key_refreshed_at")) - assert recent?(get_session(conn, "key_created_at")) - end - - test "redirect to hexpm with dead key" do - old = ~N[2018-01-01 00:00:00] - - conn = - conn(:get, "http://plugtest.localhost:5002/foo") - |> init_test_session(%{"key" => "abc", "key_refreshed_at" => old, "key_created_at" => old}) - |> call() - - assert conn.status == 302 - - assert get_resp_header(conn, "location") == - ["http://localhost:5000/login?hexdocs=plugtest&return=/foo"] - end - - test "reverify stale key succeeds", %{test: test} do - Mox.expect(HexpmMock, :verify_key, fn key, organization -> - assert key == "abc" - assert organization == "plugtest" - :ok - end) - - old = NaiveDateTime.add(NaiveDateTime.utc_now(), -3600) - Store.put!(@bucket, "plugtest/#{test}/index.html", "body") - - conn = - conn(:get, "http://plugtest.localhost:5002/#{test}/index.html") - |> init_test_session(%{"key" => "abc", "key_refreshed_at" => old, "key_created_at" => old}) - |> call() - - assert conn.status == 200 - assert conn.resp_body == "body" - end - - test "reverify stale key requires refresh and redirects", %{test: test} do - Mox.expect(HexpmMock, :verify_key, fn key, organization -> - assert key == "abc" - assert organization == "plugtest" - :refresh - end) - - old = NaiveDateTime.add(NaiveDateTime.utc_now(), -3600) - Store.put!(@bucket, "plugtest/#{test}/index.html", "body") - - conn = - conn(:get, "http://plugtest.localhost:5002/foo") - |> init_test_session(%{"key" => "abc", "key_refreshed_at" => old, "key_created_at" => old}) - |> call() - - assert conn.status == 302 - - assert get_resp_header(conn, "location") == - ["http://localhost:5000/login?hexdocs=plugtest&return=/foo"] - end - - test "reverify stale key fails" do - Mox.expect(HexpmMock, :verify_key, fn key, organization -> - assert key == "abc" - assert organization == "plugtest" - {:error, "account not authorized"} - end) - - old = NaiveDateTime.add(NaiveDateTime.utc_now(), -3600) - - conn = - conn(:get, "http://plugtest.localhost:5002/foo") - |> init_test_session(%{"key" => "abc", "key_refreshed_at" => old, "key_created_at" => old}) - |> call() - - assert conn.status == 403 - assert conn.resp_body =~ "account not authorized" - end - - test "serve 200 page", %{test: test} do - now = NaiveDateTime.utc_now() - Store.put!(@bucket, "plugtest/#{test}/index.html", "body") - - conn = - conn(:get, "http://plugtest.localhost:5002/#{test}/index.html") - |> init_test_session(%{"key" => "abc", "key_refreshed_at" => now, "key_created_at" => now}) - |> call() - - assert conn.status == 200 - assert conn.resp_body == "body" + describe "OAuth flow" do + test "redirect to OAuth authorize with no session" do + conn = conn(:get, "http://plugtest.localhost:5002/foo") |> call() + assert conn.status == 302 + + [location] = get_resp_header(conn, "location") + assert String.starts_with?(location, "http://localhost:5000/oauth/authorize?") + + uri = URI.parse(location) + query = URI.decode_query(uri.query) + + assert query["response_type"] == "code" + assert query["client_id"] == "hexdocs" + assert query["scope"] == "docs:plugtest" + assert query["code_challenge_method"] == "S256" + assert query["state"] != nil + assert query["code_challenge"] != nil + + # Should store PKCE verifier and state in session + assert get_session(conn, "oauth_code_verifier") != nil + assert get_session(conn, "oauth_state") != nil + assert get_session(conn, "oauth_return_path") == "/foo" + end + + test "OAuth callback with invalid state returns error" do + conn = + conn(:get, "http://plugtest.localhost:5002/oauth/callback?code=abc&state=wrong") + |> init_test_session(%{ + "oauth_state" => "correct_state", + "oauth_code_verifier" => "verifier" + }) + |> call() + + assert conn.status == 403 + assert conn.resp_body =~ "Invalid OAuth state" + end + + test "OAuth callback with missing code returns error" do + conn = + conn(:get, "http://plugtest.localhost:5002/oauth/callback?state=correct_state") + |> init_test_session(%{ + "oauth_state" => "correct_state", + "oauth_code_verifier" => "verifier" + }) + |> call() + + assert conn.status == 400 + assert conn.resp_body =~ "Missing authorization code" + end + + test "OAuth callback with error parameter returns error" do + conn = + conn( + :get, + "http://plugtest.localhost:5002/oauth/callback?error=access_denied&error_description=User%20denied" + ) + |> init_test_session(%{"oauth_state" => "state", "oauth_code_verifier" => "verifier"}) + |> call() + + assert conn.status == 403 + assert conn.resp_body =~ "User denied" + end + + test "serve page with valid OAuth token", %{test: test} do + Mox.expect(HexpmMock, :verify_key, fn token, organization -> + assert String.starts_with?(token, "eyJ") + assert organization == "plugtest" + :ok + end) + + now = NaiveDateTime.utc_now() + expires_at = NaiveDateTime.add(now, 1800, :second) + Store.put!(@bucket, "plugtest/#{test}/index.html", "body") + + conn = + conn(:get, "http://plugtest.localhost:5002/#{test}/index.html") + |> init_test_session(%{ + "access_token" => "eyJhbGciOiJFUzI1NiJ9.test", + "refresh_token" => "eyJhbGciOiJFUzI1NiJ9.refresh", + "token_expires_at" => expires_at, + "token_created_at" => now + }) + |> call() + + assert conn.status == 200 + assert conn.resp_body == "body" + end + + test "redirect to OAuth when token expired and no refresh token" do + now = NaiveDateTime.utc_now() + expired = NaiveDateTime.add(now, -1800, :second) + + conn = + conn(:get, "http://plugtest.localhost:5002/foo") + |> init_test_session(%{ + "access_token" => "eyJhbGciOiJFUzI1NiJ9.test", + "token_expires_at" => expired, + "token_created_at" => NaiveDateTime.add(expired, -1800, :second) + }) + |> call() + + assert conn.status == 302 + [location] = get_resp_header(conn, "location") + assert String.starts_with?(location, "http://localhost:5000/oauth/authorize?") + end end - test "serve 404 page", %{test: test} do - now = NaiveDateTime.utc_now() - Store.put!(@bucket, "plugtest/#{test}/index.html", "body") - - conn = - conn(:get, "http://plugtest.localhost:5002/#{test}/404.html") - |> init_test_session(%{"key" => "abc", "key_refreshed_at" => now, "key_created_at" => now}) - |> call() - - assert conn.status == 404 - assert conn.resp_body =~ "Page not found" - end - - test "redirect to root", %{test: test} do - now = NaiveDateTime.utc_now() - Store.put!(@bucket, "plugtest/#{test}/index.html", "body") - - conn = - conn(:get, "http://plugtest.localhost:5002/#{test}") - |> init_test_session(%{"key" => "abc", "key_refreshed_at" => now, "key_created_at" => now}) - |> call() - - assert conn.status == 302 - assert get_resp_header(conn, "location") == ["/#{test}/"] - end - - test "serve index.html for root requests", %{test: test} do - now = NaiveDateTime.utc_now() - Store.put!(@bucket, "plugtest/#{test}/index.html", "body") - - conn = - conn(:get, "http://plugtest.localhost:5002/#{test}/") - |> init_test_session(%{"key" => "abc", "key_refreshed_at" => now, "key_created_at" => now}) - |> call() - - assert conn.status == 200 - assert conn.resp_body == "body" - end - - test "serve docs_config.js for unversioned and versioned requests", %{test: test} do - now = NaiveDateTime.utc_now() - Store.put!(@bucket, "plugtest/#{test}/docs_config.js", "var versionNodes;") - - conn = - conn(:get, "http://plugtest.localhost:5002/#{test}/docs_config.js") - |> init_test_session(%{"key" => "abc", "key_refreshed_at" => now, "key_created_at" => now}) - |> call() - - assert conn.status == 200 - assert conn.resp_body == "var versionNodes;" - - conn = - conn(:get, "http://plugtest.localhost:5002/#{test}/1.0.0/docs_config.js") - |> init_test_session(%{"key" => "abc", "key_refreshed_at" => now, "key_created_at" => now}) - |> call() - - assert conn.status == 200 - assert conn.resp_body == "var versionNodes;" + describe "page serving with OAuth" do + test "serve 200 page", %{test: test} do + Mox.expect(HexpmMock, :verify_key, fn token, organization -> + assert String.starts_with?(token, "eyJ") + assert organization == "plugtest" + :ok + end) + + now = NaiveDateTime.utc_now() + expires_at = NaiveDateTime.add(now, 1800, :second) + Store.put!(@bucket, "plugtest/#{test}/index.html", "body") + + conn = + conn(:get, "http://plugtest.localhost:5002/#{test}/index.html") + |> init_test_session(%{ + "access_token" => "eyJhbGciOiJFUzI1NiJ9.test", + "refresh_token" => "eyJhbGciOiJFUzI1NiJ9.refresh", + "token_expires_at" => expires_at, + "token_created_at" => now + }) + |> call() + + assert conn.status == 200 + assert conn.resp_body == "body" + end + + test "serve 404 page", %{test: test} do + Mox.expect(HexpmMock, :verify_key, fn token, organization -> + assert String.starts_with?(token, "eyJ") + assert organization == "plugtest" + :ok + end) + + now = NaiveDateTime.utc_now() + expires_at = NaiveDateTime.add(now, 1800, :second) + Store.put!(@bucket, "plugtest/#{test}/index.html", "body") + + conn = + conn(:get, "http://plugtest.localhost:5002/#{test}/404.html") + |> init_test_session(%{ + "access_token" => "eyJhbGciOiJFUzI1NiJ9.test", + "refresh_token" => "eyJhbGciOiJFUzI1NiJ9.refresh", + "token_expires_at" => expires_at, + "token_created_at" => now + }) + |> call() + + assert conn.status == 404 + assert conn.resp_body =~ "Page not found" + end + + test "redirect to root", %{test: test} do + Mox.expect(HexpmMock, :verify_key, fn token, organization -> + assert String.starts_with?(token, "eyJ") + assert organization == "plugtest" + :ok + end) + + now = NaiveDateTime.utc_now() + expires_at = NaiveDateTime.add(now, 1800, :second) + Store.put!(@bucket, "plugtest/#{test}/index.html", "body") + + conn = + conn(:get, "http://plugtest.localhost:5002/#{test}") + |> init_test_session(%{ + "access_token" => "eyJhbGciOiJFUzI1NiJ9.test", + "refresh_token" => "eyJhbGciOiJFUzI1NiJ9.refresh", + "token_expires_at" => expires_at, + "token_created_at" => now + }) + |> call() + + assert conn.status == 302 + assert get_resp_header(conn, "location") == ["/#{test}/"] + end + + test "serve index.html for root requests", %{test: test} do + Mox.expect(HexpmMock, :verify_key, fn token, organization -> + assert String.starts_with?(token, "eyJ") + assert organization == "plugtest" + :ok + end) + + now = NaiveDateTime.utc_now() + expires_at = NaiveDateTime.add(now, 1800, :second) + Store.put!(@bucket, "plugtest/#{test}/index.html", "body") + + conn = + conn(:get, "http://plugtest.localhost:5002/#{test}/") + |> init_test_session(%{ + "access_token" => "eyJhbGciOiJFUzI1NiJ9.test", + "refresh_token" => "eyJhbGciOiJFUzI1NiJ9.refresh", + "token_expires_at" => expires_at, + "token_created_at" => now + }) + |> call() + + assert conn.status == 200 + assert conn.resp_body == "body" + end + + test "serve docs_config.js for unversioned and versioned requests", %{test: test} do + Mox.expect(HexpmMock, :verify_key, 2, fn token, organization -> + assert String.starts_with?(token, "eyJ") + assert organization == "plugtest" + :ok + end) + + now = NaiveDateTime.utc_now() + expires_at = NaiveDateTime.add(now, 1800, :second) + Store.put!(@bucket, "plugtest/#{test}/docs_config.js", "var versionNodes;") + + conn = + conn(:get, "http://plugtest.localhost:5002/#{test}/docs_config.js") + |> init_test_session(%{ + "access_token" => "eyJhbGciOiJFUzI1NiJ9.test", + "refresh_token" => "eyJhbGciOiJFUzI1NiJ9.refresh", + "token_expires_at" => expires_at, + "token_created_at" => now + }) + |> call() + + assert conn.status == 200 + assert conn.resp_body == "var versionNodes;" + + conn = + conn(:get, "http://plugtest.localhost:5002/#{test}/1.0.0/docs_config.js") + |> init_test_session(%{ + "access_token" => "eyJhbGciOiJFUzI1NiJ9.test", + "refresh_token" => "eyJhbGciOiJFUzI1NiJ9.refresh", + "token_expires_at" => expires_at, + "token_created_at" => now + }) + |> call() + + assert conn.status == 200 + assert conn.resp_body == "var versionNodes;" + end + + test "token verification fails redirects to OAuth" do + Mox.expect(HexpmMock, :verify_key, fn _token, _organization -> + {:error, "account not authorized"} + end) + + now = NaiveDateTime.utc_now() + expires_at = NaiveDateTime.add(now, 1800, :second) + + conn = + conn(:get, "http://plugtest.localhost:5002/foo") + |> init_test_session(%{ + "access_token" => "eyJhbGciOiJFUzI1NiJ9.test", + "refresh_token" => "eyJhbGciOiJFUzI1NiJ9.refresh", + "token_expires_at" => expires_at, + "token_created_at" => now + }) + |> call() + + assert conn.status == 403 + assert conn.resp_body =~ "account not authorized" + end + + test "handle no path redirects to OAuth" do + conn = conn(:get, "http://plugtest.localhost:5002/") |> call() + assert conn.status == 302 + + [location] = get_resp_header(conn, "location") + assert String.starts_with?(location, "http://localhost:5000/oauth/authorize?") + end end defp call(conn) do Hexdocs.Plug.call(conn, []) end - - defp recent?(datetime) do - abs(NaiveDateTime.diff(datetime, NaiveDateTime.utc_now())) < 3 - end end From 9ab9be8ed8c3221430c71455808405ce3ce61b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 18 Dec 2025 00:14:53 +0100 Subject: [PATCH 2/6] ex_aws config --- config/config.exs | 4 ++++ config/prod.exs | 3 --- lib/hexdocs/http.ex | 8 ++++++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/config/config.exs b/config/config.exs index e200cf0..086b69e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -41,6 +41,10 @@ config :hexdocs, :docs_private_bucket, name: "hexdocs-private-staging" config :hexdocs, :docs_public_bucket, name: "hexdocs-public-staging" +config :ex_aws, + http_client: ExAws.Request.Hackney, + json_codec: Jason + config :logger, :console, format: "[$level] $metadata$message\n" import_config "#{Mix.env()}.exs" diff --git a/config/prod.exs b/config/prod.exs index a03ba9b..cd7fe75 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -14,9 +14,6 @@ config :hexdocs, :docs_private_bucket, implementation: Hexdocs.Store.GS config :hexdocs, :docs_public_bucket, implementation: Hexdocs.Store.GS -config :ex_aws, - json_codec: Jason - config :sentry, enable_source_code_context: true, root_source_code_paths: [File.cwd!()], diff --git a/lib/hexdocs/http.ex b/lib/hexdocs/http.ex index 3148a1d..b7feafc 100644 --- a/lib/hexdocs/http.ex +++ b/lib/hexdocs/http.ex @@ -24,8 +24,12 @@ defmodule Hexdocs.HTTP do end def post(url, headers, body, opts \\ []) do - :hackney.post(url, headers, body, opts) - |> read_response() + if :with_body in opts do + :hackney.post(url, headers, body, opts) + else + :hackney.post(url, headers, body, opts) + |> read_response() + end end def delete(url, headers, opts \\ []) do From 00750fdeee202ffdbcdd8d4b997e4faa3144d678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 18 Dec 2025 00:30:42 +0100 Subject: [PATCH 3/6] HTTP cleanup --- lib/hexdocs/http.ex | 12 ++++-------- lib/hexdocs/search/typesense.ex | 2 +- lib/hexdocs/source_repo/github.ex | 5 ++--- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/lib/hexdocs/http.ex b/lib/hexdocs/http.ex index b7feafc..7aa5a68 100644 --- a/lib/hexdocs/http.ex +++ b/lib/hexdocs/http.ex @@ -8,8 +8,8 @@ defmodule Hexdocs.HTTP do :hackney.head(url, headers) end - def get(url, headers) do - :hackney.get(url, headers) + def get(url, headers, opts \\ []) do + :hackney.get(url, headers, "", opts) |> read_response() end @@ -24,12 +24,8 @@ defmodule Hexdocs.HTTP do end def post(url, headers, body, opts \\ []) do - if :with_body in opts do - :hackney.post(url, headers, body, opts) - else - :hackney.post(url, headers, body, opts) - |> read_response() - end + :hackney.post(url, headers, body, opts) + |> read_response() end def delete(url, headers, opts \\ []) do diff --git a/lib/hexdocs/search/typesense.ex b/lib/hexdocs/search/typesense.ex index 37782e4..4e189ba 100644 --- a/lib/hexdocs/search/typesense.ex +++ b/lib/hexdocs/search/typesense.ex @@ -29,7 +29,7 @@ defmodule Hexdocs.Search.Typesense do url = url("collections/#{collection()}/documents/import?action=create") headers = [{"x-typesense-api-key", api_key()}] - case HTTP.post(url, headers, ndjson, [:with_body, recv_timeout: @timeout]) do + case HTTP.post(url, headers, ndjson, recv_timeout: @timeout) do {:ok, 200, _resp_headers, ndjson} -> ndjson |> String.split("\n") diff --git a/lib/hexdocs/source_repo/github.ex b/lib/hexdocs/source_repo/github.ex index 36ec6ce..5842e63 100644 --- a/lib/hexdocs/source_repo/github.ex +++ b/lib/hexdocs/source_repo/github.ex @@ -9,16 +9,15 @@ defmodule Hexdocs.SourceRepo.GitHub do url = @github_url <> "/repos/#{repo}/tags" headers = [ - accept: "application/json" + {"accept", "application/json"} ] options = [ - :with_body, basic_auth: {user, token} ] Hexdocs.HTTP.retry("github", url, fn -> - :hackney.get(url, headers, "", options) + Hexdocs.HTTP.get(url, headers, options) end) |> case do {:ok, 200, _headers, body} -> From eeebafd3fa08f587c2fcacaf7e9b058fa8a938bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sun, 1 Feb 2026 20:33:14 +0100 Subject: [PATCH 4/6] Fix Mix.env/0 unavailable in production release --- config/config.exs | 1 + config/prod.exs | 1 + lib/hexdocs/plug.ex | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/config/config.exs b/config/config.exs index 086b69e..390c717 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,6 +1,7 @@ import Config config :hexdocs, + scheme: "http", port: "4002", hexpm_url: "http://localhost:4000", hexpm_secret: "2cd6d09334d4b00a2be4d532342b799b", diff --git a/config/prod.exs b/config/prod.exs index cd7fe75..8ce97ba 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -1,6 +1,7 @@ import Config config :hexdocs, + scheme: "https", hexpm_impl: Hexdocs.Hexpm.Impl, store_impl: Hexdocs.Store.Impl, cdn_impl: Hexdocs.CDN.Fastly, diff --git a/lib/hexdocs/plug.ex b/lib/hexdocs/plug.ex index b939288..14d6bb8 100644 --- a/lib/hexdocs/plug.ex +++ b/lib/hexdocs/plug.ex @@ -102,7 +102,7 @@ defmodule Hexdocs.Plug do end defp build_oauth_redirect_uri(_conn, organization) do - scheme = if Mix.env() == :prod, do: "https", else: "http" + scheme = Application.get_env(:hexdocs, :scheme) host = Application.get_env(:hexdocs, :host) "#{scheme}://#{organization}.#{host}/oauth/callback" end From 062c7772fda41b5ea0dd3e2ad1daf28cb20015f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sun, 1 Feb 2026 21:07:22 +0100 Subject: [PATCH 5/6] Name OAuth sessions with organization name Pass the organization name as the session name when exchanging OAuth codes, so sessions show up as "myrepo" instead of "Unnamed session" in the hexpm dashboard. --- lib/hexdocs/oauth.ex | 9 +++++++-- lib/hexdocs/plug.ex | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/hexdocs/oauth.ex b/lib/hexdocs/oauth.ex index bfb77b2..10c5a02 100644 --- a/lib/hexdocs/oauth.ex +++ b/lib/hexdocs/oauth.ex @@ -103,14 +103,16 @@ defmodule Hexdocs.OAuth do redirect_uri = Keyword.fetch!(opts, :redirect_uri) body = - Jason.encode!(%{ + %{ "grant_type" => "authorization_code", "code" => code, "redirect_uri" => redirect_uri, "client_id" => client_id, "client_secret" => client_secret, "code_verifier" => code_verifier - }) + } + |> maybe_put("name", opts[:name]) + |> Jason.encode!() url = "#{hexpm_url}/api/oauth/token" headers = [{"content-type", "application/json"}] @@ -183,4 +185,7 @@ defmodule Hexdocs.OAuth do client_secret: Application.get_env(:hexdocs, :oauth_client_secret) ] end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) end diff --git a/lib/hexdocs/plug.ex b/lib/hexdocs/plug.ex index 14d6bb8..10d8a49 100644 --- a/lib/hexdocs/plug.ex +++ b/lib/hexdocs/plug.ex @@ -138,6 +138,7 @@ defmodule Hexdocs.Plug do opts = Hexdocs.OAuth.config() |> Keyword.put(:redirect_uri, redirect_uri) + |> Keyword.put(:name, organization) case Hexdocs.OAuth.exchange_code(code, code_verifier, opts) do {:ok, tokens} -> From e937b4d4a7c7567ee8b48e5177c0e9414485ba3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sun, 1 Feb 2026 21:29:42 +0100 Subject: [PATCH 6/6] Replace hackney with Req and Jason with built-in JSON - Migrate HTTP client from hackney to Req - Configure ex_aws to use ExAws.Request.Req (built into ex_aws 2.6.0) - Configure Sentry to use Finch client - Replace Jason with Elixir's built-in JSON module --- config/config.exs | 6 ++- lib/hexdocs/cdn/fastly.ex | 14 +++--- lib/hexdocs/http.ex | 90 ++++++++++++++++++++++++------------ lib/hexdocs/oauth.ex | 12 ++--- mix.exs | 2 +- mix.lock | 8 +--- test/hexdocs/search_test.exs | 11 +++-- 7 files changed, 85 insertions(+), 58 deletions(-) diff --git a/config/config.exs b/config/config.exs index 390c717..3d1c8b2 100644 --- a/config/config.exs +++ b/config/config.exs @@ -43,8 +43,10 @@ config :hexdocs, :docs_private_bucket, name: "hexdocs-private-staging" config :hexdocs, :docs_public_bucket, name: "hexdocs-public-staging" config :ex_aws, - http_client: ExAws.Request.Hackney, - json_codec: Jason + http_client: ExAws.Request.Req, + json_codec: JSON + +config :sentry, client: Sentry.FinchClient config :logger, :console, format: "[$level] $metadata$message\n" diff --git a/lib/hexdocs/cdn/fastly.ex b/lib/hexdocs/cdn/fastly.ex index eaf2b84..0a4f72d 100644 --- a/lib/hexdocs/cdn/fastly.ex +++ b/lib/hexdocs/cdn/fastly.ex @@ -29,20 +29,18 @@ defmodule Hexdocs.CDN.Fastly do url = @fastly_url <> url headers = [ - "fastly-key": auth(), - accept: "application/json", - "content-type": "application/json" + {"fastly-key", auth()}, + {"accept", "application/json"}, + {"content-type", "application/json"} ] body = JSON.encode!(body) - Hexdocs.HTTP.retry("fastly", url, fn -> :hackney.post(url, headers, body, []) end) - |> read_body() + Hexdocs.HTTP.retry("fastly", url, fn -> Hexdocs.HTTP.post(url, headers, body) end) + |> decode_body() end - defp read_body({:ok, status, headers, client}) do - {:ok, body} = :hackney.body(client) - + defp decode_body({:ok, status, headers, body}) do body = case JSON.decode(body) do {:ok, map} -> map diff --git a/lib/hexdocs/http.ex b/lib/hexdocs/http.ex index 7aa5a68..93d56e8 100644 --- a/lib/hexdocs/http.ex +++ b/lib/hexdocs/http.ex @@ -5,62 +5,94 @@ defmodule Hexdocs.HTTP do require Logger def head(url, headers) do - :hackney.head(url, headers) + case Req.head(url, headers: headers, retry: false, decode_body: false) do + {:ok, response} -> + {:ok, response.status, normalize_headers(response.headers)} + + {:error, reason} -> + {:error, reason} + end end - def get(url, headers, opts \\ []) do - :hackney.get(url, headers, "", opts) - |> read_response() + def get(url, headers, _opts \\ []) do + case Req.get(url, headers: headers, retry: false, decode_body: false) do + {:ok, response} -> + {:ok, response.status, normalize_headers(response.headers), response.body} + + {:error, reason} -> + {:error, reason} + end end def get_stream(url, headers) do - :hackney.get(url, headers) - |> stream_response() + case Req.get(url, headers: headers, retry: false, decode_body: false, into: :self) do + {:ok, response} -> + stream = stream_body(response.body) + {:ok, response.status, normalize_headers(response.headers), stream} + + {:error, reason} -> + {:error, reason} + end end def put(url, headers, body) do - :hackney.put(url, headers, body, recv_timeout: 10_000) - |> read_response() - end + case Req.put(url, + headers: headers, + body: body, + retry: false, + decode_body: false, + receive_timeout: 10_000 + ) do + {:ok, response} -> + {:ok, response.status, normalize_headers(response.headers), response.body} - def post(url, headers, body, opts \\ []) do - :hackney.post(url, headers, body, opts) - |> read_response() + {:error, reason} -> + {:error, reason} + end end - def delete(url, headers, opts \\ []) do - :hackney.delete(url, headers, "", opts) - |> read_response() + def post(url, headers, body, _opts \\ []) do + case Req.post(url, headers: headers, body: body, retry: false, decode_body: false) do + {:ok, response} -> + {:ok, response.status, normalize_headers(response.headers), response.body} + + {:error, reason} -> + {:error, reason} + end end - defp read_response(result) do - with {:ok, status, headers, ref} <- result, - {:ok, body} <- :hackney.body(ref) do - {:ok, status, headers, body} + def delete(url, headers, _opts \\ []) do + case Req.delete(url, headers: headers, retry: false, decode_body: false) do + {:ok, response} -> + {:ok, response.status, normalize_headers(response.headers), response.body} + + {:error, reason} -> + {:error, reason} end end - defp stream_response({:ok, status, headers, ref}) do + defp normalize_headers(headers) do + Enum.map(headers, fn {name, values} -> {name, Enum.join(values, ", ")} end) + end + + defp stream_body(ref) do start_fun = fn -> :cont end after_fun = fn _ -> :ok end next_fun = fn :cont -> - case :hackney.stream_body(ref) do - {:ok, data} -> {[{:ok, data}], :cont} - :done -> {:halt, :ok} - {:error, reason} -> {[{:error, reason}], :stop} + receive do + {^ref, {:data, data}} -> {[{:ok, data}], :cont} + {^ref, :done} -> {:halt, :ok} + after + 30_000 -> {[{:error, :timeout}], :stop} end :stop -> {:halt, :ok} end - {:ok, status, headers, Stream.resource(start_fun, next_fun, after_fun)} - end - - defp stream_response(other) do - other + Stream.resource(start_fun, next_fun, after_fun) end def retry(service, url, fun) do diff --git a/lib/hexdocs/oauth.ex b/lib/hexdocs/oauth.ex index 10c5a02..90af172 100644 --- a/lib/hexdocs/oauth.ex +++ b/lib/hexdocs/oauth.ex @@ -112,17 +112,17 @@ defmodule Hexdocs.OAuth do "code_verifier" => code_verifier } |> maybe_put("name", opts[:name]) - |> Jason.encode!() + |> JSON.encode!() url = "#{hexpm_url}/api/oauth/token" headers = [{"content-type", "application/json"}] case Hexdocs.HTTP.post(url, headers, body) do {:ok, status, _headers, response_body} when status in 200..299 -> - {:ok, Jason.decode!(response_body)} + {:ok, JSON.decode!(response_body)} {:ok, status, _headers, response_body} -> - {:error, {status, Jason.decode!(response_body)}} + {:error, {status, JSON.decode!(response_body)}} {:error, reason} -> {:error, reason} @@ -151,7 +151,7 @@ defmodule Hexdocs.OAuth do client_secret = Keyword.fetch!(opts, :client_secret) body = - Jason.encode!(%{ + JSON.encode!(%{ "grant_type" => "refresh_token", "refresh_token" => refresh_token, "client_id" => client_id, @@ -163,10 +163,10 @@ defmodule Hexdocs.OAuth do case Hexdocs.HTTP.post(url, headers, body) do {:ok, status, _headers, response_body} when status in 200..299 -> - {:ok, Jason.decode!(response_body)} + {:ok, JSON.decode!(response_body)} {:ok, status, _headers, response_body} -> - {:error, {status, Jason.decode!(response_body)}} + {:error, {status, JSON.decode!(response_body)}} {:error, reason} -> {:error, reason} diff --git a/mix.exs b/mix.exs index 02bc8eb..c9145cf 100644 --- a/mix.exs +++ b/mix.exs @@ -42,7 +42,7 @@ defmodule Hexdocs.MixProject do {:ex_aws_s3, "~> 2.0"}, {:ex_aws_sqs, "~> 3.0"}, {:goth, "~> 1.0"}, - {:hackney, "~> 1.13"}, + {:req, "~> 0.5.0"}, {:logster, "~> 1.0"}, {:plug_cowboy, "~> 2.0"}, {:sentry, "~> 11.0"}, diff --git a/mix.lock b/mix.lock index 42b860d..5437ed8 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,6 @@ %{ "broadway": {:hex, :broadway, "1.2.1", "83a1567423c26885e15f6cd8670ca790370af2fcff2ede7fa88c5ea793087a67", [:mix], [{:gen_stage, "~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.3.7 or ~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "68ae63d83b55bdca0f95cd49feee5fb74c5a6bec557caf940860fe07dbc8a4fb"}, "broadway_sqs": {:hex, :broadway_sqs, "0.7.4", "ab89b298f9253adb8534f92095b56d4879e35fe2f5a0730256f7e824572c637f", [:mix], [{:broadway, "~> 1.0", [hex: :broadway, repo: "hexpm", optional: false]}, {:ex_aws_sqs, "~> 3.2.1 or ~> 3.3", [hex: :ex_aws_sqs, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.3.7 or ~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:saxy, "~> 1.1", [hex: :saxy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7140085c4f7c4b27886b3a8f3d0942976f39f195fdbc2f652c5d7b157f93ae28"}, - "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"}, @@ -11,30 +10,25 @@ "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, "goth": {:hex, :goth, "1.4.5", "ee37f96e3519bdecd603f20e7f10c758287088b6d77c0147cd5ee68cf224aade", [:mix], [{:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "0fc2dce5bd710651ed179053d0300ce3a5d36afbdde11e500d57f05f398d5ed5"}, - "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, "hex_core": {:hex, :hex_core, "0.11.0", "d1c6bbf2a4ee6b5f002bec6fa52b5080c53c8b63b7caf6eb88b943687547bff4", [:rebar3], [], "hexpm", "707893677a425491962a2db522f1d2b1f85f97ea27418b06f7929f1d30cde0b0"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "logster": {:hex, :logster, "1.1.1", "d6fddac540dd46adde0c894024500867fe63b0043713f842c62da5815e21db10", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d18e852c430812ad1c9756998ebe46ec814c724e6eb551a512d7e3f8dee24cef"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, - "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, "plug_cowboy": {:hex, :plug_cowboy, "2.7.4", "729c752d17cf364e2b8da5bdb34fb5804f56251e88bb602aff48ae0bd8673d11", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9b85632bd7012615bae0a5d70084deb1b25d2bcbb32cab82d1e9a1e023168aa3"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "saxy": {:hex, :saxy, "1.6.0", "02cb4e9bd045f25ac0c70fae8164754878327ee393c338a090288210b02317ee", [:mix], [], "hexpm", "ef42eb4ac983ca77d650fbdb68368b26570f6cc5895f0faa04d34a6f384abad3"}, "sentry": {:hex, :sentry, "11.0.4", "60371c96cefd247e0fc98840bba2648f64f19aa0b8db8e938f5a98421f55b619", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_ownership, "~> 0.3.0 or ~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:opentelemetry, ">= 0.0.0", [hex: :opentelemetry, repo: "hexpm", optional: true]}, {:opentelemetry_api, ">= 0.0.0", [hex: :opentelemetry_api, repo: "hexpm", optional: true]}, {:opentelemetry_exporter, ">= 0.0.0", [hex: :opentelemetry_exporter, repo: "hexpm", optional: true]}, {:opentelemetry_semantic_conventions, ">= 0.0.0", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "feaafc284dc204c82aadaddc884227aeaa3480decb274d30e184b9d41a700c66"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, } diff --git a/test/hexdocs/search_test.exs b/test/hexdocs/search_test.exs index 0b784a0..a297730 100644 --- a/test/hexdocs/search_test.exs +++ b/test/hexdocs/search_test.exs @@ -337,10 +337,12 @@ defmodule Hexdocs.SearchTest do headers = [{"x-typesense-api-key", api_key}, {"content-type", "application/json"}] payload = JSON.encode_to_iodata!(Typesense.collection_schema(collection)) - assert {:ok, 201, _resp_headers, _ref} = - :hackney.post("http://localhost:8108/collections", headers, payload) + assert {:ok, 201, _resp_headers, _body} = + Hexdocs.HTTP.post("http://localhost:8108/collections", headers, payload) - on_exit(fn -> :hackney.delete("http://localhost:8108/collections/#{collection}", headers) end) + on_exit(fn -> + Hexdocs.HTTP.delete("http://localhost:8108/collections/#{collection}", headers) + end) end defp typesense_search(query) do @@ -352,8 +354,7 @@ defmodule Hexdocs.SearchTest do URI.encode_query(query) headers = [{"x-typesense-api-key", api_key}] - assert {:ok, 200, _resp_headers, ref} = :hackney.get(url, headers) - assert {:ok, body} = :hackney.body(ref) + assert {:ok, 200, _resp_headers, body} = Hexdocs.HTTP.get(url, headers) assert %{"hits" => hits} = JSON.decode!(body) hits end