Skip to content

Commit e3afda4

Browse files
committed
Merge ComponentsGuide.Fetch.Get into ComponentsGuide.Fetch
1 parent 0a7804b commit e3afda4

3 files changed

Lines changed: 205 additions & 176 deletions

File tree

lib/components_guide/fetch.ex

Lines changed: 170 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,177 @@
11
defmodule ComponentsGuide.Fetch do
2-
alias __MODULE__.{Get, Request}
2+
alias ComponentsGuide.Fetch.{Request, Response}
33

4+
@timeout 5000
5+
6+
@doc ~S"""
7+
Fetches the given URL, following redirects (if any).
8+
"""
49
def get!(url_string) when is_binary(url_string) do
5-
Get.get_following_redirects!(url_string)
10+
get_following_redirects!(url_string)
11+
end
12+
13+
defmodule Timings do
14+
defstruct [:duration, :start]
15+
16+
def start() do
17+
%__MODULE__{
18+
start: System.monotonic_time()
19+
}
20+
end
21+
22+
def finish(timings = %__MODULE__{start: start}) do
23+
duration = System.monotonic_time() - start
24+
put_in(timings.duration, duration)
25+
end
26+
27+
def start_with_telemetry(event_name, metadata \\ %{}) do
28+
t = start()
29+
30+
:telemetry.execute(
31+
event_name,
32+
%{start: t.start},
33+
metadata
34+
)
35+
36+
t
37+
end
38+
39+
def finish_with_telemetry(t = %__MODULE__{}, event_name, metadata \\ %{}) do
40+
t = finish(t)
41+
42+
:telemetry.execute(
43+
event_name,
44+
%{duration: t.duration},
45+
metadata
46+
)
47+
48+
t
49+
end
50+
end
51+
52+
def get_following_redirects!(url_string) when is_binary(url_string) do
53+
response =
54+
case Request.new(url_string) do
55+
{:ok, request} ->
56+
load!(request)
57+
58+
{:error, reason} ->
59+
Response.failed(url_string, reason)
60+
end
61+
62+
case response do
63+
%Response{status: status, headers: headers} = resp when status >= 300 and status < 400 ->
64+
case Enum.find(headers, fn {key, _} -> key == "location" end) do
65+
# No redirect!
66+
nil ->
67+
resp
68+
69+
{_, location} ->
70+
IO.puts("Following #{status} redirect to #{location}")
71+
72+
case Request.new(location) do
73+
{:ok, request} ->
74+
# TODO: use existing conn if host is the same.
75+
load!(request)
76+
77+
{:error, reason} ->
78+
Response.failed(url_string, reason)
79+
end
80+
end
81+
82+
other ->
83+
other
84+
end
85+
end
86+
87+
def load!(req = %Request{uri: %URI{host: host, port: 443}}) do
88+
t = Timings.start_with_telemetry([:fetch, :load!, :start], %{req: req})
89+
90+
{:ok, conn} = Mint.HTTP.connect(:https, host, 443, mode: :passive, protocols: [:http1])
91+
{conn, response} = do_request(conn, req)
92+
Mint.HTTP.close(conn)
93+
94+
t =
95+
Timings.finish_with_telemetry(t, [:fetch, :load!, :done], %{
96+
req: req
97+
})
98+
99+
response = Response.add_timings(response, t)
100+
101+
IO.puts(
102+
"Loaded #{req.url_string} in #{System.convert_time_unit(t.duration, :native, :millisecond)}ms. #{inspect(response.done?)}"
103+
)
104+
105+
response
106+
end
107+
108+
def load_many_example(n \\ 2) do
109+
load_many!(
110+
"components.guide",
111+
Enum.map(0..n, fn _ -> Request.new!("https://components.guide/") end)
112+
)
113+
end
114+
115+
def load_many!(host, reqs) when is_binary(host) and is_list(reqs) do
116+
t = Timings.start_with_telemetry([:fetch, :load_many!, :start], %{host: host})
117+
118+
{:ok, conn} = Mint.HTTP.connect(:https, host, 443, mode: :passive, protocols: [:http1])
119+
120+
{conn, results} =
121+
Enum.reduce(reqs, {conn, []}, fn
122+
%Request{uri: %URI{host: ^host, port: 443}} = req, {conn, results} ->
123+
t = Timings.start_with_telemetry([:fetch, :load_many!, :request, :start], %{req: req})
124+
125+
{conn, response} = do_request(conn, req)
126+
127+
t =
128+
Timings.finish_with_telemetry(t, [:fetch, :load_many!, :request, :done], %{
129+
req: req
130+
})
131+
132+
response = Response.add_timings(response, t)
133+
134+
{conn, [response | results]}
135+
end)
136+
137+
Mint.HTTP.close(conn)
138+
results = Enum.reverse(results)
139+
140+
Timings.finish_with_telemetry(t, [:fetch, :load_many!, :done], %{host: host})
141+
results
6142
end
7143

8-
def load!(%Request{} = request) do
9-
Get.load!(request)
144+
defp recv_all(result = %Response{done?: true}, conn, _request_ref), do: {conn, result}
145+
146+
defp recv_all(result, conn, request_ref) do
147+
case Mint.HTTP.recv(conn, 0, @timeout) do
148+
{:ok, conn, responses} ->
149+
Response.add_responses(result, responses, request_ref)
150+
|> recv_all(conn, request_ref)
151+
152+
{:error, conn, error, _responses} ->
153+
{conn, Response.add_error(result, error)}
154+
end
155+
end
156+
157+
defp do_request(
158+
conn,
159+
%Request{
160+
method: method,
161+
uri: %URI{path: path},
162+
headers: headers,
163+
body: body,
164+
url_string: url_string
165+
}
166+
) do
167+
result = Response.new(url_string)
168+
169+
case Mint.HTTP.request(conn, method, path || "/", headers, body) do
170+
{:error, conn, reason} ->
171+
{conn, Response.add_error(result, reason)}
172+
173+
{:ok, conn, request_ref} ->
174+
recv_all(result, conn, request_ref)
175+
end
10176
end
11177
end

lib/components_guide/fetch/get.ex

Lines changed: 0 additions & 170 deletions
This file was deleted.
Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,39 @@
11
defmodule ComponentsGuide.FetchTest do
22
use ExUnit.Case, async: true
33

4-
doctest ComponentsGuide.Fetch
5-
# defdelegate test(message, a, contents), to: ComponentsGuide.Fetch.Test
4+
# doctest ComponentsGuide.Fetch
5+
6+
test "can fetch news.ycombinator.com" do
7+
response = ComponentsGuide.Fetch.get!("https://news.ycombinator.com/")
8+
9+
assert %ComponentsGuide.Fetch.Response{
10+
done?: true,
11+
url: "https://news.ycombinator.com/",
12+
status: 200,
13+
headers: _,
14+
body: _
15+
} = response
16+
end
17+
18+
test "can load HEAD for news.ycombinator.com" do
19+
response =
20+
ComponentsGuide.Fetch.load!(
21+
ComponentsGuide.Fetch.Request.new!("https://news.ycombinator.com/", method: "HEAD")
22+
)
23+
24+
assert %ComponentsGuide.Fetch.Response{
25+
done?: true,
26+
url: "https://news.ycombinator.com/",
27+
status: _,
28+
headers: _,
29+
body: _
30+
} = response
31+
32+
content_type =
33+
Enum.find_value(response.headers, fn
34+
{"content-type", value} -> value
35+
_ -> nil
36+
end)
37+
assert "text/html" == content_type
38+
end
639
end

0 commit comments

Comments
 (0)