|
| 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 |
0 commit comments