diff options
-rw-r--r-- | etc/couchdb/local.ini | 3 | ||||
-rw-r--r-- | share/www/script/test/basics.js | 4 | ||||
-rw-r--r-- | src/couchdb/Makefile.am | 2 | ||||
-rw-r--r-- | src/couchdb/couch_httpd.erl | 14 | ||||
-rw-r--r-- | src/couchdb/couch_httpd_proxy.erl | 425 | ||||
-rw-r--r-- | test/etap/180-http-proxy.ini | 20 | ||||
-rw-r--r-- | test/etap/180-http-proxy.t | 357 | ||||
-rw-r--r-- | test/etap/Makefile.am | 7 | ||||
-rw-r--r-- | test/etap/test_web.erl | 99 |
9 files changed, 926 insertions, 5 deletions
diff --git a/etc/couchdb/local.ini b/etc/couchdb/local.ini index 935bd48f..7ab05446 100644 --- a/etc/couchdb/local.ini +++ b/etc/couchdb/local.ini @@ -20,6 +20,9 @@ ; the whitelist. ;config_whitelist = [{httpd,config_whitelist}, {log,level}, {etc,etc}] +[httpd_global_handlers] +;_google = {couch_httpd_proxy, handle_proxy_req, <<"http://www.google.com">>} + [couch_httpd_auth] ; If you set this to true, you should also uncomment the WWW-Authenticate line ; above. If you don't configure a WWW-Authenticate header, CouchDB will send diff --git a/share/www/script/test/basics.js b/share/www/script/test/basics.js index 2deb094a..ce19565b 100644 --- a/share/www/script/test/basics.js +++ b/share/www/script/test/basics.js @@ -159,8 +159,8 @@ couchTests.basics = function(debug) { var loc = xhr.getResponseHeader("Location"); T(loc, "should have a Location header"); var locs = loc.split('/'); - T(locs[4] == resp.id); - T(locs[3] == "test_suite_db"); + T(locs[locs.length-1] == resp.id); + T(locs[locs.length-2] == "test_suite_db"); // test that that POST's with an _id aren't overriden with a UUID. var xhr = CouchDB.request("POST", "/test_suite_db", { diff --git a/src/couchdb/Makefile.am b/src/couchdb/Makefile.am index 8bf136d6..c325440d 100644 --- a/src/couchdb/Makefile.am +++ b/src/couchdb/Makefile.am @@ -50,6 +50,7 @@ source_files = \ couch_httpd_show.erl \ couch_httpd_view.erl \ couch_httpd_misc_handlers.erl \ + couch_httpd_proxy.erl \ couch_httpd_rewrite.erl \ couch_httpd_stats_handlers.erl \ couch_httpd_vhost.erl \ @@ -107,6 +108,7 @@ compiled_files = \ couch_httpd_db.beam \ couch_httpd_auth.beam \ couch_httpd_oauth.beam \ + couch_httpd_proxy.beam \ couch_httpd_external.beam \ couch_httpd_show.beam \ couch_httpd_view.beam \ diff --git a/src/couchdb/couch_httpd.erl b/src/couchdb/couch_httpd.erl index e853f97d..e1f6fc28 100644 --- a/src/couchdb/couch_httpd.erl +++ b/src/couchdb/couch_httpd.erl @@ -22,7 +22,7 @@ -export([parse_form/1,json_body/1,json_body_obj/1,body/1,doc_etag/1, make_etag/1, etag_respond/3]). -export([primary_header_value/2,partition/1,serve_file/3,serve_file/4, server_header/0]). -export([start_chunked_response/3,send_chunk/2,log_request/2]). --export([start_response_length/4, send/2]). +-export([start_response_length/4, start_response/3, send/2]). -export([start_json_response/2, start_json_response/3, end_json_response/1]). -export([send_response/4,send_method_not_allowed/2,send_error/4, send_redirect/2,send_chunked_error/2]). -export([send_json/2,send_json/3,send_json/4,last_chunk/1,parse_multipart_request/3]). @@ -526,6 +526,18 @@ start_response_length(#httpd{mochi_req=MochiReq}=Req, Code, Headers, Length) -> end, {ok, Resp}. +start_response(#httpd{mochi_req=MochiReq}=Req, Code, Headers) -> + log_request(Req, Code), + couch_stats_collector:increment({httpd_status_cdes, Code}), + CookieHeader = couch_httpd_auth:cookie_auth_header(Req, Headers), + Headers2 = Headers ++ server_header() ++ CookieHeader, + Resp = MochiReq:start_response({Code, Headers2}), + case MochiReq:get(method) of + 'HEAD' -> throw({http_head_abort, Resp}); + _ -> ok + end, + {ok, Resp}. + send(Resp, Data) -> Resp:send(Data), {ok, Resp}. diff --git a/src/couchdb/couch_httpd_proxy.erl b/src/couchdb/couch_httpd_proxy.erl new file mode 100644 index 00000000..d12e0c87 --- /dev/null +++ b/src/couchdb/couch_httpd_proxy.erl @@ -0,0 +1,425 @@ +% 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. +-module(couch_httpd_proxy). + +-export([handle_proxy_req/2]). + +-include("couch_db.hrl"). +-include("../ibrowse/ibrowse.hrl"). + +-define(TIMEOUT, infinity). +-define(PKT_SIZE, 4096). + + +handle_proxy_req(Req, ProxyDest) -> + + %% Bug in Mochiweb? + %% Reported here: http://github.com/mochi/mochiweb/issues/issue/16 + erase(mochiweb_request_body_length), + + Method = get_method(Req), + Url = get_url(Req, ProxyDest), + Version = get_version(Req), + Headers = get_headers(Req), + Body = get_body(Req), + Options = [ + {http_vsn, Version}, + {headers_as_is, true}, + {response_format, binary}, + {stream_to, {self(), once}} + ], + case ibrowse:send_req(Url, Headers, Method, Body, Options, ?TIMEOUT) of + {ibrowse_req_id, ReqId} -> + stream_response(Req, ProxyDest, ReqId); + {error, Reason} -> + throw({error, Reason}) + end. + + +get_method(#httpd{mochi_req=MochiReq}) -> + case MochiReq:get(method) of + Method when is_atom(Method) -> + list_to_atom(string:to_lower(atom_to_list(Method))); + Method when is_list(Method) -> + list_to_atom(string:to_lower(Method)); + Method when is_binary(Method) -> + list_to_atom(string:to_lower(?b2l(Method))) + end. + + +get_url(Req, ProxyDest) when is_binary(ProxyDest) -> + get_url(Req, ?b2l(ProxyDest)); +get_url(#httpd{mochi_req=MochiReq}=Req, ProxyDest) -> + BaseUrl = case mochiweb_util:partition(ProxyDest, "/") of + {[], "/", _} -> couch_httpd:absolute_uri(Req, ProxyDest); + _ -> ProxyDest + end, + ProxyPrefix = "/" ++ ?b2l(hd(Req#httpd.path_parts)), + RequestedPath = MochiReq:get(raw_path), + case mochiweb_util:partition(RequestedPath, ProxyPrefix) of + {[], ProxyPrefix, []} -> + BaseUrl; + {[], ProxyPrefix, [$/ | DestPath]} -> + remove_trailing_slash(BaseUrl) ++ "/" ++ DestPath; + {[], ProxyPrefix, DestPath} -> + remove_trailing_slash(BaseUrl) ++ "/" ++ DestPath; + _Else -> + throw({invalid_url_path, {ProxyPrefix, RequestedPath}}) + end. + +get_version(#httpd{mochi_req=MochiReq}) -> + MochiReq:get(version). + + +get_headers(#httpd{mochi_req=MochiReq}) -> + to_ibrowse_headers(mochiweb_headers:to_list(MochiReq:get(headers)), []). + +to_ibrowse_headers([], Acc) -> + lists:reverse(Acc); +to_ibrowse_headers([{K, V} | Rest], Acc) when is_atom(K) -> + to_ibrowse_headers([{atom_to_list(K), V} | Rest], Acc); +to_ibrowse_headers([{K, V} | Rest], Acc) when is_list(K) -> + case string:to_lower(K) of + "content-length" -> + to_ibrowse_headers(Rest, [{content_length, V} | Acc]); + % This appears to make ibrowse too smart. + %"transfer-encoding" -> + % to_ibrowse_headers(Rest, [{transfer_encoding, V} | Acc]); + _ -> + to_ibrowse_headers(Rest, [{K, V} | Acc]) + end. + +get_body(#httpd{method='GET'}) -> + fun() -> eof end; +get_body(#httpd{method='HEAD'}) -> + fun() -> eof end; +get_body(#httpd{method='DELETE'}) -> + fun() -> eof end; +get_body(#httpd{mochi_req=MochiReq}) -> + case MochiReq:get(body_length) of + undefined -> + <<>>; + {unknown_transfer_encoding, Unknown} -> + exit({unknown_transfer_encoding, Unknown}); + chunked -> + {fun stream_chunked_body/1, {init, MochiReq, 0}}; + 0 -> + <<>>; + Length when is_integer(Length) andalso Length > 0 -> + {fun stream_length_body/1, {init, MochiReq, Length}}; + Length -> + exit({invalid_body_length, Length}) + end. + + +remove_trailing_slash(Url) -> + rem_slash(lists:reverse(Url)). + +rem_slash([]) -> + []; +rem_slash([$\s | RevUrl]) -> + rem_slash(RevUrl); +rem_slash([$\t | RevUrl]) -> + rem_slash(RevUrl); +rem_slash([$\r | RevUrl]) -> + rem_slash(RevUrl); +rem_slash([$\n | RevUrl]) -> + rem_slash(RevUrl); +rem_slash([$/ | RevUrl]) -> + rem_slash(RevUrl); +rem_slash(RevUrl) -> + lists:reverse(RevUrl). + + +stream_chunked_body({init, MReq, 0}) -> + % First chunk, do expect-continue dance. + init_body_stream(MReq), + stream_chunked_body({stream, MReq, 0, [], ?PKT_SIZE}); +stream_chunked_body({stream, MReq, 0, Buf, BRem}) -> + % Finished a chunk, get next length. If next length + % is 0, its time to try and read trailers. + {CRem, Data} = read_chunk_length(MReq), + case CRem of + 0 -> + BodyData = iolist_to_binary(lists:reverse(Buf, Data)), + {ok, BodyData, {trailers, MReq, [], ?PKT_SIZE}}; + _ -> + stream_chunked_body( + {stream, MReq, CRem, [Data | Buf], BRem-size(Data)} + ) + end; +stream_chunked_body({stream, MReq, CRem, Buf, BRem}) when BRem =< 0 -> + % Time to empty our buffers to the upstream socket. + BodyData = iolist_to_binary(lists:reverse(Buf)), + {ok, BodyData, {stream, MReq, CRem, [], ?PKT_SIZE}}; +stream_chunked_body({stream, MReq, CRem, Buf, BRem}) -> + % Buffer some more data from the client. + Length = lists:min([CRem, BRem]), + Socket = MReq:get(socket), + NewState = case mochiweb_socket:recv(Socket, Length, ?TIMEOUT) of + {ok, Data} when size(Data) == CRem -> + case mochiweb_socket:recv(Socket, 2, ?TIMEOUT) of + {ok, <<"\r\n">>} -> + {stream, MReq, 0, [<<"\r\n">>, Data | Buf], BRem-Length-2}; + _ -> + exit(normal) + end; + {ok, Data} -> + {stream, MReq, CRem-Length, [Data | Buf], BRem-Length}; + _ -> + exit(normal) + end, + stream_chunked_body(NewState); +stream_chunked_body({trailers, MReq, Buf, BRem}) when BRem =< 0 -> + % Empty our buffers and send data upstream. + BodyData = iolist_to_binary(lists:reverse(Buf)), + {ok, BodyData, {trailers, MReq, [], ?PKT_SIZE}}; +stream_chunked_body({trailers, MReq, Buf, BRem}) -> + % Read another trailer into the buffer or stop on an + % empty line. + Socket = MReq:get(socket), + mochiweb_socket:setopts(Socket, [{packet, line}]), + case mochiweb_socket:recv(Socket, 0, ?TIMEOUT) of + {ok, <<"\r\n">>} -> + mochiweb_socket:setopts(Socket, [{packet, raw}]), + BodyData = iolist_to_binary(lists:reverse(Buf, <<"\r\n">>)), + {ok, BodyData, eof}; + {ok, Footer} -> + mochiweb_socket:setopts(Socket, [{packet, raw}]), + NewState = {trailers, MReq, [Footer | Buf], BRem-size(Footer)}, + stream_chunked_body(NewState); + _ -> + exit(normal) + end; +stream_chunked_body(eof) -> + % Tell ibrowse we're done sending data. + eof. + + +stream_length_body({init, MochiReq, Length}) -> + % Do the expect-continue dance + init_body_stream(MochiReq), + stream_length_body({stream, MochiReq, Length}); +stream_length_body({stream, _MochiReq, 0}) -> + % Finished streaming. + eof; +stream_length_body({stream, MochiReq, Length}) -> + BufLen = lists:min([Length, ?PKT_SIZE]), + case MochiReq:recv(BufLen) of + <<>> -> eof; + Bin -> {ok, Bin, {stream, MochiReq, Length-BufLen}} + end. + + +init_body_stream(MochiReq) -> + Expect = case MochiReq:get_header_value("expect") of + undefined -> + undefined; + Value when is_list(Value) -> + string:to_lower(Value) + end, + case Expect of + "100-continue" -> + MochiReq:start_raw_response({100, gb_trees:empty()}); + _Else -> + ok + end. + + +read_chunk_length(MochiReq) -> + Socket = MochiReq:get(socket), + mochiweb_socket:setopts(Socket, [{packet, line}]), + case mochiweb_socket:recv(Socket, 0, ?TIMEOUT) of + {ok, Header} -> + mochiweb_socket:setopts(Socket, [{packet, raw}]), + Splitter = fun(C) -> + C =/= $\r andalso C =/= $\n andalso C =/= $\s + end, + {Hex, _Rest} = lists:splitwith(Splitter, ?b2l(Header)), + {mochihex:to_int(Hex), Header}; + _ -> + exit(normal) + end. + + +stream_response(Req, ProxyDest, ReqId) -> + receive + {ibrowse_async_headers, ReqId, "100", _} -> + % ibrowse doesn't handle 100 Continue responses which + % means we have to discard them so the proxy client + % doesn't get confused. + ibrowse:stream_next(ReqId), + stream_response(Req, ProxyDest, ReqId); + {ibrowse_async_headers, ReqId, Status, Headers} -> + {Source, Dest} = get_urls(Req, ProxyDest), + FixedHeaders = fix_headers(Source, Dest, Headers, []), + case body_length(FixedHeaders) of + chunked -> + {ok, Resp} = couch_httpd:start_chunked_response( + Req, list_to_integer(Status), FixedHeaders + ), + ibrowse:stream_next(ReqId), + stream_chunked_response(Req, ReqId, Resp), + {ok, Resp}; + Length when is_integer(Length) -> + {ok, Resp} = couch_httpd:start_response_length( + Req, list_to_integer(Status), FixedHeaders, Length + ), + ibrowse:stream_next(ReqId), + stream_length_response(Req, ReqId, Resp), + {ok, Resp}; + _ -> + {ok, Resp} = couch_httpd:start_response( + Req, list_to_integer(Status), FixedHeaders + ), + ibrowse:stream_next(ReqId), + stream_length_response(Req, ReqId, Resp), + % XXX: MochiWeb apparently doesn't look at the + % response to see if it must force close the + % connection. So we help it out here. + erlang:put(mochiweb_request_force_close, true), + {ok, Resp} + end + end. + + +stream_chunked_response(Req, ReqId, Resp) -> + receive + {ibrowse_async_response, ReqId, Chunk} -> + couch_httpd:send_chunk(Resp, Chunk), + ibrowse:stream_next(ReqId), + stream_chunked_response(Req, ReqId, Resp); + {ibrowse_async_response, ReqId, {error, Reason}} -> + throw({error, Reason}); + {ibrowse_async_response_end, ReqId} -> + couch_httpd:last_chunk(Resp) + end. + + +stream_length_response(Req, ReqId, Resp) -> + receive + {ibrowse_async_response, ReqId, Chunk} -> + couch_httpd:send(Resp, Chunk), + ibrowse:stream_next(ReqId), + stream_length_response(Req, ReqId, Resp); + {ibrowse_async_response, {error, Reason}} -> + throw({error, Reason}); + {ibrowse_async_response_end, ReqId} -> + ok + end. + + +get_urls(Req, ProxyDest) -> + SourceUrl = couch_httpd:absolute_uri(Req, "/" ++ hd(Req#httpd.path_parts)), + Source = parse_url(?b2l(iolist_to_binary(SourceUrl))), + case (catch parse_url(ProxyDest)) of + Dest when is_record(Dest, url) -> + {Source, Dest}; + _ -> + DestUrl = couch_httpd:absolute_uri(Req, ProxyDest), + {Source, parse_url(DestUrl)} + end. + + +fix_headers(_, _, [], Acc) -> + lists:reverse(Acc); +fix_headers(Source, Dest, [{K, V} | Rest], Acc) -> + Fixed = case string:to_lower(K) of + "location" -> rewrite_location(Source, Dest, V); + "content-location" -> rewrite_location(Source, Dest, V); + "uri" -> rewrite_location(Source, Dest, V); + "destination" -> rewrite_location(Source, Dest, V); + "set-cookie" -> rewrite_cookie(Source, Dest, V); + _ -> V + end, + fix_headers(Source, Dest, Rest, [{K, Fixed} | Acc]). + + +rewrite_location(Source, #url{host=Host, port=Port, protocol=Proto}, Url) -> + case (catch parse_url(Url)) of + #url{host=Host, port=Port, protocol=Proto} = Location -> + DestLoc = #url{ + protocol=Source#url.protocol, + host=Source#url.host, + port=Source#url.port, + path=join_url_path(Source#url.path, Location#url.path) + }, + url_to_url(DestLoc); + #url{} -> + Url; + _ -> + url_to_url(Source#url{path=join_url_path(Source#url.path, Url)}) + end. + + +rewrite_cookie(_Source, _Dest, Cookie) -> + Cookie. + + +parse_url(Url) when is_binary(Url) -> + ibrowse_lib:parse_url(?b2l(Url)); +parse_url(Url) when is_list(Url) -> + ibrowse_lib:parse_url(?b2l(iolist_to_binary(Url))). + + +join_url_path(Src, Dst) -> + Src2 = case lists:reverse(Src) of + "/" ++ RestSrc -> lists:reverse(RestSrc); + _ -> Src + end, + Dst2 = case Dst of + "/" ++ RestDst -> RestDst; + _ -> Dst + end, + Src2 ++ "/" ++ Dst2. + + +url_to_url(#url{host=Host, port=Port, path=Path, protocol=Proto}) -> + LPort = case {Proto, Port} of + {http, 80} -> ""; + {https, 443} -> ""; + _ -> ":" ++ integer_to_list(Port) + end, + LPath = case Path of + "/" ++ _RestPath -> Path; + _ -> "/" ++ Path + end, + atom_to_list(Proto) ++ "://" ++ Host ++ LPort ++ LPath. + + +body_length(Headers) -> + case is_chunked(Headers) of + true -> chunked; + _ -> content_length(Headers) + end. + + +is_chunked([]) -> + false; +is_chunked([{K, V} | Rest]) -> + case string:to_lower(K) of + "transfer-encoding" -> + string:to_lower(V) == "chunked"; + _ -> + is_chunked(Rest) + end. + +content_length([]) -> + undefined; +content_length([{K, V} | Rest]) -> + case string:to_lower(K) of + "content-length" -> + list_to_integer(V); + _ -> + content_length(Rest) + end. + diff --git a/test/etap/180-http-proxy.ini b/test/etap/180-http-proxy.ini new file mode 100644 index 00000000..72a63f66 --- /dev/null +++ b/test/etap/180-http-proxy.ini @@ -0,0 +1,20 @@ +; Licensed to the Apache Software Foundation (ASF) under one +; or more contributor license agreements. See the NOTICE file +; distributed with this work for additional information +; regarding copyright ownership. The ASF licenses this file +; to you 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. + +[httpd_global_handlers] +_test = {couch_httpd_proxy, handle_proxy_req, <<"http://127.0.0.1:5985/">>} +_error = {couch_httpd_proxy, handle_proxy_req, <<"http://127.0.0.1:5986/">>}
\ No newline at end of file diff --git a/test/etap/180-http-proxy.t b/test/etap/180-http-proxy.t new file mode 100644 index 00000000..b91d901b --- /dev/null +++ b/test/etap/180-http-proxy.t @@ -0,0 +1,357 @@ +#!/usr/bin/env escript +% 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. + +-record(req, {method=get, path="", headers=[], body="", opts=[]}). + +default_config() -> + [ + test_util:build_file("etc/couchdb/default_dev.ini"), + test_util:source_file("test/etap/180-http-proxy.ini") + ]. + +server() -> "http://127.0.0.1:5984/_test/". +proxy() -> "http://127.0.0.1:5985/". +external() -> "https://www.google.com/". + +main(_) -> + test_util:init_code_path(), + + etap:plan(61), + case (catch test()) of + ok -> + etap:end_tests(); + Other -> + etap:diag("Test died abnormally: ~p", [Other]), + etap:bail("Bad return value.") + end, + ok. + +check_request(Name, Req, Remote, Local) -> + case Remote of + no_remote -> ok; + _ -> test_web:set_assert(Remote) + end, + Url = case proplists:lookup(url, Req#req.opts) of + none -> server() ++ Req#req.path; + {url, DestUrl} -> DestUrl + end, + Opts = [{headers_as_is, true} | Req#req.opts], + Resp =ibrowse:send_req( + Url, Req#req.headers, Req#req.method, Req#req.body, Opts + ), + %etap:diag("ibrowse response: ~p", [Resp]), + case Local of + no_local -> ok; + _ -> etap:fun_is(Local, Resp, Name) + end, + case {Remote, Local} of + {no_remote, _} -> + ok; + {_, no_local} -> + ok; + _ -> + etap:is(test_web:check_last(), was_ok, Name ++ " - request handled") + end, + Resp. + +test() -> + couch_server_sup:start_link(default_config()), + ibrowse:start(), + crypto:start(), + test_web:start_link(), + + test_basic(), + test_alternate_status(), + test_trailing_slash(), + test_passes_header(), + test_passes_host_header(), + test_passes_header_back(), + test_rewrites_location_headers(), + test_doesnt_rewrite_external_locations(), + test_rewrites_relative_location(), + test_uses_same_version(), + test_passes_body(), + test_passes_eof_body_back(), + test_passes_chunked_body(), + test_passes_chunked_body_back(), + + test_connect_error(), + + ok. + +test_basic() -> + Remote = fun(Req) -> + 'GET' = Req:get(method), + "/" = Req:get(path), + undefined = Req:get(body_length), + undefined = Req:recv_body(), + {ok, {200, [{"Content-Type", "text/plain"}], "ok"}} + end, + Local = fun({ok, "200", _, "ok"}) -> true; (_) -> false end, + check_request("Basic proxy test", #req{}, Remote, Local). + +test_alternate_status() -> + Remote = fun(Req) -> + "/alternate_status" = Req:get(path), + {ok, {201, [], "ok"}} + end, + Local = fun({ok, "201", _, "ok"}) -> true; (_) -> false end, + Req = #req{path="alternate_status"}, + check_request("Alternate status", Req, Remote, Local). + +test_trailing_slash() -> + Remote = fun(Req) -> + "/trailing_slash/" = Req:get(path), + {ok, {200, [], "ok"}} + end, + Local = fun({ok, "200", _, "ok"}) -> true; (_) -> false end, + Req = #req{path="trailing_slash/"}, + check_request("Trailing slash", Req, Remote, Local). + +test_passes_header() -> + Remote = fun(Req) -> + "/passes_header" = Req:get(path), + "plankton" = Req:get_header_value("X-CouchDB-Ralph"), + {ok, {200, [], "ok"}} + end, + Local = fun({ok, "200", _, "ok"}) -> true; (_) -> false end, + Req = #req{ + path="passes_header", + headers=[{"X-CouchDB-Ralph", "plankton"}] + }, + check_request("Passes header", Req, Remote, Local). + +test_passes_host_header() -> + Remote = fun(Req) -> + "/passes_host_header" = Req:get(path), + "www.google.com" = Req:get_header_value("Host"), + {ok, {200, [], "ok"}} + end, + Local = fun({ok, "200", _, "ok"}) -> true; (_) -> false end, + Req = #req{ + path="passes_host_header", + headers=[{"Host", "www.google.com"}] + }, + check_request("Passes host header", Req, Remote, Local). + +test_passes_header_back() -> + Remote = fun(Req) -> + "/passes_header_back" = Req:get(path), + {ok, {200, [{"X-CouchDB-Plankton", "ralph"}], "ok"}} + end, + Local = fun + ({ok, "200", Headers, "ok"}) -> + lists:member({"X-CouchDB-Plankton", "ralph"}, Headers); + (_) -> + false + end, + Req = #req{path="passes_header_back"}, + check_request("Passes header back", Req, Remote, Local). + +test_rewrites_location_headers() -> + etap:diag("Testing location header rewrites."), + do_rewrite_tests([ + {"Location", proxy() ++ "foo/bar", server() ++ "foo/bar"}, + {"Content-Location", proxy() ++ "bing?q=2", server() ++ "bing?q=2"}, + {"Uri", proxy() ++ "zip#frag", server() ++ "zip#frag"}, + {"Destination", proxy(), server()} + ]). + +test_doesnt_rewrite_external_locations() -> + etap:diag("Testing no rewrite of external locations."), + do_rewrite_tests([ + {"Location", external() ++ "search", external() ++ "search"}, + {"Content-Location", external() ++ "s?q=2", external() ++ "s?q=2"}, + {"Uri", external() ++ "f#f", external() ++ "f#f"}, + {"Destination", external() ++ "f?q=2#f", external() ++ "f?q=2#f"} + ]). + +test_rewrites_relative_location() -> + etap:diag("Testing relative rewrites."), + do_rewrite_tests([ + {"Location", "/foo", server() ++ "foo"}, + {"Content-Location", "bar", server() ++ "bar"}, + {"Uri", "/zing?q=3", server() ++ "zing?q=3"}, + {"Destination", "bing?q=stuff#yay", server() ++ "bing?q=stuff#yay"} + ]). + +do_rewrite_tests(Tests) -> + lists:foreach(fun({Header, Location, Url}) -> + do_rewrite_test(Header, Location, Url) + end, Tests). + +do_rewrite_test(Header, Location, Url) -> + Remote = fun(Req) -> + "/rewrite_test" = Req:get(path), + {ok, {302, [{Header, Location}], "ok"}} + end, + Local = fun + ({ok, "302", Headers, "ok"}) -> + etap:is( + couch_util:get_value(Header, Headers), + Url, + "Header rewritten correctly." + ), + true; + (_) -> + false + end, + Req = #req{path="rewrite_test"}, + Label = "Rewrite test for ", + check_request(Label ++ Header, Req, Remote, Local). + +test_uses_same_version() -> + Remote = fun(Req) -> + "/uses_same_version" = Req:get(path), + {1, 0} = Req:get(version), + {ok, {200, [], "ok"}} + end, + Local = fun({ok, "200", _, "ok"}) -> true; (_) -> false end, + Req = #req{ + path="uses_same_version", + opts=[{http_vsn, {1, 0}}] + }, + check_request("Uses same version", Req, Remote, Local). + +test_passes_body() -> + Remote = fun(Req) -> + 'PUT' = Req:get(method), + "/passes_body" = Req:get(path), + <<"Hooray!">> = Req:recv_body(), + {ok, {201, [], "ok"}} + end, + Local = fun({ok, "201", _, "ok"}) -> true; (_) -> false end, + Req = #req{ + method=put, + path="passes_body", + body="Hooray!" + }, + check_request("Passes body", Req, Remote, Local). + +test_passes_eof_body_back() -> + BodyChunks = [<<"foo">>, <<"bar">>, <<"bazinga">>], + Remote = fun(Req) -> + 'GET' = Req:get(method), + "/passes_eof_body" = Req:get(path), + {raw, {200, [{"Connection", "close"}], BodyChunks}} + end, + Local = fun({ok, "200", _, "foobarbazinga"}) -> true; (_) -> false end, + Req = #req{path="passes_eof_body"}, + check_request("Passes eof body", Req, Remote, Local). + +test_passes_chunked_body() -> + BodyChunks = [<<"foo">>, <<"bar">>, <<"bazinga">>], + Remote = fun(Req) -> + 'POST' = Req:get(method), + "/passes_chunked_body" = Req:get(path), + RecvBody = fun + ({Length, Chunk}, [Chunk | Rest]) -> + Length = size(Chunk), + Rest; + ({0, []}, []) -> + ok + end, + ok = Req:stream_body(1024*1024, RecvBody, BodyChunks), + {ok, {201, [], "ok"}} + end, + Local = fun({ok, "201", _, "ok"}) -> true; (_) -> false end, + Req = #req{ + method=post, + path="passes_chunked_body", + headers=[{"Transfer-Encoding", "chunked"}], + body=mk_chunked_body(BodyChunks) + }, + check_request("Passes chunked body", Req, Remote, Local). + +test_passes_chunked_body_back() -> + Name = "Passes chunked body back", + Remote = fun(Req) -> + 'GET' = Req:get(method), + "/passes_chunked_body_back" = Req:get(path), + BodyChunks = [<<"foo">>, <<"bar">>, <<"bazinga">>], + {chunked, {200, [{"Transfer-Encoding", "chunked"}], BodyChunks}} + end, + Req = #req{ + path="passes_chunked_body_back", + opts=[{stream_to, self()}] + }, + + Resp = check_request(Name, Req, Remote, no_local), + + etap:fun_is( + fun({ibrowse_req_id, _}) -> true; (_) -> false end, + Resp, + "Received an ibrowse request id." + ), + {_, ReqId} = Resp, + + % Grab headers from response + receive + {ibrowse_async_headers, ReqId, "200", Headers} -> + etap:is( + proplists:get_value("Transfer-Encoding", Headers), + "chunked", + "Response included the Transfer-Encoding: chunked header" + ), + ibrowse:stream_next(ReqId) + after 1000 -> + throw({error, timeout}) + end, + + % Check body received + % TODO: When we upgrade to ibrowse >= 2.0.0 this check needs to + % check that the chunks returned are what we sent from the + % Remote test. + etap:diag("TODO: UPGRADE IBROWSE"), + etap:is(recv_body(ReqId, []), <<"foobarbazinga">>, "Decoded chunked body."), + + % Check test_web server. + etap:is(test_web:check_last(), was_ok, Name ++ " - request handled"). + +test_connect_error() -> + Local = fun({ok, "500", _Headers, _Body}) -> true; (_) -> false end, + Req = #req{opts=[{url, "http://127.0.0.1:5984/_error"}]}, + check_request("Connect error", Req, no_remote, Local). + + +mk_chunked_body(Chunks) -> + mk_chunked_body(Chunks, []). + +mk_chunked_body([], Acc) -> + iolist_to_binary(lists:reverse(Acc, "0\r\n\r\n")); +mk_chunked_body([Chunk | Rest], Acc) -> + Size = to_hex(size(Chunk)), + mk_chunked_body(Rest, ["\r\n", Chunk, "\r\n", Size | Acc]). + +to_hex(Val) -> + to_hex(Val, []). + +to_hex(0, Acc) -> + Acc; +to_hex(Val, Acc) -> + to_hex(Val div 16, [hex_char(Val rem 16) | Acc]). + +hex_char(V) when V < 10 -> $0 + V; +hex_char(V) -> $A + V - 10. + +recv_body(ReqId, Acc) -> + receive + {ibrowse_async_response, ReqId, Data} -> + recv_body(ReqId, [Data | Acc]); + {ibrowse_async_response_end, ReqId} -> + iolist_to_binary(lists:reverse(Acc)); + Else -> + throw({error, unexpected_mesg, Else}) + after 5000 -> + throw({error, timeout}) + end. diff --git a/test/etap/Makefile.am b/test/etap/Makefile.am index 26c214d9..59d21cda 100644 --- a/test/etap/Makefile.am +++ b/test/etap/Makefile.am @@ -11,7 +11,7 @@ ## the License. noinst_SCRIPTS = run -noinst_DATA = test_util.beam +noinst_DATA = test_util.beam test_web.beam %.beam: %.erl $(ERLC) $< @@ -27,6 +27,7 @@ DISTCLEANFILES = temp.* EXTRA_DIST = \ run.tpl \ + test_web.erl \ 001-load.t \ 002-icu-driver.t \ 010-file-basics.t \ @@ -77,4 +78,6 @@ EXTRA_DIST = \ 172-os-daemon-errors.4.es \ 172-os-daemon-errors.t \ 173-os-daemon-cfg-register.es \ - 173-os-daemon-cfg-register.t + 173-os-daemon-cfg-register.t \ + 180-http-proxy.ini \ + 180-http-proxy.t diff --git a/test/etap/test_web.erl b/test/etap/test_web.erl new file mode 100644 index 00000000..16438b31 --- /dev/null +++ b/test/etap/test_web.erl @@ -0,0 +1,99 @@ +% 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. + +-module(test_web). +-behaviour(gen_server). + +-export([start_link/0, loop/1, get_port/0, set_assert/1, check_last/0]). +-export([init/1, terminate/2, code_change/3]). +-export([handle_call/3, handle_cast/2, handle_info/2]). + +-define(SERVER, test_web_server). +-define(HANDLER, test_web_handler). + +start_link() -> + gen_server:start({local, ?HANDLER}, ?MODULE, [], []), + mochiweb_http:start([ + {name, ?SERVER}, + {loop, {?MODULE, loop}}, + {port, 5985} + ]). + +loop(Req) -> + %etap:diag("Handling request: ~p", [Req]), + case gen_server:call(?HANDLER, {check_request, Req}) of + {ok, RespInfo} -> + {ok, Req:respond(RespInfo)}; + {raw, {Status, Headers, BodyChunks}} -> + Resp = Req:start_response({Status, Headers}), + lists:foreach(fun(C) -> Resp:send(C) end, BodyChunks), + erlang:put(mochiweb_request_force_close, true), + {ok, Resp}; + {chunked, {Status, Headers, BodyChunks}} -> + Resp = Req:respond({Status, Headers, chunked}), + timer:sleep(500), + lists:foreach(fun(C) -> Resp:write_chunk(C) end, BodyChunks), + Resp:write_chunk([]), + {ok, Resp}; + {error, Reason} -> + etap:diag("Error: ~p", [Reason]), + Body = lists:flatten(io_lib:format("Error: ~p", [Reason])), + {ok, Req:respond({200, [], Body})} + end. + +get_port() -> + mochiweb_socket_server:get(?SERVER, port). + +set_assert(Fun) -> + ok = gen_server:call(?HANDLER, {set_assert, Fun}). + +check_last() -> + gen_server:call(?HANDLER, last_status). + +init(_) -> + {ok, nil}. + +terminate(_Reason, _State) -> + ok. + +handle_call({check_request, Req}, _From, State) when is_function(State, 1) -> + Resp2 = case (catch State(Req)) of + {ok, Resp} -> {reply, {ok, Resp}, was_ok}; + {raw, Resp} -> {reply, {raw, Resp}, was_ok}; + {chunked, Resp} -> {reply, {chunked, Resp}, was_ok}; + Error -> {reply, {error, Error}, not_ok} + end, + Req:cleanup(), + Resp2; +handle_call({check_request, _Req}, _From, _State) -> + {reply, {error, no_assert_function}, not_ok}; +handle_call(last_status, _From, State) when is_atom(State) -> + {reply, State, nil}; +handle_call(last_status, _From, State) -> + {reply, {error, not_checked}, State}; +handle_call({set_assert, Fun}, _From, nil) -> + {reply, ok, Fun}; +handle_call({set_assert, _}, _From, State) -> + {reply, {error, assert_function_set}, State}; +handle_call(Msg, _From, State) -> + {reply, {ignored, Msg}, State}. + +handle_cast(Msg, State) -> + etap:diag("Ignoring cast message: ~p", [Msg]), + {noreply, State}. + +handle_info(Msg, State) -> + etap:diag("Ignoring info message: ~p", [Msg]), + {noreply, State}. + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. |