Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
```
73 changes: 73 additions & 0 deletions lib/http_cookie/jar/server.ex
Original file line number Diff line number Diff line change
@@ -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
63 changes: 63 additions & 0 deletions lib/http_cookie/tesla_middleware.ex
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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
Expand All @@ -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]},
Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
}
149 changes: 149 additions & 0 deletions test/http_cookie/tesla_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading