├── test ├── maxwell │ ├── multipart_test_file.sh │ ├── middleware │ │ ├── middleware_test_helper.exs │ │ ├── option_test.exs │ │ ├── header_test.exs │ │ ├── decode_rels_test.exs │ │ ├── fuse_test.exs │ │ ├── retry_test.exs │ │ ├── header_case_test.exs │ │ ├── base_url_test.exs │ │ ├── middleware_test.exs │ │ ├── logger_test.exs │ │ └── json_test.exs │ ├── adapter │ │ ├── util_test.exs │ │ ├── adapter_test.exs │ │ ├── adapter_test_helper.exs │ │ ├── ibrowse_test.exs │ │ ├── hackney_test.exs │ │ └── httpc_test.exs │ ├── builder │ │ └── builder_exception_test.exs │ ├── query_test.exs │ └── conn_test.exs └── test_helper.exs ├── .dialyzer_ignore.exs ├── .gitignore ├── .formatter.exs ├── lib ├── maxwell │ ├── builder │ │ ├── adapter.ex │ │ ├── middleware.ex │ │ └── util.ex │ ├── middleware │ │ ├── opts.ex │ │ ├── rels.ex │ │ ├── header.ex │ │ ├── retry.ex │ │ ├── middleware.ex │ │ ├── fuse.ex │ │ ├── header_case.ex │ │ ├── baseurl.ex │ │ ├── logger.ex │ │ └── json.ex │ ├── error.ex │ ├── adapter │ │ ├── hackney.ex │ │ ├── adapter.ex │ │ ├── ibrowse.ex │ │ ├── httpc.ex │ │ └── util.ex │ ├── query.ex │ ├── builder.ex │ ├── conn.ex │ └── multipart.ex ├── LICENSE └── maxwell.ex ├── CONTRIBUTORS.md ├── .travis.yml ├── LICENSE ├── CHANGELOG.md ├── mix.exs ├── mix.lock └── README.md /test/maxwell/multipart_test_file.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | echo "test multipart file" 3 | -------------------------------------------------------------------------------- /.dialyzer_ignore.exs: -------------------------------------------------------------------------------- 1 | [ 2 | {":0:unknown_function Function :httpc.request/4 does not exist."} 3 | ] 4 | -------------------------------------------------------------------------------- /test/maxwell/middleware/middleware_test_helper.exs: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Middleware.TestHelper do 2 | def request(mid, env, opts) do 3 | mid.request(env, opts) 4 | end 5 | 6 | def response(mid, env, opts) do 7 | mid.response(env, opts) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.ensure_all_started(:mimic) 2 | 3 | Mimic.copy(:httpc) 4 | Mimic.copy(:hackney) 5 | Mimic.copy(:ibrowse) 6 | 7 | ExUnit.start() 8 | 9 | Code.load_file("test/maxwell/adapter/adapter_test_helper.exs") 10 | Code.load_file("test/maxwell/middleware/middleware_test_helper.exs") 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Erlang template 2 | .eunit 3 | deps 4 | *.o 5 | *.beam 6 | *.plt 7 | erl_crash.dump 8 | ebin 9 | rel/example_project 10 | .concrete/DEV_MODE 11 | .rebar 12 | ### Elixir template 13 | /_build 14 | /deps 15 | /doc 16 | /cover 17 | erl_crash.dump 18 | *.ez 19 | 20 | # Created by .ignore support plugin (hsz.mobi) 21 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: [ 3 | ".formatter.exs", 4 | "mix.exs", 5 | "{config,lib,test}/**/*.{ex,exs}" 6 | ], 7 | locals_without_parens: [ 8 | adapter: 1, 9 | middleware: 1, 10 | middleware: 2 11 | ], 12 | export: [ 13 | locals_without_parens: [ 14 | adapter: 1, 15 | middleware: 1, 16 | middleware: 2 17 | ] 18 | ] 19 | ] 20 | -------------------------------------------------------------------------------- /test/maxwell/adapter/util_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Adapter.UtilTest do 2 | use ExUnit.Case 3 | 4 | import Maxwell.Adapter.Util 5 | 6 | test "url_serialize/4" do 7 | assert url_serialize("http://example.com", "/foo", %{"ids" => ["1", "2"]}) == 8 | "http://example.com/foo?ids[]=1&ids[]=2" 9 | 10 | assert url_serialize("http://example.com", "/foo", %{"ids" => %{"foo" => "1"}}) == 11 | "http://example.com/foo?ids[foo]=1" 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/maxwell/builder/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Builder.Adapter do 2 | @moduledoc """ 3 | Adapter macro. 4 | 5 | ### Examples 6 | # module 7 | @adapter Adapter.Module 8 | 9 | """ 10 | 11 | @doc """ 12 | * `adapter` - adapter module, for example: `Maxwell.Middleware.Hackney` 13 | 14 | ### Examples 15 | @adapter Adapter.Module 16 | """ 17 | defmacro adapter(adapter) do 18 | quote do 19 | @adapter unquote(adapter) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | Maxwell contributors 2 | ============================================ 3 | 4 | * **[Rainer Du](https://github.com/secretworry)** 5 | * **[bitwalker](https://github.com/bitwalker)** 6 | * **[falood](https://github.com/falood)** 7 | * **[Imran Ismail](https://github.com/imranismail)** 8 | * **[lattenwald](https://github.com/lattenwald)** 9 | * **[dardub](https://github.com/dardub)** 10 | * **[Chris Dosé](https://github.com/doughsay)** 11 | * **[Gerard de Bried](https://github.com/smeevil) -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | 3 | matrix: 4 | include: 5 | - otp_release: 21.3 6 | elixir: 1.8.2 7 | - otp_release: 21.3 8 | elixir: 1.9.2 9 | 10 | - otp_release: 22.1 11 | elixir: 1.8.2 12 | - otp_release: 22.1 13 | elixir: 1.9.2 14 | 15 | sudo: false 16 | 17 | before_script: 18 | - MIX_ENV=test mix do deps.get 19 | - mix format --check-formatted 20 | script: 21 | - MIX_ENV=test mix test 22 | after_script: 23 | - mix dialyzer 24 | - MIX_ENV=test mix coveralls.travis 25 | - mix deps.get --only docs 26 | - MIX_ENV=docs mix inch.report 27 | -------------------------------------------------------------------------------- /lib/maxwell/builder/middleware.ex: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Builder.Middleware do 2 | @moduledoc """ 3 | Methods for setting up middlewares. 4 | """ 5 | 6 | @doc """ 7 | Build middleware macro. 8 | 9 | * `middleware` - middleware module, for example: `Maxwell.Middleware.Json`. 10 | * `opts` - options setting in compile time, default is `[]`, for example: `[encode_func: &Poison.encode/1]`. 11 | 12 | ### Examples 13 | @middleware Middleware.Module, [] 14 | 15 | """ 16 | defmacro middleware(middleware, opts \\ []) do 17 | quote do 18 | @middleware {unquote(middleware), unquote(middleware).init(unquote(opts))} 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/maxwell/middleware/opts.ex: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Middleware.Opts do 2 | @moduledoc """ 3 | Merge adapter's options (keyword list) to adapter's options 4 | 5 | ## Examples 6 | 7 | # Client.ex 8 | use Maxwell.Builder ~(get)a 9 | middleware Maxwell.Middleware.Opts, [connect_timeout: 5000] 10 | 11 | def request do 12 | # opts is [connect_timeout: 5000, cookie: "xxxxcookieyyyy"] 13 | put_option(cookie: "xxxxcookieyyyy")|> get! 14 | end 15 | 16 | """ 17 | use Maxwell.Middleware 18 | 19 | def request(%Maxwell.Conn{} = conn, opts) do 20 | new_opts = Keyword.merge(opts, conn.opts) 21 | %{conn | opts: new_opts} 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/maxwell/error.ex: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Error do 2 | @moduledoc """ 3 | Exception `%Maxwell.Error{:url, :reason, :message, :status, :conn}` 4 | ### Examples 5 | 6 | raise Maxwell.Error, {__MODULE__, reason, conn} 7 | 8 | """ 9 | defexception [:url, :status, :method, :reason, :message, :conn] 10 | 11 | @spec exception({module, atom | binary, Maxwell.Conn.t()}) :: Exception.t() 12 | def exception({module, reason, conn}) do 13 | %Maxwell.Conn{url: url, status: status, method: method, path: path} = conn 14 | 15 | message = """ 16 | url: #{url} 17 | path: #{inspect(path)} 18 | method: #{method} 19 | status: #{status} 20 | reason: #{inspect(reason)} 21 | module: #{module} 22 | """ 23 | 24 | %Maxwell.Error{url: url, status: status, method: method, message: message, conn: conn} 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/maxwell/middleware/rels.ex: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Middleware.Rels do 2 | @moduledoc """ 3 | Decode reponse's body's rels. 4 | 5 | ## Examples 6 | 7 | # Client.ex 8 | use Maxwell.Builder ~(get)a 9 | middleware Maxwell.Middleware.Rels 10 | 11 | """ 12 | use Maxwell.Middleware 13 | 14 | def response(%Maxwell.Conn{} = conn, _opts) do 15 | link = conn.resp_headers['Link'] || conn.resp_headers["Link"] 16 | 17 | if link do 18 | rels = 19 | link 20 | |> to_string 21 | |> String.split(",") 22 | |> Enum.map(&String.trim/1) 23 | |> Enum.reduce(%{}, fn e, acc -> 24 | case Regex.named_captures(~r/(?(.+)); rel=(?(.+))/, e) do 25 | nil -> acc 26 | result -> Map.put(acc, result["key"], result["value"]) 27 | end 28 | end) 29 | 30 | Map.put(conn, :rels, rels) 31 | else 32 | conn 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/maxwell/middleware/option_test.exs: -------------------------------------------------------------------------------- 1 | defmodule OptsTest do 2 | use ExUnit.Case 3 | import Maxwell.Middleware.TestHelper 4 | 5 | alias Maxwell.Conn 6 | 7 | test "Base Middleware Opts" do 8 | conn = 9 | request( 10 | Maxwell.Middleware.Opts, 11 | %Conn{opts: []}, 12 | timeout: 1000 13 | ) 14 | 15 | assert conn.opts == [timeout: 1000] 16 | end 17 | 18 | test "Merge Middleware Opts" do 19 | conn = 20 | request( 21 | Maxwell.Middleware.Opts, 22 | %Conn{opts: [timeout: 1000]}, 23 | timeout: 2000 24 | ) 25 | 26 | assert conn.opts == [timeout: 1000] 27 | end 28 | 29 | test "Add and merge Middleware Opts" do 30 | conn = 31 | request( 32 | Maxwell.Middleware.Opts, 33 | %Conn{opts: [timeout: 1000]}, 34 | timeout: 2000, 35 | stream_to: :pid 36 | ) 37 | 38 | assert Keyword.get(conn.opts, :timeout) == 1000 39 | assert Keyword.get(conn.opts, :stream_to, :pid) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/maxwell/middleware/header_test.exs: -------------------------------------------------------------------------------- 1 | defmodule HeaderTest do 2 | use ExUnit.Case 3 | import Maxwell.Middleware.TestHelper 4 | 5 | alias Maxwell.Conn 6 | 7 | test "sets default request headers" do 8 | conn = request(Maxwell.Middleware.Headers, Conn.new(), %{"content-type" => "text/plain"}) 9 | assert conn.req_headers == %{"content-type" => "text/plain"} 10 | end 11 | 12 | test "overrides request headers" do 13 | conn = 14 | request( 15 | Maxwell.Middleware.Headers, 16 | %Conn{req_headers: %{"content-type" => "application/json"}}, 17 | %{"content-type" => "text/plain"} 18 | ) 19 | 20 | assert conn.req_headers == %{"content-type" => "application/json"} 21 | end 22 | 23 | test "raises an error if header key is not a string" do 24 | assert_raise ArgumentError, "Header keys must be strings, but got: %{key: \"value\"}", fn -> 25 | defmodule TAtom111 do 26 | use Maxwell.Builder, [:get, :post] 27 | middleware Maxwell.Middleware.Headers, %{:key => "value"} 28 | end 29 | 30 | raise "ok" 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016- 某文 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 某文 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/maxwell/middleware/header.ex: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Middleware.Headers do 2 | @moduledoc """ 3 | Add fixed headers to request's headers 4 | 5 | ## Examples 6 | 7 | # Client.ex 8 | use Maxwell.Builder ~(get)a 9 | middleware Maxwell.Middleware.Headers, %{'User-Agent' => "zhongwencool"} 10 | 11 | def request do 12 | # headers is merge to %{'User-Agent' => "zhongwencool", 'username' => "zhongwencool"} 13 | new() 14 | |> put_req_header(%{'username' => "zhongwencool"}) 15 | |> get! 16 | end 17 | 18 | """ 19 | use Maxwell.Middleware 20 | alias Maxwell.Conn 21 | 22 | def init(headers) do 23 | check_headers!(headers) 24 | conn = Conn.put_req_headers(%Conn{}, headers) 25 | conn.req_headers 26 | end 27 | 28 | def request(%Conn{} = conn, req_headers) do 29 | %{conn | req_headers: Map.merge(req_headers, conn.req_headers)} 30 | end 31 | 32 | defp check_headers!(headers) do 33 | unless Enum.all?(headers, fn {key, _value} -> is_binary(key) end) do 34 | raise(ArgumentError, "Header keys must be strings, but got: #{inspect(headers)}") 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/maxwell/middleware/decode_rels_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RelsTest do 2 | use ExUnit.Case 3 | import Maxwell.Middleware.TestHelper 4 | alias Maxwell.Conn 5 | 6 | test "Header Without Link Middleware Rels" do 7 | conn = response(Maxwell.Middleware.Rels, %Conn{resp_headers: %{}}, []) 8 | assert conn == %Conn{resp_headers: %{}} 9 | end 10 | 11 | test "Header With Link Middleware Rels and match" do 12 | conn = 13 | response( 14 | Maxwell.Middleware.Rels, 15 | %Conn{ 16 | resp_headers: %{ 17 | 'Link' => 18 | "; rel=test1, ; rel=test2, ; rel=test3" 19 | } 20 | }, 21 | [] 22 | ) 23 | 24 | assert Map.get(conn.rels, "test1") == "" 25 | assert Map.get(conn.rels, "test2") == "" 26 | assert Map.get(conn.rels, "test3") == "" 27 | end 28 | 29 | test "Header With Link Middleware Rels and don't match" do 30 | conn = 31 | response( 32 | Maxwell.Middleware.Rels, 33 | %Conn{resp_headers: %{'Link' => "lkdfjldkjfdwrongformat"}}, 34 | [] 35 | ) 36 | 37 | assert conn.rels == %{} 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/maxwell/middleware/retry.ex: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Middleware.Retry do 2 | @moduledoc """ 3 | Retries requests if a connection is refused up to a pre-defined limit. 4 | 5 | Example: 6 | defmodule MyClient do 7 | use Maxwell.Builder 8 | 9 | middleware Maxwell.Middleware.Retry, delay: 1_000, max_retries: 3 10 | end 11 | 12 | Options: 13 | 14 | - delay: number of milliseconds to wait between tries (defaults to 1_000) 15 | - max_retries: maximum number of retries (defaults to 5) 16 | """ 17 | use Maxwell.Middleware 18 | 19 | @defaults [delay: 1_000, max_retries: 5] 20 | 21 | def init(opts) do 22 | Keyword.merge(@defaults, opts) 23 | end 24 | 25 | def call(conn, next, opts) do 26 | retry_delay = Keyword.get(opts, :delay) 27 | max_retries = Keyword.get(opts, :max_retries) 28 | retry(conn, next, retry_delay, max_retries) 29 | end 30 | 31 | defp retry(conn, next, retry_delay, max_retries) when max_retries > 0 do 32 | case next.(conn) do 33 | {:error, :econnrefused, _conn} -> 34 | :timer.sleep(retry_delay) 35 | retry(conn, next, retry_delay, max_retries - 1) 36 | 37 | {:error, _reason, _conn} = err -> 38 | err 39 | 40 | conn -> 41 | conn 42 | end 43 | end 44 | 45 | defp retry(conn, next, _retry_delay, _max_retries) do 46 | next.(conn) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/maxwell/builder/builder_exception_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BuilderExceptionTest do 2 | use ExUnit.Case 3 | 4 | test "Builder Method Exception Test" do 5 | assert_raise ArgumentError, "http methods don't support gett", fn -> 6 | defmodule TAtom do 7 | use Maxwell.Builder, ~w(gett) 8 | end 9 | end 10 | end 11 | 12 | test "Builder Method Format integer " do 13 | assert_raise ArgumentError, 14 | "http methods format must be [:get] or [\"get\"] or ~w(get) or ~w(get)a 12345", 15 | fn -> 16 | defmodule TInteger do 17 | use Maxwell.Builder, 12345 18 | end 19 | end 20 | end 21 | 22 | test "Builder Adapter Exception Test" do 23 | assert_raise ArgumentError, "Adapter must be Module", fn -> 24 | defmodule TAdapter do 25 | use Maxwell.Builder, ~w(get) 26 | adapter 1 27 | end 28 | end 29 | end 30 | 31 | test "method with binary methods" do 32 | assert_raise RuntimeError, "ok", fn -> 33 | defmodule TBinary do 34 | use Maxwell.Builder, ["get", "post"] 35 | end 36 | 37 | raise "ok" 38 | end 39 | end 40 | 41 | test "method with atom methods" do 42 | assert_raise RuntimeError, "ok", fn -> 43 | defmodule TAtomList do 44 | use Maxwell.Builder, [:get, :post] 45 | end 46 | 47 | raise "ok" 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/maxwell.ex: -------------------------------------------------------------------------------- 1 | defmodule Maxwell do 2 | @moduledoc """ 3 | The maxwell specification. 4 | 5 | There are two kind of usages: basic usage and advanced middleware usage. 6 | 7 | ### Basic Usage 8 | 9 | ## Returns Origin IP, for example %{"origin" => "127.0.0.1"} 10 | "http://httpbin.org/ip" 11 | |> Maxwell.Conn.new() 12 | |> Maxwell.get!() 13 | |> Maxwell.Conn.get_resp_body() 14 | |> Poison.decode!() 15 | 16 | Find all `get_*&put_*` helper functions by `h Maxwell.Conn.xxx` 17 | 18 | ### Advanced Middleware Usage(Create API Client). 19 | 20 | defmodule Client do 21 | use Maxwell.Builder, ~w(get)a 22 | adapter Maxwell.Adapter.Ibrowse 23 | 24 | middleware Maxwell.Middleware.BaseUrl, "http://httpbin.org" 25 | middleware Maxwell.Middleware.Opts, [connect_timeout: 5000] 26 | middleware Maxwell.Middleware.Headers, %{"User-Agent" => "zhongwencool"} 27 | middleware Maxwell.Middleware.Json 28 | 29 | ## Returns origin IP, for example "127.0.0.1" 30 | def ip() do 31 | "/ip" 32 | |> new() 33 | |> get!() 34 | |> get_resp_body("origin") 35 | end 36 | 37 | ## Generates n random bytes of binary data, accepts optional seed integer parameter 38 | def get_random_bytes(size) do 39 | "/bytes/\#\{size\}" 40 | |> new() 41 | |> get!() 42 | |> get_resp_body(&to_string/1) 43 | end 44 | end 45 | 46 | """ 47 | use Maxwell.Builder 48 | end 49 | -------------------------------------------------------------------------------- /test/maxwell/middleware/fuse_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FuseTest do 2 | use ExUnit.Case, async: false 3 | alias Maxwell.Conn 4 | 5 | defmodule FuseAdapter do 6 | def call(%{path: path} = conn) do 7 | send(self(), :request_made) 8 | conn = %{conn | state: :sent} 9 | 10 | case path do 11 | "/ok" -> %{conn | status: 200, resp_body: "ok"} 12 | "/unavailable" -> {:error, :econnrefused, conn} 13 | end 14 | end 15 | end 16 | 17 | defmodule Client do 18 | use Maxwell.Builder 19 | 20 | middleware Maxwell.Middleware.Fuse, 21 | name: __MODULE__, 22 | fuse_opts: {{:standard, 2, 10_000}, {:reset, 60_000}} 23 | 24 | adapter FuseAdapter 25 | end 26 | 27 | setup do 28 | {:ok, _} = Application.ensure_all_started(:fuse) 29 | :fuse.reset(Client) 30 | :ok 31 | end 32 | 33 | test "regular endpoint" do 34 | assert Conn.new("/ok") |> Client.get!() |> Conn.get_resp_body() == "ok" 35 | end 36 | 37 | test "unavailable endpoint" do 38 | assert_raise Maxwell.Error, fn -> Conn.new("/unavailable") |> Client.get!() end 39 | assert_receive :request_made 40 | assert_raise Maxwell.Error, fn -> Conn.new("/unavailable") |> Client.get!() end 41 | assert_receive :request_made 42 | assert_raise Maxwell.Error, fn -> Conn.new("/unavailable") |> Client.get!() end 43 | assert_receive :request_made 44 | 45 | assert_raise Maxwell.Error, fn -> Conn.new("/unavailable") |> Client.get!() end 46 | refute_receive :request_made 47 | assert_raise Maxwell.Error, fn -> Conn.new("/unavailable") |> Client.get!() end 48 | refute_receive :request_made 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/maxwell/middleware/retry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RetryTest do 2 | use ExUnit.Case, async: false 3 | alias Maxwell.Conn 4 | 5 | defmodule LaggyAdapter do 6 | def start_link, do: Agent.start_link(fn -> 0 end, name: __MODULE__) 7 | 8 | def call(%{path: path} = conn) do 9 | conn = %{conn | state: :sent} 10 | 11 | Agent.get_and_update(__MODULE__, fn retries -> 12 | response = 13 | case path do 14 | "/ok" -> %{conn | status: 200, resp_body: "ok"} 15 | "/maybe" when retries < 5 -> {:error, :econnrefused, conn} 16 | "/maybe" -> %{conn | status: 200, resp_body: "maybe"} 17 | "/nope" -> {:error, :econnrefused, conn} 18 | "/boom" -> {:error, :boom, conn} 19 | end 20 | 21 | {response, retries + 1} 22 | end) 23 | end 24 | end 25 | 26 | defmodule Client do 27 | use Maxwell.Builder 28 | 29 | middleware Maxwell.Middleware.Retry, delay: 10, max_retries: 10 30 | 31 | adapter LaggyAdapter 32 | end 33 | 34 | setup do 35 | {:ok, _} = LaggyAdapter.start_link() 36 | :ok 37 | end 38 | 39 | test "pass on successful request" do 40 | assert Conn.new("/ok") |> Client.get!() |> Conn.get_resp_body() == "ok" 41 | end 42 | 43 | test "pass after retry" do 44 | assert Conn.new("/maybe") |> Client.get!() |> Conn.get_resp_body() == "maybe" 45 | end 46 | 47 | test "raise error if max_retries is exceeded" do 48 | assert_raise Maxwell.Error, fn -> Conn.new("/nope") |> Client.get!() end 49 | end 50 | 51 | test "raise error if error other than econnrefused occurs" do 52 | assert_raise Maxwell.Error, fn -> Conn.new("/boom") |> Client.get!() end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/maxwell/middleware/header_case_test.exs: -------------------------------------------------------------------------------- 1 | defmodule HeaderCaseTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Maxwell.Middleware.TestHelper 5 | alias Maxwell.Conn 6 | 7 | test "lower case" do 8 | opts = Maxwell.Middleware.HeaderCase.init(:lower) 9 | conn = %Conn{req_headers: %{"Content-Type" => "application/json"}} 10 | conn = request(Maxwell.Middleware.HeaderCase, conn, opts) 11 | assert conn.req_headers == %{"content-type" => "application/json"} 12 | end 13 | 14 | test "upper case" do 15 | opts = Maxwell.Middleware.HeaderCase.init(:upper) 16 | conn = %Conn{req_headers: %{"Content-Type" => "application/json"}} 17 | conn = request(Maxwell.Middleware.HeaderCase, conn, opts) 18 | assert conn.req_headers == %{"CONTENT-TYPE" => "application/json"} 19 | end 20 | 21 | test "title case" do 22 | opts = Maxwell.Middleware.HeaderCase.init(:title) 23 | conn = %Conn{req_headers: %{"content-type" => "application/json"}} 24 | conn = request(Maxwell.Middleware.HeaderCase, conn, opts) 25 | assert conn.req_headers == %{"Content-Type" => "application/json"} 26 | 27 | conn = %Conn{req_headers: %{"CONTENT-TYPE" => "application/json"}} 28 | conn = request(Maxwell.Middleware.HeaderCase, conn, opts) 29 | assert conn.req_headers == %{"Content-Type" => "application/json"} 30 | 31 | conn = %Conn{req_headers: %{"Content-Type" => "application/json"}} 32 | conn = request(Maxwell.Middleware.HeaderCase, conn, opts) 33 | assert conn.req_headers == %{"Content-Type" => "application/json"} 34 | end 35 | 36 | test "invalid casing style raises ArgumentError" do 37 | assert_raise ArgumentError, fn -> Maxwell.Middleware.HeaderCase.init(:foo) end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/maxwell/builder/util.ex: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Builder.Util do 2 | @moduledoc """ 3 | Utils for builder 4 | """ 5 | 6 | @doc """ 7 | Global default adapter. 8 | """ 9 | def default_adapter(), do: Maxwell.Adapter.Ibrowse 10 | 11 | @doc """ 12 | Serialize http method to atom lists. 13 | 14 | * `methods` - http methods list, for example: ~w(get), [:get], ["get"] 15 | * `default_methods` - all http method lists. 16 | * raise ArgumentError when method is not atom list, string list or ~w(get put). 17 | 18 | ### Examples 19 | [:get, :head, :delete, :trace, :options, :post, :put, :patch] 20 | 21 | """ 22 | def serialize_method_to_atom([], default_methods), do: default_methods 23 | def serialize_method_to_atom(methods = [atom | _], _) when is_atom(atom), do: methods 24 | 25 | def serialize_method_to_atom(methods = [str | _], _) when is_binary(str) do 26 | for method <- methods, do: String.to_atom(method) 27 | end 28 | 29 | def serialize_method_to_atom(methods, _) do 30 | raise ArgumentError, 31 | "http methods format must be [:get] or [\"get\"] or ~w(get) or ~w(get)a #{methods}" 32 | end 33 | 34 | @doc """ 35 | Make sure all `list` in `allow_methods`, 36 | otherwise raise ArgumentError. 37 | 38 | ## Examples 39 | ``` 40 | iex> allow_methods?([:Get], [:post, :head, :get]) 41 | ** (ArgumentError) http methods don't support Get 42 | ``` 43 | """ 44 | def allow_methods?([], _allow_methods), do: true 45 | 46 | def allow_methods?([method | methods], allow_methods) do 47 | unless method in allow_methods, 48 | do: raise(ArgumentError, "http methods don't support #{method}") 49 | 50 | allow_methods?(methods, allow_methods) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/maxwell/middleware/middleware.ex: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Middleware do 2 | @moduledoc """ 3 | Example see `Maxwell.Middleware.BaseUrl` 4 | """ 5 | 6 | @type opts :: any 7 | @type next_fn :: (Maxwell.Conn.t() -> Maxwell.Conn.t()) 8 | @type success :: Maxwell.Conn.t() 9 | @type failure :: {:error, reason :: term()} 10 | 11 | @callback init(opts) :: opts 12 | @callback call(Maxwell.Conn.t(), next_fn, opts) :: success | failure 13 | @callback request(Maxwell.Conn.t(), opts :: term()) :: success | failure 14 | @callback response(Maxwell.Conn.t(), opts :: term()) :: success | failure 15 | 16 | defmacro __using__(_opts) do 17 | quote location: :keep do 18 | @behaviour Maxwell.Middleware 19 | 20 | @doc false 21 | @spec init(Maxwell.Middleware.opts()) :: Maxwell.Middleware.opts() 22 | def init(opts), do: opts 23 | 24 | @doc false 25 | @spec call(Maxwell.Conn.t(), Maxwell.Middleware.next_fn(), Maxwell.Middleware.opts()) :: 26 | Maxwell.Middleware.success() | Maxwell.Middleware.failure() 27 | def call(%Maxwell.Conn{} = conn, next, opts) do 28 | with %Maxwell.Conn{} = conn <- request(conn, opts), 29 | %Maxwell.Conn{} = conn <- next.(conn), 30 | do: response(conn, opts) 31 | end 32 | 33 | @doc false 34 | @spec request(Maxwell.Conn.t(), Maxwell.Middleware.opts()) :: 35 | Maxwell.Middleware.success() | Maxwell.Middleware.failure() 36 | def request(%Maxwell.Conn{} = conn, opts) do 37 | conn 38 | end 39 | 40 | @doc false 41 | @spec response(Maxwell.Conn.t(), Maxwell.Middleware.opts()) :: 42 | Maxwell.Middleware.success() | Maxwell.Middleware.failure() 43 | def response(%Maxwell.Conn{} = conn, opts) do 44 | conn 45 | end 46 | 47 | defoverridable request: 2, response: 2, call: 3, init: 1 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.4.0-dev 4 | ### Changed 5 | - replace `mimerl` with `mime` 6 | 7 | ## 2.3.1 - 2020-12-01 8 | ### Changed 9 | - update deps `mimerl` 10 | 11 | ## 2.3.0 - 2019-11-03 12 | ### Added 13 | - Code Format 14 | 15 | ### Changed 16 | - Change mock to mimic 17 | - Fixed dialyzer 18 | - Deprecated elixir version > 1.8 19 | 20 | ## 2.2.2 - 2017-09-06 21 | ### Added 22 | - Fixed type specs of conn.ex, added type spec for error.ex 23 | 24 | ## 2.2.1 - 2017-05-12 25 | ### Added 26 | - Add [Conn.put_private/3 and Conn.get_private/2](https://github.com/zhongwencool/maxwell/pull/57) 27 | - Allow query strings to have nested params and arrays(https://github.com/zhongwencool/maxwell/pull/55). 28 | - Add fixed header override 29 | 30 | ### Changed 31 | - Using System.monotonic_time() to [show cost time](https://github.com/zhongwencool/maxwell/pull/48). 32 | - Improve a collection of [general API](https://github.com/zhongwencool/maxwell/pull/36). 33 | - [Refine multipart](https://github.com/zhongwencool/maxwell/pull/61) 34 | 35 | ## 2.2.0 - 2017-02-14 36 | ### Added 37 | - Add retry middleware 38 | - Add fuse middleware 39 | - Add header base middleware 40 | 41 | ### Changed 42 | - Setting log_level [by status code in Logger Middleware](https://github.com/zhongwencool/maxwell/pull/45). 43 | - Improve a collection of [general API](https://github.com/zhongwencool/maxwell/pull/36). 44 | - Improve [the BaseUrl middleware](https://github.com/zhongwencool/maxwell/pull/38) 45 | - Fixed warning by elixir v1.4.0. 46 | - Support poison ~> 3.0. 47 | - Numerous document updates. 48 | 49 | ## 2.1.0 - 2016-12-19 50 | ### Added 51 | - Support httpc adapter. 52 | 53 | ### Changed 54 | - Rewrite adapter's test case by `mock`. coverage == 100% 55 | 56 | 57 | ## 2.0.0 - 2016-12-08 58 | ### Changed 59 | - Restruct `Maxwell.conn`. 60 | - Rewrite `put_*` and `get_*` helper function. 61 | - Support send stream. 62 | - Support send file. 63 | -------------------------------------------------------------------------------- /test/maxwell/middleware/base_url_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BaseUrlTest do 2 | use ExUnit.Case 3 | import Maxwell.Middleware.TestHelper 4 | alias Maxwell.Conn 5 | 6 | test "simple base url" do 7 | opts = Maxwell.Middleware.BaseUrl.init("http://example.com") 8 | conn = request(Maxwell.Middleware.BaseUrl, %Conn{path: "/path"}, opts) 9 | assert conn.url == "http://example.com" 10 | assert conn.path == "/path" 11 | end 12 | 13 | test "base url and base path" do 14 | opts = Maxwell.Middleware.BaseUrl.init("http://example.com/api/v1") 15 | conn = request(Maxwell.Middleware.BaseUrl, %Conn{path: "/path"}, opts) 16 | assert conn.url == "http://example.com" 17 | assert conn.path == "/api/v1/path" 18 | end 19 | 20 | test "base url, base path, and default query" do 21 | opts = Maxwell.Middleware.BaseUrl.init("http://example.com/api/v1?apiKey=foo") 22 | conn = request(Maxwell.Middleware.BaseUrl, %Conn{path: "/path"}, opts) 23 | assert conn.url == "http://example.com" 24 | assert conn.path == "/api/v1/path" 25 | assert conn.query_string == %{"apiKey" => "foo"} 26 | end 27 | 28 | test "default query is merged" do 29 | opts = Maxwell.Middleware.BaseUrl.init("http://example.com/api/v1?apiKey=foo&user=me") 30 | conn = %Conn{path: "/path", query_string: %{"apiKey" => "bar", "other" => "thing"}} 31 | conn = request(Maxwell.Middleware.BaseUrl, conn, opts) 32 | assert conn.url == "http://example.com" 33 | assert conn.path == "/api/v1/path" 34 | assert conn.query_string == %{"apiKey" => "bar", "other" => "thing", "user" => "me"} 35 | end 36 | 37 | test "base url can be overriden if set on connection" do 38 | opts = Maxwell.Middleware.BaseUrl.init("http://notseen.com") 39 | conn = request(Maxwell.Middleware.BaseUrl, %Conn{url: "http://seen/path"}, opts) 40 | assert conn.url == "http://seen/path" 41 | end 42 | 43 | test "invalid base url will raise an ArgumentError" do 44 | assert_raise ArgumentError, fn -> Maxwell.Middleware.BaseUrl.init("/foo") end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/maxwell/middleware/middleware_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MiddlewareTest do 2 | use ExUnit.Case 3 | 4 | defmodule Adapter do 5 | def call(conn) do 6 | conn = %{conn | state: :sent} 7 | body = unless conn.req_body, do: %{}, else: Poison.decode!(conn.req_body) 8 | 9 | if Map.equal?(body, %{"key2" => 101, "key1" => 201}) do 10 | %{ 11 | conn 12 | | status: 200, 13 | resp_headers: %{"content-type" => "application/json"}, 14 | resp_body: "{\"key2\":101,\"key1\":201}" 15 | } 16 | else 17 | %{ 18 | conn 19 | | status: 200, 20 | resp_headers: %{"content-type" => "application/json"}, 21 | resp_body: "{\"key2\":2,\"key1\":1}" 22 | } 23 | end 24 | end 25 | end 26 | 27 | defmodule Client do 28 | use Maxwell.Builder, ["get", "post"] 29 | 30 | middleware Maxwell.Middleware.BaseUrl, "http://example.com" 31 | middleware Maxwell.Middleware.Opts, connect_timeout: 3000 32 | middleware Maxwell.Middleware.Headers, %{"Content-Type" => "application/json"} 33 | middleware Maxwell.Middleware.Json 34 | 35 | adapter Adapter 36 | end 37 | 38 | import Maxwell.Conn 39 | 40 | test "make use of base url" do 41 | assert Client.get!().url == "http://example.com" 42 | end 43 | 44 | test "make use of options" do 45 | assert Client.post!().opts == [connect_timeout: 3000] 46 | end 47 | 48 | test "make use of headers" do 49 | assert Client.get!() |> get_resp_headers() == %{"content-type" => "application/json"} 50 | assert Client.get!() |> get_resp_header("Content-Type") == "application/json" 51 | end 52 | 53 | test "make use of endeodejson" do 54 | body = 55 | new() |> put_req_body(%{"key2" => 101, "key1" => 201}) |> Client.post!() |> get_resp_body 56 | 57 | assert true == Map.equal?(body, %{"key2" => 101, "key1" => 201}) 58 | end 59 | 60 | test "make use of deodejson" do 61 | assert Client.post!() |> get_resp_body == %{"key2" => 2, "key1" => 1} 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Mixfile do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/zhongwencool/maxwell" 5 | @version "2.4.0" 6 | 7 | def project do 8 | [ 9 | app: :maxwell, 10 | version: @version, 11 | elixir: "~> 1.8", 12 | build_embedded: Mix.env() == :prod, 13 | start_permanent: Mix.env() == :prod, 14 | description: "Maxwell is an HTTP client adapter.", 15 | docs: docs(), 16 | package: package(), 17 | deps: deps(), 18 | test_coverage: [tool: ExCoveralls], 19 | xref: [exclude: [Poison, Maxwell.Adapter.Ibrowse]], 20 | dialyzer: [plt_add_deps: true], 21 | elixirc_options: [prune_code_paths: false] 22 | ] 23 | end 24 | 25 | def application do 26 | [extra_applications: extra_applications(Mix.env())] 27 | end 28 | 29 | defp extra_applications(:test), do: [:ssl, :inets, :logger, :poison, :ibrowse, :hackney] 30 | defp extra_applications(_), do: [:ssl, :inets, :logger] 31 | 32 | defp package do 33 | [ 34 | maintainers: ["zhongwencool"], 35 | links: %{ 36 | "Changelog" => "#{@source_url}/blob/master/CHANGELOG.md", 37 | "GitHub" => @source_url 38 | }, 39 | files: ~w(lib LICENSE mix.exs README.md CHANGELOG.md .formatter.exs), 40 | licenses: ["MIT"] 41 | ] 42 | end 43 | 44 | defp deps do 45 | [ 46 | {:mime, "~> 1.0 or ~> 2.0"}, 47 | {:poison, "~> 2.1 or ~> 3.0", optional: true}, 48 | {:ibrowse, "~> 4.4", optional: true}, 49 | {:hackney, "~> 1.16", optional: true}, 50 | {:fuse, "~> 2.4", optional: true}, 51 | {:excoveralls, "~> 0.13", only: :test}, 52 | {:inch_ex, "~> 2.0", only: :docs}, 53 | {:credo, "~> 1.5", only: [:dev]}, 54 | {:mimic, "~> 1.3", only: :test}, 55 | {:ex_doc, ">= 0.0.0", only: [:dev], runtime: false}, 56 | {:dialyxir, "~> 1.0.0", only: [:dev], runtime: false} 57 | ] 58 | end 59 | 60 | defp docs do 61 | [ 62 | main: "readme", 63 | source_url: @source_url, 64 | source_ref: @version, 65 | extras: [ 66 | "README.md", 67 | "CHANGELOG.md" 68 | ] 69 | ] 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/maxwell/middleware/fuse.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(:fuse) do 2 | defmodule Maxwell.Middleware.Fuse do 3 | @moduledoc """ 4 | A circuit breaker middleware which uses [fuse](https://github.com/jlouis/fuse) under the covers. 5 | 6 | To use this middleware, you will need to add `{:fuse, "~> 2.4"}` to your dependencies, and 7 | the `:fuse` application to your applications list in `mix.exs`. 8 | 9 | Example: 10 | 11 | defmodule CircuitBreakerClient do 12 | use Maxwell.Builder 13 | 14 | middleware Maxwell.Middleware.Fuse, 15 | name: __MODULE__, 16 | fuse_opts: {{:standard, 2, 10_000}, {:reset, 60_000}} 17 | end 18 | 19 | Options: 20 | 21 | - `:name` - The name of the fuse, required 22 | - `:fuse_opts` - Options to pass along to `fuse`. See `fuse` docs for more information. 23 | """ 24 | use Maxwell.Middleware 25 | 26 | # These options were borrowed from http://blog.rokkincat.com/circuit-breakers-in-elixir/ 27 | # You should tweak them for your use case, as these defaults are likely unsuitable. 28 | @default_fuse_opts {{:standard, 2, 10_000}, {:reset, 60_000}} 29 | 30 | @default_opts [fuse_opts: @default_fuse_opts] 31 | 32 | def init(opts) do 33 | _name = Keyword.fetch!(opts, :name) 34 | Keyword.merge(@default_opts, opts) 35 | end 36 | 37 | def call(conn, next, opts) do 38 | name = Keyword.get(opts, :name) 39 | 40 | conn = 41 | case :fuse.ask(name, :sync) do 42 | :ok -> 43 | run(conn, next, name) 44 | 45 | :blown -> 46 | {:error, :econnrefused, conn} 47 | 48 | {:error, :not_found} -> 49 | :fuse.install(name, Keyword.get(opts, :fuse_opts)) 50 | run(conn, next, name) 51 | end 52 | 53 | case conn do 54 | %Maxwell.Conn{} = conn -> response(conn, opts) 55 | err -> err 56 | end 57 | end 58 | 59 | defp run(conn, next, name) do 60 | case next.(conn) do 61 | {:error, _reason, _conn} = err -> 62 | :fuse.melt(name) 63 | err 64 | 65 | res -> 66 | res 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/maxwell/middleware/header_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Middleware.HeaderCase do 2 | @moduledoc """ 3 | Forces all request headers to be of a certain case. 4 | 5 | ## Examples 6 | 7 | # Lower 8 | iex> conn = %Maxwell.Conn{req_headers: %{"content-type" => "application/json}} 9 | ...> Maxwell.Middleware.HeaderCase.request(conn, :lower) 10 | %Maxwell.Conn{req_headers: %{"content-type" => "application/json}} 11 | 12 | # Upper 13 | iex> conn = %Maxwell.Conn{req_headers: %{"content-type" => "application/json}} 14 | ...> Maxwell.Middleware.HeaderCase.request(conn, :upper) 15 | %Maxwell.Conn{req_headers: %{"CONTENT-TYPE" => "application/json}} 16 | 17 | # Title 18 | iex> conn = %Maxwell.Conn{req_headers: %{"content-type" => "application/json}} 19 | ...> Maxwell.Middleware.HeaderCase.request(conn, :title) 20 | %Maxwell.Conn{req_headers: %{"Content-Type" => "application/json}} 21 | """ 22 | alias Maxwell.Conn 23 | 24 | def init(casing) when casing in [:lower, :upper, :title] do 25 | casing 26 | end 27 | 28 | def init(casing) do 29 | raise ArgumentError, 30 | "HeaderCase middleware expects a casing style of :lower, :upper, or :title - got: #{ 31 | casing 32 | }" 33 | end 34 | 35 | def request(%Conn{req_headers: headers} = conn, :lower) do 36 | new_headers = 37 | headers 38 | |> Enum.map(fn {k, v} -> {String.downcase(k), v} end) 39 | |> Enum.into(%{}) 40 | 41 | %{conn | req_headers: new_headers} 42 | end 43 | 44 | def request(%Conn{req_headers: headers} = conn, :upper) do 45 | new_headers = 46 | headers 47 | |> Enum.map(fn {k, v} -> {String.upcase(k), v} end) 48 | |> Enum.into(%{}) 49 | 50 | %{conn | req_headers: new_headers} 51 | end 52 | 53 | def request(%Conn{req_headers: headers} = conn, :title) do 54 | new_headers = 55 | headers 56 | |> Enum.map(fn {k, v} -> 57 | tk = 58 | k 59 | |> String.downcase() 60 | |> String.split(~r/[-_]/, include_captures: true, trim: true) 61 | |> Enum.map(&String.capitalize/1) 62 | |> Enum.join() 63 | 64 | {tk, v} 65 | end) 66 | |> Enum.into(%{}) 67 | 68 | %{conn | req_headers: new_headers} 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/maxwell/adapter/hackney.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(:hackney) do 2 | defmodule Maxwell.Adapter.Hackney do 3 | @moduledoc """ 4 | [`hackney`](https://github.com/benoitc/hackney) adapter 5 | """ 6 | use Maxwell.Adapter 7 | 8 | @impl true 9 | def send_direct(conn) do 10 | %Conn{ 11 | url: url, 12 | req_headers: req_headers, 13 | path: path, 14 | method: method, 15 | query_string: query_string, 16 | opts: opts, 17 | req_body: req_body 18 | } = conn 19 | 20 | url = Util.url_serialize(url, path, query_string) 21 | req_headers = Util.header_serialize(req_headers) 22 | result = :hackney.request(method, url, req_headers, req_body || "", opts) 23 | format_response(result, conn) 24 | end 25 | 26 | @impl true 27 | def send_file(conn), do: send_direct(conn) 28 | 29 | @impl true 30 | def send_stream(conn) do 31 | %Conn{ 32 | url: url, 33 | req_headers: req_headers, 34 | path: path, 35 | method: method, 36 | query_string: query_string, 37 | opts: opts, 38 | req_body: req_body 39 | } = conn 40 | 41 | url = Util.url_serialize(url, path, query_string) 42 | req_headers = Util.header_serialize(req_headers) 43 | 44 | with {:ok, ref} <- :hackney.request(method, url, req_headers, :stream, opts) do 45 | for data <- req_body, do: :ok = :hackney.send_body(ref, data) 46 | ref |> :hackney.start_response() |> format_response(conn) 47 | else 48 | error -> format_response(error, conn) 49 | end 50 | end 51 | 52 | defp format_response({:ok, status, headers, body}, conn) when is_binary(body) do 53 | headers = 54 | Enum.reduce(headers, %{}, fn {k, v}, acc -> 55 | Map.put(acc, String.downcase(to_string(k)), to_string(v)) 56 | end) 57 | 58 | %{ 59 | conn 60 | | status: status, 61 | resp_headers: headers, 62 | req_body: nil, 63 | state: :sent, 64 | resp_body: body 65 | } 66 | end 67 | 68 | defp format_response({:ok, status, headers, body}, conn) do 69 | case :hackney.body(body) do 70 | {:ok, body} -> format_response({:ok, status, headers, body}, conn) 71 | {:error, _reason} = error -> format_response(error, conn) 72 | end 73 | end 74 | 75 | defp format_response({:ok, status, headers}, conn) do 76 | format_response({:ok, status, headers, ""}, conn) 77 | end 78 | 79 | defp format_response({:error, reason}, conn) do 80 | {:error, reason, %{conn | state: :error}} 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/maxwell/adapter/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Adapter do 2 | @moduledoc """ 3 | Define adapter behaviour. 4 | 5 | ### Examples 6 | See `Maxwell.Adapter.Ibrowse`. 7 | """ 8 | @type return_t :: Maxwell.Conn.t() | {:error, any, Maxwell.Conn.t()} 9 | 10 | @callback send_direct(Maxwell.Conn.t()) :: return_t 11 | @callback send_stream(Maxwell.Conn.t()) :: return_t 12 | @callback send_file(Maxwell.Conn.t()) :: return_t 13 | 14 | defmacro __using__(_opts) do 15 | quote location: :keep do 16 | @behaviour Maxwell.Adapter 17 | 18 | alias Maxwell.Conn 19 | alias Maxwell.Adapter.Util 20 | 21 | @doc false 22 | @spec call(Conn.t()) :: Conn.t() | {:error, reason :: any(), Conn.t()} 23 | def call(conn) do 24 | case conn.req_body do 25 | {:multipart, _} -> send_multipart(conn) 26 | {:file, _} -> send_file(conn) 27 | %Stream{} -> send_stream(conn) 28 | _ -> send_direct(conn) 29 | end 30 | end 31 | 32 | @doc """ 33 | Send request without chang it's body formant. 34 | 35 | * `conn` - `%Maxwell.Conn{}` 36 | 37 | Returns `{:ok, %Maxwell.Conn{}}` or `{:error, reason_term, %Maxwell.Conn{}}`. 38 | """ 39 | def send_direct(conn) do 40 | raise Maxwell.Error, 41 | {__MODULE__, "#{__MODULE__} Adapter doesn't implement send_direct/1", conn} 42 | end 43 | 44 | @doc """ 45 | Send file request. 46 | 47 | * `conn` - `%Maxwell.Conn{}`, the req_body is `{:file, filepath}`. 48 | Auto change to chunked mode if req_headers has `%{"transfer-encoding" => "chunked"` 49 | 50 | Returns `{:ok, %Maxwell.Conn{}}` or `{:error, reason_term, %Maxwell.Conn{}}`. 51 | """ 52 | def send_file(conn) do 53 | raise Maxwell.Error, 54 | {__MODULE__, "#{__MODULE__} Adapter doesn't implement send_file/1", conn} 55 | end 56 | 57 | @doc """ 58 | Send stream request. 59 | 60 | * `conn` - `%Maxwell.Conn{}`, the req_body is `Stream`. 61 | Always chunked mode 62 | 63 | Returns `{:ok, %Maxwell.Conn{}}` or `{:error, reason_term, %Maxwell.Conn{}}`. 64 | """ 65 | def send_stream(conn) do 66 | raise Maxwell.Error, 67 | {__MODULE__, "#{__MODULE__} Adapter doesn't implement send_stream/1", conn} 68 | end 69 | 70 | defp send_multipart(conn) do 71 | %Conn{req_body: {:multipart, multiparts}} = conn 72 | {req_headers, req_body} = Util.multipart_encode(conn, multiparts) 73 | send_direct(%Conn{conn | req_headers: req_headers, req_body: req_body}) 74 | end 75 | 76 | defoverridable send_direct: 1, send_file: 1, send_stream: 1 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/maxwell/middleware/baseurl.ex: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Middleware.BaseUrl do 2 | @moduledoc """ 3 | Sets the base url for all requests in this module. 4 | 5 | You may provide any valid URL, and it will be parsed into it's requisite parts and 6 | assigned to the corresponding fields in the connection. 7 | 8 | Providing a path in the URL will be treated as if it's a base path for all requests. If you subsequently 9 | create a connection and use `put_path`, the base path set in this middleware will be prepended to the 10 | path provided to `put_path`. 11 | 12 | A base url is not valid if no host is set. You may omit the scheme, and it will default to `http://`. 13 | 14 | ## Examples 15 | 16 | iex> opts = Maxwell.Middleware.BaseUrl.init("http://example.com") 17 | ...> Maxwell.Middleware.BaseUrl.request(Maxwell.Conn.new("/foo"), opts) 18 | %Maxwell.Conn{url: "http://example.com", path: "/foo"} 19 | 20 | iex> opts = Maxwell.Middleware.BaseUrl.init("http://example.com/api/?version=1") 21 | ...> Maxwell.Middleware.BaseUrl.request(Maxwell.Conn.new("/users"), opts) 22 | %Maxwell.Conn{url: "http://example.com", path: "/api/users", query_string: %{"version" => "1"}} 23 | """ 24 | use Maxwell.Middleware 25 | alias Maxwell.Conn 26 | 27 | def init(base_url) do 28 | conn = Conn.new(base_url) 29 | opts = %{url: conn.url, path: conn.path, query: conn.query_string} 30 | 31 | case opts.url do 32 | url when url in [nil, ""] -> 33 | raise ArgumentError, 34 | "BaseUrl middleware expects a proper url containing a hostname, got #{base_url}" 35 | 36 | _ -> 37 | opts 38 | end 39 | end 40 | 41 | def request(%Conn{} = conn, %{url: base_url, path: base_path, query: default_query}) do 42 | conn 43 | |> ensure_base_url(base_url) 44 | |> ensure_base_path(base_path) 45 | |> ensure_base_query(default_query) 46 | end 47 | 48 | # Ensures there is always a base url 49 | defp ensure_base_url(%Conn{url: url} = conn, base_url) when url in [nil, ""] do 50 | %{conn | url: base_url} 51 | end 52 | 53 | defp ensure_base_url(conn, _base_url), do: conn 54 | 55 | # Ensures the base path is always present 56 | defp ensure_base_path(%Conn{path: path} = conn, base_path) when path in [nil, ""] do 57 | %{conn | path: base_path} 58 | end 59 | 60 | defp ensure_base_path(%Conn{path: path} = conn, base_path) do 61 | if String.starts_with?(path, base_path) do 62 | conn 63 | else 64 | %{conn | path: join_path(base_path, path)} 65 | end 66 | end 67 | 68 | # Ensures the default query strings are always present 69 | defp ensure_base_query(%Conn{query_string: qs} = conn, default_query) do 70 | %{conn | query_string: Map.merge(default_query, qs)} 71 | end 72 | 73 | defp join_path(a, b) do 74 | a = String.trim_trailing(a, "/") 75 | b = String.trim_leading(b, "/") 76 | a <> "/" <> b 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/maxwell/adapter/ibrowse.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(:ibrowse) do 2 | defmodule Maxwell.Adapter.Ibrowse do 3 | @moduledoc """ 4 | [`ibrowse`](https://github.com/cmullaparthi/ibrowse) adapter 5 | """ 6 | use Maxwell.Adapter 7 | 8 | @impl true 9 | def send_direct(conn) do 10 | %Conn{ 11 | url: url, 12 | req_headers: req_headers, 13 | query_string: query_string, 14 | path: path, 15 | method: method, 16 | opts: opts, 17 | req_body: req_body 18 | } = conn 19 | 20 | url = Util.url_serialize(url, path, query_string, :char_list) 21 | req_headers = Util.header_serialize(req_headers) 22 | result = :ibrowse.send_req(url, req_headers, method, req_body || "", opts) 23 | format_response(result, conn) 24 | end 25 | 26 | @impl true 27 | def send_file(conn) do 28 | %Conn{ 29 | url: url, 30 | query_string: query_string, 31 | path: path, 32 | method: method, 33 | opts: opts, 34 | req_body: {:file, filepath} 35 | } = conn 36 | 37 | url = Util.url_serialize(url, path, query_string, :char_list) 38 | chunked = Util.chunked?(conn) 39 | 40 | opts = 41 | case chunked do 42 | true -> Keyword.put(opts, :transfer_encoding, :chunked) 43 | false -> opts 44 | end 45 | 46 | req_headers = 47 | chunked 48 | |> Util.file_header_transform(conn) 49 | |> Util.header_serialize() 50 | 51 | req_body = {&Util.stream_iterate/1, filepath} 52 | result = :ibrowse.send_req(url, req_headers, method, req_body, opts) 53 | format_response(result, conn) 54 | end 55 | 56 | @impl true 57 | def send_stream(conn) do 58 | %Conn{ 59 | url: url, 60 | req_headers: req_headers, 61 | query_string: query_string, 62 | path: path, 63 | method: method, 64 | opts: opts, 65 | req_body: req_body 66 | } = conn 67 | 68 | url = Util.url_serialize(url, path, query_string, :char_list) 69 | req_headers = Util.header_serialize(req_headers) 70 | req_body = {&Util.stream_iterate/1, req_body} 71 | result = :ibrowse.send_req(url, req_headers, method, req_body, opts) 72 | format_response(result, conn) 73 | end 74 | 75 | defp format_response({:ok, status, headers, body}, conn) do 76 | {status, _} = status |> to_string |> Integer.parse() 77 | 78 | headers = 79 | Enum.reduce(headers, %{}, fn {k, v}, acc -> 80 | Map.put(acc, String.downcase(to_string(k)), to_string(v)) 81 | end) 82 | 83 | %{ 84 | conn 85 | | status: status, 86 | resp_headers: headers, 87 | resp_body: body, 88 | state: :sent, 89 | req_body: nil 90 | } 91 | end 92 | 93 | defp format_response({:error, {:conn_failed, {:error, :econnrefused}}}, conn) do 94 | {:error, :econnrefused, %{conn | state: :error}} 95 | end 96 | 97 | defp format_response({:error, {:conn_failed, {:error, reason}}}, conn) do 98 | {:error, reason, %{conn | state: :error}} 99 | end 100 | 101 | defp format_response({:error, reason}, conn) do 102 | {:error, reason, %{conn | state: :error}} 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /test/maxwell/adapter/adapter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MaxwellAdapterTest do 2 | use ExUnit.Case 3 | 4 | alias Maxwell.Conn 5 | import Maxwell.Conn 6 | 7 | defmodule TestAdapter do 8 | use Maxwell.Adapter 9 | 10 | def send_direct(conn = %Conn{status: nil}) do 11 | %{ 12 | conn 13 | | status: 200, 14 | resp_headers: %{"content-type" => "text/plain"}, 15 | resp_body: "testbody", 16 | state: :sent 17 | } 18 | end 19 | 20 | def send_direct(conn) do 21 | %{conn | status: 400} 22 | end 23 | end 24 | 25 | defmodule Client do 26 | use Maxwell.Builder 27 | adapter TestAdapter 28 | end 29 | 30 | test "return :status 200" do 31 | {:ok, result} = Client.get() 32 | assert result |> Conn.get_status() == 200 33 | end 34 | 35 | test "test :query string" do 36 | {:ok, result} = new() |> Conn.put_query_string(%{"name" => "china"}) |> Client.get() 37 | assert result.query_string == %{"name" => "china"} 38 | assert result |> Conn.get_status() == 200 39 | end 40 | 41 | test "return :status 400" do 42 | assert_raise( 43 | Maxwell.Error, 44 | "url: \npath: \"\"\nmethod: get\nstatus: 400\nreason: :response_status_not_match\nmodule: Elixir.MaxwellAdapterTest.Client\n", 45 | fn -> %Conn{status: 100} |> Client.get!() end 46 | ) 47 | end 48 | 49 | test "return resp content-type header" do 50 | {:ok, conn} = Client.get() 51 | assert conn |> get_resp_headers() == %{"content-type" => "text/plain"} 52 | assert conn |> get_resp_header("Content-Type") == "text/plain" 53 | end 54 | 55 | test "return resp_body" do 56 | {:ok, conn} = Client.get() 57 | assert conn |> get_resp_body == "testbody" 58 | assert conn |> get_resp_body(&String.length/1) == 8 59 | end 60 | 61 | test "http method" do 62 | {:ok, conn} = Client.get() 63 | conn1 = Client.get!() 64 | assert Map.equal?(conn, conn1) == true 65 | assert conn1.method == :get 66 | {:ok, conn} = Client.head() 67 | conn1 = Client.head!() 68 | assert Map.equal?(conn, conn1) == true 69 | assert conn.method == :head 70 | {:ok, conn} = Client.post() 71 | conn1 = Client.post!() 72 | assert Map.equal?(conn, conn1) == true 73 | assert conn.method == :post 74 | {:ok, conn} = Client.put() 75 | conn1 = Client.put!() 76 | assert Map.equal?(conn, conn1) == true 77 | assert conn.method == :put 78 | {:ok, conn} = Client.patch() 79 | conn1 = Client.patch!() 80 | assert Map.equal?(conn, conn1) == true 81 | assert conn.method == :patch 82 | {:ok, conn} = Client.delete() 83 | conn1 = Client.delete!() 84 | assert Map.equal?(conn, conn1) == true 85 | assert conn.method == :delete 86 | {:ok, conn} = Client.trace() 87 | conn1 = Client.trace!() 88 | assert Map.equal?(conn, conn1) == true 89 | assert conn.method == :trace 90 | {:ok, conn} = Client.options() 91 | conn1 = Client.options!() 92 | assert Map.equal?(conn, conn1) == true 93 | assert conn.method == :options 94 | end 95 | 96 | test "path + query" do 97 | conn = 98 | new("http://example.com/foo?a=1&b=foo") 99 | |> Client.get!() 100 | 101 | assert conn.url == "http://example.com" 102 | assert conn.path == "/foo" 103 | assert conn.query_string == %{"a" => "1", "b" => "foo"} 104 | assert Conn.get_status(conn) == 200 105 | end 106 | 107 | test "adapter not implement send_file/1" do 108 | assert_raise Maxwell.Error, 109 | "url: http://example.com\npath: \"/foo\"\nmethod: post\nstatus: \nreason: \"Elixir.MaxwellAdapterTest.TestAdapter Adapter doesn't implement send_file/1\"\nmodule: Elixir.MaxwellAdapterTest.TestAdapter\n", 110 | fn -> 111 | new("http://example.com/foo?a=1&b=foo") 112 | |> put_req_body({:file, "test"}) 113 | |> Client.post!() 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/maxwell/adapter/httpc.ex: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Adapter.Httpc do 2 | @moduledoc """ 3 | [`httpc`](http://erlang.org/doc/man/httpc.html) adapter 4 | """ 5 | 6 | @http_options [ 7 | :timeout, 8 | :connect_timeout, 9 | :ssl, 10 | :essl, 11 | :autoredirect, 12 | :proxy_auth, 13 | :version, 14 | :relaxed, 15 | :url_encode 16 | ] 17 | use Maxwell.Adapter 18 | 19 | @impl true 20 | def send_direct(conn) do 21 | %Conn{ 22 | url: url, 23 | req_headers: req_headers, 24 | query_string: query_string, 25 | path: path, 26 | method: method, 27 | opts: opts, 28 | req_body: req_body 29 | } = conn 30 | 31 | url = Util.url_serialize(url, path, query_string, :char_list) 32 | {content_type, req_headers} = header_serialize(req_headers) 33 | {http_opts, options} = opts_serialize(opts) 34 | result = request(method, url, req_headers, content_type, req_body, http_opts, options) 35 | format_response(result, conn) 36 | end 37 | 38 | @impl true 39 | def send_file(conn) do 40 | %Conn{ 41 | url: url, 42 | query_string: query_string, 43 | path: path, 44 | method: method, 45 | opts: opts, 46 | req_body: {:file, filepath} 47 | } = conn 48 | 49 | url = Util.url_serialize(url, path, query_string, :char_list) 50 | chunked = Util.chunked?(conn) 51 | req_headers = Util.file_header_transform(chunked, conn) 52 | 53 | req_body = 54 | case chunked do 55 | true -> {:chunkify, &Util.stream_iterate/1, filepath} 56 | false -> {&Util.stream_iterate/1, filepath} 57 | end 58 | 59 | {content_type, req_headers} = header_serialize(req_headers) 60 | {http_opts, options} = opts_serialize(opts) 61 | result = request(method, url, req_headers, content_type, req_body, http_opts, options) 62 | format_response(result, conn) 63 | end 64 | 65 | @impl true 66 | def send_stream(conn) do 67 | %Conn{ 68 | url: url, 69 | req_headers: req_headers, 70 | query_string: query_string, 71 | path: path, 72 | method: method, 73 | opts: opts, 74 | req_body: req_body 75 | } = conn 76 | 77 | url = Util.url_serialize(url, path, query_string, :char_list) 78 | chunked = Util.chunked?(conn) 79 | 80 | req_body = 81 | case chunked do 82 | true -> {:chunkify, &Util.stream_iterate/1, req_body} 83 | false -> {&Util.stream_iterate/1, req_body} 84 | end 85 | 86 | {content_type, req_headers} = header_serialize(req_headers) 87 | {http_opts, options} = opts_serialize(opts) 88 | result = request(method, url, req_headers, content_type, req_body, http_opts, options) 89 | format_response(result, conn) 90 | end 91 | 92 | defp request(method, url, req_headers, _content_type, nil, http_opts, options) do 93 | :httpc.request(method, {url, req_headers}, http_opts, options) 94 | end 95 | 96 | defp request(method, url, req_headers, content_type, req_body, http_opts, options) do 97 | :httpc.request(method, {url, req_headers, content_type, req_body}, http_opts, options) 98 | end 99 | 100 | defp header_serialize(headers) do 101 | {content_type, headers} = Map.pop(headers, "content-type") 102 | headers = Enum.map(headers, fn {key, value} -> {to_charlist(key), to_charlist(value)} end) 103 | 104 | case content_type do 105 | nil -> {nil, headers} 106 | type -> {to_charlist(type), headers} 107 | end 108 | end 109 | 110 | defp opts_serialize(opts) do 111 | Keyword.split(opts, @http_options) 112 | end 113 | 114 | defp format_response({:ok, {status_line, headers, body}}, conn) do 115 | {_http_version, status, _reason_phrase} = status_line 116 | 117 | headers = 118 | for {key, value} <- headers, into: %{} do 119 | {String.downcase(to_string(key)), to_string(value)} 120 | end 121 | 122 | %{conn | status: status, resp_headers: headers, resp_body: body, state: :sent, req_body: nil} 123 | end 124 | 125 | ## todo {:ok, request_id} 126 | 127 | # normalize :econnrefused for the Retry/Fuse middleware 128 | defp format_response({:error, {:failed_connect, info} = err}, conn) do 129 | conn = %{conn | state: :error} 130 | 131 | case List.keyfind(info, :inet, 0) do 132 | {:inet, _, :econnrefused} -> 133 | {:error, :econnrefused, %{conn | state: :error}} 134 | 135 | {:inet, _, reason} -> 136 | {:error, reason, %{conn | state: :error}} 137 | 138 | _ -> 139 | {:error, err, %{conn | state: :error}} 140 | end 141 | end 142 | 143 | defp format_response({:error, reason}, conn) do 144 | {:error, reason, %{conn | state: :error}} 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /lib/maxwell/adapter/util.ex: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Adapter.Util do 2 | @moduledoc """ 3 | Utils for Adapter 4 | """ 5 | 6 | @chunk_size 4 * 1024 * 1024 7 | alias Maxwell.{Conn, Multipart, Query} 8 | 9 | @doc """ 10 | Append path and query string to url 11 | 12 | * `url` - `conn.url` 13 | * `path` - `conn.path` 14 | * `query` - `conn.query` 15 | * `type` - `:char_list` or `:string`, default is :string 16 | 17 | ### Examples 18 | 19 | #http://example.com/home?name=foo 20 | iex> url_serialize("http://example.com", "/home", %{"name" => "foo"}) 21 | 22 | """ 23 | def url_serialize(url, path, query_string, type \\ :string) do 24 | url = url |> append_query_string(path, query_string) 25 | 26 | case type do 27 | :string -> url 28 | :char_list -> url |> to_charlist 29 | end 30 | end 31 | 32 | @doc """ 33 | Converts the headers map to a list of tuples. 34 | 35 | * `headers` - `Map.t`, for example: `%{"content-type" => "application/json"}` 36 | 37 | ### Examples 38 | 39 | iex> headers_serialize(%{"content-type" => "application/json"}) 40 | [{"content-type", "application/json"}] 41 | """ 42 | def header_serialize(headers) do 43 | Enum.into(headers, []) 44 | end 45 | 46 | @doc """ 47 | Add `content-type` to headers if don't have; 48 | Add `content-length` to headers if not chunked 49 | 50 | * `chunked` - `boolean`, is chunked mode 51 | * `conn` - `Maxwell.Conn` 52 | 53 | """ 54 | def file_header_transform(chunked, conn) do 55 | %Conn{req_body: {:file, filepath}, req_headers: req_headers} = conn 56 | 57 | req_headers = 58 | case Map.has_key?(req_headers, "content-type") do 59 | true -> 60 | req_headers 61 | 62 | false -> 63 | content_type = 64 | filepath 65 | |> Path.extname() 66 | |> String.trim_leading(".") 67 | |> MIME.type() 68 | 69 | conn 70 | |> Conn.put_req_header("content-type", content_type) 71 | |> Map.get(:req_headers) 72 | end 73 | 74 | case chunked or Map.has_key?(req_headers, "content-length") do 75 | true -> 76 | req_headers 77 | 78 | false -> 79 | len = :filelib.file_size(filepath) 80 | 81 | conn 82 | |> Conn.put_req_header("content-length", len) 83 | |> Map.get(:req_headers) 84 | end 85 | end 86 | 87 | @doc """ 88 | Check req_headers has transfer-encoding: chunked. 89 | 90 | * `conn` - `Maxwell.Conn` 91 | 92 | """ 93 | def chunked?(conn) do 94 | case Conn.get_req_header(conn, "transfer-encoding") do 95 | nil -> false 96 | type -> "chunked" == String.downcase(type) 97 | end 98 | end 99 | 100 | @doc """ 101 | Encode multipart form. 102 | 103 | * `conn` - `Maxwell.Conn` 104 | * `multiparts` - see `Maxwell.Multipart.encode_form/2` 105 | 106 | """ 107 | def multipart_encode(conn, multiparts) do 108 | boundary = Multipart.new_boundary() 109 | body = {&multipart_body/1, {:start, boundary, multiparts}} 110 | 111 | len = Multipart.len_mp_stream(boundary, multiparts) 112 | 113 | headers = 114 | conn 115 | |> Conn.put_req_header("content-type", "multipart/form-data; boundary=#{boundary}") 116 | |> Conn.put_req_header("content-length", len) 117 | |> Map.get(:req_headers) 118 | 119 | {headers, body} 120 | end 121 | 122 | @doc """ 123 | Fetch the first element from stream. 124 | 125 | """ 126 | def stream_iterate(filepath) when is_binary(filepath) do 127 | filepath 128 | |> File.stream!([], @chunk_size) 129 | |> stream_iterate 130 | end 131 | 132 | def stream_iterate(next_stream_fun) when is_function(next_stream_fun, 1) do 133 | case next_stream_fun.({:cont, nil}) do 134 | {:suspended, item, next_stream_fun} -> {:ok, item, next_stream_fun} 135 | {:halted, _} -> :eof 136 | {:done, _} -> :eof 137 | end 138 | end 139 | 140 | def stream_iterate(stream) do 141 | case Enumerable.reduce(stream, {:cont, nil}, fn item, nil -> {:suspend, item} end) do 142 | {:suspended, item, next_stream} -> {:ok, item, next_stream} 143 | {:done, _} -> :eof 144 | {:halted, _} -> :eof 145 | end 146 | end 147 | 148 | defp multipart_body({:start, boundary, multiparts}) do 149 | {body, _size} = Multipart.encode_form(boundary, multiparts) 150 | {:ok, body, :end} 151 | end 152 | 153 | defp multipart_body(:end), do: :eof 154 | 155 | defp append_query_string(url, path, query) when query == %{}, do: url <> path 156 | 157 | defp append_query_string(url, path, query) do 158 | query_string = Query.encode(query) 159 | url <> path <> "?" <> query_string 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /lib/maxwell/middleware/logger.ex: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Middleware.Logger do 2 | @moduledoc """ 3 | Log the request and response by Logger, default log_level is :info. 4 | Setting log_level in 3 ways: 5 | 6 | ### Log everything by log_level 7 | 8 | middleware Maxwell.Middleware.Logger, log_level: :debug 9 | 10 | ### Log request by specific status code. 11 | 12 | middleware Maxwell.Middleware.Logger, log_level: [debug: 200, error: 404, info: default] 13 | 14 | ### Log request by status code's Ranges 15 | 16 | middleware Maxwell.Middleware.Logger, log_level: [error: [500..599, 300..399, 400], warn: 404, debug: default] 17 | 18 | ### Examples 19 | 20 | # Client.ex 21 | use Maxwell.Builder ~(get)a 22 | 23 | middleware Maxwell.Middleware.Logger, log_level: [ 24 | info: [1..100, 200..299, 404], 25 | warn: 300..399, 26 | error: :default 27 | ] 28 | 29 | def your_own_request(url) do 30 | url |> new() |> get!() 31 | end 32 | 33 | """ 34 | use Maxwell.Middleware 35 | require Logger 36 | 37 | @levels [:info, :debug, :warn, :error] 38 | 39 | def init(opts) do 40 | case Keyword.pop(opts, :log_level) do 41 | {_, [_ | _]} -> 42 | raise ArgumentError, "Logger Middleware Options doesn't accept wrong_option (:log_level)" 43 | 44 | {nil, _} -> 45 | [default: :info] 46 | 47 | {options, _} when is_list(options) -> 48 | parse_opts(options) 49 | 50 | {level, _} -> 51 | parse_opts([{level, :default}]) 52 | end 53 | end 54 | 55 | def call(request_env, next_fn, options) do 56 | start = System.system_time(:millisecond) 57 | new_result = next_fn.(request_env) 58 | 59 | case new_result do 60 | {:error, reason, _conn} -> 61 | method = request_env.method |> to_string |> String.upcase() 62 | Logger.error("#{method} #{request_env.url}>> #{IO.ANSI.red()}ERROR: #{inspect(reason)}") 63 | 64 | %Maxwell.Conn{} = response_conn -> 65 | stop = System.system_time(:millisecond) 66 | diff = stop - start 67 | log_response_message(options, response_conn, diff) 68 | end 69 | 70 | new_result 71 | end 72 | 73 | defp log_response_message(options, conn, diff) do 74 | %Maxwell.Conn{status: status, url: url, method: method} = conn 75 | level = get_level(options, status) 76 | 77 | color = 78 | case level do 79 | nil -> nil 80 | :debug -> IO.ANSI.cyan() 81 | :info -> IO.ANSI.normal() 82 | :warn -> IO.ANSI.yellow() 83 | :error -> IO.ANSI.red() 84 | end 85 | 86 | unless is_nil(level) do 87 | message = 88 | "#{method} #{url} <<<#{color}#{status}(#{diff}ms)#{IO.ANSI.reset()}\n#{inspect(conn)}" 89 | 90 | Logger.log(level, message) 91 | end 92 | end 93 | 94 | defp get_level([], _code), do: nil 95 | defp get_level([{code, level} | _], code), do: level 96 | 97 | defp get_level([{from..to, level} | _], code) 98 | when code in from..to, 99 | do: level 100 | 101 | defp get_level([{:default, level} | _], _code), do: level 102 | defp get_level([_ | t], code), do: get_level(t, code) 103 | 104 | defp parse_opts(options), do: parse_opts(options, [], nil) 105 | defp parse_opts([], result, nil), do: Enum.reverse(result) 106 | defp parse_opts([], result, default), do: Enum.reverse([{:default, default} | result]) 107 | 108 | defp parse_opts([{level, :default} | rest], result, nil) do 109 | check_level(level) 110 | parse_opts(rest, result, level) 111 | end 112 | 113 | defp parse_opts([{level, :default} | rest], result, level) do 114 | Logger.warn("Logger Middleware: default level defined multiple times.") 115 | parse_opts(rest, result, level) 116 | end 117 | 118 | defp parse_opts([{_level, :default} | _rest], _result, _default) do 119 | raise ArgumentError, "Logger Middleware: default level conflict." 120 | end 121 | 122 | defp parse_opts([{level, codes} | rest], result, default) when is_list(codes) do 123 | check_level(level) 124 | 125 | result = 126 | Enum.reduce(codes, result, fn code, acc -> 127 | check_code(code) 128 | [{code, level} | acc] 129 | end) 130 | 131 | parse_opts(rest, result, default) 132 | end 133 | 134 | defp parse_opts([{level, code} | rest], result, default) do 135 | check_level(level) 136 | check_code(code) 137 | parse_opts(rest, [{code, level} | result], default) 138 | end 139 | 140 | defp check_level(level) when level in @levels, do: :ok 141 | 142 | defp check_level(_level) do 143 | raise ArgumentError, "Logger Middleware: level only accepts #{inspect(@levels)}." 144 | end 145 | 146 | defp check_code(code) when is_integer(code), do: :ok 147 | defp check_code(_from.._to), do: :ok 148 | 149 | defp check_code(_any) do 150 | raise ArgumentError, "Logger Middleware: status code only accepts Integer and Range." 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /test/maxwell/query_test.exs: -------------------------------------------------------------------------------- 1 | # The following module test was copied from Plug: 2 | # https://raw.githubusercontent.com/elixir-lang/plug/91b8f57dcc495735925553bcf6c53a0d0e413d86/test/plug/conn/query_test.exs 3 | # With permission: https://github.com/elixir-lang/plug/issues/539 4 | defmodule Maxwell.QueryTest do 5 | use ExUnit.Case, async: true 6 | 7 | import Maxwell.Query, only: [decode: 1, encode: 1, encode: 2] 8 | doctest Maxwell.Query 9 | 10 | test "decode queries" do 11 | params = decode("foo=bar&baz=bat") 12 | assert params["foo"] == "bar" 13 | assert params["baz"] == "bat" 14 | 15 | params = decode("users[name]=hello&users[age]=17") 16 | assert params["users"]["name"] == "hello" 17 | assert params["users"]["age"] == "17" 18 | 19 | params = decode("my+weird+field=q1%212%22%27w%245%267%2Fz8%29%3F") 20 | assert params["my weird field"] == "q1!2\"'w$5&7/z8)?" 21 | 22 | assert decode("=")[""] == "" 23 | assert decode("key=")["key"] == "" 24 | assert decode("=value")[""] == "value" 25 | 26 | assert decode("foo[]")["foo"] == [] 27 | assert decode("foo[]=")["foo"] == [""] 28 | assert decode("foo[]=bar&foo[]=baz")["foo"] == ["bar", "baz"] 29 | assert decode("foo[]=bar&foo[]=baz")["foo"] == ["bar", "baz"] 30 | 31 | params = decode("foo[]=bar&foo[]=baz&bat[]=1&bat[]=2") 32 | assert params["foo"] == ["bar", "baz"] 33 | assert params["bat"] == ["1", "2"] 34 | 35 | assert decode("x[y][z]=1")["x"]["y"]["z"] == "1" 36 | assert decode("x[y][z][]=1")["x"]["y"]["z"] == ["1"] 37 | assert decode("x[y][z]=1&x[y][z]=2")["x"]["y"]["z"] == "2" 38 | assert decode("x[y][z][]=1&x[y][z][]=2")["x"]["y"]["z"] == ["1", "2"] 39 | 40 | assert Enum.at(decode("x[y][][z]=1")["x"]["y"], 0)["z"] == "1" 41 | assert Enum.at(decode("x[y][][z][]=1")["x"]["y"], 0)["z"] |> Enum.at(0) == "1" 42 | end 43 | 44 | test "last always wins on bad queries" do 45 | assert decode("x[]=1&x[y]=1")["x"]["y"] == "1" 46 | assert decode("x[y][][w]=2&x[y]=1")["x"]["y"] == "1" 47 | assert decode("x=1&x[y]=1")["x"]["y"] == "1" 48 | end 49 | 50 | test "decode_pair simple queries" do 51 | params = decode_pair([{"foo", "bar"}, {"baz", "bat"}]) 52 | assert params["foo"] == "bar" 53 | assert params["baz"] == "bat" 54 | end 55 | 56 | test "decode_pair one-level nested query" do 57 | params = decode_pair([{"users[name]", "hello"}]) 58 | assert params["users"]["name"] == "hello" 59 | 60 | params = decode_pair([{"users[name]", "hello"}, {"users[age]", "17"}]) 61 | assert params["users"]["name"] == "hello" 62 | assert params["users"]["age"] == "17" 63 | end 64 | 65 | test "decode_pair query no override" do 66 | params = decode_pair([{"foo", "bar"}, {"foo", "baz"}]) 67 | assert params["foo"] == "baz" 68 | 69 | params = decode_pair([{"users[name]", "bar"}, {"users[name]", "baz"}]) 70 | assert params["users"]["name"] == "baz" 71 | end 72 | 73 | test "decode_pair many-levels nested query" do 74 | params = decode_pair([{"users[name]", "hello"}]) 75 | assert params["users"]["name"] == "hello" 76 | 77 | params = 78 | decode_pair([ 79 | {"users[name]", "hello"}, 80 | {"users[age]", "17"}, 81 | {"users[address][street]", "Mourato"} 82 | ]) 83 | 84 | assert params["users"]["name"] == "hello" 85 | assert params["users"]["age"] == "17" 86 | assert params["users"]["address"]["street"] == "Mourato" 87 | end 88 | 89 | test "decode_pair list query" do 90 | params = decode_pair([{"foo[]", "bar"}, {"foo[]", "baz"}]) 91 | assert params["foo"] == ["bar", "baz"] 92 | end 93 | 94 | defp decode_pair(pairs) do 95 | Enum.reduce(Enum.reverse(pairs), %{}, &Maxwell.Query.decode_pair(&1, &2)) 96 | end 97 | 98 | test "encode" do 99 | assert encode(%{foo: "bar", baz: "bat"}) == "baz=bat&foo=bar" 100 | 101 | assert encode(%{foo: nil}) == "foo=" 102 | assert encode(%{foo: "bå®"}) == "foo=b%C3%A5%C2%AE" 103 | assert encode(%{foo: 1337}) == "foo=1337" 104 | assert encode(%{foo: ["bar", "baz"]}) == "foo[]=bar&foo[]=baz" 105 | 106 | assert encode(%{users: %{name: "hello", age: 17}}) == "users[age]=17&users[name]=hello" 107 | assert encode(%{users: [name: "hello", age: 17]}) == "users[name]=hello&users[age]=17" 108 | 109 | assert encode(%{users: [name: "hello", age: 17, name: "goodbye"]}) == 110 | "users[name]=hello&users[age]=17" 111 | 112 | assert encode(%{"my weird field": "q1!2\"'w$5&7/z8)?"}) == 113 | "my+weird+field=q1%212%22%27w%245%267%2Fz8%29%3F" 114 | 115 | assert encode(%{foo: %{"my weird field": "q1!2\"'w$5&7/z8)?"}}) == 116 | "foo[my+weird+field]=q1%212%22%27w%245%267%2Fz8%29%3F" 117 | 118 | assert encode(%{}) == "" 119 | assert encode([]) == "" 120 | 121 | assert encode(%{foo: [""]}) == "foo[]=" 122 | 123 | assert encode(%{foo: ["bar", "baz"], bat: [1, 2]}) == "bat[]=1&bat[]=2&foo[]=bar&foo[]=baz" 124 | 125 | assert encode(%{x: %{y: %{z: 1}}}) == "x[y][z]=1" 126 | assert encode(%{x: %{y: %{z: [1]}}}) == "x[y][z][]=1" 127 | assert encode(%{x: %{y: %{z: [1, 2]}}}) == "x[y][z][]=1&x[y][z][]=2" 128 | assert encode(%{x: %{y: [%{z: 1}]}}) == "x[y][][z]=1" 129 | assert encode(%{x: %{y: [%{z: [1]}]}}) == "x[y][][z][]=1" 130 | end 131 | 132 | test "encode with custom encoder" do 133 | encoder = &(&1 |> to_string |> String.duplicate(2)) 134 | 135 | assert encode(%{foo: "bar", baz: "bat"}, encoder) == 136 | "baz=batbat&foo=barbar" 137 | 138 | assert encode(%{foo: ["bar", "baz"]}, encoder) == 139 | "foo[]=barbar&foo[]=bazbaz" 140 | 141 | assert encode(%{foo: URI.parse("/bar")}, encoder) == 142 | "foo=%2Fbar%2Fbar" 143 | end 144 | 145 | test "encode ignores empty maps or lists" do 146 | assert encode(%{filter: %{}, foo: "bar", baz: "bat"}) == "baz=bat&foo=bar" 147 | assert encode(%{filter: [], foo: "bar", baz: "bat"}) == "baz=bat&foo=bar" 148 | end 149 | 150 | test "encode raises when there's a map with 0 or >1 elems in a list" do 151 | message = ~r/cannot encode maps inside lists/ 152 | 153 | assert_raise ArgumentError, message, fn -> 154 | encode(%{foo: [%{a: 1, b: 2}]}) 155 | end 156 | 157 | assert_raise ArgumentError, message, fn -> 158 | encode(%{foo: [%{valid: :map}, %{}]}) 159 | end 160 | end 161 | 162 | test "raise exception on bad www-form" do 163 | assert_raise ArgumentError, fn -> 164 | decode("_utf8=%R2%9P%93") 165 | end 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 3 | "certifi": {:hex, :certifi, "2.8.0", "d4fb0a6bb20b7c9c3643e22507e42f356ac090a1dcea9ab99e27e0376d695eba", [:rebar3], [], "hexpm", "6ac7efc1c6f8600b08d625292d4bbf584e14847ce1b6b5c44d983d273e1097ea"}, 4 | "credo": {:hex, :credo, "1.5.6", "e04cc0fdc236fefbb578e0c04bd01a471081616e741d386909e527ac146016c6", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4b52a3e558bd64e30de62a648518a5ea2b6e3e5d2b164ef5296244753fc7eb17"}, 5 | "dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.17", "6f3c7e94170377ba45241d394389e800fb15adc5de51d0a3cd52ae766aafd63f", [:mix], [], "hexpm", "f93ac89c9feca61c165b264b5837bf82344d13bebc634cd575cb711e2e342023"}, 7 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 8 | "ex_doc": {:hex, :ex_doc, "0.25.5", "ac3c5425a80b4b7c4dfecdf51fa9c23a44877124dd8ca34ee45ff608b1c6deb9", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "688cfa538cdc146bc4291607764a7f1fcfa4cce8009ecd62de03b27197528350"}, 9 | "excoveralls": {:hex, :excoveralls, "0.14.4", "295498f1ae47bdc6dce59af9a585c381e1aefc63298d48172efaaa90c3d251db", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e3ab02f2df4c1c7a519728a6f0a747e71d7d6e846020aae338173619217931c1"}, 10 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 11 | "fuse": {:hex, :fuse, "2.5.0", "71afa90be21da4e64f94abba9d36472faa2d799c67fedc3bd1752a88ea4c4753", [:rebar3], [], "hexpm", "7f52a1c84571731ad3c91d569e03131cc220ebaa7e2a11034405f0bac46a4fef"}, 12 | "hackney": {:hex, :hackney, "1.18.0", "c4443d960bb9fba6d01161d01cd81173089686717d9490e5d3606644c48d121f", [:rebar3], [{:certifi, "~>2.8.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "9afcda620704d720db8c6a3123e9848d09c87586dc1c10479c42627b905b5c5e"}, 13 | "ibrowse": {:hex, :ibrowse, "4.4.1", "2b7d0637b0f8b9b4182de4bd0f2e826a4da2c9b04898b6e15659ba921a8d6ec2", [:rebar3], [], "hexpm", "1e86c591dbc6d270632625534986beca30813af7ce784e742e5fc38e342c29b3"}, 14 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 15 | "inch_ex": {:hex, :inch_ex, "2.0.0", "24268a9284a1751f2ceda569cd978e1fa394c977c45c331bb52a405de544f4de", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "96d0ec5ecac8cf63142d02f16b7ab7152cf0f0f1a185a80161b758383c9399a8"}, 16 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 17 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 18 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.2", "dc72dfe17eb240552857465cc00cce390960d9a0c055c4ccd38b70629227e97c", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fd23ae48d09b32eff49d4ced2b43c9f086d402ee4fd4fcb2d7fad97fa8823e75"}, 19 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 20 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 21 | "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, 22 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 23 | "mimic": {:hex, :mimic, "1.5.1", "085f7ebfeb5b579a13a167aec3c712d71fecfc6cb8b298c0dd3056f97ea2c2a0", [:mix], [], "hexpm", "33a50ef9ff38f8f24b2586d52e529981a3ba2b8d061c872084aff0e993bf4bd5"}, 24 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.0", "b44d75e2a6542dcb6acf5d71c32c74ca88960421b6874777f79153bbbbd7dccc", [:mix], [], "hexpm", "52b2871a7515a5ac49b00f214e4165a40724cf99798d8e4a65e4fd64ebd002c1"}, 25 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 26 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, 27 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 28 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 29 | } 30 | -------------------------------------------------------------------------------- /test/maxwell/adapter/adapter_test_helper.exs: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Adapter.TestHelper do 2 | defmacro __using__(adapter: adapter) do 3 | client = adapter |> Macro.expand(__CALLER__) |> Module.concat(TestClient) 4 | 5 | quote location: :keep do 6 | use ExUnit.Case, async: false 7 | import Maxwell.Conn 8 | 9 | defmodule unquote(client) do 10 | use Maxwell.Builder 11 | adapter unquote(adapter) 12 | 13 | middleware Maxwell.Middleware.BaseUrl, "http://httpbin.org" 14 | middleware Maxwell.Middleware.Opts, connect_timeout: 5000 15 | middleware Maxwell.Middleware.Json 16 | 17 | def get_ip_test() do 18 | "/ip" |> new() |> get!() 19 | end 20 | 21 | def encode_decode_json_test(body) do 22 | "/post" 23 | |> new() 24 | |> put_req_body(body) 25 | |> post! 26 | |> get_resp_body("json") 27 | end 28 | 29 | def user_agent_test(user_agent) do 30 | "/user-agent" 31 | |> new() 32 | |> put_req_header("user-agent", user_agent) 33 | |> get! 34 | |> get_resp_body("user-agent") 35 | end 36 | 37 | def put_json_test(json) do 38 | "/put" 39 | |> new() 40 | |> put_req_body(json) 41 | |> put! 42 | |> get_resp_body("data") 43 | end 44 | 45 | def delete_json_test(json) do 46 | "delete" 47 | |> new() 48 | |> put_req_body(json) 49 | |> delete! 50 | |> get_resp_body("data") 51 | end 52 | 53 | def multipart_test() do 54 | "/post" 55 | |> new() 56 | |> put_req_body({:multipart, [{:file, "test/maxwell/multipart_test_file.sh"}]}) 57 | |> post! 58 | end 59 | 60 | def multipart_file_content_test() do 61 | "/post" 62 | |> new() 63 | |> put_req_body({:multipart, [{:file_content, "xxx", "test.txt"}]}) 64 | |> post! 65 | end 66 | 67 | def multipart_with_extra_header_test() do 68 | "/post" 69 | |> new() 70 | |> put_req_body( 71 | {:multipart, 72 | [{:file, "test/maxwell/multipart_test_file.sh", [{"Content-Type", "image/jpeg"}]}]} 73 | ) 74 | |> post! 75 | end 76 | 77 | def file_test(filepath, content_type) do 78 | "/post" 79 | |> new() 80 | |> put_req_body({:file, filepath}) 81 | |> put_req_header("content-type", content_type) 82 | |> post! 83 | end 84 | 85 | def file_test(filepath) do 86 | "/post" 87 | |> new() 88 | |> put_req_header("content-type", "application/vnd.lotus-1-2-3") 89 | |> put_req_body({:file, filepath}) 90 | |> post! 91 | end 92 | 93 | def stream_test() do 94 | "/post" 95 | |> new() 96 | |> put_req_header("content-type", "application/vnd.lotus-1-2-3") 97 | |> put_req_header("content-length", 6) 98 | |> put_req_body(Stream.map(["1", "2", "3"], fn x -> List.duplicate(x, 2) end)) 99 | |> post! 100 | end 101 | 102 | def file_without_transfer_encoding_test(filepath, content_type) do 103 | "/post" 104 | |> new() 105 | |> put_req_body({:file, filepath}) 106 | |> put_req_header("content-type", content_type) 107 | |> post! 108 | end 109 | end 110 | 111 | if Code.ensure_loaded?(:rand) do 112 | setup do 113 | :rand.seed( 114 | :exs1024, 115 | {:erlang.phash2([node()]), :erlang.monotonic_time(), :erlang.unique_integer()} 116 | ) 117 | 118 | :ok 119 | end 120 | else 121 | setup do 122 | :random.seed( 123 | :erlang.phash2([node()]), 124 | :erlang.monotonic_time(), 125 | :erlang.unique_integer() 126 | ) 127 | 128 | :ok 129 | end 130 | end 131 | 132 | test "sync request" do 133 | assert unquote(client).get_ip_test |> get_status == 200 134 | end 135 | 136 | test "encode decode json test" do 137 | result = 138 | %{"josnkey1" => "jsonvalue1", "josnkey2" => "jsonvalue2"} 139 | |> unquote(client).encode_decode_json_test 140 | 141 | assert result == %{"josnkey1" => "jsonvalue1", "josnkey2" => "jsonvalue2"} 142 | end 143 | 144 | test "multipart body file" do 145 | conn = unquote(client).multipart_test 146 | 147 | assert get_resp_body(conn, "files") == %{ 148 | "file" => "#!/usr/bin/env bash\necho \"test multipart file\"\n" 149 | } 150 | end 151 | 152 | test "multipart body file_content" do 153 | conn = unquote(client).multipart_file_content_test 154 | assert get_resp_body(conn, "files") == %{"file" => "xxx"} 155 | end 156 | 157 | test "multipart body file extra headers" do 158 | conn = unquote(client).multipart_with_extra_header_test 159 | 160 | assert get_resp_body(conn, "files") == %{ 161 | "file" => "#!/usr/bin/env bash\necho \"test multipart file\"\n" 162 | } 163 | end 164 | 165 | test "send file without content-type" do 166 | conn = unquote(client).file_test("test/maxwell/multipart_test_file.sh") 167 | 168 | assert get_resp_body(conn, "data") == 169 | "#!/usr/bin/env bash\necho \"test multipart file\"\n" 170 | end 171 | 172 | test "send file with content-type" do 173 | conn = 174 | unquote(client).file_test("test/maxwell/multipart_test_file.sh", "application/x-sh") 175 | 176 | assert get_resp_body(conn, "data") == 177 | "#!/usr/bin/env bash\necho \"test multipart file\"\n" 178 | end 179 | 180 | test "file_without_transfer_encoding" do 181 | conn = 182 | unquote(client).file_without_transfer_encoding_test( 183 | "test/maxwell/multipart_test_file.sh", 184 | "application/x-sh" 185 | ) 186 | 187 | assert get_resp_body(conn, "data") == 188 | "#!/usr/bin/env bash\necho \"test multipart file\"\n" 189 | end 190 | 191 | test "send stream" do 192 | conn = unquote(client).stream_test 193 | assert get_resp_body(conn, "data") == "112233" 194 | end 195 | 196 | test "user-agent header test" do 197 | assert "test" |> unquote(client).user_agent_test == "test" 198 | end 199 | 200 | test "/put" do 201 | assert %{"key" => "value"} |> unquote(client).put_json_test == "{\"key\":\"value\"}" 202 | end 203 | 204 | test "/delete" do 205 | assert %{"key" => "value"} |> unquote(client).delete_json_test == "{\"key\":\"value\"}" 206 | end 207 | 208 | test "Head without body(test return {:ok, status, header})" do 209 | body = unquote(client).head! |> get_resp_body |> Kernel.to_string() 210 | assert body == "" 211 | end 212 | end 213 | end 214 | end 215 | -------------------------------------------------------------------------------- /test/maxwell/middleware/logger_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LoggerTest do 2 | use ExUnit.Case 3 | import Maxwell.Middleware.TestHelper 4 | import ExUnit.CaptureLog 5 | alias Maxwell.Conn 6 | 7 | test "Middleware Logger Request" do 8 | conn = request(Maxwell.Middleware.Logger, %Conn{url: "/path"}, []) 9 | assert conn == %Conn{url: "/path"} 10 | end 11 | 12 | test "Middleware Logger Response" do 13 | conn = response(Maxwell.Middleware.Logger, %Conn{url: "/path"}, []) 14 | assert conn == %Conn{url: "/path"} 15 | end 16 | 17 | test "Logger Call" do 18 | conn = %Conn{method: :get, url: "http://example.com", status: 200} 19 | 20 | outputstr = 21 | capture_log(fn -> 22 | Maxwell.Middleware.Logger.call( 23 | conn, 24 | fn x -> {:error, "bad request", %{x | status: 400}} end, 25 | default: :info 26 | ) 27 | end) 28 | 29 | output301 = 30 | capture_log(fn -> 31 | Maxwell.Middleware.Logger.call(conn, fn x -> %{x | status: 301} end, default: :error) 32 | end) 33 | 34 | output404 = 35 | capture_log(fn -> 36 | Maxwell.Middleware.Logger.call(conn, fn x -> %{x | status: 404} end, [{404, :debug}]) 37 | end) 38 | 39 | output500 = 40 | capture_log(fn -> 41 | Maxwell.Middleware.Logger.call(conn, fn x -> %{x | status: 500} end, [ 42 | {500..599, :warn}, 43 | {:default, :error} 44 | ]) 45 | end) 46 | 47 | outputok = 48 | capture_log(fn -> Maxwell.Middleware.Logger.call(conn, fn x -> x end, default: :info) end) 49 | 50 | nooutput = 51 | capture_log(fn -> 52 | Maxwell.Middleware.Logger.call(conn, fn x -> x end, [{400..599, :info}]) 53 | end) 54 | 55 | assert outputstr =~ 56 | ~r"\e\[31m\n\d+:\d+:\d+.\d+ \[error\] GET http://example.com>> \e\[31mERROR: \"bad request\"\n\e\[0m" 57 | 58 | assert output301 =~ 59 | ~r"\e\[31m\n\d+:\d+:\d+.\d+ \[error\] get http://example.com <<<\e\[31m301\(\d+ms\)\e\[0m\n%Maxwell.Conn\{method: :get, opts: \[\], path: \"\", private: \%\{\}, query_string: \%\{\}, req_body: nil, req_headers: \%\{\}, resp_body: \"\", resp_headers: \%\{\}, state: :unsent, status: 301, url: \"http://example.com\"\}\n\e\[0m" 60 | 61 | assert output404 =~ 62 | ~r"\e\[36m\n\d+:\d+:\d+.\d+ \[debug\] get http://example.com <<<\e\[36m404\(\d+ms\)\e\[0m\n%Maxwell.Conn\{method: :get, opts: \[\], path: \"\", private: \%\{\}, query_string: \%\{\}, req_body: nil, req_headers: \%\{\}, resp_body: \"\", resp_headers: \%\{\}, state: :unsent, status: 404, url: \"http://example.com\"\}\n\e\[0m" 63 | 64 | assert output500 =~ 65 | ~r"\e\[33m\n\d+:\d+:\d+.\d+ \[warn\] get http://example.com <<<\e\[33m500\(\d+ms\)\e\[0m\n%Maxwell.Conn\{method: :get, opts: \[\], path: \"\", private: \%\{\}, query_string: \%\{\}, req_body: nil, req_headers: \%\{\}, resp_body: \"\", resp_headers: \%\{\}, state: :unsent, status: 500, url: \"http://example.com\"\}\n\e\[0m" 66 | 67 | assert outputok =~ 68 | ~r"\e\[22m\n\d+:\d+:\d+.\d+ \[info\] get http://example.com <<<\e\[22m200\(\d+ms\)\e\[0m\n%Maxwell.Conn\{method: :get, opts: \[\], path: \"\", private: \%\{\}, query_string: \%\{\}, req_body: nil, req_headers: \%\{\}, resp_body: \"\", resp_headers: \%\{\}, state: :unsent, status: 200, url: \"http://example.com\"}\n\e\[0m" 69 | 70 | assert nooutput == "" 71 | end 72 | 73 | test "Change Middleware Logger's log_level" do 74 | assert_raise RuntimeError, "ok", fn -> 75 | defmodule TAtom0 do 76 | use Maxwell.Builder, [:get, :post] 77 | middleware Maxwell.Middleware.Logger, log_level: :debug 78 | end 79 | 80 | raise "ok" 81 | end 82 | end 83 | 84 | test "Middleware Logger with invalid log_level" do 85 | assert_raise ArgumentError, ~r/Logger Middleware: level only accepts/, fn -> 86 | defmodule TAtom1 do 87 | use Maxwell.Builder, [:get, :post] 88 | middleware Maxwell.Middleware.Logger, log_level: 1234 89 | end 90 | 91 | raise "ok" 92 | end 93 | end 94 | 95 | test "Middleware.Logger with wrong options" do 96 | assert_raise ArgumentError, 97 | "Logger Middleware Options doesn't accept wrong_option (:log_level)", 98 | fn -> 99 | defmodule TAtom2 do 100 | use Maxwell.Builder, [:get, :post] 101 | middleware Maxwell.Middleware.Logger, wrong_option: :haah 102 | end 103 | 104 | raise "ok" 105 | end 106 | end 107 | 108 | test "Complex log_level for Middleware Logger 1" do 109 | assert_raise RuntimeError, "ok", fn -> 110 | defmodule TAtom3 do 111 | use Maxwell.Builder, [:get, :post] 112 | 113 | middleware Maxwell.Middleware.Logger, 114 | log_level: [ 115 | error: 400..599 116 | ] 117 | end 118 | 119 | raise "ok" 120 | end 121 | end 122 | 123 | test "Complex log_level for Middleware Logger 2" do 124 | assert_raise RuntimeError, "ok", fn -> 125 | defmodule TAtom4 do 126 | use Maxwell.Builder, [:get, :post] 127 | 128 | middleware Maxwell.Middleware.Logger, 129 | log_level: [ 130 | info: [1..99, 100, 200..299], 131 | warn: 300..399, 132 | error: :default 133 | ] 134 | end 135 | 136 | raise "ok" 137 | end 138 | end 139 | 140 | test "Complex log_level with wrong status code" do 141 | assert_raise ArgumentError, 142 | "Logger Middleware: status code only accepts Integer and Range.", 143 | fn -> 144 | defmodule TStatusCode do 145 | use Maxwell.Builder, [:get, :post] 146 | 147 | middleware Maxwell.Middleware.Logger, 148 | log_level: [ 149 | info: ["100"] 150 | ] 151 | end 152 | 153 | raise "ok" 154 | end 155 | end 156 | 157 | test "Complex log_level with duplicated default level" do 158 | assert ExUnit.CaptureLog.capture_log(fn -> 159 | defmodule TDefaultLevel1 do 160 | use Maxwell.Builder, [:get, :post] 161 | 162 | middleware Maxwell.Middleware.Logger, 163 | log_level: [ 164 | info: :default, 165 | info: :default 166 | ] 167 | end 168 | end) =~ 169 | ~r"\e\[33m\n\d+:\d+:\d+.\d+ \[warn\] Logger Middleware: default level defined multiple times.\n\e\[0m" 170 | end 171 | 172 | test "Complex log_level with conflictive default level" do 173 | assert_raise ArgumentError, "Logger Middleware: default level conflict.", fn -> 174 | defmodule TDefaultLevel2 do 175 | use Maxwell.Builder, [:get, :post] 176 | 177 | middleware Maxwell.Middleware.Logger, 178 | log_level: [ 179 | info: :default, 180 | error: :default 181 | ] 182 | end 183 | 184 | raise "ok" 185 | end 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /lib/maxwell/middleware/json.ex: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Middleware.Json do 2 | @moduledoc """ 3 | Encode request's body to json when request's body is not nil 4 | Decode response's body to json when reponse's header contain `{'Content-Type', "application/json"}` and body is binary 5 | or Reponse's body is list 6 | 7 | It will auto add `%{'Content-Type': 'application/json'}` to request's headers 8 | 9 | Default json_lib is Poison 10 | 11 | ## Examples 12 | 13 | # Client.ex 14 | use Maxwell.Builder ~(get)a 15 | middleware Maxwell.Middleware.Json 16 | 17 | # OR 18 | 19 | middleware Maxwell.Middleware.Json, 20 | encode_content_type: "application/json", 21 | encode_func: &other_json_lib.encode/1, 22 | decode_content_types: ["yourowntype"], 23 | decode_func: &other_json_lib.decode/1 24 | 25 | """ 26 | use Maxwell.Middleware 27 | 28 | def init(opts) do 29 | check_opts(opts) 30 | encode_func = opts[:encode_func] || (&Poison.encode/1) 31 | decode_content_type = opts[:encode_content_type] || "application/json" 32 | decode_func = opts[:decode_func] || (&Poison.decode/1) 33 | decode_content_types = opts[:decode_content_types] || [] 34 | {{encode_func, decode_content_type}, {decode_func, decode_content_types}} 35 | end 36 | 37 | def request(%Maxwell.Conn{} = conn, {encode_opts, _decode_opts}) do 38 | Maxwell.Middleware.EncodeJson.request(conn, encode_opts) 39 | end 40 | 41 | def response(%Maxwell.Conn{} = conn, {_encode_opts, decode_opts}) do 42 | Maxwell.Middleware.DecodeJson.response(conn, decode_opts) 43 | end 44 | 45 | defp check_opts(opts) do 46 | for {key, value} <- opts do 47 | case key do 48 | :encode_func -> 49 | unless is_function(value, 1), 50 | do: raise(ArgumentError, "Json Middleware :encode_func only accepts function/1") 51 | 52 | :encode_content_type -> 53 | unless is_binary(value), 54 | do: raise(ArgumentError, "Json Middleware :encode_content_types only accepts string") 55 | 56 | :decode_func -> 57 | unless is_function(value, 1), 58 | do: raise(ArgumentError, "Json Middleware :decode_func only accepts function/1") 59 | 60 | :decode_content_types -> 61 | unless is_list(value), 62 | do: raise(ArgumentError, "Json Middleware :decode_content_types only accepts lists") 63 | 64 | _ -> 65 | raise(ArgumentError, "Json Middleware Options doesn't accept #{key}") 66 | end 67 | end 68 | end 69 | end 70 | 71 | defmodule Maxwell.Middleware.EncodeJson do 72 | @moduledoc """ 73 | Encode request's body to json when request's body is not nil 74 | 75 | It will auto add `%{'Content-Type': 'application/json'}` to request's headers 76 | 77 | Default json_lib is Poison 78 | 79 | ## Examples 80 | 81 | # Client.ex 82 | use Maxwell.Builder ~(get)a 83 | middleware Maxwell.Middleware.EncodeJson 84 | 85 | # OR 86 | 87 | middleware Maxwell.Middleware.EncodeJson, 88 | encode_content_type: "application/json", 89 | encode_func: &other_json_lib.encode/1 90 | 91 | """ 92 | use Maxwell.Middleware 93 | alias Maxwell.Conn 94 | 95 | def init(opts) do 96 | check_opts(opts) 97 | encode_func = opts[:encode_func] || (&Poison.encode/1) 98 | content_type = opts[:encode_content_type] || "application/json" 99 | {encode_func, content_type} 100 | end 101 | 102 | def request(conn = %Conn{req_body: req_body}, _opts) 103 | when is_nil(req_body) or is_tuple(req_body) or is_atom(req_body), 104 | do: conn 105 | 106 | def request(conn = %Conn{req_body: %Stream{}}, _opts), do: conn 107 | 108 | def request(conn = %Conn{req_body: req_body}, {encode_func, content_type}) do 109 | {:ok, req_body} = encode_func.(req_body) 110 | 111 | conn 112 | |> Conn.put_req_body(req_body) 113 | |> Conn.put_req_header("content-type", content_type) 114 | end 115 | 116 | defp check_opts(opts) do 117 | for {key, value} <- opts do 118 | case key do 119 | :encode_func -> 120 | unless is_function(value, 1), 121 | do: raise(ArgumentError, "EncodeJson :encode_func only accepts function/1") 122 | 123 | :encode_content_type -> 124 | unless is_binary(value), 125 | do: raise(ArgumentError, "EncodeJson :encode_content_types only accepts string") 126 | 127 | _ -> 128 | raise( 129 | ArgumentError, 130 | "EncodeJson Options doesn't accept #{key} (:encode_func and :encode_content_type)" 131 | ) 132 | end 133 | end 134 | end 135 | end 136 | 137 | defmodule Maxwell.Middleware.DecodeJson do 138 | @moduledoc """ 139 | Decode response's body to json when 140 | 141 | 1. The reponse headers contain a content type of `application/json` and body is binary. 142 | 2. The response is a list 143 | 144 | Default json decoder is Poison 145 | 146 | ## Examples 147 | 148 | # Client.ex 149 | use Maxwell.Builder ~(get)a 150 | middleware Maxwell.Middleware.DecodeJson 151 | 152 | # OR 153 | 154 | middleware Maxwell.Middleware.DecodeJson, 155 | decode_content_types: ["text/javascript"], 156 | decode_func: &other_json_lib.decode/1 157 | 158 | """ 159 | use Maxwell.Middleware 160 | 161 | def init(opts) do 162 | check_opts(opts) 163 | {opts[:decode_func] || (&Poison.decode/1), opts[:decode_content_types] || []} 164 | end 165 | 166 | def response(%Maxwell.Conn{} = conn, {decode_fun, valid_content_types}) do 167 | with {:ok, content_type} <- fetch_resp_content_type(conn), 168 | true <- valid_content?(content_type, conn.resp_body, valid_content_types), 169 | {:ok, resp_body} <- decode_fun.(conn.resp_body) do 170 | %{conn | resp_body: resp_body} 171 | else 172 | :error -> conn 173 | false -> conn 174 | {:error, reason} -> {:error, {:decode_json_error, reason}, conn} 175 | {:error, reason, pos} -> {:error, {:decode_json_error, reason, pos}, conn} 176 | end 177 | end 178 | 179 | defp valid_content?(content_type, body, valid_types) do 180 | (present?(body) && 181 | String.starts_with?(content_type, "application/json")) || 182 | String.starts_with?(content_type, "text/javascript") || 183 | Enum.any?(valid_types, &String.starts_with?(content_type, &1)) 184 | end 185 | 186 | defp fetch_resp_content_type(conn) do 187 | if content_type = Maxwell.Conn.get_resp_header(conn, "content-type") do 188 | {:ok, content_type} 189 | else 190 | :error 191 | end 192 | end 193 | 194 | defp present?(""), do: false 195 | defp present?([]), do: false 196 | defp present?(term) when is_binary(term) or is_list(term), do: true 197 | defp present?(_), do: false 198 | 199 | defp check_opts(opts) do 200 | for {key, value} <- opts do 201 | case key do 202 | :decode_func -> 203 | unless is_function(value, 1), 204 | do: raise(ArgumentError, "DecodeJson :decode_func only accepts function/1") 205 | 206 | :decode_content_types -> 207 | unless is_list(value), 208 | do: raise(ArgumentError, "DecodeJson :decode_content_types only accepts lists") 209 | 210 | _ -> 211 | raise( 212 | ArgumentError, 213 | "DecodeJson Options doesn't accept #{key} (:decode_func and :decode_content_types)" 214 | ) 215 | end 216 | end 217 | end 218 | end 219 | -------------------------------------------------------------------------------- /lib/maxwell/query.ex: -------------------------------------------------------------------------------- 1 | # The following module was copied from Plug: 2 | # https://raw.githubusercontent.com/elixir-lang/plug/91b8f57dcc495735925553bcf6c53a0d0e413d86/lib/plug/conn/query.ex 3 | # With permission: https://github.com/elixir-lang/plug/issues/539 4 | defmodule Maxwell.Query do 5 | @moduledoc """ 6 | Conveniences for decoding and encoding url encoded queries. 7 | 8 | This module allows a developer to build query strings 9 | that map to Elixir structures in order to make 10 | manipulation of such structures easier on the server 11 | side. Here are some examples: 12 | 13 | iex> decode("foo=bar")["foo"] 14 | "bar" 15 | 16 | If a value is given more than once, the last value takes precedence: 17 | 18 | iex> decode("foo=bar&foo=baz")["foo"] 19 | "baz" 20 | 21 | Nested structures can be created via `[key]`: 22 | 23 | iex> decode("foo[bar]=baz")["foo"]["bar"] 24 | "baz" 25 | 26 | Lists are created with `[]`: 27 | 28 | iex> decode("foo[]=bar&foo[]=baz")["foo"] 29 | ["bar", "baz"] 30 | 31 | Maps can be encoded: 32 | 33 | iex> encode(%{foo: "bar", baz: "bat"}) 34 | "baz=bat&foo=bar" 35 | 36 | Encoding keyword lists preserves the order of the fields: 37 | 38 | iex> encode([foo: "bar", baz: "bat"]) 39 | "foo=bar&baz=bat" 40 | 41 | When encoding keyword lists with duplicate keys, the key that comes first 42 | takes precedence: 43 | 44 | iex> encode([foo: "bar", foo: "bat"]) 45 | "foo=bar" 46 | 47 | Encoding named lists: 48 | 49 | iex> encode(%{foo: ["bar", "baz"]}) 50 | "foo[]=bar&foo[]=baz" 51 | 52 | Encoding nested structures: 53 | 54 | iex> encode(%{foo: %{bar: "baz"}}) 55 | "foo[bar]=baz" 56 | 57 | """ 58 | 59 | @doc """ 60 | Decodes the given binary. 61 | """ 62 | def decode(query, initial \\ %{}) 63 | 64 | def decode("", initial) do 65 | initial 66 | end 67 | 68 | def decode(query, initial) do 69 | parts = :binary.split(query, "&", [:global]) 70 | Enum.reduce(Enum.reverse(parts), initial, &decode_string_pair(&1, &2)) 71 | end 72 | 73 | defp decode_string_pair(binary, acc) do 74 | current = 75 | case :binary.split(binary, "=") do 76 | [key, value] -> 77 | {decode_www_form(key), decode_www_form(value)} 78 | 79 | [key] -> 80 | {decode_www_form(key), nil} 81 | end 82 | 83 | decode_pair(current, acc) 84 | end 85 | 86 | defp decode_www_form(value) do 87 | try do 88 | URI.decode_www_form(value) 89 | rescue 90 | ArgumentError -> 91 | raise ArgumentError, 92 | message: "invalid www-form encoding on query-string, got #{value}" 93 | end 94 | end 95 | 96 | @doc """ 97 | Decodes the given tuple and stores it in the accumulator. 98 | It parses the key and stores the value into the current 99 | accumulator. 100 | 101 | Parameter lists are added to the accumulator in reverse 102 | order, so be sure to pass the parameters in reverse order. 103 | """ 104 | def decode_pair({key, value}, acc) do 105 | parts = 106 | if key != "" and :binary.last(key) == ?] do 107 | # Remove trailing ] 108 | subkey = :binary.part(key, 0, byte_size(key) - 1) 109 | 110 | # Split the first [ then split remaining ][. 111 | # 112 | # users[address][street #=> [ "users", "address][street" ] 113 | # 114 | case :binary.split(subkey, "[") do 115 | [key, subpart] -> 116 | [key | :binary.split(subpart, "][", [:global])] 117 | 118 | _ -> 119 | [key] 120 | end 121 | else 122 | [key] 123 | end 124 | 125 | assign_parts(parts, value, acc) 126 | end 127 | 128 | # We always assign the value in the last segment. 129 | # `age=17` would match here. 130 | defp assign_parts([key], value, acc) do 131 | Map.put_new(acc, key, value) 132 | end 133 | 134 | # The current segment is a list. We simply prepend 135 | # the item to the list or create a new one if it does 136 | # not yet. This assumes that items are iterated in 137 | # reverse order. 138 | defp assign_parts([key, "" | t], value, acc) do 139 | case Map.fetch(acc, key) do 140 | {:ok, current} when is_list(current) -> 141 | Map.put(acc, key, assign_list(t, current, value)) 142 | 143 | :error -> 144 | Map.put(acc, key, assign_list(t, [], value)) 145 | 146 | _ -> 147 | acc 148 | end 149 | end 150 | 151 | # The current segment is a parent segment of a 152 | # map. We need to create a map and then 153 | # continue looping. 154 | defp assign_parts([key | t], value, acc) do 155 | case Map.fetch(acc, key) do 156 | {:ok, %{} = current} -> 157 | Map.put(acc, key, assign_parts(t, value, current)) 158 | 159 | :error -> 160 | Map.put(acc, key, assign_parts(t, value, %{})) 161 | 162 | _ -> 163 | acc 164 | end 165 | end 166 | 167 | defp assign_list(t, current, value) do 168 | if value = assign_list(t, value), do: [value | current], else: current 169 | end 170 | 171 | defp assign_list([], value), do: value 172 | defp assign_list(t, value), do: assign_parts(t, value, %{}) 173 | 174 | @doc """ 175 | Encodes the given map or list of tuples. 176 | """ 177 | def encode(kv, encoder \\ &to_string/1) do 178 | IO.iodata_to_binary(encode_pair("", kv, encoder)) 179 | end 180 | 181 | # covers structs 182 | defp encode_pair(field, %{__struct__: struct} = map, encoder) when is_atom(struct) do 183 | [field, ?= | encode_value(map, encoder)] 184 | end 185 | 186 | # covers maps 187 | defp encode_pair(parent_field, %{} = map, encoder) do 188 | encode_kv(map, parent_field, encoder) 189 | end 190 | 191 | # covers keyword lists 192 | defp encode_pair(parent_field, list, encoder) when is_list(list) and is_tuple(hd(list)) do 193 | encode_kv(Enum.uniq_by(list, &elem(&1, 0)), parent_field, encoder) 194 | end 195 | 196 | # covers non-keyword lists 197 | defp encode_pair(parent_field, list, encoder) when is_list(list) do 198 | prune( 199 | Enum.flat_map(list, fn 200 | value when is_map(value) and map_size(value) != 1 -> 201 | raise ArgumentError, 202 | "cannot encode maps inside lists when the map has 0 or more than 1 elements, " <> 203 | "got: #{inspect(value)}" 204 | 205 | value -> 206 | [?&, encode_pair(parent_field <> "[]", value, encoder)] 207 | end) 208 | ) 209 | end 210 | 211 | # covers nil 212 | defp encode_pair(field, nil, _encoder) do 213 | [field, ?=] 214 | end 215 | 216 | # encoder fallback 217 | defp encode_pair(field, value, encoder) do 218 | [field, ?= | encode_value(value, encoder)] 219 | end 220 | 221 | defp encode_kv(kv, parent_field, encoder) do 222 | prune( 223 | Enum.flat_map(kv, fn 224 | {_, value} when value in [%{}, []] -> 225 | [] 226 | 227 | {field, value} -> 228 | field = 229 | if parent_field == "" do 230 | encode_key(field) 231 | else 232 | parent_field <> "[" <> encode_key(field) <> "]" 233 | end 234 | 235 | [?&, encode_pair(field, value, encoder)] 236 | end) 237 | ) 238 | end 239 | 240 | defp encode_key(item) do 241 | item |> to_string |> URI.encode_www_form() 242 | end 243 | 244 | defp encode_value(item, encoder) do 245 | item |> encoder.() |> URI.encode_www_form() 246 | end 247 | 248 | defp prune([?& | t]), do: t 249 | defp prune([]), do: [] 250 | end 251 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Maxwell 2 | 3 | [![Build Status](https://travis-ci.org/zhongwencool/maxwell.svg?branch=master)](https://travis-ci.org/zhongwencool/maxwell) 4 | [![Inline docs](http://inch-ci.org/github/zhongwencool/maxwell.svg)](http://inch-ci.org/github/zhongwencool/maxwell) 5 | [![Coveralls Coverage](https://img.shields.io/coveralls/zhongwencool/maxwell.svg)](https://coveralls.io/github/zhongwencool/maxwell) 6 | [![Module Version](https://img.shields.io/hexpm/v/maxwell.svg)](https://hex.pm/packages/maxwell) 7 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/maxwell/) 8 | [![Total Download](https://img.shields.io/hexpm/dt/maxwell.svg)](https://hex.pm/packages/maxwell) 9 | [![License](https://img.shields.io/hexpm/l/maxwell.svg)](https://github.com/zhongwencool/maxwell/blob/master/LICENSE) 10 | [![Last Updated](https://img.shields.io/github/last-commit/zhongwencool/maxwell.svg)](https://github.com/zhongwencool/maxwell/commits/master) 11 | 12 | 13 | Maxwell is an HTTP client that provides a common interface over [:httpc](http://erlang.org/doc/man/httpc.html), [:ibrowse](https://github.com/cmullaparthi/ibrowse), [:hackney](https://github.com/benoitc/hackney). 14 | 15 | ## Getting Started 16 | 17 | The simplest way to use Maxwell is by creating a module which will be your API wrapper, using `Maxwell.Builder`: 18 | 19 | ```elixir 20 | defmodule GitHubClient do 21 | # Generates `get/1`, `get!/1`, `patch/1`, `patch!/1` public functions 22 | # You can omit the list and functions for all HTTP methods will be generated 23 | use Maxwell.Builder, ~w(get patch)a 24 | 25 | # For a complete list of middlewares, see the docs 26 | middleware Maxwell.Middleware.BaseUrl, "https://api.github.com" 27 | middleware Maxwell.Middleware.Headers, %{"content-type" => "application/vnd.github.v3+json", "user-agent" => "zhongwenool"} 28 | middleware Maxwell.Middleware.Opts, connect_timeout: 3000 29 | middleware Maxwell.Middleware.Json 30 | middleware Maxwell.Middleware.Logger 31 | 32 | # adapter can be omitted, and the default will be used (currently :ibrowse) 33 | adapter Maxwell.Adapter.Hackney 34 | 35 | # List public repositories for the specified user. 36 | def user_repos(username) do 37 | "/users/#{username}/repos" 38 | |> new() 39 | |> get() 40 | end 41 | 42 | # Edit owner repositories 43 | def edit_repo_desc(owner, repo, name, desc) do 44 | "/repos/#{owner}/#{repo}" 45 | |> new() 46 | |> put_req_body(%{name: name, description: desc}) 47 | |> patch() 48 | end 49 | end 50 | ``` 51 | 52 | `Maxwell.Builder` injects functions for all supported HTTP methods, in two flavors, the first (e.g. `get/1`) will 53 | return `{:ok, Maxwell.Conn.t}` or `{:error, term, Maxwell.Conn.t}`. The second (e.g. `get!/1`) will return 54 | `Maxwell.Conn.t` *only* if the request succeeds and returns a 2xx status code, otherwise it will raise `Maxwell.Error`. 55 | 56 | The same functions are also exported by the `Maxwell` module, which you can use if you do not wish to define a wrapper 57 | module for your API, as shown below: 58 | 59 | ```elixir 60 | iex(1)> alias Maxwell.Conn 61 | iex(2)> Conn.new("http://httpbin.org/drip") |> 62 | Conn.put_query_string(%{numbytes: 25, duration: 1, delay: 1, code: 200}) |> 63 | Maxwell.get 64 | {:ok, 65 | %Maxwell.Conn{method: :get, opts: [], path: "/drip", 66 | query_string: %{code: 200, delay: 1, duration: 1, numbytes: 25}, 67 | req_body: nil, req_headers: %{}, resp_body: '*************************', 68 | resp_headers: %{"access-control-allow-credentials" => "true", 69 | "access-control-allow-origin" => "*", 70 | "connection" => "keep-alive", 71 | "content-length" => "25", 72 | "content-type" => "application/octet-stream", 73 | "date" => "Sun, 18 Dec 2016 14:32:38 GMT", 74 | "server" => "nginx"}, state: :sent, status: 200, 75 | url: "http://httpbin.org"}} 76 | ``` 77 | 78 | There are numerous helper functions for the `Maxwell.Conn` struct. See it's module docs 79 | for a list of all functions, and detailed info about how they behave. 80 | 81 | ## Installation 82 | 83 | 1. Add maxwell to your list of dependencies in `mix.exs`: 84 | 85 | ```ex 86 | def deps do 87 | [{:maxwell, "~> 2.3"}] 88 | end 89 | ``` 90 | 91 | 2. Ensure maxwell has started before your application: 92 | 93 | ```ex 94 | def application do 95 | [applications: [:maxwell]] # also add your adapter(ibrowse, hackney) 96 | end 97 | ``` 98 | 99 | ## Adapters 100 | 101 | Maxwell has support for different adapters that do the actual HTTP request processing. 102 | 103 | ### httpc 104 | 105 | Maxwell has built-in support for the [httpc](http://erlang.org/doc/man/httpc.html) Erlang HTTP client. 106 | 107 | To use it simply place `adapter Maxwell.Adapter.Httpc` in your API client definition. 108 | 109 | ### ibrowse 110 | 111 | Maxwell has built-in support for the [ibrowse](https://github.com/cmullaparthi/ibrowse) Erlang HTTP client. 112 | 113 | To use it simply place `adapter Maxwell.Adapter.Ibrowse` in your API client definition. 114 | 115 | **NOTE**: Remember to include `:ibrowse` in your applications list. 116 | 117 | ### hackney 118 | 119 | Maxwell has built-in support for the [hackney](https://github.com/benoitc/hackney) Erlang HTTP client. 120 | 121 | To use it simply place `adapter Maxwell.Adapter.Hackney` in your API client definition. 122 | 123 | **NOTE**: Remember to include `:hackney` in your applications list. 124 | 125 | ## Built-in Middleware 126 | 127 | ### Maxwell.Middleware.BaseUrl 128 | 129 | Sets the base url for all requests. 130 | 131 | ### Maxwell.Middleware.Headers 132 | 133 | Sets default headers for all requests. 134 | 135 | ### Maxwell.Middleware.HeaderCase 136 | 137 | Enforces that all header keys share a specific casing style, e.g. lower-case, 138 | upper-case, or title-case. 139 | 140 | ### Maxwell.Middleware.Opts 141 | 142 | Sets adapter options for all requests. 143 | 144 | ### Maxwell.Middleware.Rels 145 | 146 | Decodes rel links in the response, and places them in the `:rels` key of the `Maxwell.Conn` struct. 147 | 148 | ### Maxwell.Middleware.Logger 149 | 150 | Logs information about all requests and responses. You can set `:log_level` to log the information at that level. 151 | 152 | ### Maxwell.Middleware.Json 153 | 154 | Encodes all requests as `application/json` and decodes all responses as `application/json`. 155 | 156 | ### Maxwell.Middleware.EncodeJson 157 | 158 | Encodes all requests as `application/json`. 159 | 160 | ### Maxwell.Middleware.DecodeJson 161 | 162 | Decodes all responses as `application/json`. 163 | 164 | **NOTE**: The `*Json` middlewares require [Poison](https://github.com/devinus/poison) as dependency, versions 2.x and 3.x are supported. 165 | You may provide your own encoder/decoder by providing the following options: 166 | 167 | ```ex 168 | # For the EncodeJson module 169 | middleware Maxwell.Middleware.EncodeJson, 170 | encode_content_type: "text/javascript", 171 | encode_func: &other_json_lib.encode/1] 172 | 173 | # For the DecodeJson module 174 | middleware Maxwell.Middleware.DecodeJson, 175 | decode_content_types: ["yourowntype"], 176 | decode_func: &other_json_lib.decode/1] 177 | 178 | # Both sets of options can be provided to the Json module 179 | ``` 180 | 181 | ## Custom Middlewares 182 | 183 | Take a look at the [Maxwell.Middleware](https://github.com/zhongwencool/maxwell/blob/master/lib/maxwell/middleware/middleware.ex) for more information 184 | on the behaviour. For example implementations take a look at any of the middleware modules in the repository. 185 | 186 | ## Contributing 187 | 188 | Contributions are more than welcome! 189 | 190 | Check the issues tracker for anything marked "help wanted", and post a comment that you are planning to begin working on the issue. We can 191 | then provide guidance on implementation if necessary. 192 | 193 | ## License 194 | 195 | See the [LICENSE](https://github.com/zhongwencool/maxwell/blob/master/LICENSE) file for license rights and limitations (MIT). 196 | -------------------------------------------------------------------------------- /lib/maxwell/builder.ex: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Builder do 2 | @moduledoc """ 3 | Conveniences for building maxwell. 4 | 5 | This module can be `use`-d into a module in order to build. 6 | 7 | `Maxwell.Builder` also imports the `Maxwell.Conn` module, making functions like 8 | `get_*`/`put_*` available. 9 | 10 | ## Options 11 | When used, the following options are accepted by `Maxwell.Builder`: 12 | 13 | * `~w(get)a` - only create `get/1` and `get!/1` functions, 14 | 15 | default is `~w(get head delete trace options post put patch)a` 16 | 17 | ## Examples 18 | 19 | use Maxwell.Builder 20 | use Maxwell.Builder, ~w(get put)a 21 | use Maxwell.Builder, ["get", "put"] 22 | use Maxwell.Builder, [:get, :put] 23 | 24 | """ 25 | @http_methods [:get, :head, :delete, :trace, :options, :post, :put, :patch] 26 | @method_without_body [{:get!, :get}, {:head!, :head}, {:trace!, :trace}, {:options!, :options}] 27 | @method_with_body [{:post!, :post}, {:put!, :put}, {:patch!, :patch}, {:delete!, :delete}] 28 | 29 | defmacro __using__(methods) do 30 | methods = 31 | methods 32 | |> Macro.expand(__CALLER__) 33 | |> Maxwell.Builder.Util.serialize_method_to_atom(@http_methods) 34 | 35 | Maxwell.Builder.Util.allow_methods?(methods, @http_methods) 36 | 37 | method_defs = 38 | for {method_exception, method} <- @method_without_body, method in methods do 39 | quote location: :keep do 40 | @doc """ 41 | #{unquote(method) |> to_string |> String.upcase()} http method without request body. 42 | 43 | * `conn` - `%Maxwell.Conn{}` 44 | 45 | Returns `{:ok, %Maxwell.Conn{}}` or `{:error, reason_term, %Maxwell.Conn{}}`. 46 | 47 | """ 48 | def unquote(method)(conn \\ %Maxwell.Conn{}) 49 | 50 | def unquote(method)(conn = %Maxwell.Conn{req_body: nil}) do 51 | %{conn | method: unquote(method)} |> call_middleware 52 | end 53 | 54 | def unquote(method)(conn) do 55 | raise Maxwell.Error, 56 | {__MODULE__, "#{unquote(method)}/1 should not contain body", conn} 57 | end 58 | 59 | @doc """ 60 | #{unquote(method_exception) |> to_string |> String.upcase()} http method without request body. 61 | 62 | * `conn` - see `#{unquote(method)}/1` 63 | 64 | Returns `%Maxwell.Conn{}` or raise `%MaxWell.Error{}` when status not in [200..299]. 65 | 66 | """ 67 | def unquote(method_exception)(conn \\ %Maxwell.Conn{}) 68 | 69 | def unquote(method_exception)(conn) do 70 | case unquote(method)(conn) do 71 | {:ok, %Maxwell.Conn{status: status} = new_conn} when status in 200..299 -> 72 | new_conn 73 | 74 | {:ok, new_conn} -> 75 | raise Maxwell.Error, {__MODULE__, :response_status_not_match, new_conn} 76 | 77 | {:error, reason, new_conn} -> 78 | raise Maxwell.Error, {__MODULE__, reason, new_conn} 79 | end 80 | end 81 | 82 | @doc """ 83 | #{unquote(method_exception) |> to_string |> String.upcase()} http method without request body. 84 | 85 | * `conn` - see `#{unquote(method)}/1` 86 | * `normal_statuses` - the specified status which not raise exception, for example: [200, 201] 87 | 88 | Returns `%Maxwell.Conn{}` or raise `%MaxWell.Error{}`. 89 | 90 | """ 91 | def unquote(method_exception)(conn, normal_statuses) when is_list(normal_statuses) do 92 | case unquote(method)(conn) do 93 | {:ok, %Maxwell.Conn{status: status} = new_conn} -> 94 | unless status in normal_statuses do 95 | raise Maxwell.Error, {__MODULE__, :response_status_not_match, conn} 96 | end 97 | 98 | new_conn 99 | 100 | {:error, reason, new_conn} -> 101 | raise Maxwell.Error, {__MODULE__, reason, new_conn} 102 | end 103 | end 104 | end 105 | end 106 | 107 | method_defs_with_body = 108 | for {method_exception, method} <- @method_with_body, method in methods do 109 | quote location: :keep do 110 | @doc """ 111 | #{unquote(method) |> to_string |> String.upcase()} method. 112 | 113 | * `conn` - `%Maxwell.Conn{}`. 114 | 115 | Returns `{:ok, %Maxwell.Conn{}}` or `{:error, reason, %Maxwell.Conn{}}` 116 | """ 117 | def unquote(method)(conn \\ %Maxwell.Conn{}) 118 | 119 | def unquote(method)(conn = %Maxwell.Conn{}) do 120 | %{conn | method: unquote(method)} |> call_middleware 121 | end 122 | 123 | @doc """ 124 | #{unquote(method_exception) |> to_string |> String.upcase()} http method. 125 | 126 | * `conn` - see `#{unquote(method)}/1` 127 | 128 | Return `%Maxwell.Conn{}` or raise `%Maxwell.Error{}` when status not in [200.299] 129 | """ 130 | def unquote(method_exception)(conn \\ %Maxwell.Conn{}) 131 | 132 | def unquote(method_exception)(conn) do 133 | case unquote(method)(conn) do 134 | {:ok, %Maxwell.Conn{status: status} = new_conn} when status in 200..299 -> 135 | new_conn 136 | 137 | {:ok, new_conn} -> 138 | raise Maxwell.Error, {__MODULE__, :response_status_not_match, new_conn} 139 | 140 | {:error, reason, new_conn} -> 141 | raise Maxwell.Error, {__MODULE__, reason, new_conn} 142 | end 143 | end 144 | 145 | @doc """ 146 | #{unquote(method_exception) |> to_string |> String.upcase()} http method. 147 | 148 | * `conn` - see `#{unquote(method)}/1` 149 | * `normal_statuses` - the specified status which not raise exception, for example: [200, 201] 150 | 151 | Returns `%Maxwell.Conn{}` or raise `%MaxWell.Error{}`. 152 | """ 153 | def unquote(method_exception)(conn, normal_statuses) when is_list(normal_statuses) do 154 | case unquote(method)(conn) do 155 | {:ok, %Maxwell.Conn{status: status} = new_conn} -> 156 | unless status in normal_statuses do 157 | raise Maxwell.Error, {__MODULE__, :response_status_not_match, new_conn} 158 | end 159 | 160 | new_conn 161 | 162 | {:error, reason, new_conn} -> 163 | raise Maxwell.Error, {__MODULE__, reason, new_conn} 164 | end 165 | end 166 | end 167 | end 168 | 169 | quote do 170 | unquote(method_defs) 171 | unquote(method_defs_with_body) 172 | 173 | import Maxwell.Builder.Middleware 174 | import Maxwell.Builder.Adapter 175 | import Maxwell.Conn 176 | 177 | Module.register_attribute(__MODULE__, :middleware, accumulate: true) 178 | @before_compile Maxwell.Builder 179 | end 180 | end 181 | 182 | defp generate_call_adapter(module) do 183 | adapter = Module.get_attribute(module, :adapter) 184 | conn = quote do: conn 185 | adapter_call = quote_adapter_call(adapter, conn) 186 | 187 | quote do 188 | defp call_adapter(unquote(conn)) do 189 | unquote(adapter_call) 190 | end 191 | end 192 | end 193 | 194 | defp generate_call_middleware(module) do 195 | conn = quote do: conn 196 | call_adapter = quote do: call_adapter(unquote(conn)) 197 | middleware = Module.get_attribute(module, :middleware) 198 | 199 | middleware_call = 200 | middleware |> Enum.reduce(call_adapter, "e_middleware_call(conn, &1, &2)) 201 | 202 | quote do 203 | defp call_middleware(unquote(conn)) do 204 | case unquote(middleware_call) do 205 | {:error, _, _} = err -> err 206 | %Maxwell.Conn{} = ok -> {:ok, ok} 207 | end 208 | end 209 | end 210 | end 211 | 212 | defp quote_middleware_call(conn, {mid, args}, acc) do 213 | quote do 214 | unquote(mid).call( 215 | unquote(conn), 216 | fn 217 | {:error, _} = err -> err 218 | {:error, _, _} = err -> err 219 | unquote(conn) -> unquote(acc) 220 | end, 221 | unquote(Macro.escape(args)) 222 | ) 223 | end 224 | end 225 | 226 | defp quote_adapter_call(nil, conn) do 227 | quote do 228 | unquote(Maxwell.Builder.Util.default_adapter()).call(unquote(conn)) 229 | end 230 | end 231 | 232 | defp quote_adapter_call(mod, conn) when is_atom(mod) do 233 | quote do 234 | unquote(mod).call(unquote(conn)) 235 | end 236 | end 237 | 238 | defp quote_adapter_call(_, _) do 239 | raise ArgumentError, "Adapter must be Module" 240 | end 241 | 242 | defmacro __before_compile__(conn) do 243 | [ 244 | generate_call_adapter(conn.module), 245 | generate_call_middleware(conn.module) 246 | ] 247 | end 248 | end 249 | -------------------------------------------------------------------------------- /test/maxwell/adapter/ibrowse_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.IbrowseTest do 2 | use Maxwell.Adapter.TestHelper, adapter: Maxwell.Adapter.Ibrowse 3 | end 4 | 5 | defmodule Maxwell.IbrowseMockTest do 6 | use ExUnit.Case, async: false 7 | use Mimic 8 | import Maxwell.Conn 9 | 10 | defmodule Client do 11 | use Maxwell.Builder 12 | adapter Maxwell.Adapter.Ibrowse 13 | 14 | middleware Maxwell.Middleware.BaseUrl, "http://httpbin.org/" 15 | middleware Maxwell.Middleware.Opts, connect_timeout: 5000 16 | middleware Maxwell.Middleware.Json 17 | 18 | def get_ip_test() do 19 | "/ip" |> new() |> Client.get!() 20 | end 21 | 22 | def encode_decode_json_test(body) do 23 | "post" 24 | |> new() 25 | |> put_req_body(body) 26 | |> post! 27 | |> get_resp_body("json") 28 | end 29 | 30 | def user_agent_test(user_agent) do 31 | "/user-agent" 32 | |> new() 33 | |> put_req_header("user-agent", user_agent) 34 | |> get! 35 | |> get_resp_body("user-agent") 36 | end 37 | 38 | def put_json_test(json) do 39 | "/put" 40 | |> new() 41 | |> put_req_body(json) 42 | |> put! 43 | |> get_resp_body("data") 44 | end 45 | 46 | def delete_test() do 47 | "/delete" 48 | |> new() 49 | |> delete! 50 | |> get_resp_body("data") 51 | end 52 | 53 | def normalized_error_test() do 54 | "http://broken.local" 55 | |> new() 56 | |> get 57 | end 58 | 59 | def timeout_test() do 60 | "/delay/5" 61 | |> new() 62 | |> put_query_string("foo", "bar") 63 | |> put_option(:inactivity_timeout, 1000) 64 | |> Client.get() 65 | end 66 | 67 | def file_test(filepath) do 68 | "/post" 69 | |> new() 70 | |> put_req_body({:file, filepath}) 71 | |> Client.post!() 72 | end 73 | 74 | def file_test(filepath, content_type) do 75 | "/post" 76 | |> new() 77 | |> put_req_body({:file, filepath}) 78 | |> put_req_header("content-type", content_type) 79 | |> Client.post!() 80 | end 81 | 82 | def stream_test() do 83 | "/post" 84 | |> new() 85 | |> put_req_header("content-type", "application/vnd.lotus-1-2-3") 86 | |> put_req_header("content-length", 6) 87 | |> put_req_body(Stream.map(["1", "2", "3"], fn x -> List.duplicate(x, 2) end)) 88 | |> Client.post!() 89 | end 90 | end 91 | 92 | setup do 93 | :rand.seed( 94 | :exs1024, 95 | {:erlang.phash2([node()]), :erlang.monotonic_time(), :erlang.unique_integer()} 96 | ) 97 | 98 | :ok 99 | end 100 | 101 | test "sync request" do 102 | :ibrowse 103 | |> stub(:send_req, fn _, _, _, _, _ -> 104 | {:ok, '200', 105 | [ 106 | {'Server', 'nginx'}, 107 | {'Date', 'Sun, 18 Dec 2016 03:02:14 GMT'}, 108 | {'Content-Type', 'application/json'}, 109 | {'Content-Length', '33'}, 110 | {'Connection', 'keep-alive'}, 111 | {'Access-Control-Allow-Origin', '*'}, 112 | {'Access-Control-Allow-Credentials', 'true'} 113 | ], '{\n "origin": "183.240.20.213"\n}\n'} 114 | end) 115 | 116 | assert Client.get_ip_test() |> Maxwell.Conn.get_status() == 200 117 | end 118 | 119 | test "encode decode json test" do 120 | :ibrowse 121 | |> stub(:send_req, fn _, _, _, _, _ -> 122 | {:ok, '200', 123 | [ 124 | {'Server', 'nginx'}, 125 | {'Date', 'Sun, 18 Dec 2016 03:12:20 GMT'}, 126 | {'Content-Type', 'application/json'}, 127 | {'Content-Length', '383'}, 128 | {'Connection', 'keep-alive'}, 129 | {'Access-Control-Allow-Origin', '*'}, 130 | {'Access-Control-Allow-Credentials', 'true'} 131 | ], 132 | '{\n "args": {}, \n "data": "{\\"josnkey2\\":\\"jsonvalue2\\",\\"josnkey1\\":\\"jsonvalue1\\"}", \n "files": {}, \n "form": {}, \n "headers": {\n "Content-Length": "49", \n "Content-Type": "application/json", \n "Host": "httpbin.org"\n }, \n "json": {\n "josnkey1": "jsonvalue1", \n "josnkey2": "jsonvalue2"\n }, \n "origin": "183.240.20.213", \n "url": "http://httpbin.org/post"\n}\n'} 133 | end) 134 | 135 | result = 136 | %{"josnkey1" => "jsonvalue1", "josnkey2" => "jsonvalue2"} 137 | |> Client.encode_decode_json_test() 138 | 139 | assert result == %{"josnkey1" => "jsonvalue1", "josnkey2" => "jsonvalue2"} 140 | end 141 | 142 | test "send file without content-type" do 143 | :ibrowse 144 | |> stub(:send_req, fn _, _, _, _, _ -> 145 | {:ok, '200', 146 | [ 147 | {'Server', 'nginx'}, 148 | {'Date', 'Sun, 18 Dec 2016 03:16:37 GMT'}, 149 | {'Content-Type', 'application/json'}, 150 | {'Content-Length', '316'}, 151 | {'Connection', 'keep-alive'}, 152 | {'Access-Control-Allow-Origin', '*'}, 153 | {'Access-Control-Allow-Credentials', 'true'} 154 | ], 155 | '{\n "args": {}, \n "data": "#!/usr/bin/env bash\\necho \\"test multipart file\\"\\n", \n "files": {}, \n "form": {}, \n "headers": {\n "Content-Length": "47", \n "Content-Type": "application/x-sh", \n "Host": "httpbin.org"\n }, \n "json": null, \n "origin": "183.240.20.213", \n "url": "http://httpbin.org/post"\n}\n'} 156 | end) 157 | 158 | conn = Client.file_test("test/maxwell/multipart_test_file.sh") 159 | assert get_resp_body(conn, "data") == "#!/usr/bin/env bash\necho \"test multipart file\"\n" 160 | end 161 | 162 | test "send file with content-type" do 163 | :ibrowse 164 | |> stub(:send_req, fn _, _, _, _, _ -> 165 | {:ok, '200', 166 | [ 167 | {'Server', 'nginx'}, 168 | {'Date', 'Sun, 18 Dec 2016 03:17:51 GMT'}, 169 | {'Content-Type', 'application/json'}, 170 | {'Content-Length', '316'}, 171 | {'Connection', 'keep-alive'}, 172 | {'Access-Control-Allow-Origin', '*'}, 173 | {'Access-Control-Allow-Credentials', 'true'} 174 | ], 175 | '{\n "args": {}, \n "data": "#!/usr/bin/env bash\\necho \\"test multipart file\\"\\n", \n "files": {}, \n "form": {}, \n "headers": {\n "Content-Length": "47", \n "Content-Type": "application/x-sh", \n "Host": "httpbin.org"\n }, \n "json": null, \n "origin": "183.240.20.213", \n "url": "http://httpbin.org/post"\n}\n'} 176 | end) 177 | 178 | conn = Client.file_test("test/maxwell/multipart_test_file.sh", "application/x-sh") 179 | assert get_resp_body(conn, "data") == "#!/usr/bin/env bash\necho \"test multipart file\"\n" 180 | end 181 | 182 | test "send stream" do 183 | :ibrowse 184 | |> stub(:send_req, fn _, _, _, _, _ -> 185 | {:ok, '200', 186 | [ 187 | {'Server', 'nginx'}, 188 | {'Date', 'Sun, 18 Dec 2016 03:21:15 GMT'}, 189 | {'Content-Type', 'application/json'}, 190 | {'Content-Length', '283'}, 191 | {'Connection', 'keep-alive'}, 192 | {'Access-Control-Allow-Origin', '*'}, 193 | {'Access-Control-Allow-Credentials', 'true'} 194 | ], 195 | '{\n "args": {}, \n "data": "112233", \n "files": {}, \n "form": {}, \n "headers": {\n "Content-Length": "6", \n "Content-Type": "application/vnd.lotus-1-2-3", \n "Host": "httpbin.org"\n }, \n "json": 112233, \n "origin": "183.240.20.213", \n "url": "http://httpbin.org/post"\n}\n'} 196 | end) 197 | 198 | conn = Client.stream_test() 199 | assert get_resp_body(conn, "data") == "112233" 200 | end 201 | 202 | test "user-agent header test" do 203 | :ibrowse 204 | |> stub(:send_req, fn _, _, _, _, _ -> 205 | {:ok, '200', 206 | [ 207 | {'Server', 'nginx'}, 208 | {'Date', 'Sun, 18 Dec 2016 03:21:57 GMT'}, 209 | {'Content-Type', 'application/json'}, 210 | {'Content-Length', '27'}, 211 | {'Connection', 'keep-alive'}, 212 | {'Access-Control-Allow-Origin', '*'}, 213 | {'Access-Control-Allow-Credentials', 'true'} 214 | ], '{\n "user-agent": "test"\n}\n'} 215 | end) 216 | 217 | assert "test" |> Client.user_agent_test() == "test" 218 | end 219 | 220 | test "/put" do 221 | :ibrowse 222 | |> stub(:send_req, fn _, _, _, _, _ -> 223 | {:ok, '200', 224 | [ 225 | {'Server', 'nginx'}, 226 | {'Date', 'Sun, 18 Dec 2016 03:23:00 GMT'}, 227 | {'Content-Type', 'application/json'}, 228 | {'Content-Length', '303'}, 229 | {'Connection', 'keep-alive'}, 230 | {'Access-Control-Allow-Origin', '*'}, 231 | {'Access-Control-Allow-Credentials', 'true'} 232 | ], 233 | '{\n "args": {}, \n "data": "{\\"key\\":\\"value\\"}", \n "files": {}, \n "form": {}, \n "headers": {\n "Content-Length": "15", \n "Content-Type": "application/json", \n "Host": "httpbin.org"\n }, \n "json": {\n "key": "value"\n }, \n "origin": "183.240.20.213", \n "url": "http://httpbin.org/put"\n}\n'} 234 | end) 235 | 236 | assert %{"key" => "value"} |> Client.put_json_test() == "{\"key\":\"value\"}" 237 | end 238 | 239 | test "/delete" do 240 | :ibrowse 241 | |> stub(:send_req, fn _, _, _, _, _ -> 242 | {:ok, '200', 243 | [ 244 | {'Server', 'nginx'}, 245 | {'Date', 'Sun, 18 Dec 2016 03:24:08 GMT'}, 246 | {'Content-Type', 'application/json'}, 247 | {'Content-Length', '225'}, 248 | {'Connection', 'keep-alive'}, 249 | {'Access-Control-Allow-Origin', '*'}, 250 | {'Access-Control-Allow-Credentials', 'true'} 251 | ], 252 | '{\n "args": {}, \n "data": "", \n "files": {}, \n "form": {}, \n "headers": {\n "Content-Length": "0", \n "Host": "httpbin.org"\n }, \n "json": null, \n "origin": "183.240.20.213", \n "url": "http://httpbin.org/delete"\n}\n'} 253 | end) 254 | 255 | assert Client.delete_test() == "" 256 | end 257 | 258 | test "connection refused errors are normalized" do 259 | :ibrowse 260 | |> stub(:send_req, fn _, _, _, _, _ -> 261 | {:error, {:conn_failed, {:error, :econnrefused}}} 262 | end) 263 | 264 | {:error, :econnrefused, conn} = Client.normalized_error_test() 265 | assert conn.state == :error 266 | end 267 | 268 | test "timeout errors are normalized" do 269 | :ibrowse 270 | |> stub(:send_req, fn _, _, _, _, _ -> 271 | {:error, {:conn_failed, {:error, :timeout}}} 272 | end) 273 | 274 | {:error, :timeout, conn} = Client.normalized_error_test() 275 | assert conn.state == :error 276 | end 277 | 278 | test "internal errors are normalized" do 279 | :ibrowse 280 | |> stub(:send_req, fn _, _, _, _, _ -> 281 | {:error, :somethings_wrong} 282 | end) 283 | 284 | {:error, :somethings_wrong, conn} = Client.normalized_error_test() 285 | assert conn.state == :error 286 | end 287 | 288 | test "adapter return error" do 289 | :ibrowse 290 | |> stub(:send_req, fn _, _, _, _, _ -> 291 | {:error, :req_timedout} 292 | end) 293 | 294 | {:error, :req_timedout, conn} = Client.timeout_test() 295 | assert conn.state == :error 296 | end 297 | end 298 | -------------------------------------------------------------------------------- /test/maxwell/middleware/json_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JsonTest do 2 | use ExUnit.Case 3 | 4 | defmodule ModuleAdapter do 5 | def call(conn) do 6 | conn = %{conn | state: :sent} 7 | 8 | case conn.path do 9 | "/decode" -> 10 | %{ 11 | conn 12 | | status: 200, 13 | resp_headers: %{"content-type" => "application/json"}, 14 | resp_body: "{\"value\": 123}" 15 | } 16 | 17 | "/decode_error" -> 18 | %{ 19 | conn 20 | | status: 200, 21 | resp_headers: %{"content-type" => "application/json"}, 22 | resp_body: "\"123" 23 | } 24 | 25 | "/encode" -> 26 | %{ 27 | conn 28 | | status: 200, 29 | resp_headers: %{"content-type" => "application/json"}, 30 | resp_body: conn.req_body |> String.replace("foo", "baz") 31 | } 32 | 33 | "/empty" -> 34 | %{ 35 | conn 36 | | status: 200, 37 | resp_headers: %{"content-type" => "application/json"}, 38 | resp_body: nil 39 | } 40 | 41 | "/tuple" -> 42 | %{ 43 | conn 44 | | status: 200, 45 | resp_headers: %{"content-type" => "application/json"}, 46 | resp_body: {:key, :value} 47 | } 48 | 49 | "/invalid-content-type" -> 50 | %{ 51 | conn 52 | | status: 200, 53 | resp_headers: %{"content-type" => "text/plain"}, 54 | resp_body: "hello" 55 | } 56 | 57 | "/use-defined-content-type" -> 58 | %{ 59 | conn 60 | | status: 200, 61 | resp_headers: %{"content-type" => "text/html"}, 62 | resp_body: "{\"value\": 124}" 63 | } 64 | 65 | "/not_found_404" -> 66 | %{conn | status: 404, resp_body: "404 Not Found"} 67 | 68 | "/redirection_301" -> 69 | %{conn | status: 301, resp_body: "301 Moved Permanently"} 70 | 71 | "/error" -> 72 | {:error, "hahahaha", conn} 73 | 74 | _ -> 75 | %{conn | status: 200} 76 | end 77 | end 78 | end 79 | 80 | defmodule Client do 81 | use Maxwell.Builder 82 | 83 | middleware Maxwell.Middleware.Json, 84 | encode_func: &Poison.encode/1, 85 | decode_func: &Poison.decode/1, 86 | decode_content_types: ["text/html"], 87 | encode_content_type: "application/json" 88 | 89 | middleware Maxwell.Middleware.Opts, connect_timeout: 3000 90 | 91 | adapter ModuleAdapter 92 | end 93 | 94 | alias Maxwell.Conn 95 | 96 | test "decode JSON body" do 97 | assert Conn.new("/decode") |> Client.get!() |> Conn.get_resp_body() == %{"value" => 123} 98 | end 99 | 100 | test "decode error JSON body" do 101 | conn = 102 | case Conn.new("/decode_error") |> Client.get() do 103 | {:error, {:decode_json_error, :invalid, 4}, conn_tmp1} -> conn_tmp1 104 | {:error, {:decode_json_error, :invalid}, conn_tmp2} -> conn_tmp2 105 | end 106 | 107 | assert conn == %Conn{ 108 | method: :get, 109 | opts: [connect_timeout: 3000], 110 | path: "/decode_error", 111 | query_string: %{}, 112 | req_body: nil, 113 | req_headers: %{}, 114 | resp_body: "\"123", 115 | resp_headers: %{"content-type" => "application/json"}, 116 | state: :sent, 117 | status: 200, 118 | url: "" 119 | } 120 | end 121 | 122 | test "do not decode empty body" do 123 | assert Conn.new("/empty") |> Client.get!() |> Conn.get_resp_body() == nil 124 | end 125 | 126 | test "do not decode tuple body" do 127 | assert Conn.new("/tuple") |> Client.get!() |> Conn.get_resp_body() == {:key, :value} 128 | end 129 | 130 | test "decode only if Content-Type is application/json" do 131 | assert "/invalid-content-type" |> Conn.new() |> Client.get!() |> Conn.get_resp_body() == 132 | "hello" 133 | end 134 | 135 | test "encode body as JSON" do 136 | body = 137 | "/encode" 138 | |> Conn.new() 139 | |> Conn.put_req_body(%{"foo" => "bar"}) 140 | |> Client.post!() 141 | |> Conn.get_resp_body() 142 | 143 | assert body == %{"baz" => "bar"} 144 | end 145 | 146 | test "do not encode tuple body" do 147 | assert Conn.put_req_body(Conn.new(), {:key, :vaule}) |> Client.post!() |> Conn.get_status() == 148 | 200 149 | end 150 | 151 | test "/use-defined-content-type" do 152 | body = 153 | "/use-defined-content-type" 154 | |> Conn.new() 155 | |> Conn.put_req_body(%{"foo" => "bar"}) 156 | |> Client.post!() 157 | |> Conn.get_resp_body() 158 | 159 | assert body == %{"value" => 124} 160 | end 161 | 162 | test "404 NOT FOUND" do 163 | {:ok, conn} = 164 | "/not_found_404" 165 | |> Conn.new() 166 | |> Conn.put_req_body(%{"foo" => "bar"}) 167 | |> Client.post() 168 | 169 | assert Conn.get_status(conn) == 404 170 | end 171 | 172 | test "301 Moved Permanently" do 173 | {:ok, conn} = 174 | "/redirection_301" 175 | |> Conn.new() 176 | |> Conn.put_req_body(%{"foo" => "bar"}) 177 | |> Client.post() 178 | 179 | assert Conn.get_status(conn) == 301 180 | end 181 | 182 | test "error" do 183 | result = 184 | "/error" 185 | |> Conn.new() 186 | |> Conn.put_req_body(%{"foo" => "bar"}) 187 | |> Client.post() 188 | 189 | assert {:error, "hahahaha", %Conn{}} = result 190 | end 191 | end 192 | 193 | defmodule ModuleAdapter2 do 194 | def call(conn) do 195 | %{ 196 | conn 197 | | status: 200, 198 | state: :sent, 199 | resp_headers: %{"content-type" => "text/javascript"}, 200 | resp_body: "{\"value\": 124}" 201 | } 202 | end 203 | end 204 | 205 | defmodule DecodeJsonTest do 206 | use ExUnit.Case 207 | 208 | defmodule Client do 209 | use Maxwell.Builder, ~w(post) 210 | 211 | middleware Maxwell.Middleware.EncodeJson, encode_content_type: "text/javascript" 212 | middleware Maxwell.Middleware.DecodeJson 213 | 214 | adapter ModuleAdapter2 215 | end 216 | 217 | alias Maxwell.Conn 218 | 219 | test "DecodeJsonTest add custom header" do 220 | response = Conn.put_req_body(Conn.new(), %{test: "test"}) |> Client.post!() 221 | assert Conn.get_resp_headers(response) == %{"content-type" => "text/javascript"} 222 | assert Conn.get_status(response) == 200 223 | end 224 | 225 | test "JsonTest with invalid options encode_func" do 226 | assert_raise ArgumentError, "Json Middleware :encode_func only accepts function/1", fn -> 227 | defmodule TAtom1 do 228 | use Maxwell.Builder, [:get, :post] 229 | middleware Maxwell.Middleware.Json, encode_func: :atom 230 | end 231 | 232 | raise "ok" 233 | end 234 | end 235 | 236 | test "JsonTest with invalid options encode_content_type" do 237 | assert_raise ArgumentError, "Json Middleware :encode_content_types only accepts string", fn -> 238 | defmodule TAtom2 do 239 | use Maxwell.Builder, [:get, :post] 240 | middleware Maxwell.Middleware.Json, encode_content_type: :atom 241 | end 242 | 243 | raise "ok" 244 | end 245 | end 246 | 247 | test "JsonTest with invalid options decode_func" do 248 | assert_raise ArgumentError, "Json Middleware :decode_func only accepts function/1", fn -> 249 | defmodule TAtom3 do 250 | use Maxwell.Builder, [:get, :post] 251 | middleware Maxwell.Middleware.Json, decode_func: 123 252 | end 253 | 254 | raise "ok" 255 | end 256 | end 257 | 258 | test "JsonTest with invalid options decode_content_types" do 259 | assert_raise ArgumentError, "Json Middleware :decode_content_types only accepts lists", fn -> 260 | defmodule TAtom4 do 261 | use Maxwell.Builder, [:get, :post] 262 | middleware Maxwell.Middleware.Json, decode_content_types: "application/json" 263 | end 264 | 265 | raise "ok" 266 | end 267 | end 268 | 269 | test "JsonTest with wrong options" do 270 | assert_raise ArgumentError, "Json Middleware Options doesn't accept wrong_option", fn -> 271 | defmodule TAtom5 do 272 | use Maxwell.Builder, [:get, :post] 273 | middleware Maxwell.Middleware.Json, wrong_option: "application/json" 274 | end 275 | 276 | raise "ok" 277 | end 278 | end 279 | 280 | test "EncodeJsonTest with invalid options encode_func " do 281 | assert_raise ArgumentError, "EncodeJson :encode_func only accepts function/1", fn -> 282 | defmodule TAtom6 do 283 | use Maxwell.Builder, [:get, :post] 284 | middleware Maxwell.Middleware.EncodeJson, encode_func: "application/json" 285 | end 286 | 287 | raise "ok" 288 | end 289 | end 290 | 291 | test "EncodeJsonTest with invalid options encode_content_type" do 292 | assert_raise ArgumentError, "EncodeJson :encode_content_types only accepts string", fn -> 293 | defmodule TAtom7 do 294 | use Maxwell.Builder, [:get, :post] 295 | middleware Maxwell.Middleware.EncodeJson, encode_content_type: 1234 296 | end 297 | 298 | raise "ok" 299 | end 300 | end 301 | 302 | test "EncodeJsonTest with wrong option" do 303 | assert_raise ArgumentError, 304 | "EncodeJson Options doesn't accept wrong_option (:encode_func and :encode_content_type)", 305 | fn -> 306 | defmodule TAtom8 do 307 | use Maxwell.Builder, [:get, :post] 308 | middleware Maxwell.Middleware.EncodeJson, wrong_option: 1234 309 | end 310 | 311 | raise "ok" 312 | end 313 | end 314 | 315 | test "DecodeJsonTest with invalid options decode_func " do 316 | assert_raise ArgumentError, "DecodeJson :decode_func only accepts function/1", fn -> 317 | defmodule TAtom9 do 318 | use Maxwell.Builder, [:get, :post] 319 | middleware Maxwell.Middleware.DecodeJson, decode_func: "application/json" 320 | end 321 | 322 | raise "ok" 323 | end 324 | end 325 | 326 | test "DecodeJsonTest with invalid options decode_content_types" do 327 | assert_raise ArgumentError, "DecodeJson :decode_content_types only accepts lists", fn -> 328 | defmodule TAtom10 do 329 | use Maxwell.Builder, [:get, :post] 330 | middleware Maxwell.Middleware.DecodeJson, decode_content_types: 1234 331 | end 332 | 333 | raise "ok" 334 | end 335 | end 336 | 337 | test "DecodeJsonTest with wrong option" do 338 | assert_raise ArgumentError, 339 | "DecodeJson Options doesn't accept wrong_option (:decode_func and :decode_content_types)", 340 | fn -> 341 | defmodule TAtom11 do 342 | use Maxwell.Builder, [:get, :post] 343 | middleware Maxwell.Middleware.DecodeJson, wrong_option: 1234 344 | end 345 | 346 | raise "ok" 347 | end 348 | end 349 | end 350 | -------------------------------------------------------------------------------- /test/maxwell/adapter/hackney_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.HackneyTest do 2 | use Maxwell.Adapter.TestHelper, adapter: Maxwell.Adapter.Hackney 3 | end 4 | 5 | defmodule Maxwell.HackneyMockTest do 6 | use ExUnit.Case, async: false 7 | use Mimic 8 | 9 | import Maxwell.Conn 10 | 11 | defmodule Client do 12 | use Maxwell.Builder 13 | 14 | adapter Maxwell.Adapter.Hackney 15 | 16 | middleware Maxwell.Middleware.BaseUrl, "http://httpbin.org" 17 | middleware Maxwell.Middleware.Opts, connect_timeout: 6000 18 | middleware Maxwell.Middleware.Json 19 | 20 | def get_ip_test do 21 | "ip" |> new() |> get!() 22 | end 23 | 24 | def encode_decode_json_test(body) do 25 | "/post" 26 | |> new() 27 | |> put_req_body(body) 28 | |> post! 29 | |> get_resp_body("json") 30 | end 31 | 32 | def user_agent_test(user_agent) do 33 | "/user-agent" 34 | |> new() 35 | |> put_req_header("user-agent", user_agent) 36 | |> get! 37 | |> get_resp_body("user-agent") 38 | end 39 | 40 | def put_json_test(json) do 41 | "/put" 42 | |> new() 43 | |> put_req_body(json) 44 | |> put! 45 | |> get_resp_body("data") 46 | end 47 | 48 | def delete_test() do 49 | "/delete" 50 | |> new() 51 | |> delete! 52 | |> get_resp_body("data") 53 | end 54 | 55 | def timeout_test() do 56 | "/delay/5" 57 | |> new() 58 | |> put_option(:recv_timeout, 1000) 59 | |> Client.get() 60 | end 61 | 62 | def file_test() do 63 | "/post" 64 | |> new() 65 | |> put_req_body({:file, "test/maxwell/multipart_test_file.sh"}) 66 | |> Client.post!() 67 | end 68 | 69 | def stream_test() do 70 | "/post" 71 | |> new() 72 | |> put_req_body(Stream.map(["1", "2", "3"], fn x -> List.duplicate(x, 2) end)) 73 | |> Client.post!() 74 | end 75 | end 76 | 77 | setup do 78 | :rand.seed( 79 | :exs1024, 80 | {:erlang.phash2([node()]), :erlang.monotonic_time(), :erlang.unique_integer()} 81 | ) 82 | 83 | :ok 84 | end 85 | 86 | test "sync request" do 87 | :hackney 88 | |> stub(:request, fn _, _, _, _, _ -> 89 | {:ok, 200, 90 | [ 91 | {"Server", "nginx"}, 92 | {"Date", "Sun, 18 Dec 2016 03:33:54 GMT"}, 93 | {"Content-Type", "application/json"}, 94 | {"Content-Length", "33"}, 95 | {"Connection", "keep-alive"}, 96 | {"Access-Control-Allow-Origin", "*"}, 97 | {"Access-Control-Allow-Credentials", "true"} 98 | ], make_ref()} 99 | end) 100 | |> stub(:body, fn _ -> {:ok, "{\n \"origin\": \"183.240.20.213\"\n}\n"} end) 101 | 102 | assert Client.get_ip_test() |> get_status == 200 103 | end 104 | 105 | test "encode decode json" do 106 | :hackney 107 | |> stub(:request, fn _, _, _, _, _ -> 108 | {:ok, 200, 109 | [ 110 | {"Server", "nginx"}, 111 | {"Date", "Sun, 18 Dec 2016 03:40:41 GMT"}, 112 | {"Content-Type", "application/json"}, 113 | {"Content-Length", "419"}, 114 | {"Connection", "keep-alive"}, 115 | {"Access-Control-Allow-Origin", "*"}, 116 | {"Access-Control-Allow-Credentials", "true"} 117 | ], make_ref()} 118 | end) 119 | |> stub(:body, fn _ -> 120 | {:ok, 121 | "{\n \"args\": {}, \n \"data\": \"{\\\"josnkey2\\\":\\\"jsonvalue2\\\",\\\"josnkey1\\\":\\\"jsonvalue1\\\"}\", \n \"files\": {}, \n \"form\": {}, \n \"headers\": {\n \"Content-Length\": \"49\", \n \"Content-Type\": \"application/json\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"hackney/1.6.3\"\n }, \n \"json\": {\n \"josnkey1\": \"jsonvalue1\", \n \"josnkey2\": \"jsonvalue2\"\n }, \n \"origin\": \"183.240.20.213\", \n \"url\": \"http://httpbin.org/post\"\n}\n"} 122 | end) 123 | 124 | res = 125 | %{"josnkey1" => "jsonvalue1", "josnkey2" => "jsonvalue2"} 126 | |> Client.encode_decode_json_test() 127 | 128 | assert res == %{"josnkey1" => "jsonvalue1", "josnkey2" => "jsonvalue2"} 129 | end 130 | 131 | test "send file" do 132 | :hackney 133 | |> stub(:request, fn _, _, _, _, _ -> 134 | {:ok, 200, 135 | [ 136 | {"Server", "nginx"}, 137 | {"Date", "Sun, 18 Dec 2016 03:46:14 GMT"}, 138 | {"Content-Type", "application/json"}, 139 | {"Content-Length", "352"}, 140 | {"Connection", "keep-alive"}, 141 | {"Access-Control-Allow-Origin", "*"}, 142 | {"Access-Control-Allow-Credentials", "true"} 143 | ], make_ref()} 144 | end) 145 | |> stub(:body, fn _ -> 146 | {:ok, 147 | "{\n \"args\": {}, \n \"data\": \"#!/usr/bin/env bash\\necho \\\"test multipart file\\\"\\n\", \n \"files\": {}, \n \"form\": {}, \n \"headers\": {\n \"Content-Length\": \"47\", \n \"Content-Type\": \"application/x-sh\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"hackney/1.6.3\"\n }, \n \"json\": null, \n \"origin\": \"183.240.20.213\", \n \"url\": \"http://httpbin.org/post\"\n}\n"} 148 | end) 149 | 150 | conn = Client.file_test() 151 | assert get_resp_body(conn, "data") == "#!/usr/bin/env bash\necho \"test multipart file\"\n" 152 | end 153 | 154 | test "send stream" do 155 | :hackney 156 | |> stub(:request, fn _, _, _, _, _ -> {:ok, make_ref()} end) 157 | |> stub(:send_body, fn _, _ -> :ok end) 158 | |> stub(:start_response, fn _ -> 159 | {:ok, 200, 160 | [ 161 | {"Server", "nginx"}, 162 | {"Date", "Sun, 18 Dec 2016 03:47:26 GMT"}, 163 | {"Content-Type", "application/json"}, 164 | {"Content-Length", "267"}, 165 | {"Connection", "keep-alive"}, 166 | {"Access-Control-Allow-Origin", "*"}, 167 | {"Access-Control-Allow-Credentials", "true"} 168 | ], make_ref()} 169 | end) 170 | |> stub(:body, fn _ -> 171 | {:ok, 172 | "{\n \"args\": {}, \n \"data\": \"112233\", \n \"files\": {}, \n \"form\": {}, \n \"headers\": {\n \"Content-Length\": \"6\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"hackney/1.6.3\"\n }, \n \"json\": 112233, \n \"origin\": \"183.240.20.213\", \n \"url\": \"http://httpbin.org/post\"\n}\n"} 173 | end) 174 | 175 | conn = Client.stream_test() 176 | assert get_resp_body(conn, "data") == "112233" 177 | end 178 | 179 | test "send stream error" do 180 | :hackney 181 | |> stub(:request, fn _, _, _, _, _ -> {:error, :closed} end) 182 | |> stub(:send_body, fn _, _ -> {:error, :closed} end) 183 | |> stub(:start_response, fn _ -> {:ok, 200, [], make_ref()} end) 184 | |> stub(:body, fn _ -> {:ok, "error connection closed"} end) 185 | 186 | assert_raise( 187 | Maxwell.Error, 188 | "url: http://httpbin.org\npath: \"/post\"\nmethod: post\nstatus: \nreason: :closed\nmodule: Elixir.Maxwell.HackneyMockTest.Client\n", 189 | fn -> Client.stream_test() |> get_resp_body("data") end 190 | ) 191 | end 192 | 193 | test "user-agent header test" do 194 | :hackney 195 | |> stub(:request, fn _, _, _, _, _ -> 196 | {:ok, 200, 197 | [ 198 | {"Server", "nginx"}, 199 | {"Date", "Sun, 18 Dec 2016 03:52:41 GMT"}, 200 | {"Content-Type", "application/json"}, 201 | {"Content-Length", "27"}, 202 | {"Connection", "keep-alive"}, 203 | {"Access-Control-Allow-Origin", "*"}, 204 | {"Access-Control-Allow-Credentials", "true"} 205 | ], make_ref()} 206 | end) 207 | |> stub(:body, fn _ -> 208 | {:ok, "{\n \"user-agent\": \"test\"\n}\n"} 209 | end) 210 | 211 | assert "test" |> Client.user_agent_test() == "test" 212 | end 213 | 214 | test "/put" do 215 | :hackney 216 | |> stub(:request, fn _, _, _, _, _ -> 217 | {:ok, 200, 218 | [ 219 | {"Server", "nginx"}, 220 | {"Date", "Sun, 18 Dec 2016 03:54:56 GMT"}, 221 | {"Content-Type", "application/json"}, 222 | {"Content-Length", "339"}, 223 | {"Connection", "keep-alive"}, 224 | {"Access-Control-Allow-Origin", "*"}, 225 | {"Access-Control-Allow-Credentials", "true"} 226 | ], make_ref()} 227 | end) 228 | |> stub(:body, fn _ -> 229 | {:ok, 230 | "{\n \"args\": {}, \n \"data\": \"{\\\"key\\\":\\\"value\\\"}\", \n \"files\": {}, \n \"form\": {}, \n \"headers\": {\n \"Content-Length\": \"15\", \n \"Content-Type\": \"application/json\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"hackney/1.6.3\"\n }, \n \"json\": {\n \"key\": \"value\"\n }, \n \"origin\": \"183.240.20.213\", \n \"url\": \"http://httpbin.org/put\"\n}\n"} 231 | end) 232 | 233 | assert %{"key" => "value"} |> Client.put_json_test() == "{\"key\":\"value\"}" 234 | end 235 | 236 | test "/delete" do 237 | :hackney 238 | |> stub(:request, fn _, _, _, _, _ -> 239 | {:ok, 200, 240 | [ 241 | {"Server", "nginx"}, 242 | {"Date", "Sun, 18 Dec 2016 03:53:52 GMT"}, 243 | {"Content-Type", "application/json"}, 244 | {"Content-Length", "233"}, 245 | {"Connection", "keep-alive"}, 246 | {"Access-Control-Allow-Origin", "*"}, 247 | {"Access-Control-Allow-Credentials", "true"} 248 | ], make_ref()} 249 | end) 250 | |> stub(:body, fn _ -> 251 | {:ok, 252 | "{\n \"args\": {}, \n \"data\": \"\", \n \"files\": {}, \n \"form\": {}, \n \"headers\": {\n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"hackney/1.6.3\"\n }, \n \"json\": null, \n \"origin\": \"183.240.20.213\", \n \"url\": \"http://httpbin.org/delete\"\n}\n"} 253 | end) 254 | 255 | assert Client.delete_test() == "" 256 | end 257 | 258 | test "/delete error" do 259 | :hackney 260 | |> stub(:request, fn _, _, _, _, _ -> 261 | {:ok, 200, 262 | [ 263 | {"Server", "nginx"}, 264 | {"Date", "Sun, 18 Dec 2016 03:53:52 GMT"}, 265 | {"Content-Type", "application/json"}, 266 | {"Content-Length", "233"}, 267 | {"Connection", "keep-alive"}, 268 | {"Access-Control-Allow-Origin", "*"}, 269 | {"Access-Control-Allow-Credentials", "true"} 270 | ], make_ref()} 271 | end) 272 | |> stub(:body, fn _ -> {:error, {:closed, ""}} end) 273 | 274 | assert_raise( 275 | Maxwell.Error, 276 | "url: http://httpbin.org\npath: \"/delete\"\nmethod: delete\nstatus: \nreason: {:closed, \"\"}\nmodule: Elixir.Maxwell.HackneyMockTest.Client\n", 277 | fn -> Client.delete_test() end 278 | ) 279 | end 280 | 281 | test "adapter return error" do 282 | :hackney 283 | |> stub(:request, fn _, _, _, _, _ -> {:error, :timeout} end) 284 | 285 | {:error, :timeout, conn} = Client.timeout_test() 286 | assert conn.state == :error 287 | end 288 | 289 | test "Head without body(test hackney.ex return {:ok, status, header})" do 290 | :hackney 291 | |> stub(:request, fn _, _, _, _, _ -> 292 | {:ok, 200, 293 | [ 294 | {"Server", "nginx"}, 295 | {"Date", "Sun, 18 Dec 2016 03:57:09 GMT"}, 296 | {"Content-Type", "text/html; charset=utf-8"}, 297 | {"Content-Length", "12150"}, 298 | {"Connection", "keep-alive"}, 299 | {"Access-Control-Allow-Origin", "*"}, 300 | {"Access-Control-Allow-Credentials", "true"} 301 | ]} 302 | end) 303 | 304 | assert Client.head!() |> get_resp_body == "" 305 | end 306 | end 307 | -------------------------------------------------------------------------------- /test/maxwell/adapter/httpc_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.HttpcTest do 2 | use Maxwell.Adapter.TestHelper, adapter: Maxwell.Adapter.Httpc 3 | end 4 | 5 | defmodule Maxwell.HttpcMockTest do 6 | use ExUnit.Case, async: false 7 | import Maxwell.Conn 8 | use Mimic 9 | 10 | defmodule Client do 11 | use Maxwell.Builder 12 | adapter Maxwell.Adapter.Httpc 13 | 14 | middleware Maxwell.Middleware.BaseUrl, "http://httpbin.org" 15 | middleware Maxwell.Middleware.Opts, connect_timeout: 5000 16 | middleware Maxwell.Middleware.Json 17 | 18 | def get_ip_test() do 19 | Client.get!(new("/ip")) 20 | end 21 | 22 | def encode_decode_json_test(body) do 23 | new("/post") 24 | |> put_req_body(body) 25 | |> post! 26 | |> get_resp_body("json") 27 | end 28 | 29 | def user_agent_test(user_agent) do 30 | new("/user-agent") 31 | |> put_req_header("user-agent", user_agent) 32 | |> get! 33 | |> get_resp_body("user-agent") 34 | end 35 | 36 | def put_json_test(json) do 37 | new("/put") 38 | |> put_req_body(json) 39 | |> put! 40 | |> get_resp_body("data") 41 | end 42 | 43 | def delete_test() do 44 | new("/delete") 45 | |> delete! 46 | |> get_resp_body("data") 47 | end 48 | 49 | def normalized_error_test() do 50 | get(new("http://broken.local")) 51 | end 52 | 53 | def timeout_test() do 54 | new("/delay/2") 55 | |> put_option(:timeout, 1000) 56 | |> Client.get() 57 | end 58 | 59 | def file_test(filepath) do 60 | new("/post") 61 | |> put_req_body({:file, filepath}) 62 | |> Client.post!() 63 | end 64 | 65 | def file_test(filepath, content_type) do 66 | new("/post") 67 | |> put_req_body({:file, filepath}) 68 | |> put_req_header("content-type", content_type) 69 | |> Client.post!() 70 | end 71 | 72 | def stream_test() do 73 | new("/post") 74 | |> put_req_header("content-type", "application/vnd.lotus-1-2-3") 75 | |> put_req_header("content-length", 6) 76 | |> put_req_body(Stream.map(["1", "2", "3"], fn x -> List.duplicate(x, 2) end)) 77 | |> Client.post!() 78 | end 79 | end 80 | 81 | setup do 82 | :rand.seed( 83 | :exs1024, 84 | {:erlang.phash2([node()]), :erlang.monotonic_time(), :erlang.unique_integer()} 85 | ) 86 | 87 | :ok 88 | end 89 | 90 | test "sync request" do 91 | :httpc 92 | |> stub(:request, fn _, _, _, _ -> 93 | {:ok, 94 | {{'HTTP/1.1', 200, 'OK'}, 95 | [ 96 | {'connection', 'keep-alive'}, 97 | {'date', 'Sun, 18 Dec 2016 07:05:33 GMT'}, 98 | {'server', 'nginx'}, 99 | {'content-length', '383'}, 100 | {'content-type', 'application/json'}, 101 | {'access-control-allow-origin', '*'}, 102 | {'access-control-allow-credentials', 'true'} 103 | ], 104 | '{\n "args": {}, \n "data": "{\\"josnkey2\\":\\"jsonvalue2\\",\\"josnkey1\\":\\"jsonvalue1\\"}", \n "files": {}, \n "form": {}, \n "headers": {\n "Content-Length": "49", \n "Content-Type": "application/json", \n "Host": "httpbin.org"\n }, \n "json": {\n "josnkey1": "jsonvalue1", \n "josnkey2": "jsonvalue2"\n }, \n "origin": "183.240.20.213", \n "url": "http://httpbin.org/post"\n}\n'}} 105 | end) 106 | 107 | assert Client.get_ip_test() |> Maxwell.Conn.get_status() == 200 108 | end 109 | 110 | test "encode decode json test" do 111 | :httpc 112 | |> stub(:request, fn _, _, _, _ -> 113 | {:ok, 114 | {{'HTTP/1.1', 200, 'OK'}, 115 | [ 116 | {'connection', 'keep-alive'}, 117 | {'date', 'Sun, 18 Dec 2016 07:09:37 GMT'}, 118 | {'server', 'nginx'}, 119 | {'content-length', '383'}, 120 | {'content-type', 'application/json'}, 121 | {'access-control-allow-origin', '*'}, 122 | {'access-control-allow-credentials', 'true'} 123 | ], 124 | '{\n "args": {}, \n "data": "{\\"josnkey2\\":\\"jsonvalue2\\",\\"josnkey1\\":\\"jsonvalue1\\"}", \n "files": {}, \n "form": {}, \n "headers": {\n "Content-Length": "49", \n "Content-Type": "application/json", \n "Host": "httpbin.org"\n }, \n "json": {\n "josnkey1": "jsonvalue1", \n "josnkey2": "jsonvalue2"\n }, \n "origin": "183.240.20.213", \n "url": "http://httpbin.org/post"\n}\n'}} 125 | end) 126 | 127 | result = 128 | %{"josnkey1" => "jsonvalue1", "josnkey2" => "jsonvalue2"} 129 | |> Client.encode_decode_json_test() 130 | 131 | assert result == %{"josnkey1" => "jsonvalue1", "josnkey2" => "jsonvalue2"} 132 | end 133 | 134 | test "send file without content-type" do 135 | :httpc 136 | |> stub(:request, fn _, _, _, _ -> 137 | {:ok, 138 | {{'HTTP/1.1', 200, 'OK'}, 139 | [ 140 | {'connection', 'keep-alive'}, 141 | {'date', 'Sun, 18 Dec 2016 07:22:05 GMT'}, 142 | {'server', 'nginx'}, 143 | {'content-length', '316'}, 144 | {'content-type', 'application/json'}, 145 | {'access-control-allow-origin', '*'}, 146 | {'access-control-allow-credentials', 'true'} 147 | ], 148 | '{\n "args": {}, \n "data": "#!/usr/bin/env bash\\necho \\"test multipart file\\"\\n", \n "files": {}, \n "form": {}, \n "headers": {\n "Content-Length": "47", \n "Content-Type": "application/x-sh", \n "Host": "httpbin.org"\n }, \n "json": null, \n "origin": "183.240.20.213", \n "url": "http://httpbin.org/post"\n}\n'}} 149 | end) 150 | 151 | conn = Client.file_test("test/maxwell/multipart_test_file.sh") 152 | assert get_resp_body(conn, "data") == "#!/usr/bin/env bash\necho \"test multipart file\"\n" 153 | end 154 | 155 | test "send file with content-type" do 156 | :httpc 157 | |> stub(:request, fn _, _, _, _ -> 158 | {:ok, 159 | {{'HTTP/1.1', 200, 'OK'}, 160 | [ 161 | {'connection', 'keep-alive'}, 162 | {'date', 'Sun, 18 Dec 2016 07:24:17 GMT'}, 163 | {'server', 'nginx'}, 164 | {'content-length', '316'}, 165 | {'content-type', 'application/json'}, 166 | {'access-control-allow-origin', '*'}, 167 | {'access-control-allow-credentials', 'true'} 168 | ], 169 | '{\n "args": {}, \n "data": "#!/usr/bin/env bash\\necho \\"test multipart file\\"\\n", \n "files": {}, \n "form": {}, \n "headers": {\n "Content-Length": "47", \n "Content-Type": "application/x-sh", \n "Host": "httpbin.org"\n }, \n "json": null, \n "origin": "183.240.20.213", \n "url": "http://httpbin.org/post"\n}\n'}} 170 | end) 171 | 172 | conn = Client.file_test("test/maxwell/multipart_test_file.sh", "application/x-sh") 173 | assert get_resp_body(conn, "data") == "#!/usr/bin/env bash\necho \"test multipart file\"\n" 174 | end 175 | 176 | test "send stream" do 177 | :httpc 178 | |> stub(:request, fn _, _, _, _ -> 179 | {:ok, 180 | {{'HTTP/1.1', 200, 'OK'}, 181 | [ 182 | {'connection', 'keep-alive'}, 183 | {'date', 'Sun, 18 Dec 2016 07:28:25 GMT'}, 184 | {'server', 'nginx'}, 185 | {'content-length', '283'}, 186 | {'content-type', 'application/json'}, 187 | {'access-control-allow-origin', '*'}, 188 | {'access-control-allow-credentials', 'true'} 189 | ], 190 | '{\n "args": {}, \n "data": "112233", \n "files": {}, \n "form": {}, \n "headers": {\n "Content-Length": "6", \n "Content-Type": "application/vnd.lotus-1-2-3", \n "Host": "httpbin.org"\n }, \n "json": 112233, \n "origin": "183.240.20.213", \n "url": "http://httpbin.org/post"\n}\n'}} 191 | end) 192 | 193 | conn = Client.stream_test() 194 | assert get_resp_body(conn, "data") == "112233" 195 | end 196 | 197 | test "user-agent header test" do 198 | :httpc 199 | |> stub(:request, fn _, _, _, _ -> 200 | {:ok, 201 | {{'HTTP/1.1', 200, 'OK'}, 202 | [ 203 | {'connection', 'keep-alive'}, 204 | {'date', 'Sun, 18 Dec 2016 07:30:00 GMT'}, 205 | {'server', 'nginx'}, 206 | {'content-length', '27'}, 207 | {'content-type', 'application/json'}, 208 | {'access-control-allow-origin', '*'}, 209 | {'access-control-allow-credentials', 'true'} 210 | ], '{\n "user-agent": "test"\n}\n'}} 211 | end) 212 | 213 | assert "test" |> Client.user_agent_test() == "test" 214 | end 215 | 216 | test "/put" do 217 | :httpc 218 | |> stub(:request, fn _, _, _, _ -> 219 | {:ok, 220 | {{'HTTP/1.1', 200, 'OK'}, 221 | [ 222 | {'connection', 'keep-alive'}, 223 | {'date', 'Sun, 18 Dec 2016 07:30:30 GMT'}, 224 | {'server', 'nginx'}, 225 | {'content-length', '303'}, 226 | {'content-type', 'application/json'}, 227 | {'access-control-allow-origin', '*'}, 228 | {'access-control-allow-credentials', 'true'} 229 | ], 230 | '{\n "args": {}, \n "data": "{\\"key\\":\\"value\\"}", \n "files": {}, \n "form": {}, \n "headers": {\n "Content-Length": "15", \n "Content-Type": "application/json", \n "Host": "httpbin.org"\n }, \n "json": {\n "key": "value"\n }, \n "origin": "183.240.20.213", \n "url": "http://httpbin.org/put"\n}\n'}} 231 | end) 232 | 233 | assert %{"key" => "value"} |> Client.put_json_test() == "{\"key\":\"value\"}" 234 | end 235 | 236 | test "/delete" do 237 | :httpc 238 | |> stub(:request, fn _, _, _, _ -> 239 | {:ok, 240 | {{'HTTP/1.1', 200, 'OK'}, 241 | [ 242 | {'connection', 'keep-alive'}, 243 | {'date', 'Sun, 18 Dec 2016 07:31:04 GMT'}, 244 | {'server', 'nginx'}, 245 | {'content-length', '225'}, 246 | {'content-type', 'application/json'}, 247 | {'access-control-allow-origin', '*'}, 248 | {'access-control-allow-credentials', 'true'} 249 | ], 250 | '{\n "args": {}, \n "data": "", \n "files": {}, \n "form": {}, \n "headers": {\n "Content-Length": "0", \n "Host": "httpbin.org"\n }, \n "json": null, \n "origin": "183.240.20.213", \n "url": "http://httpbin.org/delete"\n}\n'}} 251 | end) 252 | 253 | assert Client.delete_test() == "" 254 | end 255 | 256 | test "connection refused error is normalized" do 257 | :httpc 258 | |> stub(:request, fn _, _, _, _ -> 259 | {:error, {:failed_connect, [{:inet, [], :econnrefused}]}} 260 | end) 261 | 262 | {:error, :econnrefused, conn} = Client.normalized_error_test() 263 | assert conn.state == :error 264 | end 265 | 266 | test "timeout error is normalized" do 267 | :httpc 268 | |> stub(:request, fn _, _, _, _ -> 269 | {:error, {:failed_connect, [{:inet, [], :timeout}]}} 270 | end) 271 | 272 | {:error, :timeout, conn} = Client.normalized_error_test() 273 | assert conn.state == :error 274 | end 275 | 276 | test "unrecognized connection failed error is normalized" do 277 | :httpc 278 | |> stub(:request, fn _, _, _, _ -> 279 | {:error, {:failed_connect, [{:tcp, [], :i_made_this_up}]}} 280 | end) 281 | 282 | {:error, {:failed_connect, [{:tcp, [], :i_made_this_up}]}, conn} = 283 | Client.normalized_error_test() 284 | 285 | assert conn.state == :error 286 | end 287 | 288 | test "internal error is normalized" do 289 | :httpc 290 | |> stub(:request, fn _, _, _, _ -> 291 | {:error, :internal} 292 | end) 293 | 294 | {:error, :internal, conn} = Client.normalized_error_test() 295 | assert conn.state == :error 296 | end 297 | 298 | test "adapter return error" do 299 | :httpc 300 | |> stub(:request, fn _, _, _, _ -> 301 | {:error, :timeout} 302 | end) 303 | 304 | {:error, :timeout, conn} = Client.timeout_test() 305 | assert conn.state == :error 306 | end 307 | end 308 | -------------------------------------------------------------------------------- /test/maxwell/conn_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConnTest do 2 | use ExUnit.Case 3 | 4 | import Maxwell.Conn 5 | import ExUnit.CaptureIO 6 | alias Maxwell.Conn 7 | alias Maxwell.Conn.AlreadySentError 8 | alias Maxwell.Conn.NotSentError 9 | 10 | test "new/0" do 11 | assert new() == %Conn{} 12 | end 13 | 14 | test "new/1" do 15 | assert new("localhost") == %Conn{url: "http://localhost"} 16 | assert new("localhost:8080") == %Conn{url: "http://localhost:8080"} 17 | assert new("example") == %Conn{path: "/example"} 18 | assert new("example.com") == %Conn{url: "http://example.com"} 19 | assert new("example.com:8080") == %Conn{url: "http://example.com:8080"} 20 | assert new("http://example.com") == %Conn{url: "http://example.com"} 21 | assert new("https://example.com") == %Conn{url: "https://example.com"} 22 | assert new("https://example.com:8080") == %Conn{url: "https://example.com:8080"} 23 | assert new("http://example.com/foo") == %Conn{url: "http://example.com", path: "/foo"} 24 | 25 | assert new("http://example.com:8080/foo") == %Conn{ 26 | url: "http://example.com:8080", 27 | path: "/foo" 28 | } 29 | 30 | assert new("http://user:pass@example.com:8080/foo") == %Conn{ 31 | url: "http://user:pass@example.com:8080", 32 | path: "/foo" 33 | } 34 | 35 | assert new("http://example.com/foo?version=1") == %Conn{ 36 | url: "http://example.com", 37 | path: "/foo", 38 | query_string: %{"version" => "1"} 39 | } 40 | 41 | assert new("http://example.com/foo?ids[]=1&ids[]=2") == %Conn{ 42 | url: "http://example.com", 43 | path: "/foo", 44 | query_string: %{"ids" => ["1", "2"]} 45 | } 46 | 47 | assert new("http://example.com/foo?ids[foo]=1") == %Conn{ 48 | url: "http://example.com", 49 | path: "/foo", 50 | query_string: %{"ids" => %{"foo" => "1"}} 51 | } 52 | end 53 | 54 | test "deprecated: put_path/1" do 55 | assert capture_io(:stderr, fn -> 56 | assert put_path("/login") == %Conn{state: :unsent, path: "/login"} 57 | end) =~ "deprecated" 58 | end 59 | 60 | test "put_path/2 test" do 61 | assert put_path(%Conn{state: :unsent}, "/login") == %Conn{state: :unsent, path: "/login"} 62 | 63 | assert_raise AlreadySentError, "the request was already sent", fn -> 64 | put_path(%Conn{state: :sent}, "/login") 65 | end 66 | end 67 | 68 | test "deprecated: put_query_string/1" do 69 | assert capture_io(:stderr, fn -> 70 | assert put_query_string(%{"name" => "foo", "passwd" => "123"}) == 71 | %Conn{state: :unsent, query_string: %{"name" => "foo", "passwd" => "123"}} 72 | end) =~ "deprecated" 73 | end 74 | 75 | test "put_query_string/2" do 76 | assert put_query_string(new(), %{"name" => "foo", "passwd" => "123"}) == 77 | %Conn{state: :unsent, query_string: %{"name" => "foo", "passwd" => "123"}} 78 | 79 | assert_raise AlreadySentError, "the request was already sent", fn -> 80 | put_query_string(%Conn{state: :sent}, %{"name" => "foo"}) 81 | end 82 | end 83 | 84 | test "put_query_string/3" do 85 | assert put_query_string(%Conn{}, "name", "foo") == %Conn{ 86 | state: :unsent, 87 | query_string: %{"name" => "foo"} 88 | } 89 | 90 | assert put_query_string(%Conn{state: :unsent}, "name", "foo") == %Conn{ 91 | state: :unsent, 92 | query_string: %{"name" => "foo"} 93 | } 94 | 95 | assert_raise AlreadySentError, "the request was already sent", fn -> 96 | put_query_string(%Conn{state: :sent}, "name", "foo") 97 | end 98 | end 99 | 100 | test "put_req_headers/2" do 101 | assert put_req_headers(new(), %{ 102 | "cache-control" => "no-cache", 103 | "ETag" => "rFjdsDtv2qxk7K1CwG4VMlF836E=" 104 | }) == 105 | %Conn{ 106 | state: :unsent, 107 | req_headers: %{ 108 | "cache-control" => "no-cache", 109 | "etag" => "rFjdsDtv2qxk7K1CwG4VMlF836E=" 110 | } 111 | } 112 | 113 | assert put_req_headers(new(), %{ 114 | "cache-control" => "no-cache", 115 | "ETag" => "rFjdsDtv2qxk7K1CwG4VMlF836E=" 116 | }) == 117 | %Conn{ 118 | state: :unsent, 119 | req_headers: %{ 120 | "cache-control" => "no-cache", 121 | "etag" => "rFjdsDtv2qxk7K1CwG4VMlF836E=" 122 | } 123 | } 124 | 125 | assert_raise AlreadySentError, "the request was already sent", fn -> 126 | put_req_headers(%Conn{state: :sent}, %{"cache-control" => "no-cache"}) 127 | end 128 | end 129 | 130 | test "deprecated: put_req_header/1" do 131 | assert capture_io(:stderr, fn -> 132 | assert put_req_header(%{ 133 | "cache-control" => "no-cache", 134 | "ETag" => "rFjdsDtv2qxk7K1CwG4VMlF836E=" 135 | }) == 136 | %Conn{ 137 | state: :unsent, 138 | req_headers: %{ 139 | "cache-control" => "no-cache", 140 | "etag" => "rFjdsDtv2qxk7K1CwG4VMlF836E=" 141 | } 142 | } 143 | end) =~ "deprecated" 144 | end 145 | 146 | test "deprecated: put_req_header/2" do 147 | assert capture_io(:stderr, fn -> 148 | assert put_req_header(new(), %{ 149 | "cache-control" => "no-cache", 150 | "ETag" => "rFjdsDtv2qxk7K1CwG4VMlF836E=" 151 | }) == 152 | %Conn{ 153 | state: :unsent, 154 | req_headers: %{ 155 | "cache-control" => "no-cache", 156 | "etag" => "rFjdsDtv2qxk7K1CwG4VMlF836E=" 157 | } 158 | } 159 | 160 | assert put_req_header(new(), %{ 161 | "cache-control" => "no-cache", 162 | "ETag" => "rFjdsDtv2qxk7K1CwG4VMlF836E=" 163 | }) == 164 | %Conn{ 165 | state: :unsent, 166 | req_headers: %{ 167 | "cache-control" => "no-cache", 168 | "etag" => "rFjdsDtv2qxk7K1CwG4VMlF836E=" 169 | } 170 | } 171 | 172 | assert_raise AlreadySentError, "the request was already sent", fn -> 173 | put_req_header(%Conn{state: :sent}, %{"cache-control" => "no-cache"}) 174 | end 175 | end) =~ "deprecated" 176 | end 177 | 178 | test "put_req_header/3" do 179 | assert put_req_header(new(), "cache-control", "no-cache") 180 | 181 | assert put_req_header(new(), "cache-control", "no-cache") == 182 | %Conn{state: :unsent, req_headers: %{"cache-control" => "no-cache"}} 183 | 184 | assert_raise AlreadySentError, "the request was already sent", fn -> 185 | put_req_header(%Conn{state: :sent}, "cache-control", "no-cache") 186 | end 187 | end 188 | 189 | test "put_options/2" do 190 | conn = put_options(%Conn{state: :unsent, opts: [max_attempts: 3]}, connect_timeout: 3000) 191 | assert Keyword.equal?(conn.opts, connect_timeout: 3000, max_attempts: 3) 192 | 193 | assert_raise AlreadySentError, "the request was already sent", fn -> 194 | put_options(%Conn{state: :sent}, connect_timeout: 3000) 195 | end 196 | end 197 | 198 | test "deprecated: put_option/1" do 199 | assert capture_io(:stderr, fn -> 200 | conn = put_option(connect_timeout: 3000) 201 | assert Keyword.equal?(conn.opts, connect_timeout: 3000) 202 | end) =~ "deprecated" 203 | end 204 | 205 | test "deprecated: put_option/2" do 206 | assert capture_io(:stderr, fn -> 207 | conn = 208 | put_option(%Conn{state: :unsent, opts: [max_attempts: 3]}, connect_timeout: 3000) 209 | 210 | assert Keyword.equal?(conn.opts, connect_timeout: 3000, max_attempts: 3) 211 | 212 | assert_raise AlreadySentError, "the request was already sent", fn -> 213 | put_option(%Conn{state: :sent}, connect_timeout: 3000) 214 | end 215 | end) =~ "deprecated" 216 | end 217 | 218 | test "put_option/3" do 219 | conn0 = put_option(%Conn{state: :unsent, opts: [max_attempts: 3]}, :connect_timeout, 3000) 220 | assert Keyword.equal?(conn0.opts, connect_timeout: 3000, max_attempts: 3) 221 | conn1 = put_option(%Conn{state: :unsent, opts: [max_attempts: 3]}, :connect_timeout, 3000) 222 | assert Keyword.equal?(conn1.opts, connect_timeout: 3000, max_attempts: 3) 223 | 224 | assert_raise AlreadySentError, "the request was already sent", fn -> 225 | put_option(%Conn{state: :sent}, :connect_timeout, 3000) 226 | end 227 | end 228 | 229 | test "deprecated put_req_body/1" do 230 | assert capture_io(:stderr, fn -> 231 | assert put_req_body("new") == %Conn{state: :unsent, req_body: "new"} 232 | end) =~ "deprecated" 233 | end 234 | 235 | test "put_req_body/2 test" do 236 | assert put_req_body(%Conn{state: :unsent, req_body: "old"}, "new") == %Conn{ 237 | state: :unsent, 238 | req_body: "new" 239 | } 240 | 241 | assert_raise AlreadySentError, "the request was already sent", fn -> 242 | put_req_body(%Conn{state: :sent}, "new") 243 | end 244 | end 245 | 246 | test "get_status/1 test" do 247 | assert get_status(%Conn{state: :sent, status: 200}) == 200 248 | 249 | assert_raise NotSentError, "the request was not sent yet", fn -> 250 | get_status(%Conn{state: :unsent}) 251 | end 252 | end 253 | 254 | test "get_resp_headers/1" do 255 | assert get_resp_headers(%Conn{state: :sent, resp_headers: %{"server" => "Microsoft-IIS/8.5"}}) == 256 | %{"server" => "Microsoft-IIS/8.5"} 257 | 258 | assert_raise NotSentError, "the request was not sent yet", fn -> 259 | get_resp_headers(%Conn{state: :unsent}) 260 | end 261 | end 262 | 263 | test "deprecated: get_resp_header/1 and get_resp_header/2 with nil key" do 264 | assert capture_io(:stderr, fn -> 265 | assert get_resp_header(%Conn{ 266 | state: :sent, 267 | resp_headers: %{"server" => "Microsoft-IIS/8.5"} 268 | }) == 269 | %{"server" => "Microsoft-IIS/8.5"} 270 | 271 | assert get_resp_header( 272 | %Conn{state: :sent, resp_headers: %{"server" => "Microsoft-IIS/8.5"}}, 273 | nil 274 | ) == 275 | %{"server" => "Microsoft-IIS/8.5"} 276 | 277 | assert_raise NotSentError, "the request was not sent yet", fn -> 278 | get_resp_header(%Conn{state: :unsent}) 279 | end 280 | end) =~ "deprecated" 281 | end 282 | 283 | test "get_resp_header/2" do 284 | assert get_resp_header( 285 | %Conn{state: :sent, resp_headers: %{"server" => "Microsoft-IIS/8.5"}}, 286 | "Server" 287 | ) == 288 | "Microsoft-IIS/8.5" 289 | 290 | assert get_resp_header( 291 | %Conn{state: :sent, resp_headers: %{"server" => "Microsoft-IIS/8.5"}}, 292 | "Server1" 293 | ) == 294 | nil 295 | 296 | assert_raise NotSentError, "the request was not sent yet", fn -> 297 | get_resp_header(%Conn{state: :unsent}, "Server") 298 | end 299 | end 300 | 301 | test "get_req_headers/1" do 302 | assert get_req_headers(%Conn{req_headers: %{"server" => "Microsoft-IIS/8.5"}}) == 303 | %{"server" => "Microsoft-IIS/8.5"} 304 | end 305 | 306 | test "deprecated: get_req_header/1" do 307 | assert capture_io(:stderr, fn -> 308 | assert get_req_header(%Conn{req_headers: %{"server" => "Microsoft-IIS/8.5"}}) == 309 | %{"server" => "Microsoft-IIS/8.5"} 310 | end) =~ "deprecated" 311 | end 312 | 313 | test "get_req_header/2" do 314 | assert capture_io(:stderr, fn -> 315 | assert get_req_header(%Conn{req_headers: %{"server" => "Microsoft-IIS/8.5"}}, nil) == 316 | %{"server" => "Microsoft-IIS/8.5"} 317 | end) =~ "deprecated" 318 | 319 | assert get_req_header(%Conn{req_headers: %{"server" => "Microsoft-IIS/8.5"}}, "Server") == 320 | "Microsoft-IIS/8.5" 321 | 322 | assert get_req_header(%Conn{req_headers: %{"server" => "Microsoft-IIS/8.5"}}, "Server1") == 323 | nil 324 | end 325 | 326 | test "get_resp_body/1" do 327 | assert get_resp_body(%Conn{state: :sent, resp_body: "I'm ok"}) == "I'm ok" 328 | 329 | assert_raise NotSentError, "the request was not sent yet", fn -> 330 | get_resp_body(%Conn{state: :unsent}) 331 | end 332 | end 333 | 334 | test "get_resp_body/2" do 335 | assert get_resp_body(%Conn{state: :sent, resp_body: %{"foo" => %{"addr" => "China"}}}, [ 336 | "foo", 337 | "addr" 338 | ]) == "China" 339 | 340 | func = fn body -> String.split(body, ~r{,}) end 341 | assert get_resp_body(%Conn{state: :sent, resp_body: "1,2,3"}, func) == ["1", "2", "3"] 342 | 343 | assert_raise NotSentError, "the request was not sent yet", fn -> 344 | get_resp_body(%Conn{state: :unsent, resp_body: %{"foo" => "bar"}}, "foo") 345 | end 346 | end 347 | 348 | test "put_private/3" do 349 | assert put_private(%Conn{}, :user_id, "zhongwencool") == %Conn{ 350 | private: %{user_id: "zhongwencool"} 351 | } 352 | end 353 | 354 | test "get_private/2" do 355 | assert get_private(%Conn{}, :user_id) == nil 356 | assert get_private(%Conn{private: %{user_id: "zhongwencool"}}, :user_id) == "zhongwencool" 357 | end 358 | end 359 | -------------------------------------------------------------------------------- /lib/maxwell/conn.ex: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Conn do 2 | @moduledoc """ 3 | The Maxwell connection. 4 | 5 | This module defines a `Maxwell.Conn` struct and the main functions 6 | for working with Maxwell connections. 7 | 8 | ### Request fields 9 | 10 | These fields contain request information: 11 | 12 | * `url` - the requested url as a binary, example: `"www.example.com:8080/path/?foo=bar"`. 13 | * `method` - the request method as a atom, example: `GET`. 14 | * `req_headers` - the request headers as a map, example: `%{"content-type" => "text/plain"}`. 15 | * `req_body` - the request body, by default is an empty string. It is set 16 | to nil after the request is set. 17 | 18 | ### Response fields 19 | 20 | These fields contain response information: 21 | 22 | * `status` - the response status 23 | * `resp_headers` - the response headers as a map. 24 | * `resp_body` - the response body (todo desc). 25 | 26 | ### Connection fields 27 | 28 | * `state` - the connection state 29 | 30 | The connection state is used to track the connection lifecycle. It starts 31 | as `:unsent` but is changed to `:sending`, Its final result is `:sent` or `:error`. 32 | 33 | ### Protocols 34 | 35 | `Maxwell.Conn` implements Inspect protocols out of the box. 36 | The inspect protocol provides a nice representation of the connection. 37 | 38 | """ 39 | @type file_body_t :: {:file, Path.t()} 40 | @type t :: %__MODULE__{ 41 | state: :unsent | :sending | :sent | :error, 42 | method: atom, 43 | url: String.t(), 44 | path: String.t(), 45 | query_string: map, 46 | opts: Keyword.t(), 47 | req_headers: %{binary => binary}, 48 | req_body: iodata | map | Maxwell.Multipart.t() | file_body_t | Enumerable.t(), 49 | status: non_neg_integer | nil, 50 | resp_headers: %{binary => binary}, 51 | resp_body: iodata | map, 52 | private: map 53 | } 54 | 55 | defstruct state: :unsent, 56 | method: nil, 57 | url: "", 58 | path: "", 59 | query_string: %{}, 60 | req_headers: %{}, 61 | req_body: nil, 62 | opts: [], 63 | status: nil, 64 | resp_headers: %{}, 65 | resp_body: "", 66 | private: %{} 67 | 68 | alias Maxwell.{Conn, Query} 69 | 70 | defmodule AlreadySentError do 71 | @moduledoc """ 72 | Error raised when trying to modify or send an already sent request 73 | """ 74 | defexception message: "the request was already sent" 75 | end 76 | 77 | defmodule NotSentError do 78 | @moduledoc """ 79 | Error raised when no request is sent in a connection 80 | """ 81 | defexception message: "the request was not sent yet" 82 | end 83 | 84 | @doc """ 85 | Create a new connection. 86 | The url provided will be parsed by `URI.parse/1`, and the relevant connection fields will 87 | be set accordingly. 88 | 89 | ### Examples 90 | 91 | iex> new() 92 | %Maxwell.Conn{} 93 | 94 | iex> new("http://example.com/foo") 95 | %Maxwell.Conn{url: "http://example.com", path: "/foo"} 96 | 97 | iex> new("http://example.com/foo?bar=qux") 98 | %Maxwell.Conn{url: "http://example.com", path: "/foo", query_string: %{"bar" => "qux"}} 99 | """ 100 | @spec new() :: t 101 | def new(), do: %Conn{} 102 | @spec new(binary) :: t 103 | def new(url) when is_binary(url) do 104 | %URI{scheme: scheme, path: path, query: query} = uri = URI.parse(url) 105 | scheme = scheme || "http" 106 | path = path || "" 107 | 108 | conn = 109 | case uri do 110 | %URI{host: nil} -> 111 | # This is a badly formed URI, so we'll do best effort: 112 | cond do 113 | # example.com:8080 114 | scheme != nil and Integer.parse(path) != :error -> 115 | %Conn{url: "http://#{scheme}:#{path}"} 116 | 117 | # example.com 118 | String.contains?(path, ".") -> 119 | %Conn{url: "#{scheme}://#{path}"} 120 | 121 | # special case for localhost 122 | path == "localhost" -> 123 | %Conn{url: "#{scheme}://localhost"} 124 | 125 | # /example - not a valid hostname, assume it's a path 126 | String.starts_with?(path, "/") -> 127 | %Conn{path: path} 128 | 129 | # example - not a valid hostname, assume it's a path 130 | true -> 131 | %Conn{path: "/" <> path} 132 | end 133 | 134 | %URI{userinfo: nil, scheme: "http", port: 80, host: host} -> 135 | %Conn{url: "http://#{host}", path: path} 136 | 137 | %URI{userinfo: nil, scheme: "https", port: 443, host: host} -> 138 | %Conn{url: "https://#{host}", path: path} 139 | 140 | %URI{userinfo: nil, port: port, host: host} -> 141 | %Conn{url: "#{scheme}://#{host}:#{port}", path: path} 142 | 143 | %URI{userinfo: userinfo, port: port, host: host} -> 144 | %Conn{url: "#{scheme}://#{userinfo}@#{host}:#{port}", path: path} 145 | end 146 | 147 | case is_nil(query) do 148 | true -> conn 149 | false -> put_query_string(conn, Query.decode(query)) 150 | end 151 | end 152 | 153 | @doc """ 154 | Set the path of the request. 155 | 156 | ### Examples 157 | 158 | iex> put_path(new(), "delete") 159 | %Maxwell.Conn{path: "delete"} 160 | """ 161 | @spec put_path(t, String.t()) :: t | no_return 162 | def put_path(%Conn{state: :unsent} = conn, path), do: %{conn | path: path} 163 | def put_path(_conn, _path), do: raise(AlreadySentError) 164 | 165 | @doc false 166 | def put_path(path) when is_binary(path) do 167 | IO.warn("put_path/1 is deprecated, use new/1 or new/2 followed by put_path/2 instead") 168 | put_path(new(), path) 169 | end 170 | 171 | @doc """ 172 | Add query string to `conn.query_string`. 173 | 174 | * `conn` - `%Conn{}` 175 | * `query_map` - as map, for example `%{foo => bar}` 176 | 177 | ### Examples 178 | 179 | # %Conn{query_string: %{name: "zhong wen"}} 180 | put_query_string(%Conn{}, %{name: "zhong wen"}) 181 | 182 | """ 183 | @spec put_query_string(t, map()) :: t | no_return 184 | def put_query_string(%Conn{state: :unsent, query_string: qs} = conn, query) do 185 | %{conn | query_string: Map.merge(qs, query)} 186 | end 187 | 188 | def put_query_string(_conn, _query_map), do: raise(AlreadySentError) 189 | 190 | @doc false 191 | def put_query_string(query) when is_map(query) do 192 | IO.warn( 193 | "put_query_string/1 is deprecated, use new/1 or new/2 followed by put_query_string/2 instead" 194 | ) 195 | 196 | put_query_string(new(), query) 197 | end 198 | 199 | @doc """ 200 | Set a query string value for the request. 201 | 202 | ### Examples 203 | 204 | iex> put_query_string(new(), :name, "zhong wen") 205 | %Maxwell.Conn{query_string: %{:name => "zhong wen"}} 206 | """ 207 | def put_query_string(%Conn{state: :unsent, query_string: qs} = conn, key, value) do 208 | %{conn | query_string: Map.put(qs, key, value)} 209 | end 210 | 211 | def put_query_string(_conn, _key, _value), do: raise(AlreadySentError) 212 | 213 | @doc """ 214 | Merge a map of headers into the existing headers of the connection. 215 | 216 | ### Examples 217 | 218 | iex> %Maxwell.Conn{headers: %{"content-type" => "text/javascript"} 219 | |> put_req_headers(%{"Accept" => "application/json"}) 220 | %Maxwell.Conn{req_headers: %{"accept" => "application/json", "content-type" => "text/javascript"}} 221 | """ 222 | @spec put_req_headers(t, map()) :: t | no_return 223 | def put_req_headers(%Conn{state: :unsent, req_headers: headers} = conn, extra_headers) 224 | when is_map(extra_headers) do 225 | new_headers = 226 | extra_headers 227 | |> Enum.reduce(headers, fn {header_name, header_value}, acc -> 228 | Map.put(acc, String.downcase(header_name), header_value) 229 | end) 230 | 231 | %{conn | req_headers: new_headers} 232 | end 233 | 234 | def put_req_headers(_conn, _headers), do: raise(AlreadySentError) 235 | 236 | # TODO: Remove 237 | @doc false 238 | def put_req_header(headers) do 239 | IO.warn( 240 | "put_req_header/1 is deprecated, use new/1 or new/2 followed by put_req_headers/2 instead" 241 | ) 242 | 243 | put_req_headers(new(), headers) 244 | end 245 | 246 | # TODO: Remove 247 | @doc false 248 | def put_req_header(conn, headers) when is_map(headers) do 249 | IO.warn("put_req_header/2 is deprecated, use put_req_headers/1 instead") 250 | put_req_headers(conn, headers) 251 | end 252 | 253 | @doc """ 254 | Set a request header. If it already exists, it is updated. 255 | 256 | ### Examples 257 | 258 | iex> %Maxwell.Conn{req_headers: %{"content-type" => "text/javascript"}} 259 | |> put_req_header("Content-Type", "application/json") 260 | |> put_req_header("User-Agent", "zhongwencool") 261 | %Maxwell.Conn{req_headers: %{"content-type" => "application/json", "user-agent" => "zhongwenool"} 262 | """ 263 | def put_req_header(%Conn{state: :unsent, req_headers: headers} = conn, key, value) do 264 | new_headers = Map.put(headers, String.downcase(key), value) 265 | %{conn | req_headers: new_headers} 266 | end 267 | 268 | def put_req_header(_conn, _key, _value), do: raise(AlreadySentError) 269 | 270 | @doc """ 271 | Get all request headers as a map 272 | 273 | ### Examples 274 | 275 | iex> %Maxwell.Conn{req_headers: %{"cookie" => "xyz"} |> get_req_header 276 | %{"cookie" => "xyz"} 277 | """ 278 | @spec get_req_header(t) :: %{String.t() => String.t()} 279 | def get_req_headers(%Conn{req_headers: headers}), do: headers 280 | 281 | # TODO: Remove 282 | @doc false 283 | def get_req_header(conn) do 284 | IO.warn("get_req_header/1 is deprecated, use get_req_headers/1 instead") 285 | get_req_headers(conn) 286 | end 287 | 288 | @doc """ 289 | Get a request header by key. The key lookup is case-insensitive. 290 | Returns the value as a string, or nil if it doesn't exist. 291 | 292 | ### Examples 293 | 294 | iex> %Maxwell.Conn{req_headers: %{"cookie" => "xyz"} |> get_req_header("cookie") 295 | "xyz" 296 | """ 297 | @spec get_req_header(t, String.t()) :: String.t() | nil 298 | def get_req_header(conn, nil) do 299 | IO.warn("get_req_header/2 with a nil key is deprecated, use get_req_headers/2 instead") 300 | get_req_headers(conn) 301 | end 302 | 303 | def get_req_header(%Conn{req_headers: headers}, key), do: Map.get(headers, String.downcase(key)) 304 | 305 | @doc """ 306 | Set adapter options for the request. 307 | 308 | ### Examples 309 | 310 | iex> put_options(new(), connect_timeout: 4000) 311 | %Maxwell.Conn{opts: [connect_timeout: 4000]} 312 | """ 313 | @spec put_options(t, Keyword.t()) :: t | no_return 314 | def put_options(%Conn{state: :unsent, opts: opts} = conn, extra_opts) 315 | when is_list(extra_opts) do 316 | %{conn | opts: Keyword.merge(opts, extra_opts)} 317 | end 318 | 319 | def put_options(_conn, extra_opts) when is_list(extra_opts), do: raise(AlreadySentError) 320 | 321 | @doc """ 322 | Set an adapter option for the request. 323 | 324 | ### Examples 325 | 326 | iex> put_option(new(), :connect_timeout, 5000) 327 | %Maxwell.Conn{opts: [connect_timeout: 5000]} 328 | """ 329 | @spec put_option(t, atom(), term()) :: t | no_return 330 | def put_option(%Conn{state: :unsent, opts: opts} = conn, key, value) when is_atom(key) do 331 | %{conn | opts: [{key, value} | opts]} 332 | end 333 | 334 | def put_option(%Conn{}, key, _value) when is_atom(key), do: raise(AlreadySentError) 335 | 336 | # TODO: remove 337 | @doc false 338 | def put_option(opts) when is_list(opts) do 339 | IO.warn("put_option/1 is deprecated, use new/1 or new/2 followed by put_options/2 instead") 340 | put_options(new(), opts) 341 | end 342 | 343 | # TODO: remove 344 | @doc false 345 | def put_option(conn, opts) when is_list(opts) do 346 | IO.warn("put_option/2 is deprecated, use put_options/2 instead") 347 | put_options(conn, opts) 348 | end 349 | 350 | @doc """ 351 | Set the request body. 352 | 353 | ### Examples 354 | 355 | iex> put_req_body(new(), "new body") 356 | %Maxwell.Conn{req_body: "new_body"} 357 | """ 358 | @spec put_req_body(t, Enumerable.t() | binary()) :: t | no_return 359 | def put_req_body(%Conn{state: :unsent} = conn, req_body) do 360 | %{conn | req_body: req_body} 361 | end 362 | 363 | def put_req_body(_conn, _req_body), do: raise(AlreadySentError) 364 | 365 | # TODO: remove 366 | @doc false 367 | def put_req_body(body) do 368 | IO.warn("put_req_body/1 is deprecated, use new/1 or new/2 followed by put_req_body/2 instead") 369 | put_req_body(new(), body) 370 | end 371 | 372 | @doc """ 373 | Get response status. 374 | Raises `Maxwell.Conn.NotSentError` when the request is unsent. 375 | 376 | ### Examples 377 | 378 | iex> get_status(%Maxwell.Conn{status: 200}) 379 | 200 380 | """ 381 | @spec get_status(t) :: pos_integer | no_return 382 | def get_status(%Conn{status: status, state: state}) when state !== :unsent, do: status 383 | def get_status(_conn), do: raise(NotSentError) 384 | 385 | @doc """ 386 | Get all response headers as a map. 387 | 388 | ### Examples 389 | 390 | iex> %Maxwell.Conn{resp_headers: %{"cookie" => "xyz"} |> get_resp_header 391 | %{"cookie" => "xyz"} 392 | """ 393 | @spec get_resp_headers(t) :: %{String.t() => String.t()} | no_return 394 | def get_resp_headers(%Conn{state: :unsent}), do: raise(NotSentError) 395 | def get_resp_headers(%Conn{resp_headers: headers}), do: headers 396 | 397 | # TODO: remove 398 | @doc false 399 | def get_resp_header(conn) do 400 | IO.warn("get_resp_header/1 is deprecated, use get_resp_headers/1 instead") 401 | get_resp_headers(conn) 402 | end 403 | 404 | @doc """ 405 | Get a response header by key. 406 | The value is returned as a string, or nil if the header is not set. 407 | 408 | ### Examples 409 | 410 | iex> %Maxwell.Conn{resp_headers: %{"cookie" => "xyz"}} |> get_resp_header("cookie") 411 | "xyz" 412 | """ 413 | @spec get_resp_header(t, String.t()) :: String.t() | nil | no_return 414 | def get_resp_header(%Conn{state: :unsent}, _key), do: raise(NotSentError) 415 | # TODO: remove 416 | def get_resp_header(conn, nil) do 417 | IO.warn("get_resp_header/2 with a nil key is deprecated, use get_resp_headers/1 instead") 418 | get_resp_headers(conn) 419 | end 420 | 421 | def get_resp_header(%Conn{resp_headers: headers}, key), 422 | do: Map.get(headers, String.downcase(key)) 423 | 424 | @doc """ 425 | Return the response body. 426 | 427 | ### Examples 428 | 429 | iex> get_resp_body(%Maxwell.Conn{state: :sent, resp_body: "best http client"}) 430 | "best http client" 431 | """ 432 | @spec get_resp_body(t) :: binary() | map() | no_return 433 | def get_resp_body(%Conn{state: :sent, resp_body: body}), do: body 434 | def get_resp_body(_conn), do: raise(NotSentError) 435 | 436 | @doc """ 437 | Return a value from the response body by key or with a parsing function. 438 | 439 | ### Examples 440 | 441 | iex> get_resp_body(%Maxwell.Conn{state: :sent, resp_body: %{"name" => "xyz"}}, "name") 442 | "xyz" 443 | 444 | iex> func = fn(x) -> 445 | ...> [key, value] = String.split(x, ":") 446 | ...> value 447 | ...> end 448 | ...> get_resp_body(%Maxwell.Conn{state: :sent, resp_body: "name:xyz"}, func) 449 | "xyz" 450 | """ 451 | def get_resp_body(%Conn{state: state}, _) when state != :sent, do: raise(NotSentError) 452 | def get_resp_body(%Conn{resp_body: body}, func) when is_function(func, 1), do: func.(body) 453 | def get_resp_body(%Conn{resp_body: body}, keys) when is_list(keys), do: get_in(body, keys) 454 | def get_resp_body(%Conn{resp_body: body}, key), do: body[key] 455 | 456 | @doc """ 457 | Set a private value. If it already exists, it is updated. 458 | 459 | ### Examples 460 | 461 | iex> %Maxwell.Conn{private: %{}} 462 | |> put_private(:user_id, "zhongwencool") 463 | %Maxwell.Conn{private: %{user_id: "zhongwencool"}} 464 | """ 465 | @spec put_private(t, atom, term()) :: t 466 | def put_private(%Conn{private: private} = conn, key, value) do 467 | new_private = Map.put(private, key, value) 468 | %{conn | private: new_private} 469 | end 470 | 471 | @doc """ 472 | Get a private value 473 | 474 | ### Examples 475 | 476 | iex> %Maxwell.Conn{private: %{user_id: "zhongwencool"}} 477 | |> get_private(:user_id) 478 | "zhongwencool" 479 | """ 480 | @spec get_private(t, atom) :: term() 481 | def get_private(%Conn{private: private}, key) do 482 | Map.get(private, key) 483 | end 484 | 485 | defimpl Inspect, for: Conn do 486 | def inspect(conn, opts) do 487 | Inspect.Any.inspect(conn, opts) 488 | end 489 | end 490 | end 491 | -------------------------------------------------------------------------------- /lib/maxwell/multipart.ex: -------------------------------------------------------------------------------- 1 | defmodule Maxwell.Multipart do 2 | @moduledoc """ 3 | Process mutipart for adapter 4 | """ 5 | @type param_t :: {String.t(), String.t()} 6 | @type params_t :: [param_t] 7 | @type header_t :: {String.t(), String.t()} | {String.t(), String.t(), params_t} 8 | @type headers_t :: Keyword.t() 9 | @type disposition_t :: {String.t(), params_t} 10 | @type boundary_t :: String.t() 11 | @type name_t :: String.t() 12 | @type file_content_t :: binary 13 | @type part_t :: 14 | {:file, Path.t()} 15 | | {:file, Path.t(), headers_t} 16 | | {:file, Path.t(), disposition_t, headers_t} 17 | | {:file_content, file_content_t, String.t()} 18 | | {:file_content, file_content_t, String.t(), headers_t} 19 | | {:file_content, file_content_t, String.t(), disposition_t, headers_t} 20 | | {:mp_mixed, String.t(), boundary_t} 21 | | {:mp_mixed_eof, boundary_t} 22 | | {name_t, binary} 23 | | {name_t, binary, headers_t} 24 | | {name_t, binary, disposition_t, headers_t} 25 | @type t :: {:multipart, [part_t]} 26 | @eof_size 2 27 | @doc """ 28 | create a multipart struct 29 | """ 30 | @spec new() :: t 31 | def new(), do: {:multipart, []} 32 | 33 | @spec add_file(t, Path.t()) :: t 34 | def add_file(multipart, path) when is_binary(path) do 35 | append_part(multipart, {:file, path}) 36 | end 37 | 38 | @spec add_file(t, Path.t(), headers_t) :: t 39 | def add_file(multipart, path, extra_headers) 40 | when is_binary(path) and is_list(extra_headers) do 41 | append_part(multipart, {:file, path, extra_headers}) 42 | end 43 | 44 | @spec add_file(t, Path.t(), disposition_t, headers_t) :: t 45 | def add_file(multipart, path, disposition, extra_headers) 46 | when is_binary(path) and is_tuple(disposition) and is_list(extra_headers) do 47 | append_part(multipart, {:file, path, disposition, extra_headers}) 48 | end 49 | 50 | @spec add_file_with_name(t, Path.t(), String.t()) :: t 51 | @spec add_file_with_name(t, Path.t(), String.t(), headers_t) :: t 52 | def add_file_with_name(multipart, path, name, extra_headers \\ []) do 53 | filename = Path.basename(path) 54 | disposition = {"form-data", [{"name", name}, {"filename", filename}]} 55 | append_part(multipart, {:file, path, disposition, extra_headers}) 56 | end 57 | 58 | @spec add_file_content(t, file_content_t, String.t()) :: t 59 | def add_file_content(multipart, file_content, filename) do 60 | append_part(multipart, {:file_content, file_content, filename}) 61 | end 62 | 63 | @spec add_file_content(t, file_content_t, String.t(), headers_t) :: t 64 | def add_file_content(multipart, file_content, filename, extra_headers) do 65 | append_part(multipart, {:file_content, file_content, filename, extra_headers}) 66 | end 67 | 68 | @spec add_file_content(t, file_content_t, String.t(), disposition_t, headers_t) :: t 69 | def add_file_content(multipart, file_content, filename, disposition, extra_headers) do 70 | append_part(multipart, {:file_content, file_content, filename, disposition, extra_headers}) 71 | end 72 | 73 | @spec add_file_content_with_name(t, file_content_t, String.t(), String.t()) :: t 74 | @spec add_file_content_with_name(t, file_content_t, String.t(), String.t(), headers_t) :: t 75 | def add_file_content_with_name(multipart, file_content, filename, name, extra_headers \\ []) do 76 | disposition = {"form-data", [{"name", name}, {"filename", filename}]} 77 | append_part(multipart, {:file_content, file_content, filename, disposition, extra_headers}) 78 | end 79 | 80 | @spec add_field(t, String.t(), binary) :: t 81 | def add_field(multipart, name, value) when is_binary(name) and is_binary(value) do 82 | append_part(multipart, {name, value}) 83 | end 84 | 85 | @spec add_field(t, String.t(), binary, headers_t) :: t 86 | def add_field(multipart, name, value, extra_headers) 87 | when is_binary(name) and is_binary(value) and is_list(extra_headers) do 88 | append_part(multipart, {name, value, extra_headers}) 89 | end 90 | 91 | @spec add_field(t, String.t(), binary, disposition_t, headers_t) :: t 92 | def add_field(multipart, name, value, disposition, extra_headers) 93 | when is_binary(name) and is_binary(value) and is_tuple(disposition) and 94 | is_list(extra_headers) do 95 | append_part(multipart, {name, value, disposition, extra_headers}) 96 | end 97 | 98 | defp append_part({:multipart, parts}, part) do 99 | {:multipart, parts ++ [part]} 100 | end 101 | 102 | @doc """ 103 | multipart form encode. 104 | 105 | * `parts` - receives lists list's member format: 106 | 107 | 1. `{:file, path}` 108 | 2. `{:file, path, extra_headers}` 109 | 3. `{:file, path, disposition, extra_headers}` 110 | 4. `{:file_content, file_content, filename}` 111 | 5. `{:file_content, file_content, filename, extra_headers}` 112 | 6. `{:file_content, file_content, filename, disposition, extra_headers}` 113 | 7. `{:mp_mixed, name, mixed_boundary}` 114 | 8. `{:mp_mixed_eof, mixed_boundary}` 115 | 9. `{name, bin_data}` 116 | 10. `{name, bin_data, extra_headers}` 117 | 11. `{name, bin_data, disposition, extra_headers}` 118 | 119 | Returns `{body_binary, size}` 120 | 121 | """ 122 | @spec encode_form(parts :: [part_t]) :: {boundary_t, integer} 123 | def encode_form(parts), do: encode_form(new_boundary(), parts) 124 | 125 | @doc """ 126 | multipart form encode. 127 | 128 | * `boundary` - multipart boundary. 129 | * `parts` - receives lists list's member format: 130 | 131 | 1. `{:file, path}` 132 | 2. `{:file, path, extra_headers}` 133 | 3. `{:file, path, disposition, extra_headers}` 134 | 4. `{:file_content, file_content, filename}` 135 | 5. `{:file_content, file_content, filename, extra_headers}` 136 | 6. `{:file_content, file_content, filename, disposition, extra_headers}` 137 | 7. `{:mp_mixed, name, mixed_boundary}` 138 | 8. `{:mp_mixed_eof, mixed_boundary}` 139 | 9. `{name, bin_data}` 140 | 10. `{name, bin_data, extra_headers}` 141 | 11. `{name, bin_data, disposition, extra_headers}` 142 | 143 | """ 144 | @spec encode_form(boundary :: boundary_t, parts :: [part_t]) :: {boundary_t, integer} 145 | def encode_form(boundary, parts) when is_list(parts) do 146 | encode_form(parts, boundary, "", 0) 147 | end 148 | 149 | @doc """ 150 | Return a random boundary(binary) 151 | 152 | ### Examples 153 | 154 | # "---------------------------mtynipxrmpegseog" 155 | boundary = new_boundary() 156 | 157 | """ 158 | @spec new_boundary() :: boundary_t 159 | def new_boundary, do: "---------------------------" <> unique(16) 160 | 161 | @doc """ 162 | Get the size of a mp stream. Useful to calculate the content-length of a full multipart stream and send it as an identity 163 | 164 | * `boundary` - multipart boundary 165 | * `parts` - see `Maxwell.Multipart.encode_form`. 166 | 167 | Returns stream size(integer) 168 | """ 169 | @spec len_mp_stream(boundary :: boundary_t, parts :: [part_t]) :: integer 170 | def len_mp_stream(boundary, parts) do 171 | size = 172 | Enum.reduce(parts, 0, fn 173 | {:file, path}, acc_size -> 174 | {mp_header, len} = mp_file_header(%{path: path}, boundary) 175 | acc_size + byte_size(mp_header) + len + @eof_size 176 | 177 | {:file, path, extra_headers}, acc_size -> 178 | {mp_header, len} = mp_file_header(%{path: path, extra_headers: extra_headers}, boundary) 179 | acc_size + byte_size(mp_header) + len + @eof_size 180 | 181 | {:file, path, disposition, extra_headers}, acc_size -> 182 | file = %{path: path, extra_headers: extra_headers, disposition: disposition} 183 | {mp_header, len} = mp_file_header(file, boundary) 184 | acc_size + byte_size(mp_header) + len + @eof_size 185 | 186 | {:file_content, file_content, filename}, acc_size -> 187 | {mp_header, len} = 188 | mp_file_header(%{path: filename, filesize: byte_size(file_content)}, boundary) 189 | 190 | acc_size + byte_size(mp_header) + len + @eof_size 191 | 192 | {:file_content, file_content, filename, extra_headers}, acc_size -> 193 | {mp_header, len} = 194 | mp_file_header( 195 | %{path: filename, filesize: byte_size(file_content), extra_headers: extra_headers}, 196 | boundary 197 | ) 198 | 199 | acc_size + byte_size(mp_header) + len + @eof_size 200 | 201 | {:file_content, file_content, filename, disposition, extra_headers}, acc_size -> 202 | file = %{ 203 | path: filename, 204 | filesize: byte_size(file_content), 205 | extra_headers: extra_headers, 206 | disposition: disposition 207 | } 208 | 209 | {mp_header, len} = mp_file_header(file, boundary) 210 | acc_size + byte_size(mp_header) + len + @eof_size 211 | 212 | {:mp_mixed, name, mixed_boundary}, acc_size -> 213 | {mp_header, _} = mp_mixed_header(name, mixed_boundary) 214 | acc_size + byte_size(mp_header) + @eof_size + byte_size(mp_eof(mixed_boundary)) 215 | 216 | {:mp_mixed_eof, mixed_boundary}, acc_size -> 217 | acc_size + byte_size(mp_eof(mixed_boundary)) + @eof_size 218 | 219 | {name, bin}, acc_size when is_binary(bin) -> 220 | {mp_header, len} = mp_data_header(name, %{binary: bin}, boundary) 221 | acc_size + byte_size(mp_header) + len + @eof_size 222 | 223 | {name, bin, extra_headers}, acc_size when is_binary(bin) -> 224 | {mp_header, len} = 225 | mp_data_header(name, %{binary: bin, extra_headers: extra_headers}, boundary) 226 | 227 | acc_size + byte_size(mp_header) + len + @eof_size 228 | 229 | {name, bin, disposition, extra_headers}, acc_size when is_binary(bin) -> 230 | data = %{binary: bin, disposition: disposition, extra_headers: extra_headers} 231 | {mp_header, len} = mp_data_header(name, data, boundary) 232 | acc_size + byte_size(mp_header) + len + @eof_size 233 | end) 234 | 235 | size + byte_size(mp_eof(boundary)) 236 | end 237 | 238 | defp encode_form([], boundary, acc, acc_size) do 239 | mp_eof = mp_eof(boundary) 240 | {acc <> mp_eof, acc_size + byte_size(mp_eof)} 241 | end 242 | 243 | defp encode_form([{:file, path} | parts], boundary, acc, acc_size) do 244 | {mp_header, len} = mp_file_header(%{path: path}, boundary) 245 | acc_size = acc_size + byte_size(mp_header) + len + @eof_size 246 | file_content = File.read!(path) 247 | acc = acc <> mp_header <> file_content <> "\r\n" 248 | encode_form(parts, boundary, acc, acc_size) 249 | end 250 | 251 | defp encode_form([{:file, path, extra_headers} | parts], boundary, acc, acc_size) do 252 | file = %{path: path, extra_headers: extra_headers} 253 | {mp_header, len} = mp_file_header(file, boundary) 254 | acc_size = acc_size + byte_size(mp_header) + len + @eof_size 255 | file_content = File.read!(path) 256 | acc = acc <> mp_header <> file_content <> "\r\n" 257 | encode_form(parts, boundary, acc, acc_size) 258 | end 259 | 260 | defp encode_form([{:file, path, disposition, extra_headers} | parts], boundary, acc, acc_size) do 261 | file = %{path: path, extra_headers: extra_headers, disposition: disposition} 262 | {mp_header, len} = mp_file_header(file, boundary) 263 | acc_size = acc_size + byte_size(mp_header) + len + @eof_size 264 | file_content = File.read!(path) 265 | acc = acc <> mp_header <> file_content <> "\r\n" 266 | encode_form(parts, boundary, acc, acc_size) 267 | end 268 | 269 | defp encode_form([{:file_content, file_content, filename} | parts], boundary, acc, acc_size) do 270 | {mp_header, len} = 271 | mp_file_header(%{path: filename, filesize: byte_size(file_content)}, boundary) 272 | 273 | acc_size = acc_size + byte_size(mp_header) + len + @eof_size 274 | acc = acc <> mp_header <> file_content <> "\r\n" 275 | encode_form(parts, boundary, acc, acc_size) 276 | end 277 | 278 | defp encode_form( 279 | [{:file_content, file_content, filename, extra_headers} | parts], 280 | boundary, 281 | acc, 282 | acc_size 283 | ) do 284 | file = %{path: filename, filesize: byte_size(file_content), extra_headers: extra_headers} 285 | {mp_header, len} = mp_file_header(file, boundary) 286 | acc_size = acc_size + byte_size(mp_header) + len + @eof_size 287 | acc = acc <> mp_header <> file_content <> "\r\n" 288 | encode_form(parts, boundary, acc, acc_size) 289 | end 290 | 291 | defp encode_form( 292 | [{:file_content, file_content, filename, disposition, extra_headers} | parts], 293 | boundary, 294 | acc, 295 | acc_size 296 | ) do 297 | file = %{ 298 | path: filename, 299 | filesize: byte_size(file_content), 300 | extra_headers: extra_headers, 301 | disposition: disposition 302 | } 303 | 304 | {mp_header, len} = mp_file_header(file, boundary) 305 | acc_size = acc_size + byte_size(mp_header) + len + @eof_size 306 | acc = acc <> mp_header <> file_content <> "\r\n" 307 | encode_form(parts, boundary, acc, acc_size) 308 | end 309 | 310 | defp encode_form([{:mp_mixed, name, mixed_boundary} | parts], boundary, acc, acc_size) do 311 | {mp_header, _} = mp_mixed_header(name, mixed_boundary) 312 | acc_size = acc_size + byte_size(mp_header) + @eof_size 313 | acc = acc <> mp_header <> "\r\n" 314 | encode_form(parts, boundary, acc, acc_size) 315 | end 316 | 317 | defp encode_form([{:mp_mixed_eof, mixed_boundary} | parts], boundary, acc, acc_size) do 318 | eof = mp_eof(mixed_boundary) 319 | acc_size = acc_size + byte_size(eof) + @eof_size 320 | acc = acc <> eof <> "\r\n" 321 | encode_form(parts, boundary, acc, acc_size) 322 | end 323 | 324 | defp encode_form([{name, bin} | parts], boundary, acc, acc_size) do 325 | {mp_header, len} = mp_data_header(name, %{binary: bin}, boundary) 326 | acc_size = acc_size + byte_size(mp_header) + len + @eof_size 327 | acc = acc <> mp_header <> bin <> "\r\n" 328 | encode_form(parts, boundary, acc, acc_size) 329 | end 330 | 331 | defp encode_form([{name, bin, extra_headers} | parts], boundary, acc, acc_size) do 332 | {mp_header, len} = 333 | mp_data_header(name, %{binary: bin, extra_headers: extra_headers}, boundary) 334 | 335 | acc_size = acc_size + byte_size(mp_header) + len + @eof_size 336 | acc = acc <> mp_header <> bin <> "\r\n" 337 | encode_form(parts, boundary, acc, acc_size) 338 | end 339 | 340 | defp encode_form([{name, bin, disposition, extra_headers} | parts], boundary, acc, acc_size) do 341 | data = %{binary: bin, extra_headers: extra_headers, disposition: disposition} 342 | {mp_header, len} = mp_data_header(name, data, boundary) 343 | acc_size = acc_size + byte_size(mp_header) + len + @eof_size 344 | acc = acc <> mp_header <> bin <> "\r\n" 345 | encode_form(parts, boundary, acc, acc_size) 346 | end 347 | 348 | defp mp_file_header(file, boundary) do 349 | path = file[:path] 350 | file_name = path |> :filename.basename() |> to_string 351 | 352 | {disposition, params} = 353 | file[:disposition] || 354 | {"form-data", [{"name", "\"file\""}, {"filename", "\"" <> file_name <> "\""}]} 355 | 356 | content_type = 357 | path 358 | |> Path.extname() 359 | |> String.trim_leading(".") 360 | |> MIME.type() 361 | 362 | len = file[:filesize] || :filelib.file_size(path) 363 | 364 | extra_headers = file[:extra_headers] || [] 365 | extra_headers = extra_headers |> Enum.map(fn {k, v} -> {String.downcase(k), v} end) 366 | 367 | headers = 368 | [ 369 | {"content-length", len}, 370 | {"content-disposition", disposition, params}, 371 | {"content-type", content_type} 372 | ] 373 | |> replace_header_from_extra(extra_headers) 374 | |> mp_header(boundary) 375 | 376 | {headers, len} 377 | end 378 | 379 | defp mp_mixed_header(name, boundary) do 380 | headers = [ 381 | {"Content-Disposition", "form-data", [{"name", "\"" <> name <> "\""}]}, 382 | {"Content-Type", "multipart/mixed", [{"boundary", boundary}]} 383 | ] 384 | 385 | {mp_header(headers, boundary), 0} 386 | end 387 | 388 | defp mp_eof(boundary), do: "--" <> boundary <> "--\r\n" 389 | 390 | defp mp_data_header(name, data, boundary) do 391 | {disposition, params} = data[:disposition] || {"form-data", [{"name", "\"" <> name <> "\""}]} 392 | extra_headers = data[:extra_headers] || [] 393 | extra_headers = extra_headers |> Enum.map(fn {k, v} -> {String.downcase(k), v} end) 394 | 395 | content_type = 396 | name 397 | |> Path.extname() 398 | |> String.trim_leading(".") 399 | |> MIME.type() 400 | 401 | len = byte_size(data[:binary]) 402 | 403 | headers = 404 | [ 405 | {"content-length", len}, 406 | {"content-type", content_type}, 407 | {"content-disposition", disposition, params} 408 | ] 409 | |> replace_header_from_extra(extra_headers) 410 | |> mp_header(boundary) 411 | 412 | {headers, len} 413 | end 414 | 415 | defp mp_header(headers, boundary), do: "--" <> boundary <> "\r\n" <> headers_to_binary(headers) 416 | 417 | defp unique(size, acc \\ []) 418 | defp unique(0, acc), do: acc |> :erlang.list_to_binary() 419 | 420 | defp unique(size, acc) do 421 | random = Enum.random(?a..?z) 422 | unique(size - 1, [random | acc]) 423 | end 424 | 425 | defp headers_to_binary(headers) when is_list(headers) do 426 | headers = 427 | headers 428 | |> Enum.reduce([], fn header, acc -> [make_header(header) | acc] end) 429 | |> Enum.reverse() 430 | |> join("\r\n") 431 | 432 | :erlang.iolist_to_binary([headers, "\r\n\r\n"]) 433 | end 434 | 435 | defp make_header({name, value}) do 436 | value = value_to_binary(value) 437 | name <> ": " <> value 438 | end 439 | 440 | defp make_header({name, value, params}) do 441 | value = 442 | value 443 | |> value_to_binary 444 | |> header_value(params) 445 | 446 | name <> ": " <> value 447 | end 448 | 449 | defp header_value(value, params) do 450 | params = 451 | Enum.map(params, fn {k, v} -> 452 | "#{value_to_binary(k)}=#{value_to_binary(v)}" 453 | end) 454 | 455 | join([value | params], "; ") 456 | end 457 | 458 | defp replace_header_from_extra(headers, extra_headers) do 459 | extra_headers 460 | |> Enum.reduce(headers, fn {ex_header, ex_value}, acc -> 461 | case List.keymember?(acc, ex_header, 0) do 462 | true -> List.keyreplace(acc, ex_header, 0, {ex_header, ex_value}) 463 | false -> [{ex_header, ex_value} | acc] 464 | end 465 | end) 466 | end 467 | 468 | defp value_to_binary(v) when is_list(v) do 469 | :binary.list_to_bin(v) 470 | end 471 | 472 | defp value_to_binary(v) when is_atom(v) do 473 | :erlang.atom_to_binary(v, :latin1) 474 | end 475 | 476 | defp value_to_binary(v) when is_integer(v) do 477 | Integer.to_string(v) 478 | end 479 | 480 | defp value_to_binary(v) when is_binary(v) do 481 | v 482 | end 483 | 484 | defp join([], _Separator), do: "" 485 | # defp join([s], _separator), do: s 486 | defp join(l, separator) do 487 | l 488 | |> Enum.reverse() 489 | |> join(separator, []) 490 | |> :erlang.iolist_to_binary() 491 | end 492 | 493 | defp join([], _separator, acc), do: acc 494 | defp join([s | rest], separator, []), do: join(rest, separator, [s]) 495 | defp join([s | rest], separator, acc), do: join(rest, separator, [s, separator | acc]) 496 | end 497 | --------------------------------------------------------------------------------