From ee30e6607bdd54233105b8a58cd2b42d4730cd4f Mon Sep 17 00:00:00 2001 From: fishtreesugar Date: Wed, 12 Oct 2022 12:42:48 +0800 Subject: [PATCH] init --- .formatter.exs | 3 + .github/workflows/ci.yml | 27 +++++ .gitignore | 29 +++++ lib/http_client_builder.ex | 120 ++++++++++++++++++++ mix.exs | 33 ++++++ mix.lock | 13 +++ test/http_client_builder_test.exs | 178 ++++++++++++++++++++++++++++++ test/support/httpbin_client.ex | 67 +++++++++++ test/support/httpbin_client2.ex | 40 +++++++ test/test_helper.exs | 3 + 10 files changed, 513 insertions(+) create mode 100644 .formatter.exs create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100755 lib/http_client_builder.ex create mode 100755 mix.exs create mode 100755 mix.lock create mode 100755 test/http_client_builder_test.exs create mode 100755 test/support/httpbin_client.ex create mode 100755 test/support/httpbin_client2.ex create mode 100755 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d304ff3 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,3 @@ +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..87dc8a6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} + strategy: + matrix: + otp: ["24.3", "25.1"] + elixir: ["1.13", "1.14"] + steps: + - uses: actions/checkout@v3 + - uses: erlef/setup-beam@v1 + with: + otp-version: ${{matrix.otp}} + elixir-version: ${{matrix.elixir}} + - name: Install Dependencies + run: | + mix local.rebar --force + mix local.hex --force + mix deps.get + - name: Run Formatter + run: mix format --check-formatted + - name: Run Tests + run: mix test --include httpbin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09fc616 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +ex-http-client-builder-*.tar + +# Temporary files, for example, from tests. +/tmp/ + +.elixir_ls +.DS_Store diff --git a/lib/http_client_builder.ex b/lib/http_client_builder.ex new file mode 100755 index 0000000..150c303 --- /dev/null +++ b/lib/http_client_builder.ex @@ -0,0 +1,120 @@ +defmodule HttpClientBuilder do + @moduledoc """ + A set of helpers for building HTTP client with finch easily. + """ + + defmacro __using__(client_opts \\ []) do + case client_opts[:base_url_getter] do + # absent + nil -> + :ok + + # anonymous function + {:fn, _, _} -> + :ok + + # function capture + {:&, _, _} -> + :ok + + _ -> + raise ":base_url_getter option is not a nullary anonymous function or function capture " + end + + case client_opts[:runtime_headers_getter] do + # absent + nil -> + :ok + + # anonymous function + {:fn, _, _} -> + :ok + + # function capture + {:&, _, _} -> + :ok + + _ -> + raise ":runtime_headers_getter option is not a nullary anonymous function or function capture " + end + + quote do + def child_spec(opts) do + default_pools = %{:default => [size: 50]} + pools = Keyword.get(unquote(client_opts), :pools, default_pools) + + %{ + id: __MODULE__, + start: {Finch, :start_link, [[name: __MODULE__, pools: pools]]}, + type: :supervisor + } + end + + defp split_opts(opts) do + {params, rest_opts} = Keyword.pop(opts, :params) + + default_headers = Keyword.get(unquote(client_opts), :headers, []) + {headers, rest_opts} = Keyword.pop(rest_opts, :headers, default_headers) + + default_request_opts = Keyword.get(unquote(client_opts), :request_opts, []) + {body, request_opts} = Keyword.pop(rest_opts, :body, nil) + + final_request_opts = + if Enum.empty?(request_opts), do: default_request_opts, else: request_opts + + {params, headers, body, final_request_opts} + end + + def get(url, opts \\ []) do + do_request(:get, url, opts) + end + + def post(url, opts \\ []) do + do_request(:post, url, opts) + end + + def put(url, opts \\ []) do + do_request(:put, url, opts) + end + + def delete(url, opts \\ []) do + do_request(:delete, url, opts) + end + + def patch(url, opts \\ []) do + do_request(:patch, url, opts) + end + + @doc """ + If `base_url_getter` option passed, it accept url's path, + otherwise it accept full url. be careful when overriding this function for specific endpoint + """ + def do_request(method, url_or_path, opts) do + {params, compile_time_headers, body, request_opts} = split_opts(opts) + + url = build_url(url_or_path, params) + + headers = + if unquote(client_opts)[:runtime_headers_getter] do + unquote(client_opts)[:runtime_headers_getter].() ++ compile_time_headers + else + compile_time_headers + end + + method + |> Finch.build(url, headers, body) + |> Finch.request(__MODULE__, request_opts) + end + + defp build_url(url_or_path, params) do + base_url_getter = unquote(client_opts)[:base_url_getter] + base_url = if base_url_getter, do: base_url_getter.(), else: "" + query = if is_nil(params), do: "", else: "?" <> Plug.Conn.Query.encode(params) + + base_url <> url_or_path <> query + end + + defoverridable get: 2, post: 2, put: 2, delete: 2, patch: 2, do_request: 3, build_url: 2 + end + end +end diff --git a/mix.exs b/mix.exs new file mode 100755 index 0000000..0f580e0 --- /dev/null +++ b/mix.exs @@ -0,0 +1,33 @@ +defmodule HttpClientBuilder.MixProject do + use Mix.Project + + def project do + [ + app: :http_client_builder, + version: "1.0.0", + elixir: "~> 1.11", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:finch, "~> 0.9"}, + # for encode plug compat query string + {:plug, "~> 1.12"}, + {:jason, "~> 1.2", only: [:test]} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100755 index 0000000..24eca95 --- /dev/null +++ b/mix.lock @@ -0,0 +1,13 @@ +%{ + "castore": {:hex, :castore, "0.1.18", "deb5b9ab02400561b6f5708f3e7660fc35ca2d51bfc6a940d2f513f89c2975fc", [:mix], [], "hexpm", "61bbaf6452b782ef80b33cdb45701afbcf0a918a45ebe7e73f1130d661e66a06"}, + "finch": {:hex, :finch, "0.13.0", "c881e5460ec563bf02d4f4584079e62201db676ed4c0ef3e59189331c4eddf7b", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "49957dcde10dcdc042a123a507a9c5ec5a803f53646d451db2f7dea696fba6cc"}, + "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, + "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, + "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, + "mint": {:hex, :mint, "1.4.2", "50330223429a6e1260b2ca5415f69b0ab086141bc76dc2fbf34d7c389a6675b2", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "ce75a5bbcc59b4d7d8d70f8b2fc284b1751ffb35c7b6a6302b5192f8ab4ddd80"}, + "nimble_options": {:hex, :nimble_options, "0.4.0", "c89babbab52221a24b8d1ff9e7d838be70f0d871be823165c94dd3418eea728f", [:mix], [], "hexpm", "e6701c1af326a11eea9634a3b1c62b475339ace9456c1a23ec3bc9a847bca02d"}, + "nimble_pool": {:hex, :nimble_pool, "0.2.6", "91f2f4c357da4c4a0a548286c84a3a28004f68f05609b4534526871a22053cde", [:mix], [], "hexpm", "1c715055095d3f2705c4e236c18b618420a35490da94149ff8b580a2144f653f"}, + "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, + "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, +} diff --git a/test/http_client_builder_test.exs b/test/http_client_builder_test.exs new file mode 100755 index 0000000..6af447f --- /dev/null +++ b/test/http_client_builder_test.exs @@ -0,0 +1,178 @@ +defmodule HttpClientBuilderTest do + use ExUnit.Case + + alias HttpbinClient + import ExUnit.CaptureLog + + setup do + {:ok, _pid} = start_supervised(HttpbinClient) + {:ok, _pid} = start_supervised(HttpbinClient2) + :ok + end + + test "returns child_spec with pools opts" do + child_spec_with_pools_opts = %{ + id: HttpbinClient, + start: {Finch, :start_link, [[name: HttpbinClient, pools: HttpbinClient.pools_opts()]]}, + type: :supervisor + } + + child_spec_with_pools_opts2 = %{ + id: HttpbinClient2, + start: {Finch, :start_link, [[name: HttpbinClient2, pools: HttpbinClient.pools_opts()]]}, + type: :supervisor + } + + assert HttpbinClient.child_spec([]) == child_spec_with_pools_opts + assert HttpbinClient2.child_spec([]) == child_spec_with_pools_opts2 + end + + @tag :httpbin + test "multiple client could work together" do + params = %{"foo" => "bar", "baz" => "quux"} + + with {:ok, resp1} <- HttpbinClient.get_endpoint(params), + {:ok, resp2} <- HttpbinClient2.get_endpoint(params) do + resp_body1 = Jason.decode!(resp1.body) + resp_body2 = Jason.decode!(resp2.body) + + assert Map.get(resp_body1, "args") == Map.get(resp_body2, "args") + else + {:error, _error} -> assert false + end + end + + @tag :httpbin + test "request to httpbin get endpoint" do + params = %{"foo" => "bar", "baz" => "quux"} + + case HttpbinClient.get_endpoint(params) do + {:ok, resp} -> + resp_body = Jason.decode!(resp.body) + assert Map.get(resp_body, "args") == params + + {:error, _error} -> + assert false + end + end + + @tag :httpbin + test "request to httpbin get endpoint with encoded array query params" do + params = %{"foo" => [1, "2"], "baz" => "quux"} + + case HttpbinClient.get_endpoint(params) do + {:ok, resp} -> + resp_body = Jason.decode!(resp.body) + + assert Map.get(resp_body, "args") == %{ + # Note: This's httpbin's style not plug's style + # plug returns "foo" => ["1", "2"] + "foo[]" => ["1", "2"], + "baz" => "quux" + } + + {:error, _error} -> + assert false + end + end + + @tag :httpbin + test "request to httpbin post endpoint" do + params = %{"foo" => "bar", "baz" => "quux"} + body = %{"this" => "is", "a" => "body"} + + log = + capture_log(fn -> + case HttpbinClient.post_endpoint(params, Jason.encode!(body)) do + {:ok, resp} -> + resp_body = Jason.decode!(resp.body) + + assert Map.get(resp_body, "args") == params + assert Map.get(resp_body, "json") == body + + {:error, _error} -> + assert false + end + end) + + assert log =~ "override spercific path /post" + end + + @tag :httpbin + test "request to httpbin put endpoint" do + params = %{"foo" => "bar", "baz" => "quux"} + body = %{"this" => "is", "a" => "body"} + + case HttpbinClient.put_endpoint(params, Jason.encode!(body)) do + {:ok, resp} -> + resp_body = Jason.decode!(resp.body) + + assert Map.get(resp_body, "args") == params + assert Map.get(resp_body, "json") == body + + {:error, _error} -> + assert false + end + end + + @tag :httpbin + test "request to httpbin delete endpoint" do + body = %{"this" => "is", "a" => "body"} + + case HttpbinClient.delete_endpoint(Jason.encode!(body)) do + {:ok, resp} -> + resp_body = Jason.decode!(resp.body) + + assert Map.get(resp_body, "json") == body + + {:error, _error} -> + assert false + end + end + + @tag :httpbin + test "request to httpbin patch endpoint" do + body = %{"this" => "is", "a" => "body"} + + case HttpbinClient.patch_endpoint(Jason.encode!(body)) do + {:ok, resp} -> + resp_body = Jason.decode!(resp.body) + + assert Map.get(resp_body, "json") == body + + {:error, _error} -> + assert false + end + end + + @tag :httpbin + test "request with custom header" do + custom_header_name = "X-Custom-Header" + custom_header_value = "FTW" + + log = + capture_log(fn -> + case HttpbinClient.post_endpoint(nil, nil, [{custom_header_name, custom_header_value}]) do + {:ok, resp} -> + resp_body = Jason.decode!(resp.body) + assert get_in(resp_body, ["headers", custom_header_name]) == custom_header_value + + {:error, _error} -> + assert false + end + end) + + assert log =~ "override spercific path /post" + end + + @tag :httpbin + test "handle timeout" do + case HttpbinClient.timeout_soon_endpoint(nil) do + {:ok, _resp} -> + assert false, "Should be timeout" + + {:error, %{reason: reason}} -> + assert reason == :timeout + end + end +end diff --git a/test/support/httpbin_client.ex b/test/support/httpbin_client.ex new file mode 100755 index 0000000..bb7fda8 --- /dev/null +++ b/test/support/httpbin_client.ex @@ -0,0 +1,67 @@ +defmodule HttpbinClient do + @moduledoc """ + Directly request to httpbin + """ + + @default_headers [ + {"accept", "application/json"} + ] + + @pools %{ + default: [ + size: 23, + count: 3 + ] + } + + require Logger + + use HttpClientBuilder, + headers: @default_headers, + pools: @pools, + base_url_getter: &base_url/0, + runtime_headers_getter: &headers/0 + + def base_url, do: "https://httpbin.org" + + def headers, do: [{"content-type", "application/json"}] + + # expose for testing + def pools_opts do + @pools + end + + def get_endpoint(params, headers \\ []) do + get("/get", params: params, headers: headers) + end + + def post_endpoint(params, body, headers \\ []) do + post("/post", params: params, body: body, headers: headers) + end + + def put_endpoint(params, body, headers \\ []) do + put("/put", params: params, body: body, headers: headers) + end + + def delete_endpoint(body, headers \\ []) do + delete("/delete", body: body, headers: headers) + end + + def patch_endpoint(body, headers \\ []) do + patch("/patch", body: body, headers: headers) + end + + def timeout_soon_endpoint(params, headers \\ []) do + get("/get", params: params, headers: headers, receive_timeout: 1) + end + + def do_request(method, url = "/post", opts) do + Logger.info("override spercific path #{url}") + + super(method, url, opts) + end + + def do_request(method, url, opts) do + super(method, url, opts) + end +end diff --git a/test/support/httpbin_client2.ex b/test/support/httpbin_client2.ex new file mode 100755 index 0000000..d2b0728 --- /dev/null +++ b/test/support/httpbin_client2.ex @@ -0,0 +1,40 @@ +defmodule HttpbinClient2 do + @moduledoc """ + For test multiple client in one supervisor tree + """ + + @default_headers [ + {"accept", "application/json"}, + {"content-type", "application/json"} + ] + + @pools %{ + default: [ + size: 23, + count: 3 + ] + } + + use HttpClientBuilder, headers: @default_headers, pools: @pools + + # expose for testing + def pools_opts do + @pools + end + + def get_endpoint(params, headers \\ []) do + get("https://httpbin.org/get", params: params, headers: headers) + end + + def post_endpoint(params, body, headers \\ []) do + post("https://httpbin.org/post", params: params, body: body, headers: headers) + end + + def put_endpoint(params, body, headers \\ []) do + put("https://httpbin.org/put", params: params, body: body, headers: headers) + end + + def delete_endpoint(body, headers \\ []) do + delete("https://httpbin.org/delete", body: body, headers: headers) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100755 index 0000000..d83078d --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,3 @@ +# use --include httpbin to include it +ExUnit.configure(exclude: [httpbin: true]) +ExUnit.start()