From 0a65e77c7f807ec79ffcde910909e0049a35c37b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dino=20Kovac=CC=8C?= Date: Sat, 26 Apr 2025 15:20:59 +0200 Subject: [PATCH 1/2] Add basic Tesla Middleware --- lib/http_cookie/jar/server.ex | 73 ++++++++++++++ lib/http_cookie/tesla_middleware.ex | 63 ++++++++++++ mix.exs | 6 ++ mix.lock | 1 + test/http_cookie/tesla_test.exs | 149 ++++++++++++++++++++++++++++ test/support/tesla_plug_adapter.ex | 64 ++++++++++++ 6 files changed, 356 insertions(+) create mode 100644 lib/http_cookie/jar/server.ex create mode 100644 lib/http_cookie/tesla_middleware.ex create mode 100644 test/http_cookie/tesla_test.exs create mode 100644 test/support/tesla_plug_adapter.ex diff --git a/lib/http_cookie/jar/server.ex b/lib/http_cookie/jar/server.ex new file mode 100644 index 0000000..ca7657b --- /dev/null +++ b/lib/http_cookie/jar/server.ex @@ -0,0 +1,73 @@ +defmodule HttpCookie.Jar.Server do + use GenServer + + @moduledoc """ + HTTP Cookie Jar Server + + Thin GenServer wrapper around HttpCookie.Jar. + This is a convenience to enable usage with HTTP clients + which don't have a way to store and pass back the updated jar. + """ + + alias HttpCookie.Jar + + @doc """ + Starts the jar server. + """ + @spec start_link() :: {:ok, pid()} + @spec start_link(opts :: keyword()) :: {:ok, pid()} + def start_link(opts \\ []) do + jar = Keyword.get_lazy(opts, :jar, fn -> Jar.new() end) + GenServer.start_link(__MODULE__, jar) + end + + @doc """ + Processes the response header list for the given request URL. + Parses set-cookie headers and stores valid cookies. + """ + @spec put_cookies_from_headers(pid :: pid(), request_url :: URI.t(), headers :: list()) :: + Jar.t() + def put_cookies_from_headers(pid, request_url, headers) do + GenServer.call(pid, {:put_cookies_from_headers, request_url, headers}) + end + + @doc """ + Formats the cookie for sending in a request header for the provided URL. + """ + @spec get_cookie_header_value(pid :: pid(), request_url :: URI.t()) :: + {:ok, String.t()} | {:error, :no_matching_cookies} + def get_cookie_header_value(pid, request_url) do + GenServer.call(pid, {:get_cookie_header_value, request_url}) + end + + @doc """ + Returns the internal cookie jar. + """ + @spec get_cookie_jar(pid :: pid()) :: {:ok, Jar.t()} + def get_cookie_jar(pid) do + GenServer.call(pid, :get_cookie_jar) + end + + @impl true + def init(jar), do: {:ok, jar} + + @impl true + def handle_call({:put_cookies_from_headers, request_url, headers}, _from, jar) do + new_jar = Jar.put_cookies_from_headers(jar, request_url, headers) + {:reply, :ok, new_jar} + end + + def handle_call({:get_cookie_header_value, request_url}, _from, jar) do + case Jar.get_cookie_header_value(jar, request_url) do + {:ok, header_value, new_jar} -> + {:reply, {:ok, header_value}, new_jar} + + {:error, :no_matching_cookies} -> + {:reply, {:error, :no_matching_cookies}, jar} + end + end + + def handle_call(:get_cookie_jar, _from, jar) do + {:reply, jar, jar} + end +end diff --git a/lib/http_cookie/tesla_middleware.ex b/lib/http_cookie/tesla_middleware.ex new file mode 100644 index 0000000..d8bbd6e --- /dev/null +++ b/lib/http_cookie/tesla_middleware.ex @@ -0,0 +1,63 @@ +if Code.ensure_loaded?(Tesla) do + defmodule HttpCookie.TeslaMiddleware do + @moduledoc """ + Tesla middleware to automatically manage cookies using http_cookie. + + Make sure to list the middleware _after_ any redirect handling + middleware like Tesla.Middleware.FollowRedirects to ensure + the cookie handling code is called before/after every redirect. + + ## Options + + - `:jar_server` - HttpCookie.Jar.Server instance pid (required) + + ## Examples + + server_pid = HttpCookie.Jar.Server.start_link([]) + client = Tesla.client([{HttpCookie.TeslaMiddleware, jar_server: server_pid}]) + """ + + @behaviour Tesla.Middleware + + alias HttpCookie.Jar + + @impl Tesla.Middleware + def call(env, next, options) do + jar_server = Keyword.fetch!(options, :jar_server) + + with %Tesla.Env{} = env <- preprocess(env, jar_server) do + env + |> Tesla.run(next) + |> postprocess(jar_server) + end + end + + defp preprocess(env, jar_server) do + if Tesla.get_header(env, "cookie") do + # cookie header was already set, do nothing + # to allow manually setting the cookie header + env + else + url = URI.parse(env.url) + + case Jar.Server.get_cookie_header_value(jar_server, url) do + {:ok, header_value} -> + Tesla.put_header(env, "cookie", header_value) + + {:error, :no_matching_cookies} -> + env + end + end + end + + defp postprocess({:ok, env}, jar_server) do + url = URI.parse(env.url) + Jar.Server.put_cookies_from_headers(jar_server, url, env.headers) + {:ok, env} + end + + defp postprocess({:error, reason}, _jar_server) do + {:error, reason} + end + end +end diff --git a/mix.exs b/mix.exs index 63c6377..2899a60 100644 --- a/mix.exs +++ b/mix.exs @@ -9,6 +9,7 @@ defmodule HttpCookie.MixProject do app: :http_cookie, version: @version, elixir: "~> 1.14", + elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, description: description(), package: package(), @@ -25,6 +26,10 @@ defmodule HttpCookie.MixProject do ] end + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + defp description do "Standards-compliant HTTP Cookie implementation." end @@ -47,6 +52,7 @@ defmodule HttpCookie.MixProject do {:nimble_parsec, "~> 1.0", only: [:dev, :test]}, {:ex_doc, ">= 0.0.0", only: [:dev, :test], runtime: false}, {:req, "~> 0.5.0", optional: true}, + {:tesla, "~> 1.11", optional: true}, {:plug, "~> 1.0", only: :test}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, {:benchee, "~> 1.0", only: [:dev, :test]}, diff --git a/mix.lock b/mix.lock index 562f225..ba63d3e 100644 --- a/mix.lock +++ b/mix.lock @@ -25,5 +25,6 @@ "req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [: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", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"}, "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "tesla": {:hex, :tesla, "1.14.1", "71c5b031b4e089c0fbfb2b362e24b4478465773ae4ef569760a8c2899ad1e73c", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "c1dde8140a49a3bef5bb622356e77ac5a24ad0c8091f12c3b7fc1077ce797155"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, } diff --git a/test/http_cookie/tesla_test.exs b/test/http_cookie/tesla_test.exs new file mode 100644 index 0000000..d26be88 --- /dev/null +++ b/test/http_cookie/tesla_test.exs @@ -0,0 +1,149 @@ +defmodule HttpCookie.TeslaTest do + use ExUnit.Case, async: true + + alias HttpCookie.Jar + alias HttpCookie.TestSupport.TeslaPlugAdapter + + setup do + jar_server = start_supervised!(Jar.Server) + [jar_server: jar_server] + end + + test "end-to-end", %{jar_server: jar_server} do + plug = + fn + %{request_path: "/one"} = conn -> + conn + |> Plug.Conn.put_resp_header("set-cookie", "foo=bar") + |> Plug.Conn.resp(200, "Have a cookie") + + %{request_path: "/two"} = conn -> + conn = Plug.Conn.fetch_cookies(conn) + assert conn.req_cookies == %{"foo" => "bar"} + + conn + |> Plug.Conn.prepend_resp_headers([ + {"set-cookie", "foo2=bar2"}, + {"set-cookie", "foo3=bar3"} + ]) + |> Plug.Conn.resp(200, "Have some more") + + %{request_path: "/three"} = conn -> + conn = Plug.Conn.fetch_cookies(conn) + assert conn.req_cookies == %{"foo" => "bar", "foo2" => "bar2", "foo3" => "bar3"} + + conn + |> Plug.Conn.resp(200, "No more cookies for you, come back one year") + end + + tesla = + Tesla.client( + [{HttpCookie.TeslaMiddleware, jar_server: jar_server}], + {TeslaPlugAdapter, plug: plug} + ) + + assert %{status: 200} = Tesla.get!(tesla, "https://example.com/one") + assert %{status: 200} = Tesla.get!(tesla, "https://example.com/two") + assert %{status: 200} = Tesla.get!(tesla, "https://example.com/three") + + jar = Jar.Server.get_cookie_jar(jar_server) + + assert %{ + "example.com" => %{ + cookies: %{ + {"foo", "/"} => _, + {"foo2", "/"} => _, + {"foo3", "/"} => _ + } + } + } = jar.cookies + end + + test "picks up cookies from redirect response", %{jar_server: jar_server} do + plug = + fn + %{request_path: "/redirect-me"} = conn -> + conn + |> Plug.Conn.put_resp_header("set-cookie", "redirected=yes") + |> Plug.Conn.put_resp_header("location", "/first-stop") + |> Plug.Conn.resp(302, "Go away") + + %{request_path: "/first-stop"} = conn -> + conn = Plug.Conn.fetch_cookies(conn) + assert conn.req_cookies == %{"redirected" => "yes"} + + conn + |> Plug.Conn.put_resp_header("set-cookie", "stopped=yeah") + |> Plug.Conn.put_resp_header("location", "/final-destination") + |> Plug.Conn.resp(302, "Almost there") + + %{request_path: "/final-destination"} = conn -> + conn = Plug.Conn.fetch_cookies(conn) + assert conn.req_cookies == %{"redirected" => "yes", "stopped" => "yeah"} + + Plug.Conn.resp(conn, 200, "You made it!") + end + + tesla = + Tesla.client( + [ + {Tesla.Middleware.FollowRedirects, max_redirects: 3}, + {HttpCookie.TeslaMiddleware, jar_server: jar_server} + ], + {TeslaPlugAdapter, plug: plug} + ) + + assert %{status: 200} = Tesla.get!(tesla, "https://example.com/redirect-me") + + jar = Jar.Server.get_cookie_jar(jar_server) + + assert %{ + "example.com" => %{ + cookies: %{ + {"redirected", "/"} => _, + {"stopped", "/"} => _ + } + } + } = jar.cookies + end + + test "doesn't override existing cookie header", %{jar_server: jar_server} do + plug = + fn + %{request_path: "/redirect-me"} = conn -> + conn + |> Plug.Conn.put_resp_header("set-cookie", "redirected=yes") + |> Plug.Conn.put_resp_header("location", "/first-stop") + |> Plug.Conn.resp(302, "Go away") + + %{request_path: "/first-stop"} = conn -> + conn = Plug.Conn.fetch_cookies(conn) + assert conn.req_cookies == %{"there-can-only-be" => "one"} + + conn + |> Plug.Conn.put_resp_header("set-cookie", "stopped=yeah") + |> Plug.Conn.put_resp_header("location", "/final-destination") + |> Plug.Conn.resp(302, "Almost there") + + %{request_path: "/final-destination"} = conn -> + conn = Plug.Conn.fetch_cookies(conn) + assert conn.req_cookies == %{"there-can-only-be" => "one"} + + Plug.Conn.resp(conn, 200, "You made it!") + end + + tesla = + Tesla.client( + [ + {Tesla.Middleware.FollowRedirects, max_redirects: 3}, + {HttpCookie.TeslaMiddleware, jar_server: jar_server} + ], + {TeslaPlugAdapter, plug: plug} + ) + + assert %{status: 200} = + Tesla.get!(tesla, "https://example.com/redirect-me", + headers: [{"cookie", "there-can-only-be=one"}] + ) + end +end diff --git a/test/support/tesla_plug_adapter.ex b/test/support/tesla_plug_adapter.ex new file mode 100644 index 0000000..b05d60f --- /dev/null +++ b/test/support/tesla_plug_adapter.ex @@ -0,0 +1,64 @@ +defmodule HttpCookie.TestSupport.TeslaPlugAdapter do + @moduledoc """ + + """ + + @behaviour Tesla.Adapter + + @impl Tesla.Adapter + def call(env, opts) do + plug = Keyword.fetch!(opts, :plug) + + env = + env + |> env_to_conn() + |> plug.() + |> conn_to_env(env) + + {:ok, env} + end + + defp env_to_conn(env) do + uri = URI.parse(env.url) + + uri = + if env.query not in [nil, []] do + query = URI.encode_query(env.query) + query_uri = URI.parse("?#{query}") + URI.merge(uri, query_uri) + else + uri + end + + method = + env.method + |> to_string() + |> String.upcase() + + port = + case uri.scheme do + "http" -> 80 + "https" -> 443 + end + + %Plug.Conn{ + host: uri.host, + method: method, + request_path: uri.path, + path_info: String.split(uri.path, "/"), + port: port, + query_string: uri.query, + scheme: String.to_atom(uri.scheme), + req_headers: env.headers + } + end + + defp conn_to_env(conn, env) do + %{ + env + | status: Plug.Conn.Status.code(conn.status), + headers: conn.resp_headers, + body: conn.resp_body + } + end +end From a1da6cc7b8a4326c2880236c3b6f85faf1c02f7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dino=20Kovac=CC=8C?= Date: Sat, 26 Apr 2025 15:32:16 +0200 Subject: [PATCH 2/2] Add Tesla usage to README --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 76fd0e7..803ff27 100644 --- a/README.md +++ b/README.md @@ -49,3 +49,15 @@ req = %{private: %{cookie_jar: updated_jar}} = Req.get!(req, url: "/one", cookie_jar: empty_jar) %{private: %{cookie_jar: updated_jar}} = Req.get!(req, url: "/two", cookie_jar: updated_jar) ``` + +### Usage with `Tesla` + +HttpCookie can be used with [Tesla](https://github.com/elixir-tesla/tesla) to automatically set and parse cookies in HTTP requests: + +```elixir +{:ok, server_pid} = HttpCookie.Jar.Server.start_link() +tesla = Tesla.client([{HttpCookie.TeslaMiddleware, jar_server: server_pid}]) + +Tesla.get!(tesla, "https://example.com/one") +Tesla.get!(tesla, "https://example.com/two") +```