Skip to content

Commit 8db1acf

Browse files
committed
Low level API
1 parent 2041a75 commit 8db1acf

6 files changed

Lines changed: 422 additions & 26 deletions

File tree

lib/xhttp.ex

Lines changed: 0 additions & 18 deletions
This file was deleted.

lib/xhttp/conn.ex

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
defmodule XHTTP.Conn do
2+
alias XHTTP.{Conn, Request, Response}
3+
4+
@type t() :: %Conn{}
5+
6+
@type request_ref() :: reference()
7+
@type tcp_message() ::
8+
{:tcp, :gen_tcp.socket(), binary()}
9+
| {:tcp_closed, :gen_tcp.socket()}
10+
| {:tcp_error, :gen_tcp.socket(), term()}
11+
@type response() ::
12+
{:status, request_ref(), status_line()}
13+
| {:headers, request_ref(), headers()}
14+
| {:body, request_ref(), binary()}
15+
| {:done, request_ref()}
16+
@type status_line() :: {http_version(), status(), reason()}
17+
@type http_version() :: {non_neg_integer(), non_neg_integer()}
18+
@type status() :: non_neg_integer()
19+
@type reason() :: String.t()
20+
@type headers() :: [{String.t(), String.t()}]
21+
22+
defstruct [
23+
:socket,
24+
:host,
25+
:request,
26+
:transport,
27+
buffer: ""
28+
]
29+
30+
@spec connect(hostname :: String.t(), port :: :inet.port_number(), opts :: Keyword.t()) ::
31+
{:ok, t()}
32+
| {:error, term()}
33+
def connect(hostname, port, opts \\ []) do
34+
transport = Keyword.get(opts, :transport, :gen_tcp)
35+
transport_opts = [active: true, mode: :binary]
36+
37+
case transport.connect(String.to_charlist(hostname), port, transport_opts) do
38+
{:ok, socket} ->
39+
{:ok, %Conn{socket: socket, host: hostname, transport: transport}}
40+
41+
{:error, reason} ->
42+
{:error, reason}
43+
end
44+
end
45+
46+
@spec request(t(), method :: atom | String.t(), path :: String.t(), headers(), body :: binary()) ::
47+
{:ok, t(), request_ref()}
48+
| {:error, term()}
49+
def request(%Conn{request: request}, _method, _path, _headers, _body) when is_reference(request) do
50+
{:error, :request_already_in_flight}
51+
end
52+
53+
# TODO: Allow streaming body
54+
def request(
55+
%Conn{socket: socket, host: host, transport: transport} = conn,
56+
method,
57+
path,
58+
headers,
59+
body
60+
) do
61+
method = normalize_method(method)
62+
iodata = Request.encode(method, path, host, headers, body)
63+
64+
case transport.send(socket, iodata) do
65+
:ok ->
66+
request_ref = make_ref()
67+
conn = %Conn{conn | request: new_request(request_ref, method)}
68+
{:ok, conn, request_ref}
69+
70+
{:error, reason} ->
71+
{:error, reason}
72+
end
73+
end
74+
75+
@spec stream(t(), tcp_message()) ::
76+
{:ok, t(), [response()]}
77+
| {:error, t(), term()}
78+
| :unknown
79+
def stream(%Conn{socket: socket}, {:tcp_closed, socket}) do
80+
{:error, :closed}
81+
end
82+
83+
def stream(%Conn{socket: socket}, {:tcp_error, socket, reason}) do
84+
{:error, reason}
85+
end
86+
87+
def stream(%Conn{socket: socket, buffer: buffer, request: request} = conn, {:tcp, socket, data}) do
88+
data = buffer <> data
89+
90+
case decode(request.state, conn, data, []) do
91+
{:ok, conn, responses} -> {:ok, conn, Enum.reverse(responses)}
92+
other -> other
93+
end
94+
catch
95+
:throw, {:xhttp, reason} ->
96+
{:error, request.ref, reason}
97+
end
98+
99+
def stream(%Conn{socket: socket, request: request} = conn, {:tcp_closed, socket}) do
100+
# TODO: Update conn state informing socket is closed
101+
if request.body_left == :until_closed do
102+
{:ok, conn, [{:done, request.ref}]}
103+
else
104+
{:error, conn, :closed}
105+
end
106+
end
107+
108+
def stream(%Conn{socket: socket} = conn, {:tcp_error, socket, reason}) do
109+
# TODO: Update conn state informing socket is closed
110+
{:error, conn, reason}
111+
end
112+
113+
def stream(%Conn{}, _other) do
114+
:unknown
115+
end
116+
117+
defp decode(:status, conn, data, []) do
118+
case Response.decode_status_line(data) do
119+
{:ok, {_version, status, _reason} = status_line, rest} ->
120+
conn = put_in(conn.request.status, status)
121+
decode(:headers, conn, rest, [{:status, conn.request.ref, status_line}])
122+
123+
:more ->
124+
conn = put_in(conn.request.state, :status)
125+
{:ok, conn, []}
126+
127+
:error ->
128+
{:error, :invalid_response}
129+
end
130+
end
131+
132+
defp decode(:headers, conn, data, responses) do
133+
case Response.decode_header(data) do
134+
{:ok, {name, value}, rest} ->
135+
responses = add_header(name, value, conn.request.ref, responses)
136+
decode(:headers, conn, rest, responses)
137+
138+
{:ok, :eof, rest} ->
139+
responses = reverse_headers(responses)
140+
content_length = content_length(responses)
141+
conn = put_in(conn.request.content_length, content_length)
142+
conn = put_in(conn.request.state, :body)
143+
decode(:body, conn, rest, responses)
144+
145+
:more ->
146+
conn = put_in(conn.request.state, :headers)
147+
{:ok, conn, responses}
148+
149+
:error ->
150+
{:error, :invalid_response}
151+
end
152+
end
153+
154+
defp decode(:body, conn, data, responses) do
155+
request_ref = conn.request.ref
156+
body_left = body_left(conn.request)
157+
conn = put_in(conn.request.body_left, body_left)
158+
159+
cond do
160+
body_left == :none ->
161+
conn = put_in(conn.buffer, data)
162+
responses = [{:done, request_ref} | responses]
163+
{:ok, conn, responses}
164+
165+
body_left == :until_closed or body_left > byte_size(data) ->
166+
conn = put_in(conn.request.body_left, body_left - byte_size(data))
167+
responses = [{:done, request_ref}, {:body, request_ref, data} | responses]
168+
{:ok, conn, responses}
169+
170+
body_left == byte_size(data) ->
171+
conn = put_in(conn.request.body_left, 0)
172+
responses = [{:done, request_ref}, {:body, request_ref, data} | responses]
173+
{:ok, conn, responses}
174+
175+
body_left < byte_size(data) ->
176+
{body, rest} = :binary.part(data, 0, body_left)
177+
conn = put_in(conn.buffer, rest)
178+
conn = put_in(conn.request.body_left, 0)
179+
responses = [{:done, request_ref}, {:body, request_ref, body} | responses]
180+
{:ok, conn, responses}
181+
end
182+
end
183+
184+
defp add_header(name, value, request_ref, [{:headers, request_ref, headers} | responses]) do
185+
headers = [{name, value} | headers]
186+
[{:headers, request_ref, headers} | responses]
187+
end
188+
189+
defp add_header(name, value, request_ref, responses) do
190+
headers = [{name, value}]
191+
[{:headers, request_ref, headers} | responses]
192+
end
193+
194+
defp reverse_headers([{:headers, request_ref, headers} | responses]) do
195+
[{:headers, request_ref, Enum.reverse(headers)} | responses]
196+
end
197+
198+
defp reverse_headers(responses) do
199+
responses
200+
end
201+
202+
defp content_length([{:headers, _request_ref, headers} | _responses]) do
203+
with [string] <- get_header(headers, "content-length"),
204+
{length, ""} <- Integer.parse(string) do
205+
length
206+
else
207+
[] ->
208+
nil
209+
210+
_other ->
211+
throw({:xhttp, :invalid_response})
212+
end
213+
end
214+
215+
defp body_left(%{body_left: nil, method: method, status: status, content_length: content_length}) do
216+
cond do
217+
method == "HEAD" or status in 100..199 or status in [204, 304] ->
218+
:none
219+
220+
# method == "CONNECT" and status in 200..299 -> nil
221+
# transfer-encoding
222+
223+
content_length ->
224+
content_length
225+
226+
true ->
227+
:until_closed
228+
end
229+
end
230+
231+
defp body_left(%{body_left: body_left}) do
232+
body_left
233+
end
234+
235+
defp get_header(headers, name) do
236+
for {n, v} <- headers, n == name, do: v
237+
end
238+
239+
defp normalize_method(atom) when is_atom(atom), do: atom |> Atom.to_string() |> String.upcase()
240+
defp normalize_method(binary) when is_binary(binary), do: String.upcase(binary)
241+
242+
defp new_request(ref, method) do
243+
%{
244+
ref: ref,
245+
state: :status,
246+
method: method,
247+
status: nil,
248+
content_length: nil,
249+
body_left: nil
250+
}
251+
end
252+
end

lib/xhttp/request.ex

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
defmodule XHTTP.Request do
2+
@moduledoc false
3+
4+
def encode(method, path, host, headers, body) do
5+
headers = add_default_headers(headers, host, body)
6+
7+
[
8+
encode_request_line(method, path),
9+
encode_headers(headers),
10+
"\r\n",
11+
encode_body(body)
12+
]
13+
end
14+
15+
defp encode_request_line(method, path) do
16+
# TODO: URI.encode/1 the path?
17+
[method, ?\s, path, " HTTP/1.1\r\n"]
18+
end
19+
20+
defp add_default_headers(headers, host, body) do
21+
headers
22+
|> put_new_header("host", host)
23+
|> add_content_length(body)
24+
end
25+
26+
defp add_content_length(headers, nil) do
27+
headers
28+
end
29+
30+
defp add_content_length(headers, body) do
31+
length = body |> byte_size() |> Integer.to_string()
32+
put_new_header(headers, "content-length", length)
33+
end
34+
35+
defp put_new_header(headers, name, value) do
36+
case :lists.keyfind(name, 1, headers) do
37+
{^name, _} -> headers
38+
false -> [{name, value} | headers]
39+
end
40+
end
41+
42+
# TODO: Consider ordering some of the headers, from RFC2616 4.2:
43+
# > The order in which header fields with differing field names are
44+
# > received is not significant. However, it is "good practice" to send
45+
# > general-header fields first, followed by request-header or response-
46+
# > header fields, and ending with the entity-header fields.
47+
defp encode_headers(headers) do
48+
# TODO: Consider validating header names and values, CRLF not allowed unless before LWS
49+
Enum.reduce(headers, "", fn {name, value}, acc ->
50+
[acc, name, ": ", value, "\r\n"]
51+
end)
52+
end
53+
54+
defp encode_body(nil), do: ""
55+
defp encode_body(body), do: body
56+
end

lib/xhttp/response.ex

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
defmodule XHTTP.Response do
2+
@moduledoc false
3+
4+
def decode_status_line(binary) do
5+
case :erlang.decode_packet(:http_bin, binary, []) do
6+
{:ok, {:http_response, version, status, reason}, rest} ->
7+
{:ok, {version, status, reason}, rest}
8+
9+
{:ok, _other, _rest} ->
10+
:error
11+
12+
{:more, _length} ->
13+
:more
14+
15+
{:error, _reason} ->
16+
:error
17+
end
18+
end
19+
20+
def decode_header(binary) do
21+
case :erlang.decode_packet(:httph_bin, binary, []) do
22+
{:ok, {:http_header, _unused, name, _reserved, value}, rest} ->
23+
{:ok, {header_name(name), value}, rest}
24+
25+
{:ok, :http_eoh, rest} ->
26+
{:ok, :eof, rest}
27+
28+
{:ok, _other, _rest} ->
29+
:error
30+
31+
{:more, _length} ->
32+
:more
33+
34+
{:error, _reason} ->
35+
:error
36+
end
37+
end
38+
39+
defp header_name(atom) when is_atom(atom), do: atom |> Atom.to_string() |> String.downcase()
40+
defp header_name(binary) when is_binary(binary), do: String.downcase(binary)
41+
end

0 commit comments

Comments
 (0)