# Copyright(c) 2015-2021 ACCESS CO., LTD. All rights reserved. use Croma defmodule Antikythera.Httpc do @default_max_body 10 * 1024 * 1024 # To fetch log files created by `GearLog.Writer` this must be larger than the max log file size. @maximum_max_body 150 * 1024 * 1024 defun default_max_body() :: pos_integer, do: @default_max_body defun maximum_max_body() :: pos_integer, do: @maximum_max_body @max_retry_attempts 3 @moduledoc """ HTTP client library. This is a wrapper around [`hackney`](https://github.com/benoitc/hackney), an HTTP client library. `Httpc` supports the following features: - gzip-compression is tranparently handled - headers are represented by maps instead of lists - header names are always lower case - TCP connections are automatically re-established when closed by server due to keepalive timeout ## Body format The `body` argument in `post/4`, `put/4`, `patch/4`, `request/5` takes either: - `binary` - Sends raw data - `iolist` - Sends raw data list (for more details see [documentation for built-in types](https://hexdocs.pm/elixir/typespecs.html#built-in-types)) - `{:form, [{key, value}]}` - Sends key-value data as x-www-form-urlencoded - `{:json, map}` - Converts map into JSON and sends as application/json - `{:file, path}` - Sends given file contents ## Options - `:timeout` - Timeout to establish a connection, in milliseconds. Default is `8000`. - `:recv_timeout` - Timeout used when receiving a response. Default is `5000`. - `:params` - An enumerable of 2-tuples that will be URL-encoded and appended to the URL as query string parameters. - `:cookie` - An enumerable of name-value pairs of cookies. `Httpc` automatically URL-encodes the given names/values for you. - `:basic_auth` - A pair of `{username, password}` tuple to be used for HTTP basic authentication. - `:proxy` - A proxy to be used for the request; it can be a regular URL or a `{host, port}` tuple. - `:proxy_auth` - Proxy authentication `{username, password}` tuple. - `:ssl` - SSL options supported by the `ssl` erlang module. - `:skip_ssl_verification` - Whether to verify server's SSL certificate or not. Defaults to `false`. Specify `skip_ssl_verification: true` when accessing insecure server with HTTPS. - `:max_body` - Maximum content-length of the response body (compressed size if it's compressed). Defaults to `#{@default_max_body}` (#{div(@default_max_body, 1024 * 1024)}MB) and must not exceed #{div(@maximum_max_body, 1024 * 1024)}MB. Responses having body larger than the specified size will be rejected with `{:error, :response_too_large}`. - `:follow_redirect` - A boolean that causes redirects to be followed. Defaults to `false`. - `:max_redirect` - An integer denoting the maximum number of redirects to follow if `follow_redirect: true` is given. - `:skip_body_decompression` - By default gzip-compressed body is automatically decompressed (i.e. defaults to `false`). Pass `skip_body_decompression: true` if compressed body is what you need. """ alias Croma.Result, as: R alias Antikythera.{MapUtil, Url} alias Antikythera.Http.{Status, Method, Headers, SetCookie, SetCookiesMap} defmodule ReqBody do @moduledoc """ Type for `Antikythera.Httpc`'s request body. """ @type json_obj :: %{(atom | String.t()) => any} @type t :: binary | iolist | {:form, [{term, term}]} | {:json, json_obj} | {:file, Path.t()} defun valid?(t :: term) :: boolean do b when is_binary(b) -> true io when is_list(io) -> true {:form, l} when is_list(l) -> true {:json, m} when is_map(m) -> true {:file, b} when is_binary(b) -> true _otherwise -> false end def convert_body_and_headers_by_body_type(body, headers) do case body do {:json, map} -> Poison.encode(map) |> R.map(fn json -> {json, Map.put(headers, "content-type", "application/json")} end) other_body -> # {:form, l} and {:file, b} can be left as-is because hackney handles this internally {:ok, {other_body, headers}} end end end defmodule Response do @moduledoc """ A struct to represent an HTTP response. Response headers are converted to a `Antikythera.Http.Headers.t` and all header names are lower-cased. `set-cookie` response headers are handled separately and stored in `cookies` field as a `Antikythera.Http.SetCookiesMap.t`. """ use Croma.Struct, recursive_new?: true, fields: [ status: Status.Int, body: Croma.Binary, headers: Headers, cookies: SetCookiesMap ] end defun request( method :: v[Method.t()], url :: v[Url.t()], body :: v[ReqBody.t()], headers :: v[Headers.t()] \\ %{}, options :: Keyword.t() \\ [] ) :: R.t(Response.t()) do downcased_headers = Map.new(headers, fn {k, v} -> {String.downcase(k), v} end) headers_with_encoding = Map.put_new(downcased_headers, "accept-encoding", "gzip") options_map = normalize_options(options) url_with_params = case options_map[:params] do nil -> url params -> uri = URI.parse(url) query_string = case uri.query do nil -> URI.encode_query(params) qs -> qs <> "&" <> URI.encode_query(params) end %URI{uri | query: query_string} |> URI.to_string() end hackney_opts = hackney_options(options_map) ReqBody.convert_body_and_headers_by_body_type(body, headers_with_encoding) |> R.bind(fn {hackney_body, headers} -> request_impl(method, url_with_params, headers, hackney_body, hackney_opts, options_map) end) end defun request!( method :: Method.t(), url :: Url.t(), body :: ReqBody.t(), headers :: Headers.t() \\ %{}, options :: Keyword.t() \\ [] ) :: Response.t() do request(method, url, body, headers, options) |> R.get!() end Enum.each([:get, :delete, :options, :head], fn method -> defun unquote(method)( url :: Url.t(), headers :: Headers.t() \\ %{}, options :: Keyword.t() \\ [] ) :: R.t(Response.t()) do request(unquote(method), url, "", headers, options) end # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom defun unquote(:"#{method}!")( url :: Url.t(), headers :: Headers.t() \\ %{}, options :: Keyword.t() \\ [] ) :: Response.t() do request!(unquote(method), url, "", headers, options) end end) Enum.each([:post, :put, :patch], fn method -> defun unquote(method)( url :: Url.t(), body :: ReqBody.t(), headers :: Headers.t(), options :: Keyword.t() \\ [] ) :: R.t(Response.t()) do request(unquote(method), url, body, headers, options) end # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom defun unquote(:"#{method}!")( url :: Url.t(), body :: ReqBody.t(), headers :: Headers.t(), options :: Keyword.t() \\ [] ) :: Response.t() do request!(unquote(method), url, body, headers, options) end end) defp request_impl(method, url_with_params, headers, body, hackney_opts, options_map) do send_request_with_retry( method, url_with_params, Map.to_list(headers), body, hackney_opts, options_map, 0 ) end defp send_request_with_retry( method, url, headers_list, body, hackney_opts, options_map, attempts ) do case send_request(method, url, headers_list, body, hackney_opts, options_map) do {:ok, _} = ok -> ok # connection is closed on server side {:error, :closed} -> require AntikytheraCore.Logger, as: L L.info("{:error, :closed} returned by hackney: attempts=#{attempts} url=#{url}") attempts2 = attempts + 1 if attempts2 < @max_retry_attempts do # Since hackney's socket pool may have not yet cleaned up the closed socket, we should wait for a moment :timer.sleep(10) send_request_with_retry( method, url, headers_list, body, hackney_opts, options_map, attempts2 ) else {:error, :closed} end {:error, _} = error -> error end end defp send_request(method, url, headers_list, body, hackney_opts, options_map) do case :hackney.request(method, url, headers_list, body, hackney_opts) do # HEAD method {:ok, resp_status, resp_headers} -> make_response(resp_status, resp_headers, "", options_map) {:ok, resp_status, resp_headers, resp_body} -> make_response(resp_status, resp_headers, resp_body, options_map) {:error, reason} -> {:error, reason} end end defp make_response(status, headers_list, body1, options_map) do if byte_size(body1) <= options_map[:max_body] do headers_grouped = Enum.group_by(headers_list, fn {k, _} -> String.downcase(k) end, &elem(&1, 1)) {body2, headers_map, cookies_map} = make_response_impl(headers_grouped, body1, options_map[:skip_body_decompression]) {:ok, %Response{status: status, body: body2, headers: headers_map, cookies: cookies_map}} else # The returned body might be truncated and thus we can't reliably uncompress the body if it's compressed. # In this case we give up returning partial information and simply return an error. {:error, :response_too_large} end end defp make_response_impl(headers_grouped1, body1, skip_body_decompression) do {cookie_strings, headers_grouped2} = Map.pop(headers_grouped1, "set-cookie", []) headers_map1 = MapUtil.map_values(headers_grouped2, fn {_, vs} -> Enum.join(vs, ", ") end) {body2, headers_map2} = if body1 != "" and !skip_body_decompression and headers_map1["content-encoding"] == "gzip" do decompress_body(body1, headers_map1) else {body1, headers_map1} end cookies_map = Map.new(cookie_strings, &SetCookie.parse!/1) {body2, headers_map2, cookies_map} end defp decompress_body(body, headers_map) do uncompressed = :zlib.gunzip(body) content_length = Integer.to_string(byte_size(uncompressed)) new_headers_map = headers_map |> Map.delete("content-encoding") |> Map.put("content-length", content_length) {uncompressed, new_headers_map} end defp normalize_options(options) do options_map = Map.new(options) case options_map[:max_body] do nil -> Map.put(options_map, :max_body, @default_max_body) max when max in 0..@maximum_max_body -> options_map end end defp hackney_options(options_map) do max_body = Map.fetch!(options_map, :max_body) base_opts = [{:path_encode_fun, &encode_path/1}, {:max_body, max_body}, {:with_body, true}] Enum.reduce(options_map, base_opts, fn {k, v}, opts -> case convert_option(k, v) do nil -> opts opt -> [opt | opts] end end) end defunp convert_option(name, value) :: any do :timeout, value -> {:connect_timeout, value} :recv_timeout, value -> {:recv_timeout, value} # :params are used in URL, not a hackney option :cookie, cs -> {:cookie, Enum.map(cs, fn {n, v} -> {URI.encode_www_form(n), URI.encode_www_form(v)} end)} :basic_auth, {_u, _p} = t -> {:basic_auth, t} :proxy, proxy -> {:proxy, proxy} :proxy_auth, {_u, _p} = t -> {:proxy_auth, t} :ssl, ssl -> {:ssl_options, ssl} :skip_ssl_verification, true -> :insecure # :max_body is treated differently as it has the default value :follow_redirect, true -> {:follow_redirect, true} :max_redirect, max -> {:max_redirect, max} # :skip_body_decompression is used in processing response body, not here _, _ -> nil end defunpt encode_path(path :: String.t()) :: String.t() do encode_path_impl(path, "") end defp hex(n) when n <= 9, do: n + ?0 defp hex(n), do: n + ?A - 10 defmacrop is_hex(c) do quote do unquote(c) in ?0..?9 or unquote(c) in ?A..?F or unquote(c) in ?a..?f end end defunp encode_path_impl(path :: String.t(), acc :: String.t()) :: String.t() do "", acc -> acc <>, acc when is_hex(a) and is_hex(b) -> encode_path_impl(rest, <>) <>, acc -> import Bitwise case URI.char_unescaped?(c) do true -> encode_path_impl(rest, <>) false -> encode_path_impl(rest, <>) end end end defmodule Antikythera.Httpc.Mockable do @moduledoc """ Just wrapping `Httpc` without any modification. Can be mocked with `:meck.expect(Httpc.Mockable, :request, ...)` without interfering other Httpc action. """ defdelegate request(method, url, body, headers, options), to: Antikythera.Httpc defdelegate request!(method, url, body, headers, options), to: Antikythera.Httpc Enum.each([:get, :delete, :options, :head], fn method -> defdelegate unquote(method)(url, headers, options), to: Antikythera.Httpc # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom defdelegate unquote(:"#{method}!")(url, headers, options), to: Antikythera.Httpc end) Enum.each([:post, :put, :patch], fn method -> defdelegate unquote(method)(url, body, headers, options), to: Antikythera.Httpc # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom defdelegate unquote(:"#{method}!")(url, body, headers, options), to: Antikythera.Httpc end) end