Skip to content

Commit

Permalink
Merge pull request #34 from angelikatyborska/allow-custom-http-client
Browse files Browse the repository at this point in the history
  • Loading branch information
angelikatyborska committed Mar 27, 2021
2 parents dbc6524 + aff37d6 commit fd7ffc0
Show file tree
Hide file tree
Showing 15 changed files with 191 additions and 24 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog

## [Unreleased]

- Replace HTTPoison with hackney
- Make hackney optional, allow passing a custom HTTPClient

## 1.0.0 (2020-04-13)

### Changed
Expand Down
5 changes: 3 additions & 2 deletions README.md
Expand Up @@ -37,12 +37,13 @@ HTTP/1.1 200 OK

Make sure to read about the [prerequisites](#prerequisites) first.

Add Vnu as a dependency to your project's `mix.exs`:
Add Vnu as a dependency to your project's `mix.exs`. To use the built-in, Hackney-based HTTP client adapter, add `:hackney` too:

```elixir
defp deps do
[
{:vnu, "~> 1.0", only: [:dev, :test], runtime: false}
{:vnu, "~> 1.0", only: [:dev, :test], runtime: false},
{:hackney, "~> 1.17"}
]
end
```
Expand Down
2 changes: 2 additions & 0 deletions lib/mix/tasks/vnu.validate.html.ex
Expand Up @@ -19,6 +19,8 @@ defmodule Mix.Tasks.Vnu.Validate.Html do
Their presence will mean the document is invalid. Defaults to `false`.
- `--filter` - A module implementing the `Vnu.MessageFilter` behavior that will be used to exclude messages matching the filter from the result.
Defaults to `nil` (no excluded messages).
- `--http-client` - A module implementing the `Vnu.HTTPClient` behaviour that will be used to make the HTTP request to the server.
Defaults to `Vnu.HTTPClient.Hackney`.
## Exit code
Expand Down
13 changes: 7 additions & 6 deletions lib/vnu.ex
Expand Up @@ -15,6 +15,8 @@ defmodule Vnu do
Defaults to `http://localhost:8888`.
- `:filter` - A module implementing the `Vnu.MessageFilter` behavior that will be used to exclude messages matching the filter from the result.
Defaults to `nil` (no excluded messages).
- `:http_client` - A module implementing the `Vnu.HTTPClient` behaviour that will be used to make the HTTP request to the server.
Defaults to `Vnu.HTTPClient.Hackney`.
## Examples
Expand Down Expand Up @@ -58,7 +60,7 @@ defmodule Vnu do
iex> Vnu.validate_html("", server_url: "http://wrong-domain")
{:error, %Vnu.Error{
reason: :unexpected_server_response,
message: "Could not contact the server, got error: %HTTPoison.Error{id: nil, reason: :nxdomain}"
message: "Could not contact the server, got error: :nxdomain"
}}
"""

Expand Down Expand Up @@ -101,7 +103,7 @@ defmodule Vnu do
iex> Vnu.validate_css("", server_url: "http://wrong-domain")
{:error, %Vnu.Error{
reason: :unexpected_server_response,
message: "Could not contact the server, got error: %HTTPoison.Error{id: nil, reason: :nxdomain}"
message: "Could not contact the server, got error: :nxdomain"
}}
"""

Expand All @@ -124,9 +126,6 @@ defmodule Vnu do
See `validate_html/2` for the list of options and other details.
## Options
- `:server_url` - The URL of [the Checker server](https://github.com/validator/validator). Defaults to `http://localhost:8888`.
## Examples
iex> Vnu.validate_svg(~S(
Expand Down Expand Up @@ -156,7 +155,7 @@ defmodule Vnu do
iex> Vnu.validate_svg("", server_url: "http://wrong-domain")
{:error, %Vnu.Error{
reason: :unexpected_server_response,
message: "Could not contact the server, got error: %HTTPoison.Error{id: nil, reason: :nxdomain}"
message: "Could not contact the server, got error: :nxdomain"
}}
"""

Expand All @@ -181,6 +180,8 @@ defmodule Vnu do
- `:server_url` - The URL of [the Checker server](https://github.com/validator/validator). Defaults to `http://localhost:8888`.
- `:fail_on_warnings` - Messages of type `:info` and subtype `:warning` will be treated as if they were validation errors.
Their presence will mean the document is invalid. Defaults to `false`.
- `:http_client` - A module implementing the `Vnu.HTTPClient` behaviour that will be used to make the HTTP request to the server.
Defaults to `Vnu.HTTPClient.Hackney`.
## Examples
Expand Down
2 changes: 2 additions & 0 deletions lib/vnu/assertions.ex
Expand Up @@ -13,6 +13,8 @@ defmodule Vnu.Assertions do
Their presence will mean the document is invalid. Defaults to `false`.
- `:filter` - A module implementing the `Vnu.MessageFilter` behavior that will be used to exclude messages matching the filter from the result.
Defaults to `nil` (no excluded messages).
- `:http_client` - A module implementing the `Vnu.HTTPClient` behaviour that will be used to make the HTTP request to the server.
Defaults to `Vnu.HTTPClient.Hackney`.
- `:message_print_limit` - The maximum number of validation messages that will me printed in the error when the assertion fails.
Can be an integer or `:infinity`. Defaults to `:infinity`.
"""
Expand Down
23 changes: 20 additions & 3 deletions lib/vnu/cli.ex
Expand Up @@ -8,7 +8,12 @@ defmodule Vnu.CLI do
def validate(argv, format) do
{opts, files, invalid_args} =
OptionParser.parse(argv,
strict: [server_url: :string, fail_on_warnings: :boolean, filter: :string]
strict: [
http_client: :string,
server_url: :string,
fail_on_warnings: :boolean,
filter: :string
]
)

if invalid_args != [] do
Expand All @@ -21,9 +26,21 @@ defmodule Vnu.CLI do
Mix.raise("No files given")
end

opts = Keyword.update(opts, :filter, nil, &Module.concat([&1]))
opts =
if Keyword.get(opts, :http_client) do
Keyword.update!(opts, :http_client, &Module.concat([&1]))
else
opts
end

opts =
if Keyword.get(opts, :filter) do
Keyword.update!(opts, :filter, &Module.concat([&1]))
else
opts
end

if Keyword.get(opts, :filter, nil) do
if Keyword.get(opts, :filter) || Keyword.get(opts, :http_client) do
# must compile to ensure the filter module is available
Mix.Task.run("compile", [])
end
Expand Down
39 changes: 36 additions & 3 deletions lib/vnu/config.ex
Expand Up @@ -3,15 +3,21 @@ defmodule Vnu.Config do

alias Vnu.Error

defstruct [:server_url, :format, :filter]
defstruct [:http_client, :server_url, :format, :filter]

@defaults [server_url: "http://localhost:8888", format: :html, filter: nil]
@defaults [
http_client: Vnu.HTTPClient.Hackney,
server_url: "http://localhost:8888",
format: :html,
filter: nil
]

@doc false
def new(opts) when is_list(opts) do
opts = Keyword.merge(@defaults, opts)

%__MODULE__{}
|> set_http_client(opts)
|> set_server_url(opts)
|> set_format(opts)
|> set_filter(opts)
Expand All @@ -25,7 +31,34 @@ defmodule Vnu.Config do
end
end

defp set_server_url(config, opts) do
defp set_http_client(config, opts) do
http_client = Keyword.get(opts, :http_client)

cond do
!is_atom(http_client) ->
{:error,
Error.new(
:invalid_config,
"Expected http_client to be a module, got: #{inspect(http_client)}"
)}

!Code.ensure_loaded?(http_client) || !Kernel.function_exported?(http_client, :post, 3) ->
{:error,
Error.new(
:invalid_config,
"Expected http_client to be a module that implements the Vnu.HTTPClient behaviour, got: #{
inspect(http_client)
}"
)}

true ->
{:ok, %{config | http_client: http_client}}
end
end

defp set_server_url({:error, error}, _opts), do: {:error, error}

defp set_server_url({:ok, config}, opts) do
server_url = Keyword.get(opts, :server_url)

if is_bitstring(server_url) do
Expand Down
8 changes: 4 additions & 4 deletions lib/vnu/http.ex
Expand Up @@ -22,15 +22,15 @@ defmodule Vnu.HTTP do

headers = [{"Content-Type", "#{content_type}; charset=utf-8"}]

case HTTPoison.post(url, html, headers, follow_redirect: true) do
{:ok, %HTTPoison.Response{body: body, status_code: 200}} ->
case config.http_client.post(url, html, headers) do
{:ok, %{body: body, status: 200}} ->
parse_body(body)

{:ok, %HTTPoison.Response{body: body, status_code: status_code}} ->
{:ok, %{body: body, status: status}} ->
{:error,
Error.new(
:unexpected_server_response,
"Expected the server to respond with status code 200, instead got #{status_code} with body: #{
"Expected the server to respond with status code 200, instead got #{status} with body: #{
inspect(body)
}"
)}
Expand Down
18 changes: 18 additions & 0 deletions lib/vnu/http_client.ex
@@ -0,0 +1,18 @@
defmodule Vnu.HTTPClient do
@moduledoc """
Specification for a Vnu HTTP client.
"""

@type url() :: String.t()
@type body() :: String.t()
@type header() :: {String.t(), String.t()}

@type status() :: non_neg_integer()

@type response() :: %{status: status(), body: body()}

@doc """
A callback to make a POST HTTP request.
"""
@callback post(url(), body(), [header()]) :: {:ok, response()} | {:error, any()}
end
34 changes: 34 additions & 0 deletions lib/vnu/http_client/hackney.ex
@@ -0,0 +1,34 @@
defmodule Vnu.HTTPClient.Hackney do
@moduledoc """
HTTPoison-based HTTP client adapter.
"""

@behaviour Vnu.HTTPClient
require Logger

@impl true
def post(url, body, headers) do
if Code.ensure_loaded?(:hackney) do
with {:ok, status, _headers, body_ref} <-
:hackney.request(:post, url, headers, body, follow_redirect: true),
{:ok, body} <- :hackney.body(body_ref) do
{:ok, %{status: status, body: body}}
else
{:error, error} ->
{:error, error}
end
else
Logger.error("""
Could not find hackney dependency.
Please add :httpoison to your dependencies:
{:hackney, "~> 1.17"}
Or use a different HTTP client. See Vnu.HTTPClient for more information.
""")

raise "missing hackney dependency"
end
end
end
1 change: 0 additions & 1 deletion mix-older-deps.lock
Expand Up @@ -10,7 +10,6 @@
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
"ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"},
"hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"},
"httpoison": {:hex, :httpoison, "1.0.0", "1f02f827148d945d40b24f0b0a89afe40bfe037171a6cf70f2486976d86921cd", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "07967c56199f716ce9adb27415ccea1bd76c44f777dd0a6d4166c3d932f37fdf"},
"idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"},
"jason": {:hex, :jason, "1.0.0", "0f7cfa9bdb23fed721ec05419bcee2b2c21a77e926bce0deda029b5adc716fe2", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b96c400e04b7b765c0854c05a4966323e90c0d11fee0483b1567cda079abb205"},
"makeup": {:hex, :makeup, "1.0.1", "82f332e461dc6c79dbd82fbe2a9c10d48ed07146f0a478286e590c83c52010b5", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "49736fe5b66a08d8575bf5321d716bac5da20c8e6b97714fec2bcd6febcfa1f8"},
Expand Down
4 changes: 2 additions & 2 deletions mix.exs
Expand Up @@ -12,7 +12,7 @@ defmodule Vnu.MixProject do
deps: deps(),
aliases: aliases(),
docs: [extras: ["README.md", "CHANGELOG.md"]],
dialyzer: [plt_add_apps: [:mix, :ex_unit]],
dialyzer: [plt_add_apps: [:mix, :ex_unit, :hackney]],
description: description(),
package: package(),
name: "Vnu",
Expand Down Expand Up @@ -49,8 +49,8 @@ defmodule Vnu.MixProject do
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:httpoison, "~> 1.0"},
{:jason, "~> 1.0"},
{:hackney, "~> 1.17", optional: true},
{:dialyxir, "~> 1.0", only: [:dev], runtime: false},
{:credo, "~> 1.0", only: [:dev], runtime: false},
{:ex_doc, "~> 0.21", only: [:dev], runtime: false},
Expand Down
2 changes: 0 additions & 2 deletions mix.lock
Expand Up @@ -7,14 +7,12 @@
"cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"},
"credo": {:hex, :credo, "1.5.5", "e8f422026f553bc3bebb81c8e8bf1932f498ca03339856c7fec63d3faac8424b", [: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", "dd8623ab7091956a855dc9f3062486add9c52d310dfd62748779c4315d8247de"},
"dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"},
"earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"},
"earmark_parser": {:hex, :earmark_parser, "1.4.12", "b245e875ec0a311a342320da0551da407d9d2b65d98f7a9597ae078615af3449", [:mix], [], "hexpm", "711e2cc4d64abb7d566d43f54b78f7dc129308a63bc103fbd88550d2174b3160"},
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
"ex_doc": {:hex, :ex_doc, "0.24.0", "2df14354835afaabdf87cb2971ea9485d8a36ff590e4b6c250b4f60c8fdf9143", [: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", "a0f4bcff21ceebea48414e49885d2a3e542200f76a2facf3f8faa54935eeb721"},
"excoveralls": {:hex, :excoveralls, "0.14.0", "4b562d2acd87def01a3d1621e40037fdbf99f495ed3a8570dfcf1ab24e15f76d", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "94f17478b0cca020bcd85ce7eafea82d2856f7ed022be777734a2f864d36091a"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"hackney": {:hex, :hackney, "1.17.0", "717ea195fd2f898d9fe9f1ce0afcc2621a41ecfe137fae57e7fe6e9484b9aa99", [:rebar3], [{:certifi, "~>2.5", [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", [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", "64c22225f1ea8855f584720c0e5b3cd14095703af1c9fbc845ba042811dc671c"},
"httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"},
"makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"},
Expand Down
32 changes: 31 additions & 1 deletion test/vnu/config_test.exs
Expand Up @@ -5,7 +5,37 @@ defmodule Vnu.ConfigTest do
describe "new" do
test "defaults" do
{:ok, config} = Config.new([])
assert config == %Config{server_url: "http://localhost:8888/", format: :html}

assert config == %Config{
http_client: Vnu.HTTPClient.Hackney,
server_url: "http://localhost:8888/",
format: :html
}
end

test "http_client must be a module" do
{:error, %Error{} = error} = Config.new(http_client: "apple")

assert error.reason == :invalid_config
assert error.message == "Expected http_client to be a module, got: \"apple\""
end

test "http_client must implement exist" do
{:error, %Error{} = error} = Config.new(http_client: SomeModule.That.DoesntExist)

assert error.reason == :invalid_config

assert error.message ==
"Expected http_client to be a module that implements the Vnu.HTTPClient behaviour, got: SomeModule.That.DoesntExist"
end

test "http_client must implement the Vnu.HTTPClient behaviour" do
{:error, %Error{} = error} = Config.new(http_client: String)

assert error.reason == :invalid_config

assert error.message ==
"Expected http_client to be a module that implements the Vnu.HTTPClient behaviour, got: String"
end

test "adds trailing slash to server_url" do
Expand Down
27 changes: 27 additions & 0 deletions test/vnu/http_test.exs
Expand Up @@ -121,5 +121,32 @@ defmodule Vnu.HTTPTest do
Config.new!(server_url: "https://validator.w3.org/nu", format: :html)
)
end

test "works with a custom HTTPClient" do
defmodule MyHTTPClient do
def post(_url, body, _header) do
if is_bitstring(body) do
{:ok,
%{
status: 200,
body: Jason.encode!(%{messages: [%{type: "error", message: body}]})
}}
else
{:error, "oops"}
end
end
end

config = Config.new!(http_client: MyHTTPClient, format: :html)

{:ok, %Result{} = result} = HTTP.get_result("echo... echo... echo...", config)

assert result.messages == [%Vnu.Message{message: "echo... echo... echo...", type: :error}]

{:error, %Error{} = error} = HTTP.get_result(123, config)

assert error.message == "Could not contact the server, got error: \"oops\""
assert error.reason == :unexpected_server_response
end
end
end

0 comments on commit fd7ffc0

Please sign in to comment.