├── .gitignore ├── .travis.yml ├── README.md ├── config └── config.exs ├── lib ├── httpehaviour.ex └── httpehaviour │ ├── client.ex │ └── transformer.ex ├── mix.exs ├── mix.lock └── test ├── fixtures └── image.png ├── httpehaviour_test.exs ├── test_helper.exs └── transformer_test.exs /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: elixir 3 | elixir: 4 | - 1.0.4 5 | - 1.0.5 6 | otp_release: 7 | - 17.0 8 | - 17.1 9 | - 17.3 10 | - 17.4 11 | - 17.5 12 | - 18.0 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HTTPehaviour [![Build Status](https://travis-ci.org/edgurgel/httpehaviour.svg?branch=master)](https://travis-ci.org/edgurgel/httpehaviour) [![Hex pm](http://img.shields.io/hexpm/v/httpehaviour.svg?style=flat)](https://hex.pm/packages/httpehaviour) 2 | 3 | HTTP client for Elixir, based on [HTTPoison](https://github.com/edgurgel/httpoison). 4 | 5 | [Documentation](http://hexdocs.pm/httpehaviour/) 6 | 7 | ## But why not HTTPoison? 8 | 9 | HTTPoison does not provide a clean way of overriding steps of the HTTP request. This project is an attempt to fix this. 10 | 11 | ## Installation 12 | 13 | First, add HTTPehaviour to your `mix.exs` dependencies: 14 | 15 | ```elixir 16 | def deps do 17 | [{:httpehaviour, "~> 0.9"}] 18 | end 19 | ``` 20 | 21 | and run `$ mix deps.get`. Now, list the `:httpehaviour` application as part of your application dependencies: 22 | 23 | ```elixir 24 | def application do 25 | [applications: [:httpehaviour]] 26 | end 27 | ``` 28 | 29 | ## Usage 30 | 31 | ```iex 32 | iex> HTTPehaviour.start 33 | iex> HTTPehaviour.get! "http://httparrot.herokuapp.com/get" 34 | %HTTPehaviour.Response{ 35 | body: "{\n \"args\": {},\n \"headers\": {} ...", 36 | headers: %{"connection" => "keep-alive", "content-length" => "517", ...}, 37 | status_code: 200 38 | } 39 | iex> HTTPehaviour.get! "http://localhost:1" 40 | ** (HTTPehaviour.Error) :econnrefused 41 | iex> HTTPehaviour.get "http://localhost:1" 42 | {:error, %HTTPehaviour.Error{id: nil, reason: :econnrefused}} 43 | ``` 44 | 45 | You can also easily pattern match on the `HTTPehaviour.Response` struct: 46 | 47 | ```elixir 48 | case HTTPehaviour.get(url) do 49 | {:ok, %HTTPehaviour.Response{status_code: 200, body: body}} -> 50 | IO.puts body 51 | {:ok, %HTTPehaviour.Response{status_code: 404}} -> 52 | IO.puts "Not found :(" 53 | {:error, %HTTPehaviour.Error{reason: reason}} -> 54 | IO.inspect reason 55 | end 56 | ``` 57 | 58 | ### Overriding parts of the request 59 | 60 | The request will follow like this: 61 | 62 | * `init_request/1` which will come with the original Request; 63 | * `process_request_url/2`, `process_request_body/2` & `process_request_headers/2`; 64 | * The request is executed to the HTTP server; 65 | * `process_response_status_code/2`, `process_response_headers/2`, `process_request_body/2` or `process_response_chunk/2`; 66 | * Then finally `terminate_request/1` is called to do any cleanup and change the state; 67 | * Response will have the state that got passed through the previous functions. 68 | 69 | If any callback is called and returns `{ :halt, state }`, it will finish it and return `HTTPehaviour.Error` 70 | 71 | You can define a module that implement the following callbacks 72 | 73 | ```elixir 74 | defcallback init_request(request :: HTTPehaviour.Request.t) :: { :continue, any } | { :halt, any } 75 | 76 | defcallback process_request_url(url :: binary, state :: any) :: { :continue, binary, any } | { :halt, any } 77 | defcallback process_request_body(body :: binary, state :: any) :: { :continue, binary, any } | { :halt, any } 78 | defcallback process_request_headers(headers :: HTTPehaviour.headers, state :: any) :: { :continue, HTTPehaviour.headers, any } | { :halt, any } 79 | 80 | defcallback process_response_status_code(status_code :: integer, state :: any) :: { :continue, integer, any } | { :halt, any } 81 | defcallback process_response_headers(headers :: HTTPehaviour.headers, state :: any) :: { :continue, HTTPehaviour.headers, any } | { :halt, any } 82 | defcallback process_response_body(body :: binary, state :: any) :: { :continue, binary, any } | { :halt, any } 83 | defcallback process_response_chunk(chunk :: binary, state :: any) :: { :continue, binary, any } | { :halt, any } 84 | 85 | defcallback terminate_request(state :: any) :: any 86 | ``` 87 | 88 | Here's a simple example to build a client for the GitHub API 89 | 90 | ```elixir 91 | defmodule GitHub do 92 | use HTTPehaviour.Client 93 | @expected_fields ~w( 94 | login id avatar_url gravatar_id url html_url followers_url 95 | following_url gists_url starred_url subscriptions_url 96 | organizations_url repos_url events_url received_events_url type 97 | site_admin name company blog location email hireable bio 98 | public_repos public_gists followers following created_at updated_at) 99 | 100 | def process_request_url(url, state) do 101 | { :continue, "https://api.github.com" <> url, state } 102 | end 103 | 104 | def process_response_body(body, state) do 105 | body = body |> Poison.decode! 106 | |> Dict.take(@expected_fields) 107 | |> Enum.map(fn({k, v}) -> {String.to_atom(k), v} end) 108 | { :continue, body, state } 109 | end 110 | 111 | def users do 112 | get!("/users/edgurgel", [], behaviour: __MODULE__).body[:public_repos] 113 | end 114 | end 115 | ``` 116 | 117 | One can pass `state` data through the request and even get the final state back after the request is completed. 118 | 119 | The request will run: 120 | 121 | `init_request` -> `process_request_url` -> `process_request_headers` -> `process_request_body` -> `process_response_status_code` -> `process_request_headers` -> `process_response_body` -> `terminate_request` 122 | 123 | For async requests it will do `process_response_chunk` instead of `process_response_body` 124 | 125 | This is still a work in progress. 126 | 127 | You can see more usage examples in the test files (located in the [`test/`](test)) directory. 128 | 129 | ## License 130 | 131 | Copyright © 2015 Eduardo Gurgel 132 | 133 | This work is free. You can redistribute it and/or modify it under the 134 | terms of the MIT License. See the LICENSE file for more details. 135 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for third- 9 | # party users, it should be done in your mix.exs file. 10 | 11 | # Sample configuration: 12 | # 13 | # config :logger, :console, 14 | # level: :info, 15 | # format: "$date $time [$level] $metadata$message\n", 16 | # metadata: [:user_id] 17 | 18 | # It is also possible to import configuration files, relative to this 19 | # directory. For example, you can emulate configuration per environment 20 | # by uncommenting the line below and defining dev.exs, test.exs and such. 21 | # Configuration from the imported file will override the ones defined 22 | # here (which is why it is important to import them last). 23 | # 24 | # import_config "#{Mix.env}.exs" 25 | -------------------------------------------------------------------------------- /lib/httpehaviour.ex: -------------------------------------------------------------------------------- 1 | defmodule HTTPehaviour do 2 | @moduledoc """ 3 | The HTTP client for Elixir. 4 | """ 5 | 6 | @type headers :: [{binary, binary}] 7 | 8 | @doc """ 9 | Start httpehaviour and dependencies. 10 | """ 11 | def start, do: :application.ensure_all_started(:httpehaviour) 12 | 13 | defmodule Request do 14 | defstruct method: nil, url: nil, body: nil, headers: [] 15 | @type t :: %Request{ method: nil, url: binary, body: binary, headers: HTTPehaviour.headers } 16 | end 17 | 18 | defmodule Response do 19 | defstruct status_code: nil, body: nil, headers: [], state: nil 20 | @type t :: %Response{ status_code: integer, body: binary, headers: HTTPehaviour.headers, state: any } 21 | end 22 | 23 | defmodule AsyncResponse do 24 | defstruct id: nil 25 | @type t :: %AsyncResponse { id: reference } 26 | end 27 | 28 | defmodule AsyncStatus do 29 | defstruct id: nil, status_code: nil 30 | @type t :: %AsyncStatus { id: reference, status_code: integer } 31 | end 32 | 33 | defmodule AsyncHeaders do 34 | defstruct id: nil, headers: [] 35 | @type t :: %AsyncHeaders { id: reference, headers: HTTPehaviour.headers } 36 | end 37 | 38 | defmodule AsyncChunk do 39 | defstruct id: nil, chunk: nil 40 | @type t :: %AsyncChunk { id: reference, chunk: binary } 41 | end 42 | 43 | defmodule AsyncEnd do 44 | defstruct id: nil, state: nil 45 | @type t :: %AsyncEnd { id: reference, state: any } 46 | end 47 | 48 | defmodule Error do 49 | defexception reason: nil, id: nil, state: nil 50 | @type t :: %Error { id: reference, reason: any, state: any } 51 | 52 | def message(%Error{reason: reason, id: nil}), do: inspect(reason) 53 | def message(%Error{reason: reason, id: id}), do: "[Reference: #{id}] - #{inspect reason}" 54 | end 55 | 56 | @spec get(binary, headers, [{atom, any}]) :: {:ok, Response.t | AsyncResponse.t} | {:error, Error.t} 57 | def get(url, headers \\ [], options \\ []), do: request(:get, url, "", headers, options) 58 | 59 | @spec get!(binary, headers, [{atom, any}]) :: Response.t | AsyncResponse.t 60 | def get!(url, headers \\ [], options \\ []), do: request!(:get, url, "", headers, options) 61 | 62 | @spec put(binary, binary, headers, [{atom, any}]) :: {:ok, Response.t | AsyncResponse.t } | {:error, Error.t} 63 | def put(url, body, headers \\ [], options \\ []), do: request(:put, url, body, headers, options) 64 | 65 | @spec put!(binary, binary, headers, [{atom, any}]) :: Response.t | AsyncResponse.t 66 | def put!(url, body, headers \\ [], options \\ []), do: request!(:put, url, body, headers, options) 67 | 68 | @spec head(binary, headers, [{atom, any}]) :: {:ok, Response.t | AsyncResponse.t} | {:error, Error.t} 69 | def head(url, headers \\ [], options \\ []), do: request(:head, url, "", headers, options) 70 | 71 | @spec head!(binary, headers, [{atom, any}]) :: Response.t | AsyncResponse.t 72 | def head!(url, headers \\ [], options \\ []), do: request!(:head, url, "", headers, options) 73 | 74 | @spec post(binary, binary, headers, [{atom, any}]) :: {:ok, Response.t | AsyncResponse.t} | {:error, Error.t} 75 | def post(url, body, headers \\ [], options \\ []), do: request(:post, url, body, headers, options) 76 | 77 | @spec post!(binary, binary, headers, [{atom, any}]) :: Response.t | AsyncResponse.t 78 | def post!(url, body, headers \\ [], options \\ []), do: request!(:post, url, body, headers, options) 79 | 80 | @spec patch(binary, binary, headers, [{atom, any}]) :: {:ok, Response.t | AsyncResponse.t} | {:error, Error.t} 81 | def patch(url, body, headers \\ [], options \\ []), do: request(:patch, url, body, headers, options) 82 | 83 | @spec patch!(binary, binary, headers, [{atom, any}]) :: Response.t | AsyncResponse.t 84 | def patch!(url, body, headers \\ [], options \\ []), do: request!(:patch, url, body, headers, options) 85 | 86 | @spec delete(binary, headers, [{atom, any}]) :: {:ok, Response.t | AsyncResponse.t} | {:error, Error.t} 87 | def delete(url, headers \\ [], options \\ []), do: request(:delete, url, "", headers, options) 88 | 89 | @spec delete!(binary, headers, [{atom, any}]) :: Response.t | AsyncResponse.t 90 | def delete!(url, headers \\ [], options \\ []), do: request!(:delete, url, "", headers, options) 91 | 92 | @spec options(binary, headers, [{atom, any}]) :: {:ok, Response.t | AsyncResponse.t} | {:error, Error.t} 93 | def options(url, headers \\ [], options \\ []), do: request(:options, url, "", headers, options) 94 | 95 | @spec options!(binary, headers, [{atom, any}]) :: Response.t | AsyncResponse.t 96 | def options!(url, headers \\ [], options \\ []), do: request!(:options, url, "", headers, options) 97 | 98 | 99 | @spec request!(atom, binary, binary, headers, [{atom, any}]) :: Response.t 100 | def request!(method, url, body \\ "", headers \\ [], options \\ []) do 101 | case request(method, url, body, headers, options) do 102 | {:ok, response} -> response 103 | {:error, error} -> raise error 104 | end 105 | end 106 | 107 | @spec request(atom, binary, binary, headers, [{atom, any}]) :: {:ok, Response.t | AsyncResponse.t} 108 | | {:error, Error.t} 109 | def request(method, url, body \\ "", headers \\ [], options \\ []) do 110 | if Keyword.has_key?(options, :params) do 111 | url = url <> "?" <> URI.encode_query(options[:params]) 112 | end 113 | 114 | behaviour = Keyword.get options, :behaviour, nil 115 | state = nil 116 | 117 | try do 118 | if behaviour do 119 | { url, headers, body, state } = init_state(method, url, headers, body, behaviour) 120 | end 121 | hn_options = build_hackney_options(options, behaviour, state) 122 | case do_request(method, to_string(url), headers, body, hn_options) do 123 | { :ok, status_code, headers, _client } when status_code in [204, 304] -> 124 | response(status_code, headers, "", behaviour, state) 125 | { :ok, status_code, headers } -> response(status_code, headers, "", behaviour, state) 126 | { :ok, status_code, headers, client } -> 127 | case :hackney.body(client) do 128 | { :ok, body } -> response(status_code, headers, body, behaviour, state) 129 | { :error, reason } -> { :error, %Error { reason: reason, state: state } } 130 | end 131 | { :ok, id } -> { :ok, %AsyncResponse { id: id } } 132 | { :error, reason } -> { :error, %Error { reason: reason, state: state } } 133 | end 134 | catch 135 | { :halt, state } -> { :error, %Error { reason: :halted, state: state } } 136 | end 137 | end 138 | 139 | defp init_state(_method, _url, _headers, _body, nil), do: nil 140 | 141 | defp init_state(method, url, headers, body, behaviour) when is_atom(behaviour) do 142 | { :continue, state } = behaviour.init_request(%Request { method: method, url: url, headers: headers, body: body }) |> continue_or_halt 143 | { :continue, url, state } = behaviour.process_request_url(url, state) |> continue_or_halt 144 | { :continue, headers, state } = behaviour.process_request_headers(headers, state) |> continue_or_halt 145 | { :continue, body, state } = behaviour.process_request_body(body, state) |> continue_or_halt 146 | { url, headers, body, state } 147 | end 148 | 149 | defp continue_or_halt({ :halt, _ } = it), do: throw it 150 | defp continue_or_halt(it), do: it 151 | 152 | defp do_request(method, url, headers, body, hn_options) do 153 | if is_map(headers) do 154 | headers = Enum.into(headers, []) 155 | end 156 | :hackney.request(method, url, headers, body, hn_options) 157 | end 158 | 159 | defp build_hackney_options(options, behaviour, state) do 160 | timeout = Keyword.get options, :timeout 161 | recv_timeout = Keyword.get options, :recv_timeout 162 | stream_to = Keyword.get options, :stream_to 163 | proxy = Keyword.get options, :proxy 164 | proxy_auth = Keyword.get options, :proxy_auth 165 | 166 | hn_options = Keyword.get options, :hackney, [] 167 | 168 | if timeout, do: hn_options = [{:connect_timeout, timeout} | hn_options] 169 | if recv_timeout, do: hn_options = [{:recv_timeout, recv_timeout} | hn_options] 170 | if proxy, do: hn_options = [{:proxy, proxy} | hn_options] 171 | if proxy_auth, do: hn_options = [{:proxy_auth, proxy_auth} | hn_options] 172 | 173 | if stream_to do 174 | { :ok, pid } = HTTPehaviour.Transformer.start_link([stream_to, behaviour, state]) 175 | hn_options = [:async, { :stream_to, pid } | hn_options] 176 | end 177 | 178 | hn_options 179 | end 180 | 181 | defp response(status_code, headers, body, nil, _) do 182 | { :ok, %Response { status_code: status_code, headers: headers, body: body } } 183 | end 184 | 185 | defp response(status_code, headers, body, behaviour, state) when is_atom(behaviour) do 186 | { :continue, status_code, state } = behaviour.process_response_status_code(status_code, state) |> continue_or_halt 187 | { :continue, headers, state } = behaviour.process_response_headers(headers, state) |> continue_or_halt 188 | { :continue, body, state } = behaviour.process_response_body(body, state) |> continue_or_halt 189 | state = behaviour.terminate_request(state) 190 | { :ok, %Response { status_code: status_code, headers: headers, body: body, state: state } } 191 | end 192 | end 193 | -------------------------------------------------------------------------------- /lib/httpehaviour/client.ex: -------------------------------------------------------------------------------- 1 | defmodule HTTPehaviour.Client do 2 | use Behaviour 3 | 4 | defcallback init_request(request :: HTTPehaviour.Request.t) :: { :continue, any } | { :halt, any } 5 | 6 | defcallback process_request_url(url :: binary, state :: any) :: { :continue, binary, any } | { :halt, any } 7 | defcallback process_request_body(body :: binary, state :: any) :: { :continue, binary, any } | { :halt, any } 8 | defcallback process_request_headers(headers :: HTTPehaviour.headers, state :: any) :: { :continue, HTTPehaviour.headers, any } | { :halt, any } 9 | 10 | defcallback process_response_status_code(status_code :: integer, state :: any) :: { :continue, integer, any } | { :halt, any } 11 | defcallback process_response_headers(headers :: HTTPehaviour.headers, state :: any) :: { :continue, HTTPehaviour.headers, any } | { :halt, any } 12 | defcallback process_response_body(body :: binary, state :: any) :: { :continue, binary, any } | { :halt, any } 13 | defcallback process_response_chunk(chunk :: binary, state :: any) :: { :continue, binary, any } | { :halt, any } 14 | 15 | defcallback terminate_request(state :: any) :: any 16 | 17 | defmacro __using__(_) do 18 | quote location: :keep do 19 | @behaviour HTTPehaviour.Client 20 | import HTTPehaviour 21 | 22 | @doc false 23 | def init_request(args), do: { :continue, args } 24 | 25 | def process_request_url(url, state), do: { :continue, url, state } 26 | def process_request_body(body, state), do: { :continue, body, state } 27 | def process_request_headers(headers, state), do: { :continue, headers, state } 28 | 29 | def process_response_status_code(status_code, state), do: { :continue, status_code, state } 30 | def process_response_headers(headers, state), do: { :continue, headers, state } 31 | def process_response_body(body, state), do: { :continue, body, state } 32 | def process_response_chunk(chunk, state), do: { :continue, chunk, state } 33 | 34 | def terminate_request(state), do: state 35 | 36 | defoverridable [init_request: 1, process_request_url: 2, process_request_body: 2, process_request_headers: 2, 37 | process_response_status_code: 2, process_response_headers: 2, process_response_body: 2, 38 | process_response_chunk: 2, terminate_request: 1] 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/httpehaviour/transformer.ex: -------------------------------------------------------------------------------- 1 | defmodule HTTPehaviour.Transformer do 2 | use GenServer 3 | 4 | def start_link(args \\ []) do 5 | GenServer.start_link(__MODULE__, args, []) 6 | end 7 | 8 | def init([target, behaviour, req_state]) do 9 | { :ok, { target, behaviour, req_state } } 10 | end 11 | 12 | def handle_info({ :hackney_response, id, { :status, status_code, _reason } }, 13 | { target, nil, req_state }) do 14 | send target, %HTTPehaviour.AsyncStatus { id: id, status_code: status_code } 15 | { :noreply, { target, nil, req_state } } 16 | end 17 | def handle_info({ :hackney_response, id, { :status, status_code, _reason } }, 18 | { target, behaviour, req_state }) do 19 | case behaviour.process_response_status_code(status_code, req_state) do 20 | { :continue, status_code, req_state } -> 21 | send target, %HTTPehaviour.AsyncStatus { id: id, status_code: status_code } 22 | { :noreply, { target, behaviour, req_state } } 23 | { :halt, req_state } -> 24 | stop_and_send_error(target, id, :halted, req_state) 25 | end 26 | end 27 | 28 | def handle_info({ :hackney_response, id, { :headers, headers } }, 29 | { target, nil, req_state }) do 30 | send target, %HTTPehaviour.AsyncHeaders { id: id, headers: headers } 31 | { :noreply, { target, nil, req_state } } 32 | end 33 | def handle_info({ :hackney_response, id, { :headers, headers } }, 34 | { target, behaviour, req_state }) do 35 | case behaviour.process_response_headers(headers, req_state) do 36 | { :continue, headers, req_state } -> 37 | send target, %HTTPehaviour.AsyncHeaders { id: id, headers: headers } 38 | { :noreply, { target, behaviour, req_state } } 39 | { :halt, req_state } -> 40 | stop_and_send_error(target, id, :halted, req_state) 41 | end 42 | end 43 | 44 | def handle_info({ :hackney_response, id, :done }, 45 | { target, behaviour, req_state }) do 46 | if behaviour do 47 | req_state = behaviour.terminate_request(req_state) 48 | end 49 | 50 | send target, %HTTPehaviour.AsyncEnd { id: id, state: req_state } 51 | { :stop, :normal, nil } 52 | end 53 | 54 | def handle_info({ :hackney_response, id, { :error, reason } }, 55 | { target, _, req_state }) do 56 | stop_and_send_error(target, id, reason, req_state) 57 | end 58 | 59 | def handle_info({ :hackney_response, id, chunk }, 60 | { target, nil, req_state }) do 61 | send target, %HTTPehaviour.AsyncChunk { id: id, chunk: chunk } 62 | { :noreply, { target, nil, req_state } } 63 | end 64 | def handle_info({ :hackney_response, id, chunk }, 65 | { target, behaviour, req_state }) do 66 | case behaviour.process_response_chunk(chunk, req_state) do 67 | { :continue, chunk, req_state } -> 68 | send target, %HTTPehaviour.AsyncChunk { id: id, chunk: chunk } 69 | { :noreply, { target, behaviour, req_state } } 70 | { :halt, req_state } -> 71 | stop_and_send_error(target, id, :halted, req_state) 72 | end 73 | end 74 | 75 | defp stop_and_send_error(target, id, reason, req_state) do 76 | send target, %HTTPehaviour.Error { id: id, reason: reason, state: req_state } 77 | { :stop, :normal, nil } 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule HTTPehaviour.Mixfile do 2 | use Mix.Project 3 | 4 | @description """ 5 | Yet Yet Another HTTP client for Elixir powered by hackney 6 | """ 7 | 8 | def project do 9 | [app: :httpehaviour, 10 | version: "0.9.0", 11 | elixir: "~> 1.0", 12 | name: "HTTPehaviour", 13 | description: @description, 14 | package: package, 15 | deps: deps, 16 | source_url: "https://github.com/edgurgel/httpehaviour"] 17 | end 18 | 19 | def application do 20 | [applications: [:hackney]] 21 | end 22 | 23 | defp deps do 24 | [{:hackney, "~> 1.0" }, 25 | {:exjsx, "~> 3.1", only: :test}, 26 | {:httparrot, "~> 0.3.4", only: :test}, 27 | {:meck, "~> 0.8.2", only: :test}, 28 | {:earmark, "~> 0.1.17", only: :docs}, 29 | {:ex_doc, "~> 0.8.0", only: :docs}] 30 | end 31 | 32 | defp package do 33 | [ contributors: ["Eduardo Gurgel Pinho"], 34 | licenses: ["MIT"], 35 | links: %{"Github" => "https://github.com/edgurgel/httpehaviour"} ] 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"cowboy": {:hex, :cowboy, "1.0.0"}, 2 | "cowlib": {:hex, :cowlib, "1.0.1"}, 3 | "exjsx": {:hex, :exjsx, "3.1.0"}, 4 | "hackney": {:hex, :hackney, "1.1.0"}, 5 | "httparrot": {:hex, :httparrot, "0.3.4"}, 6 | "idna": {:hex, :idna, "1.0.2"}, 7 | "jsx": {:hex, :jsx, "2.4.0"}, 8 | "meck": {:hex, :meck, "0.8.2"}, 9 | "ranch": {:hex, :ranch, "1.0.0"}, 10 | "ssl_verify_hostname": {:hex, :ssl_verify_hostname, "1.0.4"}} 11 | -------------------------------------------------------------------------------- /test/fixtures/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edgurgel/httpehaviour/ef64c9307003eef375bd13f210d3ab78f5f97ae4/test/fixtures/image.png -------------------------------------------------------------------------------- /test/httpehaviour_test.exs: -------------------------------------------------------------------------------- 1 | defmodule HTTPehaviourTest do 2 | use ExUnit.Case 3 | import PathHelpers 4 | import :meck 5 | 6 | setup do 7 | on_exit fn -> unload end 8 | :ok 9 | end 10 | 11 | defmodule MyClient do 12 | use HTTPehaviour.Client 13 | 14 | def init_request(_args), do: { :continue, [:init_request] } 15 | 16 | def process_request_url(url, state), do: { :continue, url, [:process_request_url | state] } 17 | def process_request_body(body, state), do: { :continue, body, [:process_request_body | state] } 18 | def process_request_headers(headers, state), do: { :continue, headers, [:process_request_headers | state] } 19 | 20 | def process_response_status_code(status_code, state), do: { :continue, status_code, [:process_response_status_code | state] } 21 | def process_response_headers(headers, state), do: { :continue, headers, [:process_response_headers | state] } 22 | def process_response_body(body, state), do: { :continue, body, [:process_response_body | state] } 23 | def process_response_chunk(chunk, state), do: { :continue, chunk, [:process_response_chunk | state] } 24 | 25 | def terminate_request(state), do: [:terminate_request | state] 26 | end 27 | 28 | defmodule DefaultClient do 29 | use HTTPehaviour.Client 30 | end 31 | 32 | test "request using default" do 33 | response = HTTPehaviour.get!("localhost:8080/get", [], behaviour: DefaultClient) 34 | 35 | request = %HTTPehaviour.Request{body: "", headers: [], method: :get, url: "localhost:8080/get"} 36 | assert response.state == request 37 | assert response.status_code == 200 38 | end 39 | 40 | test "request using custom Client" do 41 | response = HTTPehaviour.get!("localhost:8080/get", [], behaviour: MyClient) 42 | 43 | path = [:terminate_request, :process_response_body, :process_response_headers, :process_response_status_code, :process_request_body, 44 | :process_request_headers, :process_request_url, :init_request] 45 | assert(response.state == path) 46 | end 47 | 48 | test "asynchronous request using Client" do 49 | path = [:terminate_request, :process_response_chunk, :process_response_headers, :process_response_status_code, :process_request_body, 50 | :process_request_headers, :process_request_url, :init_request] 51 | 52 | {:ok, %HTTPehaviour.AsyncResponse{id: id}} = HTTPehaviour.get "localhost:8080/get", [], [behaviour: MyClient, stream_to: self] 53 | 54 | assert_receive %HTTPehaviour.AsyncStatus{ id: ^id, status_code: 200 }, 1_000 55 | assert_receive %HTTPehaviour.AsyncHeaders{ id: ^id, headers: headers }, 1_000 56 | assert_receive %HTTPehaviour.AsyncChunk{ id: ^id, chunk: _chunk }, 1_000 57 | assert_receive %HTTPehaviour.AsyncEnd{ id: ^id, state: ^path }, 1_000 58 | assert is_list(headers) 59 | end 60 | 61 | test "get" do 62 | assert_response HTTPehaviour.get("localhost:8080/deny"), fn(response) -> 63 | assert :erlang.size(response.body) == 197 64 | end 65 | end 66 | 67 | test "get with params" do 68 | resp = HTTPehaviour.get("localhost:8080/get", [], params: %{foo: "bar", baz: "bong"}) 69 | assert_response resp, fn(response) -> 70 | args = JSX.decode!(response.body)["args"] 71 | assert args["foo"] == "bar" 72 | assert args["baz"] == "bong" 73 | assert (args |> Dict.keys |> length) == 2 74 | end 75 | end 76 | 77 | test "head" do 78 | assert_response HTTPehaviour.head("localhost:8080/get"), fn(response) -> 79 | assert response.body == "" 80 | end 81 | end 82 | 83 | test "post charlist body" do 84 | assert_response HTTPehaviour.post("localhost:8080/post", 'test') 85 | end 86 | 87 | test "post binary body" do 88 | { :ok, file } = File.read(fixture_path("image.png")) 89 | 90 | assert_response HTTPehaviour.post("localhost:8080/post", file) 91 | end 92 | 93 | test "post form data" do 94 | assert_response HTTPehaviour.post("localhost:8080/post", {:form, [key: "value"]}, %{"Content-type" => "application/x-www-form-urlencoded"}), fn(response) -> 95 | Regex.match?(~r/"key".*"value"/, response.body) 96 | end 97 | end 98 | 99 | test "put" do 100 | assert_response HTTPehaviour.put("localhost:8080/put", "test") 101 | end 102 | 103 | test "patch" do 104 | assert_response HTTPehaviour.patch("localhost:8080/patch", "test") 105 | end 106 | 107 | test "delete" do 108 | assert_response HTTPehaviour.delete("localhost:8080/delete") 109 | end 110 | 111 | test "options" do 112 | assert_response HTTPehaviour.options("localhost:8080/get"), fn(response) -> 113 | assert get_header(response.headers, "content-length") == "0" 114 | assert is_binary(get_header(response.headers, "allow")) 115 | end 116 | end 117 | 118 | test "hackney option follow redirect absolute url" do 119 | hackney = [follow_redirect: true] 120 | assert_response HTTPehaviour.get("http://localhost:8080/redirect-to?url=http%3A%2F%2Flocalhost:8080%2Fget", [], [ hackney: hackney ]) 121 | end 122 | 123 | test "hackney option follow redirect relative url" do 124 | hackney = [follow_redirect: true] 125 | assert_response HTTPehaviour.get("http://localhost:8080/relative-redirect/1", [], [ hackney: hackney ]) 126 | end 127 | 128 | test "basic_auth hackney option" do 129 | hackney = [basic_auth: {"user", "pass"}] 130 | assert_response HTTPehaviour.get("http://localhost:8080/basic-auth/user/pass", [], [ hackney: hackney ]) 131 | end 132 | 133 | test "explicit http scheme" do 134 | assert_response HTTPehaviour.head("http://localhost:8080/get") 135 | end 136 | 137 | test "https scheme" do 138 | assert_response HTTPehaviour.head("https://localhost:8433/get", [], [ hackney: [:insecure]]) 139 | end 140 | 141 | test "char list URL" do 142 | assert_response HTTPehaviour.head('localhost:8080/get') 143 | end 144 | 145 | test "request headers as a map" do 146 | map_header = %{"X-Header" => "X-Value"} 147 | assert HTTPehaviour.get!("localhost:8080/get", map_header).body =~ "X-Value" 148 | end 149 | 150 | test "cached request" do 151 | if_modified = %{"If-Modified-Since" => "Tue, 11 Dec 2012 10:10:24 GMT"} 152 | response = HTTPehaviour.get!("localhost:8080/cache", if_modified) 153 | assert %HTTPehaviour.Response{status_code: 304, body: ""} = response 154 | end 155 | 156 | test "send cookies" do 157 | response = HTTPehaviour.get!("localhost:8080/cookies", %{}, hackney: [cookie: [{"SESSION", "123"}]]) 158 | assert response.body =~ ~r(\"SESSION\".*\"123\") 159 | end 160 | 161 | test "exception" do 162 | assert HTTPehaviour.get "localhost:9999" == {:error, %HTTPehaviour.Error{reason: :econnrefused}} 163 | assert_raise HTTPehaviour.Error, ":econnrefused", fn -> 164 | HTTPehaviour.get! "localhost:9999" 165 | end 166 | end 167 | 168 | test "asynchronous request" do 169 | {:ok, %HTTPehaviour.AsyncResponse{id: id}} = HTTPehaviour.get "localhost:8080/get", [], [stream_to: self] 170 | 171 | assert_receive %HTTPehaviour.AsyncStatus{ id: ^id, status_code: 200 }, 1_000 172 | assert_receive %HTTPehaviour.AsyncHeaders{ id: ^id, headers: headers }, 1_000 173 | assert_receive %HTTPehaviour.AsyncChunk{ id: ^id, chunk: _chunk }, 1_000 174 | assert_receive %HTTPehaviour.AsyncEnd{ id: ^id }, 1_000 175 | assert is_list(headers) 176 | end 177 | 178 | test "request raises error tuple" do 179 | reason = {:closed, "Something happened"} 180 | expect(:hackney, :request, 5, {:error, reason}) 181 | 182 | assert_raise HTTPehaviour.Error, "{:closed, \"Something happened\"}", fn -> 183 | HTTPehaviour.get!("http://localhost") 184 | end 185 | 186 | assert HTTPehaviour.get("http://localhost") == {:error, %HTTPehaviour.Error{reason: reason}} 187 | 188 | assert validate :hackney 189 | end 190 | 191 | test "passing connect_timeout option" do 192 | expect(:hackney, :request, [:post, "localhost", [], "body", [connect_timeout: 12345]], 193 | { :ok, 200, "headers", :client }) 194 | expect(:hackney, :body, 1, {:ok, "response"}) 195 | 196 | assert HTTPehaviour.post!("localhost", "body", [], timeout: 12345) == 197 | %HTTPehaviour.Response{ status_code: 200, 198 | headers: "headers", 199 | body: "response" } 200 | 201 | assert validate :hackney 202 | end 203 | 204 | test "passing recv_timeout option" do 205 | expect(:hackney, :request, [{[:post, "localhost", [], "body", [recv_timeout: 12345]], 206 | {:ok, 200, "headers", :client}}]) 207 | expect(:hackney, :body, 1, {:ok, "response"}) 208 | 209 | assert HTTPehaviour.post!("localhost", "body", [], recv_timeout: 12345) == 210 | %HTTPehaviour.Response{ status_code: 200, 211 | headers: "headers", 212 | body: "response" } 213 | 214 | assert validate :hackney 215 | end 216 | 217 | test "passing proxy option" do 218 | expect(:hackney, :request, [{[:post, "localhost", [], "body", [proxy: "proxy"]], 219 | {:ok, 200, "headers", :client}}]) 220 | expect(:hackney, :body, 1, {:ok, "response"}) 221 | 222 | assert HTTPehaviour.post!("localhost", "body", [], proxy: "proxy") == 223 | %HTTPehaviour.Response{ status_code: 200, 224 | headers: "headers", 225 | body: "response" } 226 | 227 | assert validate :hackney 228 | end 229 | 230 | test "passing proxy option with proxy_auth" do 231 | expect(:hackney, :request, [{[:post, "localhost", [], "body", [proxy_auth: {"username", "password"}, proxy: "proxy"]], 232 | {:ok, 200, "headers", :client}}]) 233 | expect(:hackney, :body, 1, {:ok, "response"}) 234 | 235 | assert HTTPehaviour.post!("localhost", "body", [], [proxy: "proxy", proxy_auth: {"username", "password"}]) == 236 | %HTTPehaviour.Response{ status_code: 200, 237 | headers: "headers", 238 | body: "response" } 239 | 240 | assert validate :hackney 241 | end 242 | 243 | defp assert_response({:ok, response}, function \\ nil) do 244 | assert is_list(response.headers) 245 | assert response.status_code == 200 246 | assert get_header(response.headers, "connection") == "keep-alive" 247 | assert is_binary(response.body) 248 | 249 | unless function == nil, do: function.(response) 250 | end 251 | 252 | defp get_header(headers, key) do 253 | headers 254 | |> Enum.filter(fn({k, _}) -> k == key end) 255 | |> hd 256 | |> elem(1) 257 | end 258 | end 259 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | {:ok, _} = :application.ensure_all_started(:httparrot) 3 | 4 | defmodule PathHelpers do 5 | def fixture_path do 6 | Path.expand("fixtures", __DIR__) 7 | end 8 | 9 | def fixture_path(file_path) do 10 | Path.join fixture_path, file_path 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/transformer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TransformerTest do 2 | use ExUnit.Case 3 | import HTTPehaviour.Transformer 4 | 5 | defmodule ContinueClient do 6 | use HTTPehaviour.Client 7 | 8 | def init_request(_args), do: { :continue, [:init_request] } 9 | 10 | def process_response_status_code(status_code, state), do: { :continue, status_code, [:process_response_status_code | state] } 11 | def process_response_headers(headers, state), do: { :continue, headers, [:process_response_headers | state] } 12 | def process_response_chunk(chunk, state), do: { :continue, chunk, [:process_response_chunk | state] } 13 | 14 | def terminate_request(state), do: [:terminate_request | state] 15 | end 16 | 17 | defmodule HaltClient do 18 | use HTTPehaviour.Client 19 | 20 | def init_request(_args), do: { :halt, [:init_request] } 21 | 22 | def process_response_status_code(_status_code, state), do: { :halt, [:process_response_status_code | state] } 23 | def process_response_headers(_headers, state), do: { :halt, [:process_response_headers | state] } 24 | def process_response_chunk(_chunk, state), do: { :halt, [:process_response_chunk | state] } 25 | 26 | def terminate_request(state), do: [:terminate_request | state] 27 | end 28 | 29 | test "receive response headers" do 30 | headers = [{"header", "value"}] 31 | message = { :headers, headers } 32 | state = { self, nil, :req_state } 33 | assert handle_info({ :hackney_response, :id, message }, state) == { :noreply, state } 34 | 35 | assert_receive %HTTPehaviour.AsyncHeaders{ id: :id, headers: ^headers } 36 | end 37 | 38 | test "receive response headers having a behaviour with continue" do 39 | headers = [{"header", "value"}] 40 | message = { :headers, headers } 41 | state = { self, ContinueClient, [:req_state] } 42 | new_state = { self, ContinueClient, [:process_response_headers, :req_state] } 43 | assert handle_info({ :hackney_response, :id, message }, state) == { :noreply, new_state } 44 | 45 | assert_receive %HTTPehaviour.AsyncHeaders{ id: :id, headers: ^headers } 46 | end 47 | 48 | test "receive response headers having a behaviour with halt" do 49 | headers = [{"header", "value"}] 50 | message = { :headers, headers } 51 | state = { self, HaltClient, [:req_state] } 52 | new_state = [:process_response_headers, :req_state] 53 | assert handle_info({ :hackney_response, :id, message }, state) == { :stop, :normal, nil } 54 | 55 | assert_receive %HTTPehaviour.Error{ id: :id, reason: :halted, state: ^new_state } 56 | end 57 | 58 | test "receive status code" do 59 | message = { :status, 200, :reason } 60 | state = { self, nil, :req_state } 61 | assert handle_info({ :hackney_response, :id, message }, state) == { :noreply, state } 62 | 63 | assert_receive %HTTPehaviour.AsyncStatus{ id: :id, status_code: 200 } 64 | end 65 | 66 | test "receive status code having a behaviour with continue" do 67 | message = { :status, 200, :reason } 68 | state = { self, ContinueClient, [:req_state] } 69 | new_state = { self, ContinueClient, [:process_response_status_code, :req_state] } 70 | assert handle_info({ :hackney_response, :id, message }, state) == { :noreply, new_state } 71 | 72 | assert_receive %HTTPehaviour.AsyncStatus{ id: :id, status_code: 200 } 73 | end 74 | 75 | test "receive status code having a behaviour with halt" do 76 | message = { :status, 200, :reason } 77 | state = { self, HaltClient, [:req_state] } 78 | new_state = [:process_response_status_code, :req_state] 79 | assert handle_info({ :hackney_response, :id, message }, state) == { :stop, :normal, nil } 80 | 81 | assert_receive %HTTPehaviour.Error{ id: :id, reason: :halted, state: ^new_state } 82 | end 83 | 84 | test "receive body chunk" do 85 | message = "chunk" 86 | state = { self, nil, :req_state } 87 | assert handle_info({ :hackney_response, :id, message }, state) == { :noreply, state } 88 | 89 | assert_receive %HTTPehaviour.AsyncChunk{ id: :id, chunk: ^message } 90 | end 91 | 92 | test "receive body chunk having a behaviour with continue" do 93 | message = "chunk" 94 | state = { self, ContinueClient, [:req_state] } 95 | new_state = { self, ContinueClient, [:process_response_chunk, :req_state] } 96 | assert handle_info({ :hackney_response, :id, message }, state) == { :noreply, new_state } 97 | 98 | assert_receive %HTTPehaviour.AsyncChunk{ id: :id, chunk: ^message } 99 | end 100 | 101 | test "receive body chunk having a behaviour with halt" do 102 | message = "chunk" 103 | state = { self, HaltClient, [:req_state] } 104 | new_state = [:process_response_chunk, :req_state] 105 | assert handle_info({ :hackney_response, :id, message }, state) == { :stop, :normal, nil } 106 | 107 | assert_receive %HTTPehaviour.Error{ id: :id, reason: :halted, state: ^new_state } 108 | end 109 | 110 | test "receive done" do 111 | message = :done 112 | state = { self, nil, :req_state } 113 | assert handle_info({ :hackney_response, :id, message }, state) == { :stop, :normal, nil } 114 | 115 | assert_receive %HTTPehaviour.AsyncEnd{ id: :id ,state: :req_state } 116 | end 117 | 118 | test "receive done having a behaviour" do 119 | message = :done 120 | state = { self, ContinueClient, [:req_state] } 121 | assert handle_info({ :hackney_response, :id, message }, state) == { :stop, :normal, nil } 122 | 123 | assert_receive %HTTPehaviour.AsyncEnd{ id: :id, state: [:terminate_request, :req_state]} 124 | end 125 | end 126 | --------------------------------------------------------------------------------