Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

HTTP proxy handler.

The second of two new features to replace the _externals protocols. This
allows users to configure CouchDB to proxy requests to an external HTTP
server. The external HTTP server is not required to be on the same host
running CouchDB.

The configuration looks like such:

[httpd_global_handlers]
_google = {couch_httpd_proxy, handle_proxy_req, <<"http://www.google.com">>}

You can then hit this proxy at the url:

http://127.0.0.1:5984/_google

If you add any path after the proxy name, or make a request with a query
string, those will be appended to the URL specified in the configuration.

Ie:

    http://127.0.0.1:5984/_google/search?q=plankton

would translate to:

    http://www.google.com/search?q=plankton

Obviously, request bodies are handled as expected.



git-svn-id: https://svn.apache.org/repos/asf/couchdb/trunk@1031877 13f79535-47bb-0310-9956-ffa450edef68
  • Loading branch information...
commit 11469d902b15145d361f9f7ec66a09ac3d04757c 1 parent c878511
Paul J. Davis authored
3  etc/couchdb/local.ini
@@ -20,6 +20,9 @@
20 20
 ; the whitelist.
21 21
 ;config_whitelist = [{httpd,config_whitelist}, {log,level}, {etc,etc}]
22 22
 
  23
+[httpd_global_handlers]
  24
+;_google = {couch_httpd_proxy, handle_proxy_req, <<"http://www.google.com">>}
  25
+
23 26
 [couch_httpd_auth]
24 27
 ; If you set this to true, you should also uncomment the WWW-Authenticate line
25 28
 ; above. If you don't configure a WWW-Authenticate header, CouchDB will send
4  share/www/script/test/basics.js
@@ -159,8 +159,8 @@ couchTests.basics = function(debug) {
159 159
   var loc = xhr.getResponseHeader("Location");
160 160
   T(loc, "should have a Location header");
161 161
   var locs = loc.split('/');
162  
-  T(locs[4] == resp.id);
163  
-  T(locs[3] == "test_suite_db");
  162
+  T(locs[locs.length-1] == resp.id);
  163
+  T(locs[locs.length-2] == "test_suite_db");
164 164
 
165 165
   // test that that POST's with an _id aren't overriden with a UUID.
166 166
   var xhr = CouchDB.request("POST", "/test_suite_db", {
2  src/couchdb/Makefile.am
@@ -50,6 +50,7 @@ source_files = \
50 50
     couch_httpd_show.erl \
51 51
     couch_httpd_view.erl \
52 52
     couch_httpd_misc_handlers.erl \
  53
+    couch_httpd_proxy.erl \
53 54
 	couch_httpd_rewrite.erl \
54 55
     couch_httpd_stats_handlers.erl \
55 56
 	couch_httpd_vhost.erl \
@@ -107,6 +108,7 @@ compiled_files = \
107 108
     couch_httpd_db.beam \
108 109
     couch_httpd_auth.beam \
109 110
     couch_httpd_oauth.beam \
  111
+    couch_httpd_proxy.beam \
110 112
     couch_httpd_external.beam \
111 113
     couch_httpd_show.beam \
112 114
     couch_httpd_view.beam \
14  src/couchdb/couch_httpd.erl
@@ -22,7 +22,7 @@
22 22
 -export([parse_form/1,json_body/1,json_body_obj/1,body/1,doc_etag/1, make_etag/1, etag_respond/3]).
23 23
 -export([primary_header_value/2,partition/1,serve_file/3,serve_file/4, server_header/0]).
24 24
 -export([start_chunked_response/3,send_chunk/2,log_request/2]).
25  
--export([start_response_length/4, send/2]).
  25
+-export([start_response_length/4, start_response/3, send/2]).
26 26
 -export([start_json_response/2, start_json_response/3, end_json_response/1]).
27 27
 -export([send_response/4,send_method_not_allowed/2,send_error/4, send_redirect/2,send_chunked_error/2]).
28 28
 -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) ->
526 526
     end,
527 527
     {ok, Resp}.
528 528
 
  529
+start_response(#httpd{mochi_req=MochiReq}=Req, Code, Headers) ->
  530
+    log_request(Req, Code),
  531
+    couch_stats_collector:increment({httpd_status_cdes, Code}),
  532
+    CookieHeader = couch_httpd_auth:cookie_auth_header(Req, Headers),
  533
+    Headers2 = Headers ++ server_header() ++ CookieHeader,
  534
+    Resp = MochiReq:start_response({Code, Headers2}),
  535
+    case MochiReq:get(method) of
  536
+        'HEAD' -> throw({http_head_abort, Resp});
  537
+        _ -> ok
  538
+    end,
  539
+    {ok, Resp}.
  540
+
529 541
 send(Resp, Data) ->
530 542
     Resp:send(Data),
531 543
     {ok, Resp}.
425  src/couchdb/couch_httpd_proxy.erl
... ...
@@ -0,0 +1,425 @@
  1
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
  2
+% use this file except in compliance with the License. You may obtain a copy of
  3
+% the License at
  4
+%
  5
+%   http://www.apache.org/licenses/LICENSE-2.0
  6
+%
  7
+% Unless required by applicable law or agreed to in writing, software
  8
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  9
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
  10
+% License for the specific language governing permissions and limitations under
  11
+% the License.
  12
+-module(couch_httpd_proxy).
  13
+
  14
+-export([handle_proxy_req/2]).
  15
+
  16
+-include("couch_db.hrl").
  17
+-include("../ibrowse/ibrowse.hrl").
  18
+
  19
+-define(TIMEOUT, infinity).
  20
+-define(PKT_SIZE, 4096).
  21
+
  22
+
  23
+handle_proxy_req(Req, ProxyDest) ->
  24
+
  25
+    %% Bug in Mochiweb?
  26
+    %% Reported here: http://github.com/mochi/mochiweb/issues/issue/16
  27
+    erase(mochiweb_request_body_length),
  28
+
  29
+    Method = get_method(Req),
  30
+    Url = get_url(Req, ProxyDest),
  31
+    Version = get_version(Req),
  32
+    Headers = get_headers(Req),
  33
+    Body = get_body(Req),
  34
+    Options = [
  35
+        {http_vsn, Version},
  36
+        {headers_as_is, true},
  37
+        {response_format, binary},
  38
+        {stream_to, {self(), once}}
  39
+    ],
  40
+    case ibrowse:send_req(Url, Headers, Method, Body, Options, ?TIMEOUT) of
  41
+        {ibrowse_req_id, ReqId} ->
  42
+            stream_response(Req, ProxyDest, ReqId);
  43
+        {error, Reason} ->
  44
+            throw({error, Reason})
  45
+    end.
  46
+    
  47
+
  48
+get_method(#httpd{mochi_req=MochiReq}) ->
  49
+    case MochiReq:get(method) of
  50
+        Method when is_atom(Method) ->
  51
+            list_to_atom(string:to_lower(atom_to_list(Method)));
  52
+        Method when is_list(Method) ->
  53
+            list_to_atom(string:to_lower(Method));
  54
+        Method when is_binary(Method) ->
  55
+            list_to_atom(string:to_lower(?b2l(Method)))
  56
+    end.
  57
+
  58
+
  59
+get_url(Req, ProxyDest) when is_binary(ProxyDest) ->
  60
+    get_url(Req, ?b2l(ProxyDest));
  61
+get_url(#httpd{mochi_req=MochiReq}=Req, ProxyDest) ->
  62
+    BaseUrl = case mochiweb_util:partition(ProxyDest, "/") of
  63
+        {[], "/", _} -> couch_httpd:absolute_uri(Req, ProxyDest);
  64
+        _ -> ProxyDest
  65
+    end,
  66
+    ProxyPrefix = "/" ++ ?b2l(hd(Req#httpd.path_parts)),
  67
+    RequestedPath = MochiReq:get(raw_path),
  68
+    case mochiweb_util:partition(RequestedPath, ProxyPrefix) of
  69
+        {[], ProxyPrefix, []} ->
  70
+            BaseUrl;
  71
+        {[], ProxyPrefix, [$/ | DestPath]} ->
  72
+            remove_trailing_slash(BaseUrl) ++ "/" ++ DestPath;
  73
+        {[], ProxyPrefix, DestPath} ->
  74
+            remove_trailing_slash(BaseUrl) ++ "/" ++ DestPath;
  75
+        _Else ->
  76
+            throw({invalid_url_path, {ProxyPrefix, RequestedPath}})
  77
+    end.
  78
+
  79
+get_version(#httpd{mochi_req=MochiReq}) ->
  80
+    MochiReq:get(version).
  81
+
  82
+
  83
+get_headers(#httpd{mochi_req=MochiReq}) ->
  84
+    to_ibrowse_headers(mochiweb_headers:to_list(MochiReq:get(headers)), []).
  85
+
  86
+to_ibrowse_headers([], Acc) ->
  87
+    lists:reverse(Acc);
  88
+to_ibrowse_headers([{K, V} | Rest], Acc) when is_atom(K) ->
  89
+    to_ibrowse_headers([{atom_to_list(K), V} | Rest], Acc);
  90
+to_ibrowse_headers([{K, V} | Rest], Acc) when is_list(K) ->
  91
+    case string:to_lower(K) of
  92
+        "content-length" ->
  93
+            to_ibrowse_headers(Rest, [{content_length, V} | Acc]);
  94
+        % This appears to make ibrowse too smart.
  95
+        %"transfer-encoding" ->
  96
+        %    to_ibrowse_headers(Rest, [{transfer_encoding, V} | Acc]);
  97
+        _ ->
  98
+            to_ibrowse_headers(Rest, [{K, V} | Acc])
  99
+    end.
  100
+
  101
+get_body(#httpd{method='GET'}) ->
  102
+    fun() -> eof end;
  103
+get_body(#httpd{method='HEAD'}) ->
  104
+    fun() -> eof end;
  105
+get_body(#httpd{method='DELETE'}) ->
  106
+    fun() -> eof end;
  107
+get_body(#httpd{mochi_req=MochiReq}) ->
  108
+    case MochiReq:get(body_length) of
  109
+        undefined ->
  110
+            <<>>;
  111
+        {unknown_transfer_encoding, Unknown} ->
  112
+            exit({unknown_transfer_encoding, Unknown});
  113
+        chunked ->
  114
+            {fun stream_chunked_body/1, {init, MochiReq, 0}};
  115
+        0 ->
  116
+            <<>>;
  117
+        Length when is_integer(Length) andalso Length > 0 ->
  118
+            {fun stream_length_body/1, {init, MochiReq, Length}};
  119
+        Length ->
  120
+            exit({invalid_body_length, Length})
  121
+    end.
  122
+
  123
+
  124
+remove_trailing_slash(Url) ->
  125
+    rem_slash(lists:reverse(Url)).
  126
+
  127
+rem_slash([]) ->
  128
+    [];
  129
+rem_slash([$\s | RevUrl]) ->
  130
+    rem_slash(RevUrl);
  131
+rem_slash([$\t | RevUrl]) ->
  132
+    rem_slash(RevUrl);
  133
+rem_slash([$\r | RevUrl]) ->
  134
+    rem_slash(RevUrl);
  135
+rem_slash([$\n | RevUrl]) ->
  136
+    rem_slash(RevUrl);
  137
+rem_slash([$/ | RevUrl]) ->
  138
+    rem_slash(RevUrl);
  139
+rem_slash(RevUrl) ->
  140
+    lists:reverse(RevUrl).
  141
+
  142
+
  143
+stream_chunked_body({init, MReq, 0}) ->
  144
+    % First chunk, do expect-continue dance.
  145
+    init_body_stream(MReq),
  146
+    stream_chunked_body({stream, MReq, 0, [], ?PKT_SIZE});
  147
+stream_chunked_body({stream, MReq, 0, Buf, BRem}) ->
  148
+    % Finished a chunk, get next length. If next length
  149
+    % is 0, its time to try and read trailers.
  150
+    {CRem, Data} = read_chunk_length(MReq),
  151
+    case CRem of
  152
+        0 ->
  153
+            BodyData = iolist_to_binary(lists:reverse(Buf, Data)),
  154
+            {ok, BodyData, {trailers, MReq, [], ?PKT_SIZE}}; 
  155
+        _ ->
  156
+            stream_chunked_body(
  157
+                {stream, MReq, CRem, [Data | Buf], BRem-size(Data)}
  158
+            )
  159
+    end;
  160
+stream_chunked_body({stream, MReq, CRem, Buf, BRem}) when BRem =< 0 ->
  161
+    % Time to empty our buffers to the upstream socket.
  162
+    BodyData = iolist_to_binary(lists:reverse(Buf)),
  163
+    {ok, BodyData, {stream, MReq, CRem, [], ?PKT_SIZE}};
  164
+stream_chunked_body({stream, MReq, CRem, Buf, BRem}) ->
  165
+    % Buffer some more data from the client.
  166
+    Length = lists:min([CRem, BRem]),
  167
+    Socket = MReq:get(socket),
  168
+    NewState = case mochiweb_socket:recv(Socket, Length, ?TIMEOUT) of
  169
+        {ok, Data} when size(Data) == CRem ->
  170
+            case mochiweb_socket:recv(Socket, 2, ?TIMEOUT) of
  171
+                {ok, <<"\r\n">>} ->
  172
+                    {stream, MReq, 0, [<<"\r\n">>, Data | Buf], BRem-Length-2};
  173
+                _ ->
  174
+                    exit(normal)
  175
+            end;
  176
+        {ok, Data} ->
  177
+            {stream, MReq, CRem-Length, [Data | Buf], BRem-Length};
  178
+        _ ->
  179
+            exit(normal)
  180
+    end,
  181
+    stream_chunked_body(NewState);
  182
+stream_chunked_body({trailers, MReq, Buf, BRem}) when BRem =< 0 ->
  183
+    % Empty our buffers and send data upstream.
  184
+    BodyData = iolist_to_binary(lists:reverse(Buf)),
  185
+    {ok, BodyData, {trailers, MReq, [], ?PKT_SIZE}};
  186
+stream_chunked_body({trailers, MReq, Buf, BRem}) ->
  187
+    % Read another trailer into the buffer or stop on an
  188
+    % empty line.
  189
+    Socket = MReq:get(socket),
  190
+    mochiweb_socket:setopts(Socket, [{packet, line}]),
  191
+    case mochiweb_socket:recv(Socket, 0, ?TIMEOUT) of
  192
+        {ok, <<"\r\n">>} ->
  193
+            mochiweb_socket:setopts(Socket, [{packet, raw}]),
  194
+            BodyData = iolist_to_binary(lists:reverse(Buf, <<"\r\n">>)),
  195
+            {ok, BodyData, eof};
  196
+        {ok, Footer} ->
  197
+            mochiweb_socket:setopts(Socket, [{packet, raw}]),
  198
+            NewState = {trailers, MReq, [Footer | Buf], BRem-size(Footer)},
  199
+            stream_chunked_body(NewState);
  200
+        _ ->
  201
+            exit(normal)
  202
+    end;
  203
+stream_chunked_body(eof) ->
  204
+    % Tell ibrowse we're done sending data.
  205
+    eof.
  206
+
  207
+
  208
+stream_length_body({init, MochiReq, Length}) ->
  209
+    % Do the expect-continue dance
  210
+    init_body_stream(MochiReq),
  211
+    stream_length_body({stream, MochiReq, Length});
  212
+stream_length_body({stream, _MochiReq, 0}) ->
  213
+    % Finished streaming.
  214
+    eof;
  215
+stream_length_body({stream, MochiReq, Length}) ->
  216
+    BufLen = lists:min([Length, ?PKT_SIZE]),
  217
+    case MochiReq:recv(BufLen) of
  218
+        <<>> -> eof;
  219
+        Bin -> {ok, Bin, {stream, MochiReq, Length-BufLen}}
  220
+    end.
  221
+
  222
+
  223
+init_body_stream(MochiReq) ->
  224
+    Expect = case MochiReq:get_header_value("expect") of
  225
+        undefined ->
  226
+            undefined;
  227
+        Value when is_list(Value) ->
  228
+            string:to_lower(Value)
  229
+    end,
  230
+    case Expect of
  231
+        "100-continue" ->
  232
+            MochiReq:start_raw_response({100, gb_trees:empty()});
  233
+        _Else ->
  234
+            ok
  235
+    end.
  236
+
  237
+
  238
+read_chunk_length(MochiReq) ->
  239
+    Socket = MochiReq:get(socket),
  240
+    mochiweb_socket:setopts(Socket, [{packet, line}]),
  241
+    case mochiweb_socket:recv(Socket, 0, ?TIMEOUT) of
  242
+        {ok, Header} ->
  243
+            mochiweb_socket:setopts(Socket, [{packet, raw}]),
  244
+            Splitter = fun(C) ->
  245
+                C =/= $\r andalso C =/= $\n andalso C =/= $\s
  246
+            end,
  247
+            {Hex, _Rest} = lists:splitwith(Splitter, ?b2l(Header)),
  248
+            {mochihex:to_int(Hex), Header};
  249
+        _ ->
  250
+            exit(normal)
  251
+    end.
  252
+
  253
+
  254
+stream_response(Req, ProxyDest, ReqId) ->
  255
+    receive
  256
+        {ibrowse_async_headers, ReqId, "100", _} ->
  257
+            % ibrowse doesn't handle 100 Continue responses which
  258
+            % means we have to discard them so the proxy client
  259
+            % doesn't get confused.
  260
+            ibrowse:stream_next(ReqId),
  261
+            stream_response(Req, ProxyDest, ReqId);
  262
+        {ibrowse_async_headers, ReqId, Status, Headers} ->
  263
+            {Source, Dest} = get_urls(Req, ProxyDest),
  264
+            FixedHeaders = fix_headers(Source, Dest, Headers, []),
  265
+            case body_length(FixedHeaders) of
  266
+                chunked ->
  267
+                    {ok, Resp} = couch_httpd:start_chunked_response(
  268
+                        Req, list_to_integer(Status), FixedHeaders
  269
+                    ),
  270
+                    ibrowse:stream_next(ReqId),
  271
+                    stream_chunked_response(Req, ReqId, Resp),
  272
+                    {ok, Resp};
  273
+                Length when is_integer(Length) ->
  274
+                    {ok, Resp} = couch_httpd:start_response_length(
  275
+                        Req, list_to_integer(Status), FixedHeaders, Length
  276
+                    ),
  277
+                    ibrowse:stream_next(ReqId),
  278
+                    stream_length_response(Req, ReqId, Resp),
  279
+                    {ok, Resp};
  280
+                _ ->
  281
+                    {ok, Resp} = couch_httpd:start_response(
  282
+                        Req, list_to_integer(Status), FixedHeaders
  283
+                    ),
  284
+                    ibrowse:stream_next(ReqId),
  285
+                    stream_length_response(Req, ReqId, Resp),
  286
+                    % XXX: MochiWeb apparently doesn't look at the
  287
+                    % response to see if it must force close the
  288
+                    % connection. So we help it out here.
  289
+                    erlang:put(mochiweb_request_force_close, true),
  290
+                    {ok, Resp}
  291
+            end
  292
+    end.
  293
+
  294
+
  295
+stream_chunked_response(Req, ReqId, Resp) ->
  296
+    receive
  297
+        {ibrowse_async_response, ReqId, Chunk} ->
  298
+            couch_httpd:send_chunk(Resp, Chunk),
  299
+            ibrowse:stream_next(ReqId),
  300
+            stream_chunked_response(Req, ReqId, Resp);
  301
+        {ibrowse_async_response, ReqId, {error, Reason}} ->
  302
+            throw({error, Reason});
  303
+        {ibrowse_async_response_end, ReqId} ->
  304
+            couch_httpd:last_chunk(Resp)
  305
+    end.
  306
+
  307
+
  308
+stream_length_response(Req, ReqId, Resp) ->
  309
+    receive
  310
+        {ibrowse_async_response, ReqId, Chunk} ->
  311
+            couch_httpd:send(Resp, Chunk),
  312
+            ibrowse:stream_next(ReqId),
  313
+            stream_length_response(Req, ReqId, Resp);
  314
+        {ibrowse_async_response, {error, Reason}} ->
  315
+            throw({error, Reason});
  316
+        {ibrowse_async_response_end, ReqId} ->
  317
+            ok
  318
+    end.
  319
+
  320
+
  321
+get_urls(Req, ProxyDest) ->
  322
+    SourceUrl = couch_httpd:absolute_uri(Req, "/" ++ hd(Req#httpd.path_parts)),
  323
+    Source = parse_url(?b2l(iolist_to_binary(SourceUrl))),
  324
+    case (catch parse_url(ProxyDest)) of
  325
+        Dest when is_record(Dest, url) ->
  326
+            {Source, Dest};
  327
+        _ ->
  328
+            DestUrl = couch_httpd:absolute_uri(Req, ProxyDest),
  329
+            {Source, parse_url(DestUrl)}
  330
+    end.
  331
+
  332
+
  333
+fix_headers(_, _, [], Acc) ->
  334
+    lists:reverse(Acc);
  335
+fix_headers(Source, Dest, [{K, V} | Rest], Acc) ->
  336
+    Fixed = case string:to_lower(K) of
  337
+        "location" -> rewrite_location(Source, Dest, V);
  338
+        "content-location" -> rewrite_location(Source, Dest, V);
  339
+        "uri" -> rewrite_location(Source, Dest, V);
  340
+        "destination" -> rewrite_location(Source, Dest, V);
  341
+        "set-cookie" -> rewrite_cookie(Source, Dest, V);
  342
+        _ -> V
  343
+    end,
  344
+    fix_headers(Source, Dest, Rest, [{K, Fixed} | Acc]).
  345
+
  346
+
  347
+rewrite_location(Source, #url{host=Host, port=Port, protocol=Proto}, Url) ->
  348
+    case (catch parse_url(Url)) of
  349
+        #url{host=Host, port=Port, protocol=Proto} = Location ->
  350
+            DestLoc = #url{
  351
+                protocol=Source#url.protocol,
  352
+                host=Source#url.host,
  353
+                port=Source#url.port,
  354
+                path=join_url_path(Source#url.path, Location#url.path)
  355
+            },
  356
+            url_to_url(DestLoc);
  357
+        #url{} ->
  358
+            Url;
  359
+        _ ->
  360
+            url_to_url(Source#url{path=join_url_path(Source#url.path, Url)})
  361
+    end.
  362
+
  363
+
  364
+rewrite_cookie(_Source, _Dest, Cookie) ->
  365
+    Cookie.
  366
+
  367
+
  368
+parse_url(Url) when is_binary(Url) ->
  369
+    ibrowse_lib:parse_url(?b2l(Url));
  370
+parse_url(Url) when is_list(Url) ->
  371
+    ibrowse_lib:parse_url(?b2l(iolist_to_binary(Url))).
  372
+
  373
+
  374
+join_url_path(Src, Dst) ->
  375
+    Src2 = case lists:reverse(Src) of
  376
+        "/" ++ RestSrc -> lists:reverse(RestSrc);
  377
+        _ -> Src
  378
+    end,
  379
+    Dst2 = case Dst of
  380
+        "/" ++ RestDst -> RestDst;
  381
+        _ -> Dst
  382
+    end,
  383
+    Src2 ++ "/" ++ Dst2.
  384
+
  385
+
  386
+url_to_url(#url{host=Host, port=Port, path=Path, protocol=Proto}) ->
  387
+    LPort = case {Proto, Port} of
  388
+        {http, 80} -> "";
  389
+        {https, 443} -> "";
  390
+        _ -> ":" ++ integer_to_list(Port)
  391
+    end,
  392
+    LPath = case Path of
  393
+        "/" ++ _RestPath -> Path;
  394
+        _ -> "/" ++ Path
  395
+    end,
  396
+    atom_to_list(Proto) ++ "://" ++ Host ++ LPort ++ LPath.
  397
+
  398
+
  399
+body_length(Headers) ->
  400
+    case is_chunked(Headers) of
  401
+        true -> chunked;
  402
+        _ -> content_length(Headers)
  403
+    end.
  404
+
  405
+
  406
+is_chunked([]) ->
  407
+    false;
  408
+is_chunked([{K, V} | Rest]) ->
  409
+    case string:to_lower(K) of
  410
+        "transfer-encoding" ->
  411
+            string:to_lower(V) == "chunked";
  412
+        _ ->
  413
+            is_chunked(Rest)
  414
+    end.
  415
+
  416
+content_length([]) ->
  417
+    undefined;
  418
+content_length([{K, V} | Rest]) ->
  419
+    case string:to_lower(K) of
  420
+        "content-length" ->
  421
+            list_to_integer(V);
  422
+        _ ->
  423
+            content_length(Rest)
  424
+    end.
  425
+
20  test/etap/180-http-proxy.ini
... ...
@@ -0,0 +1,20 @@
  1
+; Licensed to the Apache Software Foundation (ASF) under one
  2
+; or more contributor license agreements.  See the NOTICE file
  3
+; distributed with this work for additional information
  4
+; regarding copyright ownership.  The ASF licenses this file
  5
+; to you under the Apache License, Version 2.0 (the
  6
+; "License"); you may not use this file except in compliance
  7
+; with the License.  You may obtain a copy of the License at
  8
+; 
  9
+;   http://www.apache.org/licenses/LICENSE-2.0
  10
+;
  11
+; Unless required by applicable law or agreed to in writing,
  12
+; software distributed under the License is distributed on an
  13
+; "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
  14
+; KIND, either express or implied.  See the License for the
  15
+; specific language governing permissions and limitations
  16
+; under the License.
  17
+
  18
+[httpd_global_handlers]
  19
+_test = {couch_httpd_proxy, handle_proxy_req, <<"http://127.0.0.1:5985/">>}
  20
+_error = {couch_httpd_proxy, handle_proxy_req, <<"http://127.0.0.1:5986/">>}
357  test/etap/180-http-proxy.t
... ...
@@ -0,0 +1,357 @@
  1
+#!/usr/bin/env escript
  2
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
  3
+% use this file except in compliance with the License. You may obtain a copy of
  4
+% the License at
  5
+%
  6
+%   http://www.apache.org/licenses/LICENSE-2.0
  7
+%
  8
+% Unless required by applicable law or agreed to in writing, software
  9
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  10
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
  11
+% License for the specific language governing permissions and limitations under
  12
+% the License.
  13
+
  14
+-record(req, {method=get, path="", headers=[], body="", opts=[]}).
  15
+
  16
+default_config() ->
  17
+    [
  18
+        test_util:build_file("etc/couchdb/default_dev.ini"),
  19
+        test_util:source_file("test/etap/180-http-proxy.ini")
  20
+    ].
  21
+
  22
+server() -> "http://127.0.0.1:5984/_test/".
  23
+proxy() -> "http://127.0.0.1:5985/".
  24
+external() -> "https://www.google.com/".
  25
+
  26
+main(_) ->
  27
+    test_util:init_code_path(),
  28
+
  29
+    etap:plan(61),
  30
+    case (catch test()) of
  31
+        ok ->
  32
+            etap:end_tests();
  33
+        Other ->
  34
+            etap:diag("Test died abnormally: ~p", [Other]),
  35
+            etap:bail("Bad return value.")
  36
+    end,
  37
+    ok.
  38
+
  39
+check_request(Name, Req, Remote, Local) ->
  40
+    case Remote of
  41
+        no_remote -> ok;
  42
+        _ -> test_web:set_assert(Remote)
  43
+    end,
  44
+    Url = case proplists:lookup(url, Req#req.opts) of
  45
+        none -> server() ++ Req#req.path;
  46
+        {url, DestUrl} -> DestUrl
  47
+    end,
  48
+    Opts = [{headers_as_is, true} | Req#req.opts],
  49
+    Resp =ibrowse:send_req(
  50
+        Url, Req#req.headers, Req#req.method, Req#req.body, Opts
  51
+    ),
  52
+    %etap:diag("ibrowse response: ~p", [Resp]),
  53
+    case Local of
  54
+        no_local -> ok;
  55
+        _ -> etap:fun_is(Local, Resp, Name)
  56
+    end,
  57
+    case {Remote, Local} of
  58
+        {no_remote, _} ->
  59
+            ok;
  60
+        {_, no_local} ->
  61
+            ok;
  62
+        _ ->
  63
+            etap:is(test_web:check_last(), was_ok, Name ++ " - request handled")
  64
+    end,
  65
+    Resp.
  66
+
  67
+test() ->
  68
+    couch_server_sup:start_link(default_config()),
  69
+    ibrowse:start(),
  70
+    crypto:start(),
  71
+    test_web:start_link(),
  72
+    
  73
+    test_basic(),
  74
+    test_alternate_status(),
  75
+    test_trailing_slash(),
  76
+    test_passes_header(),
  77
+    test_passes_host_header(),
  78
+    test_passes_header_back(),
  79
+    test_rewrites_location_headers(),
  80
+    test_doesnt_rewrite_external_locations(),
  81
+    test_rewrites_relative_location(),
  82
+    test_uses_same_version(),
  83
+    test_passes_body(),
  84
+    test_passes_eof_body_back(),
  85
+    test_passes_chunked_body(),
  86
+    test_passes_chunked_body_back(),
  87
+
  88
+    test_connect_error(),
  89
+    
  90
+    ok.
  91
+
  92
+test_basic() ->
  93
+    Remote = fun(Req) ->
  94
+        'GET' = Req:get(method),
  95
+        "/" = Req:get(path),
  96
+        undefined = Req:get(body_length),
  97
+        undefined = Req:recv_body(),
  98
+        {ok, {200, [{"Content-Type", "text/plain"}], "ok"}}
  99
+    end,
  100
+    Local = fun({ok, "200", _, "ok"}) -> true; (_) -> false end,
  101
+    check_request("Basic proxy test", #req{}, Remote, Local).
  102
+
  103
+test_alternate_status() ->
  104
+    Remote = fun(Req) ->
  105
+        "/alternate_status" = Req:get(path),
  106
+        {ok, {201, [], "ok"}}
  107
+    end,
  108
+    Local = fun({ok, "201", _, "ok"}) -> true; (_) -> false end,
  109
+    Req = #req{path="alternate_status"},
  110
+    check_request("Alternate status", Req, Remote, Local).
  111
+
  112
+test_trailing_slash() ->
  113
+    Remote = fun(Req) ->
  114
+        "/trailing_slash/" = Req:get(path),
  115
+        {ok, {200, [], "ok"}}
  116
+    end,
  117
+    Local = fun({ok, "200", _, "ok"}) -> true; (_) -> false end,
  118
+    Req = #req{path="trailing_slash/"},
  119
+    check_request("Trailing slash", Req, Remote, Local).
  120
+
  121
+test_passes_header() ->
  122
+    Remote = fun(Req) ->
  123
+        "/passes_header" = Req:get(path),
  124
+        "plankton" = Req:get_header_value("X-CouchDB-Ralph"),
  125
+        {ok, {200, [], "ok"}}
  126
+    end,
  127
+    Local = fun({ok, "200", _, "ok"}) -> true; (_) -> false end,
  128
+    Req = #req{
  129
+        path="passes_header",
  130
+        headers=[{"X-CouchDB-Ralph", "plankton"}]
  131
+    },
  132
+    check_request("Passes header", Req, Remote, Local).
  133
+
  134
+test_passes_host_header() ->
  135
+    Remote = fun(Req) ->
  136
+        "/passes_host_header" = Req:get(path),
  137
+        "www.google.com" = Req:get_header_value("Host"),
  138
+        {ok, {200, [], "ok"}}
  139
+    end,
  140
+    Local = fun({ok, "200", _, "ok"}) -> true; (_) -> false end,
  141
+    Req = #req{
  142
+        path="passes_host_header",
  143
+        headers=[{"Host", "www.google.com"}]
  144
+    },
  145
+    check_request("Passes host header", Req, Remote, Local).
  146
+
  147
+test_passes_header_back() ->
  148
+    Remote = fun(Req) ->
  149
+        "/passes_header_back" = Req:get(path),
  150
+        {ok, {200, [{"X-CouchDB-Plankton", "ralph"}], "ok"}}
  151
+    end,
  152
+    Local = fun
  153
+        ({ok, "200", Headers, "ok"}) ->
  154
+            lists:member({"X-CouchDB-Plankton", "ralph"}, Headers);
  155
+        (_) ->
  156
+            false
  157
+    end,
  158
+    Req = #req{path="passes_header_back"},
  159
+    check_request("Passes header back", Req, Remote, Local).
  160
+
  161
+test_rewrites_location_headers() ->
  162
+    etap:diag("Testing location header rewrites."),
  163
+    do_rewrite_tests([
  164
+        {"Location", proxy() ++ "foo/bar", server() ++ "foo/bar"},
  165
+        {"Content-Location", proxy() ++ "bing?q=2", server() ++ "bing?q=2"},
  166
+        {"Uri", proxy() ++ "zip#frag", server() ++ "zip#frag"},
  167
+        {"Destination", proxy(), server()}
  168
+    ]).
  169
+
  170
+test_doesnt_rewrite_external_locations() ->
  171
+    etap:diag("Testing no rewrite of external locations."),
  172
+    do_rewrite_tests([
  173
+        {"Location", external() ++ "search", external() ++ "search"},
  174
+        {"Content-Location", external() ++ "s?q=2", external() ++ "s?q=2"},
  175
+        {"Uri", external() ++ "f#f", external() ++ "f#f"},
  176
+        {"Destination", external() ++ "f?q=2#f", external() ++ "f?q=2#f"}
  177
+    ]).
  178
+
  179
+test_rewrites_relative_location() ->
  180
+    etap:diag("Testing relative rewrites."),
  181
+    do_rewrite_tests([
  182
+        {"Location", "/foo", server() ++ "foo"},
  183
+        {"Content-Location", "bar", server() ++ "bar"},
  184
+        {"Uri", "/zing?q=3", server() ++ "zing?q=3"},
  185
+        {"Destination", "bing?q=stuff#yay", server() ++ "bing?q=stuff#yay"}
  186
+    ]).
  187
+
  188
+do_rewrite_tests(Tests) ->
  189
+    lists:foreach(fun({Header, Location, Url}) ->
  190
+        do_rewrite_test(Header, Location, Url)
  191
+    end, Tests).
  192
+    
  193
+do_rewrite_test(Header, Location, Url) ->
  194
+    Remote = fun(Req) ->
  195
+        "/rewrite_test" = Req:get(path),
  196
+        {ok, {302, [{Header, Location}], "ok"}}
  197
+    end,
  198
+    Local = fun
  199
+        ({ok, "302", Headers, "ok"}) ->
  200
+            etap:is(
  201
+                couch_util:get_value(Header, Headers),
  202
+                Url,
  203
+                "Header rewritten correctly."
  204
+            ),
  205
+            true;
  206
+        (_) ->
  207
+            false
  208
+    end,
  209
+    Req = #req{path="rewrite_test"},
  210
+    Label = "Rewrite test for ",
  211
+    check_request(Label ++ Header, Req, Remote, Local).
  212
+
  213
+test_uses_same_version() ->
  214
+    Remote = fun(Req) ->
  215
+        "/uses_same_version" = Req:get(path),
  216
+        {1, 0} = Req:get(version),
  217
+        {ok, {200, [], "ok"}}
  218
+    end,
  219
+    Local = fun({ok, "200", _, "ok"}) -> true; (_) -> false end,
  220
+    Req = #req{
  221
+        path="uses_same_version",
  222
+        opts=[{http_vsn, {1, 0}}]
  223
+    },
  224
+    check_request("Uses same version", Req, Remote, Local).
  225
+
  226
+test_passes_body() ->
  227
+    Remote = fun(Req) ->
  228
+        'PUT' = Req:get(method),
  229
+        "/passes_body" = Req:get(path),
  230
+        <<"Hooray!">> = Req:recv_body(),
  231
+        {ok, {201, [], "ok"}}
  232
+    end,
  233
+    Local = fun({ok, "201", _, "ok"}) -> true; (_) -> false end,
  234
+    Req = #req{
  235
+        method=put,
  236
+        path="passes_body",
  237
+        body="Hooray!"
  238
+    },
  239
+    check_request("Passes body", Req, Remote, Local).
  240
+
  241
+test_passes_eof_body_back() ->
  242
+    BodyChunks = [<<"foo">>, <<"bar">>, <<"bazinga">>],
  243
+    Remote = fun(Req) ->
  244
+        'GET' = Req:get(method),
  245
+        "/passes_eof_body" = Req:get(path),
  246
+        {raw, {200, [{"Connection", "close"}], BodyChunks}}
  247
+    end,
  248
+    Local = fun({ok, "200", _, "foobarbazinga"}) -> true; (_) -> false end,
  249
+    Req = #req{path="passes_eof_body"},
  250
+    check_request("Passes eof body", Req, Remote, Local).
  251
+
  252
+test_passes_chunked_body() ->
  253
+    BodyChunks = [<<"foo">>, <<"bar">>, <<"bazinga">>],
  254
+    Remote = fun(Req) ->
  255
+        'POST' = Req:get(method),
  256
+        "/passes_chunked_body" = Req:get(path),
  257
+        RecvBody = fun
  258
+            ({Length, Chunk}, [Chunk | Rest]) ->
  259
+                Length = size(Chunk),
  260
+                Rest;
  261
+            ({0, []}, []) ->
  262
+                ok
  263
+        end,
  264
+        ok = Req:stream_body(1024*1024, RecvBody, BodyChunks),
  265
+        {ok, {201, [], "ok"}}
  266
+    end,
  267
+    Local = fun({ok, "201", _, "ok"}) -> true; (_) -> false end,
  268
+    Req = #req{
  269
+        method=post,
  270
+        path="passes_chunked_body",
  271
+        headers=[{"Transfer-Encoding", "chunked"}],
  272
+        body=mk_chunked_body(BodyChunks)
  273
+    },
  274
+    check_request("Passes chunked body", Req, Remote, Local).
  275
+
  276
+test_passes_chunked_body_back() ->
  277
+    Name = "Passes chunked body back",
  278
+    Remote = fun(Req) ->
  279
+        'GET' = Req:get(method),
  280
+        "/passes_chunked_body_back" = Req:get(path),
  281
+        BodyChunks = [<<"foo">>, <<"bar">>, <<"bazinga">>],
  282
+        {chunked, {200, [{"Transfer-Encoding", "chunked"}], BodyChunks}}
  283
+    end,
  284
+    Req = #req{
  285
+        path="passes_chunked_body_back",
  286
+        opts=[{stream_to, self()}]
  287
+    },
  288
+
  289
+    Resp = check_request(Name, Req, Remote, no_local),
  290
+
  291
+    etap:fun_is(
  292
+        fun({ibrowse_req_id, _}) -> true; (_) -> false end,
  293
+        Resp,
  294
+        "Received an ibrowse request id."
  295
+    ),
  296
+    {_, ReqId} = Resp,
  297
+    
  298
+    % Grab headers from response
  299
+    receive
  300
+        {ibrowse_async_headers, ReqId, "200", Headers} ->
  301
+            etap:is(
  302
+                proplists:get_value("Transfer-Encoding", Headers),
  303
+                "chunked",
  304
+                "Response included the Transfer-Encoding: chunked header"
  305
+            ),
  306
+        ibrowse:stream_next(ReqId)
  307
+    after 1000 ->
  308
+        throw({error, timeout})
  309
+    end,
  310
+    
  311
+    % Check body received
  312
+    % TODO: When we upgrade to ibrowse >= 2.0.0 this check needs to
  313
+    %       check that the chunks returned are what we sent from the
  314
+    %       Remote test.
  315
+    etap:diag("TODO: UPGRADE IBROWSE"),
  316
+    etap:is(recv_body(ReqId, []), <<"foobarbazinga">>, "Decoded chunked body."),
  317
+
  318
+    % Check test_web server.
  319
+    etap:is(test_web:check_last(), was_ok, Name ++ " - request handled").
  320
+
  321
+test_connect_error() ->
  322
+    Local = fun({ok, "500", _Headers, _Body}) -> true; (_) -> false end,
  323
+    Req = #req{opts=[{url, "http://127.0.0.1:5984/_error"}]},
  324
+    check_request("Connect error", Req, no_remote, Local).
  325
+
  326
+
  327
+mk_chunked_body(Chunks) ->
  328
+    mk_chunked_body(Chunks, []).
  329
+
  330
+mk_chunked_body([], Acc) ->
  331
+    iolist_to_binary(lists:reverse(Acc, "0\r\n\r\n"));
  332
+mk_chunked_body([Chunk | Rest], Acc) ->
  333
+    Size = to_hex(size(Chunk)),
  334
+    mk_chunked_body(Rest, ["\r\n", Chunk, "\r\n", Size | Acc]).
  335
+
  336
+to_hex(Val) ->
  337
+    to_hex(Val, []).
  338
+
  339
+to_hex(0, Acc) ->
  340
+    Acc;
  341
+to_hex(Val, Acc) ->
  342
+    to_hex(Val div 16, [hex_char(Val rem 16) | Acc]).
  343
+
  344
+hex_char(V) when V < 10 -> $0 + V;
  345
+hex_char(V) -> $A + V - 10.
  346
+
  347
+recv_body(ReqId, Acc) ->
  348
+    receive
  349
+        {ibrowse_async_response, ReqId, Data} ->
  350
+            recv_body(ReqId, [Data | Acc]);
  351
+        {ibrowse_async_response_end, ReqId} ->
  352
+            iolist_to_binary(lists:reverse(Acc));
  353
+        Else ->
  354
+            throw({error, unexpected_mesg, Else})
  355
+    after 5000 ->
  356
+        throw({error, timeout})
  357
+    end.
7  test/etap/Makefile.am
@@ -11,7 +11,7 @@
11 11
 ## the License.
12 12
 
13 13
 noinst_SCRIPTS = run
14  
-noinst_DATA = test_util.beam
  14
+noinst_DATA = test_util.beam test_web.beam
15 15
 
16 16
 %.beam: %.erl
17 17
 	$(ERLC) $<
@@ -27,6 +27,7 @@ DISTCLEANFILES = temp.*
27 27
 
28 28
 EXTRA_DIST = \
29 29
 	run.tpl \
  30
+	test_web.erl \
30 31
     001-load.t \
31 32
     002-icu-driver.t \
32 33
     010-file-basics.t \
@@ -77,4 +78,6 @@ EXTRA_DIST = \
77 78
     172-os-daemon-errors.4.es \
78 79
     172-os-daemon-errors.t \
79 80
 	173-os-daemon-cfg-register.es \
80  
-	173-os-daemon-cfg-register.t
  81
+	173-os-daemon-cfg-register.t \
  82
+	180-http-proxy.ini \
  83
+	180-http-proxy.t
99  test/etap/test_web.erl
... ...
@@ -0,0 +1,99 @@
  1
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
  2
+% use this file except in compliance with the License. You may obtain a copy of
  3
+% the License at
  4
+%
  5
+%   http://www.apache.org/licenses/LICENSE-2.0
  6
+%
  7
+% Unless required by applicable law or agreed to in writing, software
  8
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  9
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
  10
+% License for the specific language governing permissions and limitations under
  11
+% the License.
  12
+
  13
+-module(test_web).
  14
+-behaviour(gen_server).
  15
+
  16
+-export([start_link/0, loop/1, get_port/0, set_assert/1, check_last/0]).
  17
+-export([init/1, terminate/2, code_change/3]).
  18
+-export([handle_call/3, handle_cast/2, handle_info/2]).
  19
+
  20
+-define(SERVER, test_web_server).
  21
+-define(HANDLER, test_web_handler).
  22
+
  23
+start_link() ->
  24
+    gen_server:start({local, ?HANDLER}, ?MODULE, [], []),
  25
+    mochiweb_http:start([
  26
+        {name, ?SERVER},
  27
+        {loop, {?MODULE, loop}},
  28
+        {port, 5985}
  29
+    ]).
  30
+
  31
+loop(Req) ->
  32
+    %etap:diag("Handling request: ~p", [Req]),
  33
+    case gen_server:call(?HANDLER, {check_request, Req}) of
  34
+        {ok, RespInfo} ->
  35
+            {ok, Req:respond(RespInfo)};
  36
+        {raw, {Status, Headers, BodyChunks}} ->
  37
+            Resp = Req:start_response({Status, Headers}),
  38
+            lists:foreach(fun(C) -> Resp:send(C) end, BodyChunks),
  39
+            erlang:put(mochiweb_request_force_close, true),
  40
+            {ok, Resp};
  41
+        {chunked, {Status, Headers, BodyChunks}} ->
  42
+            Resp = Req:respond({Status, Headers, chunked}),
  43
+            timer:sleep(500),
  44
+            lists:foreach(fun(C) -> Resp:write_chunk(C) end, BodyChunks),
  45
+            Resp:write_chunk([]),
  46
+            {ok, Resp};
  47
+        {error, Reason} ->
  48
+            etap:diag("Error: ~p", [Reason]),
  49
+            Body = lists:flatten(io_lib:format("Error: ~p", [Reason])),
  50
+            {ok, Req:respond({200, [], Body})}
  51
+    end.
  52
+
  53
+get_port() ->
  54
+    mochiweb_socket_server:get(?SERVER, port).
  55
+
  56
+set_assert(Fun) ->
  57
+    ok = gen_server:call(?HANDLER, {set_assert, Fun}).
  58
+
  59
+check_last() ->
  60
+    gen_server:call(?HANDLER, last_status).
  61
+
  62
+init(_) ->
  63
+    {ok, nil}.
  64
+
  65
+terminate(_Reason, _State) ->
  66
+    ok.
  67
+
  68
+handle_call({check_request, Req}, _From, State) when is_function(State, 1) ->
  69
+    Resp2 = case (catch State(Req)) of
  70
+        {ok, Resp} -> {reply, {ok, Resp}, was_ok};
  71
+        {raw, Resp} -> {reply, {raw, Resp}, was_ok};
  72
+        {chunked, Resp} -> {reply, {chunked, Resp}, was_ok};
  73
+        Error -> {reply, {error, Error}, not_ok}
  74
+    end,
  75
+    Req:cleanup(),
  76
+    Resp2;
  77
+handle_call({check_request, _Req}, _From, _State) ->
  78
+    {reply, {error, no_assert_function}, not_ok};
  79
+handle_call(last_status, _From, State) when is_atom(State) ->
  80
+    {reply, State, nil};
  81
+handle_call(last_status, _From, State) ->
  82
+    {reply, {error, not_checked}, State};
  83
+handle_call({set_assert, Fun}, _From, nil) ->
  84
+    {reply, ok, Fun};
  85
+handle_call({set_assert, _}, _From, State) ->
  86
+    {reply, {error, assert_function_set}, State};
  87
+handle_call(Msg, _From, State) ->
  88
+    {reply, {ignored, Msg}, State}.
  89
+
  90
+handle_cast(Msg, State) ->
  91
+    etap:diag("Ignoring cast message: ~p", [Msg]),
  92
+    {noreply, State}.
  93
+
  94
+handle_info(Msg, State) ->
  95
+    etap:diag("Ignoring info message: ~p", [Msg]),
  96
+    {noreply, State}.
  97
+
  98
+code_change(_OldVsn, State, _Extra) ->
  99
+    {ok, State}.

0 notes on commit 11469d9

Please sign in to comment.
Something went wrong with that request. Please try again.