diff --git a/.credo.exs b/.credo.exs index a07bb48a3f9..384b1915517 100644 --- a/.credo.exs +++ b/.credo.exs @@ -30,7 +30,6 @@ ~r"/src/fast_pbkdf2/", ~r"/src/jason", ~r"/src/hackney", - ~r"/src/httpotion", ~r"/src/file_system", ~r"/src/credo", ~r"/src/idna", diff --git a/.gitignore b/.gitignore index 28dcadf05f7..cb42cdb7578 100644 --- a/.gitignore +++ b/.gitignore @@ -130,7 +130,6 @@ test/javascript/junit.xml /_build/ /src/bunt /src/credo/ -/src/httpotion/ /src/jason/ /src/junit_formatter/ diff --git a/mix.exs b/mix.exs index 701bef5f691..cb6962c2716 100644 --- a/mix.exs +++ b/mix.exs @@ -73,7 +73,7 @@ defmodule CouchDBTest.Mixfile do end # Run "mix help compile.app" to learn about applications. - def application, do: [applications: [:logger, :httpotion]] + def application, do: [applications: [:logger]] # Specifies which paths to compile per environment. defp elixirc_paths(:test), do: ["test/elixir/lib", "test/elixir/test/support"] @@ -84,9 +84,7 @@ defmodule CouchDBTest.Mixfile do defp deps() do deps1 = [ {:junit_formatter, "~> 3.4", only: [:dev, :test, :integration]}, - {:httpotion, ">= 3.2.0", only: [:dev, :test, :integration], runtime: false}, {:excoveralls, "~> 0.18.5", only: :test}, - {:ibrowse, path: path("ibrowse"), override: true}, {:credo, "== 1.7.19", only: [:dev, :test, :integration], runtime: false} ] @@ -95,14 +93,14 @@ defmodule CouchDBTest.Mixfile do deps_list = deps1 ++ deps2 - [:config, :couch, :fabric] + [:config, :couch, :fabric, :gun, :cowlib] |> Enum.map(&path("#{&1}/ebin")) |> Enum.map(&String.to_charlist/1) |> Enum.each(&:code.add_patha/1) # Some deps may be missing during source check # Besides we don't want to spend time checking them anyway - List.foldl([:ibrowse | extra_deps], deps_list, fn dep, acc -> + List.foldl(extra_deps, deps_list, fn dep, acc -> if File.dir?(acc[dep][:path]) do acc else @@ -133,7 +131,6 @@ defmodule CouchDBTest.Mixfile do "credo", "excoveralls", "hackney", - "httpotion", "ibrowse", "idna", "jason", diff --git a/src/chttpd/test/eunit/chttpd_db_attachment_size_tests.erl b/src/chttpd/test/eunit/chttpd_db_attachment_size_tests.erl index 4202916164d..1b49beb9228 100644 --- a/src/chttpd/test/eunit/chttpd_db_attachment_size_tests.erl +++ b/src/chttpd/test/eunit/chttpd_db_attachment_size_tests.erl @@ -394,8 +394,9 @@ req(Method, Url, Headers, Body) -> {ok, Code, _, Res} = test_request:request(Method, Url, Headers1, Body), {Code, json_decode(Res)}. -% Data streaming generator for ibrowse client. ibrowse will repeatedly call the -% function with State and it should return {ok, Data, NewState} or eof at end. +% Data streaming generator for the test http client, which repeatedly calls +% the function with State; it should return {ok, Data, NewState} or eof at +% the end. data_stream_fun(Size) -> Fun = fun (0) -> eof; diff --git a/src/chttpd/test/eunit/chttpd_dbs_info_test.erl b/src/chttpd/test/eunit/chttpd_dbs_info_test.erl index a53442f98f2..0749c2f709e 100644 --- a/src/chttpd/test/eunit/chttpd_dbs_info_test.erl +++ b/src/chttpd/test/eunit/chttpd_dbs_info_test.erl @@ -21,7 +21,7 @@ -define(CONTENT_JSON, {"Content-Type", "application/json"}). start() -> - Ctx = test_util:start_couch([inets, chttpd]), + Ctx = test_util:start_couch([chttpd]), DbDir = config:get("couchdb", "database_dir"), Suffix = ?b2l(couch_uuids:random()), test_util:with_couch_server_restart(fun() -> @@ -183,17 +183,12 @@ should_return_nothing_when_db_not_exist_for_get_dbs_info(_) -> should_return_500_time_out_when_time_is_not_enough_for_get_dbs_info(_) -> mock_timeout(), - Auth = base64:encode_to_string(?USER ++ ":" ++ ?PASS), - Headers = [{"Authorization", "Basic " ++ Auth}], - Request = {dbs_info_url("buffer_response=true"), Headers}, + Url = dbs_info_url("buffer_response=true"), {Props} = test_util:wait( fun() -> - % Use httpc to avoid ibrowse returning {error, - % retry_later} in some cases, causing test_request to - % sleep and retry, resulting in timeout failures. - case httpc:request(get, Request, [], []) of - {ok, {{_, Code, _}, _, Body}} -> + case test_request:get(Url, [?CONTENT_JSON, ?AUTH]) of + {ok, Code, _, Body} -> ?assertEqual(500, Code), jiffy:decode(Body); _ -> diff --git a/src/couch/src/couch.app.src b/src/couch/src/couch.app.src index 5f1fb9800bd..140140f8214 100644 --- a/src/couch/src/couch.app.src +++ b/src/couch/src/couch.app.src @@ -36,6 +36,7 @@ os_mon, % Upstream deps + gun, ibrowse, mochiweb, diff --git a/src/couch/src/couch_gun.erl b/src/couch/src/couch_gun.erl new file mode 100644 index 00000000000..ede2b0ade06 --- /dev/null +++ b/src/couch/src/couch_gun.erl @@ -0,0 +1,448 @@ +% Licensed under the Apache License, Version 2.0 (the "License"); you may not +% use this file except in compliance with the License. You may obtain a copy of +% the License at +% +% http://www.apache.org/licenses/LICENSE-2.0 +% +% Unless required by applicable law or agreed to in writing, software +% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +% License for the specific language governing permissions and limitations under +% the License. + +% Helper module to use gun instead of other http clients we had. +% +% Functions: +% +% * req/3,4,5: For basic synchronous requests +% +% * parse_url/1: Helper parser to turn urls with possible auth bits +% embedded into a gun uri map +% +% * open/3, close/1: Open/close connections +% +% * send/5,6: Send requests on opened connections. Can take {Fun, State} for +% a streaming body. +% +% * await/3: Wait for response a send. +% +% * headers/1, method/1, basic_auth/2: convert existing calling conventions +% to gun's format. These are helpers to avoid modifying all the call sites +% right off the bat. For example, the codebase expects headers to be +% strings and methods to be atom so we transform them here accordingly. +% +% * norm_error/1: shorten/normalize gun's error reason + +-module(couch_gun). + +-export([ + req/3, + req/4, + req/5, + parse_url/1, + open/3, + close/1, + send/5, + send/6, + await/3, + headers/1, + method/1, + basic_auth/2, + norm_error/1 +]). + +-define(DEFAULT_TIMEOUT, 30000). + +req(Method, Url, Headers) -> + req(Method, Url, Headers, <<>>, #{}). + +req(Method, Url, Headers, Body) -> + req(Method, Url, Headers, Body, #{}). + +req(Method, Url, Headers, Body, #{} = Opts) when is_atom(Method), is_list(Url) -> + case parse_url(Url) of + {ok, #{transport := Transport, host := Host, port := Port} = Parsed} -> + #{path := Path, userinfo := UserInfo} = Parsed, + Timeout = maps:get(timeout, Opts, ?DEFAULT_TIMEOUT), + Headers1 = auth_headers(headers(Headers), UserInfo, Opts), + OpenOpts = maps:with([tls_opts, tcp_opts, gun_opts], Opts), + case open(Host, Port, OpenOpts#{transport => Transport}) of + {ok, Pid} -> + try + case gun:await_up(Pid, Timeout) of + {ok, _} -> + Ref = send(Pid, Method, Path, Headers1, Body), + await(Pid, Ref, Timeout); + {error, Reason} -> + {error, norm_error(Reason)} + end + after + close(Pid) + end; + {error, Reason} -> + {error, norm_error(Reason)} + end; + {error, _} = Error -> + Error + end. + +parse_url("http://" ++ Rest) -> + parse_auth(tcp, 80, Rest); +parse_url("https://" ++ Rest) -> + parse_auth(tls, 443, Rest); +parse_url(_) -> + {error, invalid_uri}. + +parse_auth(Transport, DefaultPort, Rest) -> + {Auth, Path} = + case lists:splitwith(fun(C) -> C /= $/ andalso C /= $? end, Rest) of + {A, ""} -> {A, "/"}; + {A, "?" ++ _ = Query} -> {A, "/" ++ Query}; + {A, P} -> {A, P} + end, + {UserInfo, HostPort} = + case string:split(Auth, "@", trailing) of + [Creds, HP] -> {Creds, HP}; + [HP] -> {undefined, HP} + end, + case parse_host_port(HostPort, DefaultPort) of + {error, _} = Error -> + Error; + {Host, Port} -> + {ok, #{ + transport => Transport, + host => Host, + port => Port, + path => Path, + userinfo => UserInfo + }} + end. + +parse_host_port("", _DefaultPort) -> + {error, invalid_uri}; +parse_host_port("[" ++ Rest, DefaultPort) -> + % ipv6 with brackets [...] + case string:split(Rest, "]") of + ["", _] -> + {error, invalid_uri}; + [Host, ""] -> + {Host, DefaultPort}; + [Host, ":" ++ PortStr] -> + case string:to_integer(PortStr) of + {Port, ""} when is_integer(Port) -> {Host, Port}; + _ -> {error, invalid_uri} + end; + _ -> + {error, invalid_uri} + end; +parse_host_port(HostPort, DefaultPort) -> + case string:split(HostPort, ":", trailing) of + ["", _] -> + {error, invalid_uri}; + [Host, PortStr] -> + case string:to_integer(PortStr) of + {Port, ""} when is_integer(Port) -> {Host, Port}; + _ -> {error, invalid_uri} + end; + [Host] -> + {Host, DefaultPort} + end. + +% Open an http 1.1 connection. Opts #{} may have these fields: +% transport - tcp (default) | tls +% tls_opts - TSL client opts. Default is [] +% tcp_opts - Options for gen_tcp +% gun_opts - Other options for gun:open/3 +% +% Host maybe a string or address tuple. Connection pid returned but we don't +% wait for it to be up. Caller should do that (call gun:wait_up/2) or if they +% don't care just do a send right away. +% +open(Host, Port, #{} = Opts) -> + Transport = maps:get(transport, Opts, tcp), + OpenOpts0 = #{transport => Transport, protocols => [http], retry => 0}, + OpenOpts1 = + case Opts of + #{tcp_opts := TcpOpts} -> OpenOpts0#{tcp_opts => TcpOpts}; + #{} -> OpenOpts0 + end, + OpenOpts2 = + case Transport of + tls -> OpenOpts1#{tls_opts => maps:get(tls_opts, Opts, [])}; + tcp -> OpenOpts1 + end, + OpenOpts = maps:merge(OpenOpts2, maps:get(gun_opts, Opts, #{})), + gun:open(host(Host), Port, OpenOpts). + +close(Pid) when is_pid(Pid) -> + try + gun:close(Pid) + catch + _:_ -> ok + end, + ok. + +% Send a request and get back a stream reference. The body may be given as +% {Fun, State} tuple. So Fun(State) should return {ok, Data, NewState} and then +% return eof at the end. That will be sent chunked unless a content-length +% header is set. +send(Pid, Method, Path, Headers, Body) -> + send(Pid, Method, Path, Headers, Body, #{}). + +send(Pid, Method, Path, Headers, {Fun, State}, ReqOpts) when is_function(Fun, 1) -> + SRef = gun:headers(Pid, method(Method), Path, headers(Headers), ReqOpts), + ok = send_body(Pid, SRef, Fun, State), + SRef; +send(Pid, Method, Path, Headers, Body, ReqOpts) when is_pid(Pid) -> + gun:request(Pid, method(Method), Path, headers(Headers), body(Body), ReqOpts). + +body([]) -> + <<>>; +body(Body) -> + Body. + +% Send streaming body helper. +send_body(Pid, SRef, Fun, State) -> + case Fun(State) of + {ok, Data, State1} -> + send_body(Pid, SRef, Fun, State1, Data); + eof -> + ok = gun:data(Pid, SRef, fin, <<>>) + end. + +send_body(Pid, SRef, Fun, State, Data0) -> + case Fun(State) of + {ok, Data, State1} -> + % Send pending data before sending the next + % we're doing one chunk at a time here + ok = gun:data(Pid, SRef, nofin, Data0), + send_body(Pid, SRef, Fun, State1, Data); + eof -> + ok = gun:data(Pid, SRef, fin, Data0) + end. + +% Wait for a response. First wait for status + headers then body. 1xx info +% responses are skipped and we don't care about trailer either. If we got a bad +% connection and didn't find out until calling send and await we'll get the +% error here. +await(Pid, SRef, Timeout) when is_pid(Pid) -> + MRef = monitor(process, Pid), + try await_headers(Pid, SRef, Timeout, MRef) of + {response, fin, Code, RespHeaders} -> + {ok, Code, headers_from_gun(RespHeaders), <<>>}; + {response, nofin, Code, RespHeaders} -> + case gun:await_body(Pid, SRef, Timeout, MRef) of + {ok, RespBody} -> + {ok, Code, headers_from_gun(RespHeaders), RespBody}; + {ok, RespBody, _Trailers} -> + {ok, Code, headers_from_gun(RespHeaders), RespBody}; + {error, Reason} -> + {error, norm_error(Reason)} + end; + {error, Reason} -> + {error, norm_error(Reason)} + after + demonitor(MRef, [flush]) + end. + +await_headers(Pid, SRef, Timeout, MRef) -> + case gun:await(Pid, SRef, Timeout, MRef) of + {inform, _Status, _Headers} -> await_headers(Pid, SRef, Timeout, MRef); + Other -> Other + end. + +headers_from_gun(Headers) -> + [{binary_to_list(K), binary_to_list(V)} || {K, V} <- Headers]. + +% Transform our request headers into gun's lowercase binary shape. Previous +% http client accepted special atom headers like {basic_auth, {User, Pass}} and +% {cookie, Value}, {content_type, Type}, {content_length, Len}. We handle those +% here to avoid modifying all the call sites. + +headers(Headers) -> + [header(H) || H <- Headers]. + +header({basic_auth, {User, Pass}}) -> + basic_auth(User, Pass); +header({cookie, Cookie}) -> + {~"cookie", to_bin(Cookie)}; +header({content_type, Value}) -> + {~"content-type", to_bin(Value)}; +header({content_length, Value}) -> + {~"content-length", to_bin(Value)}; +header({Name, Value}) -> + {string:lowercase(to_bin(Name)), to_bin(Value)}. + +method(Method) when is_atom(Method) -> + string:uppercase(atom_to_binary(Method, utf8)). + +basic_auth(User, Pass) -> + UserPass = base64:encode(iolist_to_binary([User, $:, Pass])), + {~"authorization", <<"Basic ", UserPass/binary>>}. + +% If headers already have authorization set use that, otherwise take from the +% userinfo field from the url +auth_headers(Headers, UserInfo, Opts) -> + case lists:keymember(~"authorization", 1, Headers) of + true -> + Headers; + false -> + case {Opts, UserInfo} of + {#{basic_auth := {User, Pass}}, _} -> + [basic_auth(User, Pass) | Headers]; + {#{}, undefined} -> + Headers; + {#{}, UserInfo} -> + case string:split(UserInfo, ":") of + [User, Pass] -> [basic_auth(User, Pass) | Headers]; + [User] -> [basic_auth(User, "") | Headers] + end + end + end. + +to_bin(V) when is_atom(V) -> + atom_to_binary(V, utf8); +to_bin(V) -> + iolist_to_binary(V). + +% gun expects IPs as address tuples +host(Host) when is_list(Host) -> + case inet:parse_strict_address(Host) of + {ok, Ip} -> Ip; + {error, _} -> Host + end; +host(Host) -> + Host. + +% To implify error handling return just the {error, Reason} to make it easier +% for callers to handle it instead of the multi-level nested error shapes from +% gun. +norm_error({stream_error, Reason}) -> + norm_error(Reason); +norm_error({connection_error, Reason}) -> + norm_error(Reason); +norm_error({down, {shutdown, Reason}}) -> + norm_error(Reason); +norm_error({down, Reason}) -> + norm_error(Reason); +norm_error({shutdown, Reason}) -> + norm_error(Reason); +norm_error(Reason) -> + Reason. + +-ifdef(TEST). + +-include_lib("couch/include/couch_eunit.hrl"). + +parse_url_test() -> + ?assertEqual( + {ok, #{transport => tcp, host => "h", port => 80, path => "/", userinfo => undefined}}, + parse_url("http://h") + ), + ?assertEqual( + {ok, #{ + transport => tcp, host => "h", port => 5984, path => "/db?a=b", userinfo => undefined + }}, + parse_url("http://h:5984/db?a=b") + ), + ?assertEqual( + {ok, #{transport => tls, host => "h", port => 443, path => "/", userinfo => undefined}}, + parse_url("https://h") + ), + ?assertEqual( + {ok, #{ + transport => tcp, host => "127.0.0.1", port => 80, path => "/", userinfo => undefined + }}, + parse_url("http://127.0.0.1") + ), + ?assertEqual( + {ok, #{transport => tcp, host => "::1", port => 5984, path => "/db", userinfo => undefined}}, + parse_url("http://[::1]:5984/db") + ), + ?assertEqual( + {ok, #{transport => tcp, host => "::1", port => 80, path => "/", userinfo => undefined}}, + parse_url("http://[::1]") + ), + ?assertEqual( + {ok, #{transport => tcp, host => "h", port => 80, path => "/", userinfo => "u:p"}}, + parse_url("http://u:p@h") + ), + ?assertEqual( + {ok, #{ + transport => tcp, + host => "h", + port => 15984, + path => "/_dbs_info?startkey=\"db1\"&endkey=\"db2\"", + userinfo => undefined + }}, + parse_url("http://h:15984/_dbs_info?startkey=\"db1\"&endkey=\"db2\"") + ), + ?assertEqual( + {ok, #{transport => tcp, host => "h", port => 80, path => "/?q=1", userinfo => undefined}}, + parse_url("http://h?q=1") + ), + ?assertEqual({error, invalid_uri}, parse_url("a potato")), + ?assertEqual({error, invalid_uri}, parse_url("ftp://h/")), + ?assertEqual({error, invalid_uri}, parse_url("http://")), + ?assertEqual({error, invalid_uri}, parse_url("http://:80/")), + ?assertEqual({error, invalid_uri}, parse_url("http://h:x/")), + ?assertEqual({error, invalid_uri}, parse_url("http://[::1")), + ?assertEqual({error, invalid_uri}, parse_url("http://[]:80/")). + +host_test() -> + ?assertEqual("cdb.example.com", host("cdb.example.com")), + ?assertEqual({127, 0, 0, 1}, host("127.0.0.1")), + ?assertEqual({0, 0, 0, 0, 0, 0, 0, 1}, host("::1")), + ?assertEqual({1, 2, 3, 4}, host({1, 2, 3, 4})). + +headers_test() -> + ?assertEqual([], headers([])), + ?assertEqual( + [{~"content-type", ~"application/json"}], + headers([{"Content-Type", "application/json"}]) + ), + ?assertEqual( + [{~"x-foo", ~"1"}, {~"accept", ~"*/*"}], + headers([{'X-Foo', "1"}, {~"Accept", ~"*/*"}]) + ), + ?assertEqual( + [basic_auth("u", "p"), {~"cookie", ~"k=v"}], + headers([{basic_auth, {"u", "p"}}, {cookie, "k=v"}]) + ), + ?assertEqual( + [{~"content-type", ~"text/plain"}, {~"content-length", ~"3"}], + headers([{content_type, "text/plain"}, {content_length, "3"}]) + ). + +method_test() -> + ?assertEqual(~"GET", method(get)), + ?assertEqual(~"COPY", method(copy)), + ?assertEqual(~"DELETE", method('Delete')). + +basic_auth_test() -> + ?assertEqual( + {~"authorization", <<"Basic ", (base64:encode(~"u:p"))/binary>>}, + basic_auth("u", "p") + ), + ?assertEqual(basic_auth("u", "p"), basic_auth(~"u", ~"p")). + +auth_headers_test() -> + Auth = basic_auth("u", "p"), + Override = [{~"authorization", ~"Bearer dabears"}], + ?assertEqual([], auth_headers([], undefined, #{})), + ?assertEqual([Auth], auth_headers([], undefined, #{basic_auth => {"u", "p"}})), + ?assertEqual([Auth], auth_headers([], "u:p", #{})), + ?assertEqual([basic_auth("u", "")], auth_headers([], "u", #{})), + % The option take effect when header not set + ?assertEqual([Auth], auth_headers([], "x:y", #{basic_auth => {"u", "p"}})), + % If header is set go with the header + ?assertEqual(Override, auth_headers(Override, "x:y", #{basic_auth => {"u", "p"}})). + +norm_error_test() -> + ?assertEqual(econnrefused, norm_error({down, {shutdown, econnrefused}})), + ?assertEqual(closed, norm_error({stream_error, closed})), + ?assertEqual(closed, norm_error({connection_error, closed})), + ?assertEqual(timeout, norm_error(timeout)), + ?assertEqual(normal, norm_error({down, normal})). + +-endif. diff --git a/src/couch/src/test_request.erl b/src/couch/src/test_request.erl index d7364012fdc..dde4af4f5b2 100644 --- a/src/couch/src/test_request.erl +++ b/src/couch/src/test_request.erl @@ -20,6 +20,8 @@ -export([options/1, options/2, options/3]). -export([request/3, request/4, request/5]). +-define(TIMEOUT, 30000). + copy(Url) -> copy(Url, []). @@ -86,25 +88,101 @@ request(Method, Url, Headers, Body, Opts) -> request(_Method, _Url, _Headers, _Body, _Opts, 0) -> {error, request_failed}; request(Method, Url, Headers, Body, Opts, N) -> - case code:is_loaded(ibrowse) of - false -> - {ok, _} = ibrowse:start(); - _ -> - ok - end, - case ibrowse:send_req(Url, Headers, Method, Body, Opts) of - {ok, Code0, RespHeaders, RespBody0} -> - Code = list_to_integer(Code0), - RespBody = iolist_to_binary(RespBody0), - {ok, Code, RespHeaders, RespBody}; - {error, {'EXIT', {normal, _}}} -> - % Connection closed right after a successful request that - % used the same connection. - request(Method, Url, Headers, Body, Opts, N - 1); - {error, retry_later} -> - % CouchDB is busy, let’s wait a bit - timer:sleep(3000 div N), + {ok, _} = application:ensure_all_started(gun), + Headers1 = headers(Headers, Opts), + ReqOpts = #{timeout => ?TIMEOUT, tls_opts => [{verify, verify_none}]}, + case couch_gun:req(Method, Url, Headers1, Body, ReqOpts) of + {ok, Code, RespHeaders, RespBody} -> + {ok, Code, canonical_headers(RespHeaders), RespBody}; + {error, closed} -> + % Retry. Possible race with the server starting. request(Method, Url, Headers, Body, Opts, N - 1); Error -> Error end. + +headers(Headers, Opts) -> + lists:foldl(fun apply_opt/2, couch_gun:headers(Headers), Opts). + +apply_opt({host_header, Value}, Headers) -> + [Host] = couch_gun:headers([{host, Value}]), + lists:keystore(~"host", 1, Headers, Host); +apply_opt({basic_auth, {User, Pass}}, Headers) -> + Auth = couch_gun:basic_auth(User, Pass), + lists:keystore(~"authorization", 1, Headers, Auth); +apply_opt(_Other, Headers) -> + Headers. + +% Gun returns headers as lower case we update them to camel case to avoid +% updating all the test call sites at this moment. Some are non-standard so we +% handle them as special cases +% +canonical_headers(Headers) -> + [{canonical_name(K), V} || {K, V} <- Headers]. + +canonical_name("etag") -> + "ETag"; +canonical_name("www-authenticate") -> + "WWW-Authenticate"; +canonical_name("content-md5") -> + "Content-MD5"; +canonical_name("x-couchdb-body-time") -> + "X-CouchDB-Body-Time"; +canonical_name("x-couch-request-id") -> + "X-Couch-Request-ID"; +canonical_name("x-couch-update-newrev") -> + "X-Couch-Update-NewRev"; +canonical_name("x-couchdb-vhost-path") -> + "x-couchdb-vhost-path"; +canonical_name(Name) -> + Parts = string:split(Name, "-", all), + lists:flatten(lists:join("-", [cap(S) || S <- Parts])). + +cap("") -> + ""; +cap([C | Rest]) -> + string:uppercase([C]) ++ string:lowercase(Rest). + +-ifdef(TEST). + +-include_lib("couch/include/couch_eunit.hrl"). + +canonical_name_test() -> + ?assertEqual("Content-Type", canonical_name("content-type")), + ?assertEqual("ETag", canonical_name("etag")), + ?assertEqual("X-Couch-Request-ID", canonical_name("x-couch-request-id")), + ?assertEqual("X-Foo-", canonical_name("x-foo-")). + +headers_test() -> + ?assertEqual([], headers([], [])), + ?assertEqual( + [{~"content-type", ~"application/json"}], + headers([{"Content-Type", "application/json"}], []) + ), + Auth = couch_gun:basic_auth("u", "p"), + ?assertEqual( + [{~"authorization", ~"Basic dTpw"}], + headers([{basic_auth, {"u", "p"}}], []) + ), + ?assertEqual( + [{~"cookie", ~"k=v"}], + headers([{cookie, "k=v"}], []) + ), + ?assertEqual( + [{~"accept", ~"*/*"}, {~"host", ~"potato.local"}], + headers([{"Accept", "*/*"}], [{host_header, "potato.local"}]) + ), + ?assertEqual( + [{~"host", ~"b"}], + headers([{"Host", "a"}], [{host_header, "b"}]) + ), + ?assertEqual( + [Auth], + headers([], [{basic_auth, {"u", "p"}}]) + ), + ?assertEqual( + [Auth], + headers([{basic_auth, {"x", "y"}}], [{basic_auth, {"u", "p"}}]) + ). + +-endif. diff --git a/src/couch/src/test_util.erl b/src/couch/src/test_util.erl index fd5364fb74a..dabbb33825c 100644 --- a/src/couch/src/test_util.erl +++ b/src/couch/src/test_util.erl @@ -46,7 +46,7 @@ -record(test_context, {mocked = [], started = [], module}). --define(DEFAULT_APPS, [inets, ibrowse, ssl, config, couch_epi, couch_event, couch]). +-define(DEFAULT_APPS, [inets, gun, ssl, config, couch_epi, couch_event, couch]). srcdir() -> code:priv_dir(couch) ++ "/../../". @@ -58,7 +58,8 @@ init_code_path() -> Paths = [ "couchdb", "jiffy", - "ibrowse", + "gun", + "cowlib", "mochiweb", "snappy" ], diff --git a/src/couch_prometheus/test/eunit/couch_prometheus_e2e_tests.erl b/src/couch_prometheus/test/eunit/couch_prometheus_e2e_tests.erl index 913b80834c5..1d4e444478d 100644 --- a/src/couch_prometheus/test/eunit/couch_prometheus_e2e_tests.erl +++ b/src/couch_prometheus/test/eunit/couch_prometheus_e2e_tests.erl @@ -105,7 +105,7 @@ t_prometheus_port(_) -> t_reject_prometheus_port(Port) -> Response = test_request:get(node_local_url(Port), [?CONTENT_JSON, ?AUTH]), - ?assertEqual({error, {conn_failed, {error, econnrefused}}}, Response). + ?assertEqual({error, econnrefused}, Response). t_no_duplicate_metrics(Port) -> Url = node_local_url(Port), diff --git a/test/elixir/lib/couch.ex b/test/elixir/lib/couch.ex index a119095a9f0..dde356bedce 100644 --- a/test/elixir/lib/couch.ex +++ b/test/elixir/lib/couch.ex @@ -42,9 +42,12 @@ defmodule Couch.Session do # if the need arises. def go(%Couch.Session{} = sess, method, url, opts) do parse_response = Keyword.get(opts, :parse_response, true) - opts = opts - |> Keyword.merge(cookie: sess.cookie) - |> Keyword.delete(:parse_response) + + opts = + opts + |> Keyword.merge(cookie: sess.cookie) + |> Keyword.delete(:parse_response) + if parse_response do Couch.request(method, url, opts) else @@ -54,9 +57,12 @@ defmodule Couch.Session do def go!(%Couch.Session{} = sess, method, url, opts) do parse_response = Keyword.get(opts, :parse_response, true) - opts = opts - |> Keyword.merge(cookie: sess.cookie) - |> Keyword.delete(:parse_response) + + opts = + opts + |> Keyword.merge(cookie: sess.cookie) + |> Keyword.delete(:parse_response) + if parse_response do Couch.request!(method, url, opts) else @@ -66,112 +72,37 @@ defmodule Couch.Session do end defmodule Couch do - use HTTPotion.Base - @moduledoc """ CouchDB library to power test suite. """ - # These constants are supplied to the underlying HTTP client and control - # how long we will wait before timing out a test. The inactivity timeout - # specifically fires during an active HTTP response and defaults to 10_000 - # if not specified. We're defining it to a different value than the - # request_timeout largely just so we know which timeout fired. - @request_timeout 60_000 - @inactivity_timeout 55_000 - - def process_url("http://" <> _ = url) do - url - end - - def process_url(url) do - base_url = System.get_env("EX_COUCH_URL") || "http://127.0.0.1:15984" - base_url <> url - end - - def process_request_headers(headers, _body, options) do - headers = Keyword.put(headers, :"User-Agent", "couch-potion") - - headers = - if headers[:"Content-Type"] do - headers - else - Keyword.put(headers, :"Content-Type", "application/json") - end - - case Keyword.get(options, :cookie) do - nil -> - headers - - cookie -> - Keyword.put(headers, :Cookie, cookie) - end - end - - def process_options(options) do - options - |> set_auth_options() - |> set_inactivity_timeout() - |> set_request_timeout() - end + defdelegate process_url(url), to: Couch.Http - def process_request_body(body) do - if is_map(body) do - :jiffy.encode(body, [:use_nil]) - else - body - end - end + def get(url, opts \\ []), do: request(:get, url, opts) + def get!(url, opts \\ []), do: request!(:get, url, opts) + def put(url, opts \\ []), do: request(:put, url, opts) + def put!(url, opts \\ []), do: request!(:put, url, opts) + def post(url, opts \\ []), do: request(:post, url, opts) + def post!(url, opts \\ []), do: request!(:post, url, opts) + def delete(url, opts \\ []), do: request(:delete, url, opts) + def delete!(url, opts \\ []), do: request!(:delete, url, opts) + def head(url, opts \\ []), do: request(:head, url, opts) + def head!(url, opts \\ []), do: request!(:head, url, opts) - def process_response_body(_headers, body) when body == [] do - "" + def request(method, url, opts \\ []) do + Couch.Http.request(method, url, opts, :json) end - def process_response_body(headers, body) do - content_type = headers[:"Content-Type"] - - if !!content_type and String.match?(content_type, ~r/application\/json/) do - body |> IO.iodata_to_binary() |> :jiffy.decode([:return_maps, :use_nil]) - else - process_response_body(body) - end - end + def request!(method, url, opts \\ []) do + case request(method, url, opts) do + %Couch.ErrorResponse{message: message} -> + raise "HTTP request failed: #{method} #{url}: #{message}" - def set_auth_options(options) do - cond do - Keyword.get(options, :no_auth, false) -> - options - Keyword.get(options, :cookie) == nil -> - headers = Keyword.get(options, :headers, []) - if headers[:basic_auth] != nil or headers[:authorization] != nil - or List.keymember?(headers, :"X-Auth-CouchDB-UserName", 0) do - options - else - username = System.get_env("EX_USERNAME") || "adm" - password = System.get_env("EX_PASSWORD") || "pass" - Keyword.put(options, :basic_auth, {username, password}) - end - true -> - options + resp -> + resp end end - def set_inactivity_timeout(options) do - Keyword.update( - options, - :ibrowse, - [{:inactivity_timeout, @inactivity_timeout}], - fn ibrowse -> - Keyword.put_new(ibrowse, :inactivity_timeout, @inactivity_timeout) - end - ) - end - - def set_request_timeout(options) do - timeout = Application.get_env(:httpotion, :default_timeout, @request_timeout) - Keyword.put_new(options, :timeout, timeout) - end - def login(userinfo) do [user, pass] = String.split(userinfo, ":", parts: 2) login(user, pass) diff --git a/test/elixir/lib/couch/dbtest.ex b/test/elixir/lib/couch/dbtest.ex index 693e6f0f39c..d221151a350 100644 --- a/test/elixir/lib/couch/dbtest.ex +++ b/test/elixir/lib/couch/dbtest.ex @@ -63,7 +63,7 @@ defmodule Couch.DBTest do on_exit(fn -> query = %{:rev => user["_rev"]} resp = Couch.delete("/_users/#{user["_id"]}", query: query) - assert HTTPotion.Response.success?(resp) + assert Couch.Response.success?(resp) end) context = Map.put(context, :user, user) @@ -159,7 +159,7 @@ defmodule Couch.DBTest do end resp = Couch.post("/_users", body: user_doc) - assert HTTPotion.Response.success?(resp) + assert Couch.Response.success?(resp) assert resp.body["ok"] Map.put(user_doc, "_rev", resp.body["rev"]) end @@ -337,7 +337,7 @@ defmodule Couch.DBTest do options = Map.put(options, :body, body) resp = Couch.post("/_replicate", Enum.to_list(options)) - assert HTTPotion.Response.success?(resp), "#{inspect(resp)}" + assert Couch.Response.success?(resp), "#{inspect(resp)}" resp.body end @@ -562,7 +562,7 @@ defmodule Couch.DBTest do defp restart_node(node, port) do url = "http://127.0.0.1:#{port}/_node/#{node}/_restart" resp = Couch.post(url) - assert HTTPotion.Response.success?(resp) + assert Couch.Response.success?(resp) assert resp.body["ok"] # make sure node went down. we assuming the node can't bounce quick # enough to inroduce a race here @@ -575,7 +575,7 @@ defmodule Couch.DBTest do url = "http://127.0.0.1:#{port}/_up" resp = Couch.get(url) - case HTTPotion.Response.success?(resp) do + case Couch.Response.success?(resp) do true -> resp.status_code in 200..399 false -> false end @@ -584,7 +584,7 @@ defmodule Couch.DBTest do defp node_to_port(node) do url = "/_node/#{node}/_config/chttpd/port" resp = Couch.get(url) - assert HTTPotion.Response.success?(resp) + assert Couch.Response.success?(resp) resp.body end end diff --git a/test/elixir/lib/couch/http.ex b/test/elixir/lib/couch/http.ex new file mode 100644 index 00000000000..35b37b2d6a7 --- /dev/null +++ b/test/elixir/lib/couch/http.ex @@ -0,0 +1,503 @@ +defmodule Couch.Headers do + @moduledoc """ + Response headers with case-insensitive access. + + Headers are stored in hdrs map in lower-case format (as gun returns them). + Then we normalize at access time. Same key headers are appended in order + of arrival. + """ + + @behaviour Access + + defstruct hdrs: %{} + + def new(headers) when is_list(headers) do + hdrs = + Enum.reduce(headers, %{}, fn {name, value}, acc -> + value = to_string(value) + + Map.update(acc, norm(name), value, fn + values when is_list(values) -> values ++ [value] + value0 -> [value0, value] + end) + end) + + %__MODULE__{hdrs: hdrs} + end + + def get(headers, key, default \\ nil) + + def get(%__MODULE__{hdrs: hdrs}, key, default) do + Map.get(hdrs, norm(key), default) + end + + @impl Access + def fetch(%__MODULE__{hdrs: hdrs}, key), do: Map.fetch(hdrs, norm(key)) + + @impl Access + def get_and_update(%__MODULE__{hdrs: hdrs} = headers, key, fun) do + {value, hdrs} = Map.get_and_update(hdrs, norm(key), fun) + {value, %{headers | hdrs: hdrs}} + end + + @impl Access + def pop(%__MODULE__{hdrs: hdrs} = headers, key) do + {value, hdrs} = Map.pop(hdrs, norm(key)) + {value, %{headers | hdrs: hdrs}} + end + + defp norm(key), do: key |> to_string() |> String.downcase() +end + +defmodule Couch.Response do + @moduledoc """ + Response to the request + """ + defstruct status_code: nil, headers: %Couch.Headers{}, body: "" + def success?(%__MODULE__{status_code: code}), do: code in 200..299 + def success?(_), do: false +end + +defmodule Couch.ErrorResponse do + @moduledoc """ + Error response to the request + """ + defstruct message: "" +end + +defmodule Couch.AsyncResponse do + @moduledoc """ + Response to a streaming request iniated by the stream_to: pid option + """ + defstruct [:id] +end + +defmodule Couch.AsyncHeaders do + @moduledoc """ + Streaming header response to a streaming request initiated by the stream_to: pid option + """ + defstruct [:id, :status_code, :headers] +end + +defmodule Couch.AsyncChunk do + @moduledoc """ + Streaming body chunk to a streaming request initiated by the stream_to: pid option + """ + defstruct [:id, :chunk] +end + +defmodule Couch.AsyncEnd do + @moduledoc """ + Stream end for a streaming request initiated by the stream_to: pid option + """ + defstruct [:id] +end + +defmodule Couch.Http do + @moduledoc """ + + Small http client built on gun. It looks a bit odd because it's trying to + mimick the now removed httpotion client shape a bit. Some of the patterns + here are also copied from couch_gun.erl. The one differense if we keep a + connected process cached in the process dict to speed up test runs here. + + Normal requests return Couch.Response | Couch.ErrorResponse results. + + Streaming requests should pass `stream_to: pid` as the option. Their response + will be Couch.AsyncResponse then followed by Couch.AsyncChunk messages and + finally Couch.AsyncEnd. + + Some requests options are: + :body + :headers + :query + :timeout + :cookie + :no_auth + """ + + @request_timeout 60_000 + @inactivity_timeout 55_000 + @attempts 3 + + def base_url do + System.get_env("EX_COUCH_URL") || "http://127.0.0.1:15984" + end + + def process_url("http://" <> _ = url), do: url + def process_url("https://" <> _ = url), do: url + def process_url(url), do: base_url() <> url + + def request(method, url, options, body_mode) when body_mode in [:json, :raw] do + ensure_gun_started() + url = url |> to_string() |> process_url() + url = append_query(url, Keyword.get(options, :query)) + method = method |> to_string() |> String.upcase() + headers = build_headers(options) + body = encode_body(Keyword.get(options, :body, "")) + + case Keyword.get(options, :stream_to) do + nil -> + sync_request(method, url, headers, body, options, body_mode) + + target when is_pid(target) -> + async_request(method, url, headers, body, target) + end + end + + defp sync_request(method, url, headers, body, options, body_mode) do + with {:ok, origin, path} <- parse_url(url), + {:ok, status, resp_headers, resp_body} <- + do_sync_request(origin, method, path, headers, body, options, 1) do + resp_headers = Couch.Headers.new(resp_headers) + + %Couch.Response{ + status_code: status, + headers: resp_headers, + body: process_body(body_mode, resp_headers, resp_body) + } + else + {:error, reason} -> %Couch.ErrorResponse{message: error_message(reason)} + end + end + + defp do_sync_request(origin, method, path, headers, body, options, attempt) do + timeout = Keyword.get(options, :timeout, @request_timeout) + deadline = now_ms() + timeout + conn = cached_conn(origin) + stream = :gun.request(conn, method, path, headers, body, %{}) + mref = Process.monitor(conn) + result = await_response(conn, stream, mref, deadline) + Process.demonitor(mref, [:flush]) + + case result do + {:ok, _status, resp_headers, _body} = ok -> + # If server closed the connection we drop it as well + if close_after?(resp_headers), do: invalidate(origin, conn) + ok + + {:error, reason} -> + reason = norm_error(reason) + # Teardown the cached connection on error and start fresh + invalidate(origin, conn) + + if conn_lost?(reason) and attempt < @attempts do + # Retry a few times on times or server start/stop race + do_sync_request(origin, method, path, headers, body, options, attempt + 1) + else + {:error, reason} + end + end + end + + defp await_response(conn, stream, mref, deadline) do + case gun_await(conn, stream, mref, deadline) do + {:inform, _status, _headers} -> + # These are 1xx and such and we don't care about them + await_response(conn, stream, mref, deadline) + + {:response, :fin, status, headers} -> + {:ok, status, headers, ""} + + {:response, :nofin, status, headers} -> + collect_body(conn, stream, mref, deadline, status, headers, []) + + {:error, _} = error -> + error + end + end + + defp collect_body(conn, stream, mref, deadline, status, headers, acc) do + case gun_await(conn, stream, mref, deadline) do + {:data, :nofin, data} -> + collect_body(conn, stream, mref, deadline, status, headers, [acc | data]) + + {:data, :fin, data} -> + {:ok, status, headers, IO.iodata_to_binary([acc | data])} + + {:trailers, _} -> + # We don't care about trailers + {:ok, status, headers, IO.iodata_to_binary(acc)} + + {:error, _} = error -> + error + end + end + + defp gun_await(conn, stream, mref, deadline) do + remaining = deadline - now_ms() + + if remaining <= 0 do + {:error, :req_timedout} + else + :gun.await(conn, stream, min(remaining, @inactivity_timeout), mref) + end + end + + # Streaming stuff + # + # A helper relay process opens the connection, makes the request forward + # response to the target as Couch.Async* messages. + + defp async_request(method, url, headers, body, target) do + case parse_url(url) do + {:ok, origin, path} -> + relay = spawn(fn -> relay_init(target, origin, method, path, headers, body) end) + %Couch.AsyncResponse{id: relay} + + {:error, reason} -> + %Couch.ErrorResponse{message: error_message(reason)} + end + end + + defp relay_init(target, origin, method, path, headers, body) do + tref = Process.monitor(target) + conn = open_conn(origin) + cref = Process.monitor(conn) + stream = :gun.request(conn, method, path, headers, body, %{}) + relay_loop(%{target: target, conn: conn, stream: stream, tref: tref, cref: cref}) + end + + defp relay_loop(state) do + %{target: target, conn: conn, stream: stream} = state + + receive do + {:gun_inform, ^conn, ^stream, _status, _headers} -> + # skip 1xx stuff + relay_loop(state) + + {:gun_response, ^conn, ^stream, fin, status, headers} -> + async_headers = %Couch.AsyncHeaders{ + id: self(), + status_code: status, + headers: Couch.Headers.new(headers) + } + + send(target, async_headers) + if fin == :fin, do: relay_done(state), else: relay_loop(state) + + {:gun_data, ^conn, ^stream, fin, data} -> + send(target, %Couch.AsyncChunk{id: self(), chunk: data}) + if fin == :fin, do: relay_done(state), else: relay_loop(state) + + {:gun_trailers, ^conn, ^stream, _trailers} -> + # don't care about trailers + relay_done(state) + + {:gun_error, ^conn, ^stream, _reason} -> + relay_done(state) + + {:gun_error, ^conn, _reason} -> + relay_done(state) + + {:DOWN, mref, :process, _pid, _reason} -> + cond do + mref == state.cref -> + # connection died + send(target, %Couch.AsyncEnd{id: self()}) + :ok + + mref == state.tref -> + # target (test) process died, clean up + close_conn(conn) + :ok + + true -> + relay_loop(state) + end + end + end + + defp relay_done(state) do + send(state.target, %Couch.AsyncEnd{id: self()}) + close_conn(state.conn) + :ok + end + + # Connection handling. This works for the test with one + # test client and one server decently enough. + defp cached_conn(origin) do + key = {:couch_http_conn, origin} + + case Process.get(key) do + pid when is_pid(pid) -> + if Process.alive?(pid) do + pid + else + Process.delete(key) + cached_conn(origin) + end + + nil -> + conn = open_conn(origin) + Process.put(key, conn) + conn + end + end + + defp invalidate(origin, conn) do + key = {:couch_http_conn, origin} + if Process.get(key) == conn, do: Process.delete(key) + close_conn(conn) + end + + defp open_conn({transport, host, port}) do + host_chars = String.to_charlist(host) + # gun expects IP address tuples + host_addr = + case :inet.parse_strict_address(host_chars) do + {:ok, addr} -> addr + {:error, _} -> host_chars + end + + opts = %{transport: transport, protocols: [:http], retry: 0} + + opts = + case transport do + :tls -> Map.put(opts, :tls_opts, [{:verify, :verify_none}]) + :tcp -> opts + end + + {:ok, conn} = :gun.open(host_addr, port, opts) + conn + end + + defp close_conn(conn) do + try do + :gun.close(conn) + catch + _, _ -> :ok + end + end + + defp ensure_gun_started() do + case Process.get(:couch_http_gun_started) do + true -> + :ok + + _ -> + {:ok, _} = Application.ensure_all_started(:gun) + Process.put(:couch_http_gun_started, true) + :ok + end + end + + defp parse_url(url) do + case URI.parse(url) do + %URI{scheme: scheme, host: host} = uri + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + transport = if scheme == "https", do: :tls, else: :tcp + path = uri.path || "/" + path = if uri.query, do: path <> "?" <> uri.query, else: path + {:ok, {transport, host, uri.port}, path} + + _ -> + {:error, :invalid_uri} + end + end + + defp append_query(url, query) when query == nil or query == [] or query == %{} do + url + end + + defp append_query(url, query) do + sep = if String.contains?(url, "?"), do: "&", else: "?" + url <> sep <> URI.encode_query(query) + end + + defp encode_body(nil), do: "" + defp encode_body(body) when is_map(body), do: :jiffy.encode(body, [:use_nil]) + defp encode_body(body), do: body + + defp build_headers(options) do + headers = + for {k, v} <- Keyword.get(options, :headers, []) do + {k |> to_string() |> String.downcase(), to_string(v)} + end + + headers = + headers + |> put_new_header("user-agent", "couch-potion") + |> put_new_header("content-type", "application/json") + + case Keyword.get(options, :cookie) do + nil -> set_auth(headers, options) + cookie -> put_new_header(headers, "cookie", cookie) + end + end + + # Auth may come from the environtment test setup + defp set_auth(headers, options) do + conf_auth? = + List.keymember?(headers, "authorization", 0) or + List.keymember?(headers, "x-auth-couchdb-username", 0) + + if Keyword.get(options, :no_auth, false) or conf_auth? do + headers + else + username = System.get_env("EX_USERNAME") || "adm" + password = System.get_env("EX_PASSWORD") || "pass" + credentials = Base.encode64("#{username}:#{password}") + [{"authorization", "Basic #{credentials}"} | headers] + end + end + + defp put_new_header(headers, key, value) do + if List.keymember?(headers, key, 0) do + headers + else + [{key, value} | headers] + end + end + + # Response stuff + + defp process_body(:raw, _headers, body), do: body + + defp process_body(:json, headers, body) do + content_type = headers["content-type"] + + json? = + is_binary(content_type) and + String.match?(content_type, ~r/application\/json/) + + if json? and body != "" do + :jiffy.decode(body, [:return_maps, :use_nil]) + else + body + end + end + + # Errors + + defp norm_error({:stream_error, reason}), do: norm_error(reason) + defp norm_error({:connection_error, reason}), do: norm_error(reason) + defp norm_error({:down, {:shutdown, reason}}), do: norm_error(reason) + defp norm_error({:down, reason}), do: norm_error(reason) + defp norm_error({:shutdown, reason}), do: norm_error(reason) + defp norm_error(reason), do: reason + + defp conn_lost?(:closed), do: true + defp conn_lost?({:closed, _}), do: true + defp conn_lost?(:normal), do: true + defp conn_lost?(:shutdown), do: true + defp conn_lost?(:noproc), do: true + defp conn_lost?(:einval), do: true + defp conn_lost?(:socket_closed_remotely), do: true + defp conn_lost?(_), do: false + + defp close_after?(headers) do + case List.keyfind(headers, "connection", 0) do + {_, value} -> String.downcase(value) == "close" + nil -> false + end + end + + defp error_message(:timeout), do: "req_timedout" + defp error_message(:req_timedout), do: "req_timedout" + defp error_message(reason) when is_atom(reason), do: Atom.to_string(reason) + defp error_message(reason), do: inspect(reason) + + # Helpers + defp now_ms(), do: System.monotonic_time(:millisecond) +end diff --git a/test/elixir/lib/couch_raw.ex b/test/elixir/lib/couch_raw.ex index 641612c9cd7..afe5f85b92b 100644 --- a/test/elixir/lib/couch_raw.ex +++ b/test/elixir/lib/couch_raw.ex @@ -1,105 +1,32 @@ defmodule Rawresp do - use HTTPotion.Base - @moduledoc """ - HTTP client that provides raw response as result + HTTP client that provides raw response as result. Same as `Couch` but + response bodies are returned as-is, without JSON decoding. """ - @request_timeout 60_000 - @inactivity_timeout 55_000 - - def process_url("http://" <> _ = url) do - url - end - - def process_url(url) do - base_url = System.get_env("EX_COUCH_URL") || "http://127.0.0.1:15984" - base_url <> url - end - - def process_request_headers(headers, _body, options) do - headers = - headers - |> Keyword.put(:"User-Agent", "couch-potion") - - headers = - if headers[:"Content-Type"] do - headers - else - Keyword.put(headers, :"Content-Type", "application/json") - end - - case Keyword.get(options, :cookie) do - nil -> - headers - - cookie -> - Keyword.put(headers, :Cookie, cookie) - end - end - - def process_options(options) do - options - |> set_auth_options() - |> set_inactivity_timeout() - |> set_request_timeout() - end - - def process_request_body(body) do - if is_map(body) do - :jiffy.encode(body, [:use_nil]) - else - body - end - end - - def set_auth_options(options) do - if Keyword.get(options, :cookie) == nil do - headers = Keyword.get(options, :headers, []) - - if headers[:basic_auth] != nil or headers[:authorization] != nil do - options - else - username = System.get_env("EX_USERNAME") || "adm" - password = System.get_env("EX_PASSWORD") || "pass" - Keyword.put(options, :basic_auth, {username, password}) - end - else - options - end - end - - def set_inactivity_timeout(options) do - Keyword.update( - options, - :ibrowse, - [{:inactivity_timeout, @inactivity_timeout}], - fn ibrowse -> - Keyword.put_new(ibrowse, :inactivity_timeout, @inactivity_timeout) - end - ) - end - - def set_request_timeout(options) do - timeout = Application.get_env(:httpotion, :default_timeout, @request_timeout) - Keyword.put_new(options, :timeout, timeout) - end - - def login(userinfo) do - [user, pass] = String.split(userinfo, ":", parts: 2) - login(user, pass) - end - - def login(user, pass, expect \\ :success) do - resp = Couch.post("/_session", body: %{:username => user, :password => pass}) - if expect == :success do - true = resp.body["ok"] - cookie = resp.headers[:"set-cookie"] - [token | _] = String.split(cookie, ";") - %Couch.Session{cookie: token} - else - true = Map.has_key?(resp.body, "error") - %Couch.Session{error: resp.body["error"]} + def get(url, opts \\ []), do: request(:get, url, opts) + def get!(url, opts \\ []), do: request!(:get, url, opts) + def put(url, opts \\ []), do: request(:put, url, opts) + def put!(url, opts \\ []), do: request!(:put, url, opts) + def post(url, opts \\ []), do: request(:post, url, opts) + def post!(url, opts \\ []), do: request!(:post, url, opts) + def delete(url, opts \\ []), do: request(:delete, url, opts) + def delete!(url, opts \\ []), do: request!(:delete, url, opts) + def head(url, opts \\ []), do: request(:head, url, opts) + def head!(url, opts \\ []), do: request!(:head, url, opts) + def options(url, opts \\ []), do: request(:options, url, opts) + + def request(method, url, opts \\ []) do + Couch.Http.request(method, url, opts, :raw) + end + + def request!(method, url, opts \\ []) do + case request(method, url, opts) do + %Couch.ErrorResponse{message: message} -> + raise "HTTP request failed: #{method} #{url}: #{message}" + + resp -> + resp end end end diff --git a/test/elixir/lib/step/start.ex b/test/elixir/lib/step/start.ex index b86b14a4feb..1f1ca2ac93d 100644 --- a/test/elixir/lib/step/start.ex +++ b/test/elixir/lib/step/start.ex @@ -4,7 +4,7 @@ defmodule Couch.Test.Setup.Step.Start do list of applications from DEFAULT_APPS macro defined in `test_util.erl`. At the time of writing this list included: - inets - - ibrowse + - gun - ssl - config - couch_epi diff --git a/test/elixir/test/attachments_multipart_test.exs b/test/elixir/test/attachments_multipart_test.exs index f940bae10ef..16723704819 100644 --- a/test/elixir/test/attachments_multipart_test.exs +++ b/test/elixir/test/attachments_multipart_test.exs @@ -312,14 +312,11 @@ defmodule AttachmentMultipartTest do end defp put_multipart_new_edits_false(db_name, rev, multipart_data) do - # Help ensure we're re-using client connections - ibrowse_opts = [{:max_sessions, 1}, {:max_pipeline_size, 1}] resp = Couch.put( "/#{db_name}/multipart_replicated_changes?new_edits=false&rev=#{rev}", body: multipart_data, - headers: ["Content-Type": "multipart/related;boundary=\"abc123\""], - ibrowse: ibrowse_opts + headers: ["Content-Type": "multipart/related;boundary=\"abc123\""] ) assert resp.status_code in [201, 202] diff --git a/test/elixir/test/attachments_test.exs b/test/elixir/test/attachments_test.exs index 2d3251b197e..5e1376eefc7 100644 --- a/test/elixir/test/attachments_test.exs +++ b/test/elixir/test/attachments_test.exs @@ -131,10 +131,10 @@ defmodule AttachmentsTest do assert resp.status_code in [201, 202] rev = resp.body["rev"] - resp = Couch.delete("/#{db_name}/bin_doc/foo.txt", body: "some payload", query: %{w: 3, rev: rev}, ibrowse: [{:max_sessions, 1}, {:max_pipeline_size, 1}]) + resp = Couch.delete("/#{db_name}/bin_doc/foo.txt", body: "some payload", query: %{w: 3, rev: rev}) assert resp.status_code == 200 - resp = Couch.get("/", timeout: 1000, ibrowse: [{:max_sessions, 1}, {:max_pipeline_size, 1}]) + resp = Couch.get("/", timeout: 1000) assert resp.status_code == 200 end diff --git a/test/elixir/test/changes_async_test.exs b/test/elixir/test/changes_async_test.exs index 75362d8a9c8..97a4b2ebbe2 100644 --- a/test/elixir/test/changes_async_test.exs +++ b/test/elixir/test/changes_async_test.exs @@ -38,12 +38,10 @@ defmodule ChangesAsyncTest do assert last_seq_prefix == "1-", "seq must start with 1-" last_seq = changes["last_seq"] - {:ok, worker_pid} = HTTPotion.spawn_link_worker_process(Couch.process_url("")) req_id = Couch.get("/#{db_name}/_changes?feed=longpoll&since=#{last_seq}", - stream_to: self(), - direct: worker_pid + stream_to: self() ) :ok = wait_for_headers(req_id.id, 200) @@ -60,8 +58,7 @@ defmodule ChangesAsyncTest do req_id = Couch.get("/#{db_name}/_changes?feed=longpoll&since=now", - stream_to: self(), - direct: worker_pid + stream_to: self() ) :ok = wait_for_headers(req_id.id, 200) @@ -82,12 +79,10 @@ defmodule ChangesAsyncTest do check_empty_db(db_name) create_doc(db_name, sample_doc_foo()) - {:ok, worker_pid} = HTTPotion.spawn_link_worker_process(Couch.process_url("")) req_id = Rawresp.get("/#{db_name}/_changes?feed=eventsource&timeout=500", - stream_to: self(), - direct: worker_pid + stream_to: self() ) :ok = wait_for_headers(req_id.id, 200) @@ -99,8 +94,6 @@ defmodule ChangesAsyncTest do assert length(changes) == 2 assert Enum.at(changes, 0)["id"] == "foo" assert Enum.at(changes, 1)["id"] == "bar" - - HTTPotion.stop_worker_process(worker_pid) end @tag :with_db @@ -110,12 +103,10 @@ defmodule ChangesAsyncTest do check_empty_db(db_name) create_doc(db_name, sample_doc_foo()) - {:ok, worker_pid} = HTTPotion.spawn_link_worker_process(Couch.process_url("")) req_id = Rawresp.get("/#{db_name}/_changes?feed=eventsource&limit=1", - stream_to: self(), - direct: worker_pid + stream_to: self() ) :ok = wait_for_headers(req_id.id, 200) @@ -125,8 +116,6 @@ defmodule ChangesAsyncTest do changes = process_response(req_id.id, &parse_event/1) assert length(changes) == 1 assert Enum.at(changes, 0)["id"] == "foo" - - HTTPotion.stop_worker_process(worker_pid) end @tag :with_db @@ -136,12 +125,10 @@ defmodule ChangesAsyncTest do check_empty_db(db_name) create_doc(db_name, sample_doc_foo()) - {:ok, worker_pid} = HTTPotion.spawn_link_worker_process(Couch.process_url("")) req_id = Rawresp.get("/#{db_name}/_changes?feed=eventsource&limit=2", - stream_to: self(), - direct: worker_pid + stream_to: self() ) :ok = wait_for_headers(req_id.id, 200) @@ -152,8 +139,6 @@ defmodule ChangesAsyncTest do assert length(changes) == 2 assert Enum.at(changes, 0)["id"] == "foo" assert Enum.at(changes, 1)["id"] == "bar" - - HTTPotion.stop_worker_process(worker_pid) end @tag :with_db @@ -166,12 +151,9 @@ defmodule ChangesAsyncTest do t0 = :erlang.monotonic_time(:millisecond) - {:ok, worker_pid} = HTTPotion.spawn_link_worker_process(Couch.process_url("")) - req_id = Rawresp.get("/#{db_name}/_changes?feed=eventsource&timeout=1100&limit=2", - stream_to: self(), - direct: worker_pid + stream_to: self() ) changes = process_response(req_id.id, &parse_event/1, 5000) @@ -181,8 +163,6 @@ defmodule ChangesAsyncTest do assert length(changes) == 1 assert Enum.at(changes, 0)["id"] == "foo" assert dt_msec > 1000 - - HTTPotion.stop_worker_process(worker_pid) end @tag :with_db @@ -197,34 +177,31 @@ defmodule ChangesAsyncTest do lines = String.split(resp.body, "\n") - all_lines = lines - |> Enum.map(fn p -> Enum.at(String.split(p, ":"), 0) end) + all_lines = + lines + |> Enum.map(fn p -> Enum.at(String.split(p, ":"), 0) end) allowed = ["", "data", "id", "event"] - allowed_lines = all_lines - |> Enum.filter(fn p -> Enum.member?(allowed, p) end) + allowed_lines = + all_lines + |> Enum.filter(fn p -> Enum.member?(allowed, p) end) assert length(all_lines) == length(allowed_lines) - end @tag :with_db test "eventsource heartbeat", context do db_name = context[:db_name] - {:ok, worker_pid} = HTTPotion.spawn_link_worker_process(Couch.process_url("")) - req_id = Rawresp.get("/#{db_name}/_changes?feed=eventsource&heartbeat=10", - stream_to: {self(), :once}, - direct: worker_pid + stream_to: self() ) :ok = wait_for_headers(req_id.id, 200) beats = wait_for_heartbeats(req_id.id, 0, 3) assert beats == 3 - HTTPotion.stop_worker_process(worker_pid) end @tag :with_db @@ -247,13 +224,11 @@ defmodule ChangesAsyncTest do last_seq = changes["last_seq"] # longpoll waits until a matching change before returning - {:ok, worker_pid} = HTTPotion.spawn_link_worker_process(Couch.process_url("")) req_id = Couch.get( "/#{db_name}/_changes?feed=longpoll&filter=changes_filter/bop&since=#{last_seq}", - stream_to: self(), - direct: worker_pid + stream_to: self() ) :ok = wait_for_headers(req_id.id, 200) @@ -279,20 +254,17 @@ defmodule ChangesAsyncTest do create_doc(db_name, %{bop: false}) create_doc(db_name, %{_id: "bingo", bop: "bingo"}) - {:ok, worker_pid} = HTTPotion.spawn_link_worker_process(Couch.process_url("")) - req_id = Rawresp.get( "/#{db_name}/_changes?feed=continuous&filter=changes_filter/bop&timeout=500", - stream_to: self(), - direct: worker_pid + stream_to: self() ) :ok = wait_for_headers(req_id.id, 200) create_doc(db_name, %{_id: "rusty", bop: "plankton"}) retry_until(fn -> - changes = process_response(req_id.id, &parse_changes_line_chunk/1) + changes = process_response(req_id.id, &parse_changes_line/1) changes_ids = changes @@ -300,8 +272,8 @@ defmodule ChangesAsyncTest do |> Enum.map(fn p -> p["id"] end) Enum.member?(changes_ids, "bingo") and - Enum.member?(changes_ids, "rusty") and - length(changes_ids) == 2 + Enum.member?(changes_ids, "rusty") and + length(changes_ids) == 2 end) end @@ -313,21 +285,18 @@ defmodule ChangesAsyncTest do create_doc(db_name, %{_id: "doc1", value: 1}) create_doc(db_name, %{_id: "doc2", value: 2}) - {:ok, worker_pid} = HTTPotion.spawn_link_worker_process(Couch.process_url("")) - req_id = Rawresp.post( "/#{db_name}/_changes?feed=continuous&timeout=500&filter=_doc_ids", body: doc_ids, headers: ["Content-Type": "application/json"], - stream_to: self(), - direct: worker_pid + stream_to: self() ) :ok = wait_for_headers(req_id.id, 200) create_doc(db_name, %{_id: "doc3", value: 3}) - changes = process_response(req_id.id, &parse_changes_line_chunk/1) + changes = process_response(req_id.id, &parse_changes_line/1) changes_ids = changes @@ -352,15 +321,12 @@ defmodule ChangesAsyncTest do assert length(resp.body["results"]) == 4 seq = Enum.at(resp.body["results"], 1)["seq"] - {:ok, worker_pid} = HTTPotion.spawn_link_worker_process(Couch.process_url("")) - # simulate an EventSource request with a Last-Event-ID header req_id = Rawresp.get( "/#{db_name}/_changes?feed=eventsource&timeout=100&since=0", headers: [Accept: "text/event-stream", "Last-Event-ID": seq], - stream_to: self(), - direct: worker_pid + stream_to: self() ) changes = process_response(req_id.id, &parse_event/1) @@ -369,13 +335,16 @@ defmodule ChangesAsyncTest do defp wait_for_heartbeats(id, beats, expexted_beats) do if beats < expexted_beats do - :ibrowse.stream_next(id) - is_heartbeat = process_response(id, &parse_heartbeat/1) - - case is_heartbeat do - :heartbeat -> wait_for_heartbeats(id, beats + 1, expexted_beats) - :timeout -> beats - _ -> wait_for_heartbeats(id, beats, expexted_beats) + case next_chunk(id) do + :timeout -> + beats + + chunk -> + if Regex.match?(~r/event: heartbeat/, chunk) do + wait_for_heartbeats(id, beats + 1, expexted_beats) + else + wait_for_heartbeats(id, beats, expexted_beats) + end end else beats @@ -384,7 +353,7 @@ defmodule ChangesAsyncTest do defp wait_for_headers(id, status, timeout \\ 1000) do receive do - %HTTPotion.AsyncHeaders{id: ^id, status_code: ^status} -> + %Couch.AsyncHeaders{id: ^id, status_code: ^status} -> :ok _ -> @@ -394,24 +363,51 @@ defmodule ChangesAsyncTest do end end - defp process_response(id, chunk_parser, timeout \\ 3000) do + # Gather response until stream ends and also handles timeouts as we'd expect + # them in the _changes feeds responses normally and we have tests for those + defp process_response(id, parser, timeout \\ 3000) do + acc = Process.delete({:chunk_acc, id}) || [] + case gather_response(id, acc, timeout) do + {:done, body} -> + parser.(body) + {:timeout, []} -> + :timeout + {:timeout, acc} -> + Process.put({:chunk_acc, id}, acc) + :timeout + end + end + + defp gather_response(id, acc, timeout) do receive do - %HTTPotion.AsyncChunk{id: ^id} = msg -> - chunk_parser.(msg) + %Couch.AsyncChunk{id: ^id, chunk: chunk} -> + gather_response(id, [acc | chunk], timeout) + %Couch.AsyncEnd{id: ^id} -> + if acc == [], do: {:timeout, []}, else: {:done, IO.iodata_to_binary(acc)} + _ -> + gather_response(id, acc, timeout) + after + timeout -> {:timeout, acc} + end + end + defp next_chunk(id, timeout \\ 3000) do + receive do + %Couch.AsyncChunk{id: ^id, chunk: chunk} -> + chunk _ -> - process_response(id, chunk_parser, timeout) + next_chunk(id, timeout) after timeout -> :timeout end end - defp parse_chunk(msg) do - msg.chunk |> IO.iodata_to_binary() |> :jiffy.decode([:return_maps, :use_nil]) + defp parse_chunk(body) do + :jiffy.decode(body, [:return_maps, :use_nil]) end - defp parse_event(msg) do - captures = Regex.scan(~r/data: (.*)/, msg.chunk) + defp parse_event(body) do + captures = Regex.scan(~r/data: (.*)/, body) captures |> Enum.map(fn p -> Enum.at(p, 1) end) @@ -423,16 +419,6 @@ defmodule ChangesAsyncTest do end) end - defp parse_heartbeat(msg) do - is_heartbeat = Regex.match?(~r/event: heartbeat/, msg.chunk) - - if is_heartbeat do - :heartbeat - else - :other - end - end - defp parse_changes_response(changes) do {length(changes["results"]), String.slice(changes["last_seq"], 0..1)} end @@ -466,31 +452,23 @@ defmodule ChangesAsyncTest do assert String.at(change["last_seq"], 0) == "1" # create_doc_bar(db_name,"bar") - {:ok, worker_pid} = HTTPotion.spawn_worker_process(Couch.process_url("")) - %HTTPotion.AsyncResponse{id: req_id} = + %Couch.AsyncResponse{id: req_id} = Rawresp.get("/#{db_name}/_changes?feed=#{feed}&timeout=500", - stream_to: self(), - direct: worker_pid + stream_to: self() ) :ok = wait_for_headers(req_id, 200) create_doc_bar(db_name, "bar") - changes = process_response(req_id, &parse_changes_line_chunk/1) + changes = process_response(req_id, &parse_changes_line/1) assert length(changes) == 3 - - HTTPotion.stop_worker_process(worker_pid) end def create_doc_bar(db_name, id) do create_doc(db_name, %{:_id => id, :bar => 1}) end - defp parse_changes_line_chunk(msg) do - parse_changes_line(msg.chunk) - end - defp parse_changes_line(body) do body_lines = String.split(body, "\n") diff --git a/test/elixir/test/design_paths_test.exs b/test/elixir/test/design_paths_test.exs index b3e10c1654b..1485eb87844 100644 --- a/test/elixir/test/design_paths_test.exs +++ b/test/elixir/test/design_paths_test.exs @@ -25,11 +25,9 @@ defmodule DesignPathTest do resp = Couch.get("/#{db_name}/_design/test") assert resp.body["_id"] == "_design/test" - resp = - Couch.get(Couch.process_url("/#{db_name}/_design%2Ftest"), - follow_redirects: true - ) - + resp = Couch.get("/#{db_name}/_design%2Ftest") + assert resp.status_code == 301 + resp = Couch.get(resp.headers["location"]) assert resp.body["_id"] == "_design/test" resp = Couch.get("/#{db_name}/_design/test/_view/testing") @@ -50,11 +48,9 @@ defmodule DesignPathTest do resp = Couch.get("/#{db_name}/_design/test2") assert resp.body["_id"] == "_design/test2" - resp = - Couch.get(Couch.process_url("/#{db_name}/_design%2Ftest2"), - follow_redirects: true - ) - + resp = Couch.get("/#{db_name}/_design%2Ftest2") + assert resp.status_code == 301 + resp = Couch.get(resp.headers["location"]) assert resp.body["_id"] == "_design/test2" resp = Couch.get("/#{db_name}/_design/test2/_view/testing") diff --git a/test/elixir/test/replication_test.exs b/test/elixir/test/replication_test.exs index 75e69ed2db5..b86d285748c 100644 --- a/test/elixir/test/replication_test.exs +++ b/test/elixir/test/replication_test.exs @@ -87,7 +87,7 @@ defmodule ReplicationTest do opts = [headers: [Accept: "application/json"], query: query] resp = Couch.get("/#{tgt_db_name}/#{doc["_id"]}", opts) - assert HTTPotion.Response.success?(resp) + assert Couch.Response.success?(resp) assert is_map(resp.body) refute Map.has_key?(resp.body, "_conflicts") refute Map.has_key?(resp.body, "_deleted_conflicts") @@ -904,11 +904,11 @@ defmodule ReplicationTest do assert history["doc_write_failures"] == 0 resp = Couch.get!("/#{tgt_db_name}/foo1") - assert HTTPotion.Response.success?(resp) + assert Couch.Response.success?(resp) assert resp.body["value"] == 1 resp = Couch.get!("/#{tgt_db_name}/foo2") - assert HTTPotion.Response.success?(resp) + assert Couch.Response.success?(resp) assert resp.body["value"] == 2 resp = Couch.get!("/#{tgt_db_name}/foo3") @@ -932,23 +932,23 @@ defmodule ReplicationTest do assert history["doc_write_failures"] == 0 resp = Couch.get!("/#{tgt_db_name}/foo1") - assert HTTPotion.Response.success?(resp) + assert Couch.Response.success?(resp) assert resp.body["value"] == 1 resp = Couch.get!("/#{tgt_db_name}/foo2") - assert HTTPotion.Response.success?(resp) + assert Couch.Response.success?(resp) assert resp.body["value"] == 2 resp = Couch.get!("/#{tgt_db_name}/foo3") - assert HTTPotion.Response.success?(resp) + assert Couch.Response.success?(resp) assert resp.body["value"] == 3 resp = Couch.get!("/#{tgt_db_name}/foo4") - assert HTTPotion.Response.success?(resp) + assert Couch.Response.success?(resp) assert resp.body["value"] == 4 resp = Couch.get!("/#{tgt_db_name}/_design/mydesign") - assert HTTPotion.Response.success?(resp) + assert Couch.Response.success?(resp) end def run_by_id_repl(src_prefix, tgt_prefix) do @@ -1046,8 +1046,8 @@ defmodule ReplicationTest do assert orig.status_code == 404 assert copy.status_code == 404 else - assert HTTPotion.Response.success?(orig) - assert HTTPotion.Response.success?(copy) + assert Couch.Response.success?(orig) + assert Couch.Response.success?(copy) assert cmp_json(orig.body, copy.body) end end) @@ -1059,7 +1059,7 @@ defmodule ReplicationTest do is_doc_id = &Enum.member?(doc_ids, &1) if is_doc_id.(doc["_id"]) or is_doc_id.(encoded_id) do - assert HTTPotion.Response.success?(copy) + assert Couch.Response.success?(copy) else assert copy.status_code == 404 end @@ -1101,8 +1101,8 @@ defmodule ReplicationTest do assert orig.status_code == 404 assert copy.status_code == 404 else - assert HTTPotion.Response.success?(orig) - assert HTTPotion.Response.success?(copy) + assert Couch.Response.success?(orig) + assert Couch.Response.success?(copy) assert cmp_json(orig.body, copy.body) end end) @@ -1116,7 +1116,7 @@ defmodule ReplicationTest do is_doc_id = &Enum.member?(all_doc_ids, &1) if is_doc_id.(doc["_id"]) or is_doc_id.(encoded_id) do - assert HTTPotion.Response.success?(copy) + assert Couch.Response.success?(copy) else assert copy.status_code == 404 end @@ -1161,7 +1161,7 @@ defmodule ReplicationTest do query = %{"conflicts" => "true"} copy = Couch.get!("/#{tgt_db_name}/#{conflict_id}", query: query) - assert HTTPotion.Response.success?(copy) + assert Couch.Response.success?(copy) assert copy.body["integer"] == 666 assert String.starts_with?(copy.body["_rev"], "4-") assert not Map.has_key?(doc, "_conflicts") @@ -1537,7 +1537,7 @@ defmodule ReplicationTest do if String.starts_with?(doc["_id"], "_design/") do assert resp.status_code == 404 else - assert HTTPotion.Response.success?(resp) + assert Couch.Response.success?(resp) assert cmp_json(doc, resp.body) end end) @@ -1593,7 +1593,7 @@ defmodule ReplicationTest do def get_db_info(db_name) do resp = Couch.get("/#{db_name}") - assert HTTPotion.Response.success?(resp) + assert Couch.Response.success?(resp) resp.body end @@ -1610,7 +1610,7 @@ defmodule ReplicationTest do def get_db_changes(db_name, query \\ %{}) do resp = Couch.get("/#{db_name}/_changes", query: query) - assert HTTPotion.Response.success?(resp), "#{inspect(resp)} #{inspect(query)}" + assert Couch.Response.success?(resp), "#{inspect(resp)} #{inspect(query)}" resp.body end @@ -1618,7 +1618,7 @@ defmodule ReplicationTest do query = %{w: 3} body = %{docs: docs} resp = Couch.post("/#{db_name}/_bulk_docs", query: query, body: body) - assert HTTPotion.Response.success?(resp) + assert Couch.Response.success?(resp) for {doc, resp} <- Enum.zip(docs, resp.body) do assert resp["ok"], "Error saving doc: #{doc["_id"]}" @@ -1628,7 +1628,7 @@ defmodule ReplicationTest do def set_security(db_name, sec_props) do resp = Couch.put("/#{db_name}/_security", body: :jiffy.encode(sec_props, [:use_nil])) - assert HTTPotion.Response.success?(resp) + assert Couch.Response.success?(resp) assert resp.body["ok"] end @@ -1652,7 +1652,7 @@ defmodule ReplicationTest do retry_until(fn -> resp = Couch.put(uri, headers: headers, query: params, body: att[:body]) - assert HTTPotion.Response.success?(resp) + assert Couch.Response.success?(resp) Map.put(doc, "_rev", resp.body["rev"]) end) end @@ -1718,7 +1718,7 @@ defmodule ReplicationTest do def try_get_task(repl_id) do resp = Couch.get("/_active_tasks") - assert HTTPotion.Response.success?(resp) + assert Couch.Response.success?(resp) assert is_list(resp.body) Enum.find(resp.body, nil, fn task ->