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(couch_httpd_spatial).
14-include("couch_db.hrl").
15-include("couch_spatial.hrl").
16
17-export([handle_spatial_req/3, spatial_etag/3, spatial_etag/4,
18         load_index/3, handle_compact_req/3, handle_design_info_req/3,
19         handle_spatial_cleanup_req/2, parse_spatial_params/1,
20         make_spatial_fold_funs/6]).
21
22-import(couch_httpd,
23        [send_json/2, send_json/3, send_method_not_allowed/2, send_chunk/2,
24         start_json_response/2, start_json_response/3, end_json_response/1]).
25
26% Either answer a normal spatial query, or keep dispatching if the path part
27% after _spatial starts with an underscore.
28handle_spatial_req(#httpd{
29        path_parts=[_, _, _Dname, _, SpatialName|_]}=Req, Db, DDoc) ->
30    case SpatialName of
31    % the path after _spatial starts with an underscore => dispatch
32    <<$_,_/binary>> ->
33        dispatch_sub_spatial_req(Req, Db, DDoc);
34    _ ->
35        handle_spatial(Req, Db, DDoc)
36    end.
37
38% the dispatching of endpoints below _spatial needs to be done manually
39dispatch_sub_spatial_req(#httpd{
40        path_parts=[_, _, _DName, Spatial, SpatialDisp|_]}=Req,
41        Db, DDoc) ->
42    Conf = couch_config:get("httpd_design_handlers",
43        ?b2l(<<Spatial/binary, "/", SpatialDisp/binary>>)),
44    Fun = geocouch_duplicates:make_arity_3_fun(Conf),
45    apply(Fun, [Req, Db, DDoc]).
46
47handle_spatial(#httpd{method='GET',
48        path_parts=[_, _, DName, _, SpatialName]}=Req, Db, DDoc) ->
49    ?LOG_DEBUG("Spatial query (~p): ~n~p", [DName, DDoc#doc.id]),
50    #spatial_query_args{
51        stale = Stale
52    } = QueryArgs = parse_spatial_params(Req),
53    {ok, Index, Group} = couch_spatial:get_spatial_index(
54                           Db, DDoc#doc.id, SpatialName, Stale),
55    output_spatial_index(Req, Index, Group, Db, QueryArgs);
56handle_spatial(Req, _Db, _DDoc) ->
57    send_method_not_allowed(Req, "GET,HEAD").
58
59% pendant is in couch_httpd_db
60handle_compact_req(#httpd{method='POST',
61        path_parts=[DbName, _ , DName|_]}=Req, Db, _DDoc) ->
62    ok = couch_db:check_is_admin(Db),
63    couch_httpd:validate_ctype(Req, "application/json"),
64    ok = couch_spatial_compactor:start_compact(DbName, DName),
65    send_json(Req, 202, {[{ok, true}]});
66handle_compact_req(Req, _Db, _DDoc) ->
67    send_method_not_allowed(Req, "POST").
68
69% pendant is in couch_httpd_db
70handle_spatial_cleanup_req(#httpd{method='POST'}=Req, Db) ->
71    % delete unreferenced index files
72    ok = couch_db:check_is_admin(Db),
73    couch_httpd:validate_ctype(Req, "application/json"),
74    ok = couch_spatial:cleanup_index_files(Db),
75    send_json(Req, 202, {[{ok, true}]});
76
77handle_spatial_cleanup_req(Req, _Db) ->
78    send_method_not_allowed(Req, "POST").
79
80% pendant is in couch_httpd_db
81handle_design_info_req(#httpd{
82            method='GET',
83            path_parts=[_DbName, _Design, DesignName, _, _]
84        }=Req, Db, _DDoc) ->
85    DesignId = <<"_design/", DesignName/binary>>,
86    {ok, GroupInfoList} = couch_spatial:get_group_info(Db, DesignId),
87    send_json(Req, 200, {[
88        {name, DesignName},
89        {spatial_index, {GroupInfoList}}
90    ]});
91handle_design_info_req(Req, _Db, _DDoc) ->
92    send_method_not_allowed(Req, "GET").
93
94
95load_index(Req, Db, {DesignId, SpatialName}) ->
96    QueryArgs = parse_spatial_params(Req),
97    Stale = QueryArgs#spatial_query_args.stale,
98    case couch_spatial:get_spatial_index(Db, DesignId, SpatialName, Stale) of
99    {ok, Index, Group} ->
100          {ok, Index, Group, QueryArgs};
101    {not_found, Reason} ->
102        throw({not_found, Reason})
103    end.
104
105output_spatial_index(Req, Index, Group, _Db,
106        #spatial_query_args{count=true}=QueryArgs) ->
107    #spatial_query_args{
108        bbox = Bbox,
109        geometry = QueryGeom
110    } = QueryArgs,
111
112    FoldFun = case QueryGeom of
113    % simple bounding box comparison
114    nil ->
115        fun(_Item, Acc) ->
116            {ok, Acc+1}
117        end;
118    % full intersection on geometry level
119    _ ->
120        QueryGeom2 = erlgeom:to_geom(QueryGeom),
121        fun({{_DocId, _Bbox}, {Geom, _Value}}, Acc) ->
122            case condition_disjoint(QueryGeom2, Geom) of
123                true -> {ok, Acc+1};
124                false -> {ok, Acc}
125            end
126        end
127    end,
128    {ok, Count} = couch_spatial:fold(Index, FoldFun, 0, Bbox, nil),
129    send_json(Req, {[{"count",Count}]});
130
131% counterpart in couch_httpd_view is output_map_view/6
132output_spatial_index(Req, Index, Group, Db, QueryArgs) ->
133    #spatial_query_args{
134        bbox = Bbox,
135        bounds = Bounds,
136        limit = Limit,
137        skip = SkipCount
138    } = QueryArgs,
139    CurrentEtag = spatial_etag(Db, Group, Index),
140    HelperFuns = #spatial_fold_helper_funs{
141        start_response = fun json_spatial_start_resp/3,
142        send_row = fun send_json_spatial_row/3
143    },
144    couch_httpd:etag_respond(Req, CurrentEtag, fun() ->
145        FoldFun = make_spatial_fold_funs(
146                    Req, QueryArgs, CurrentEtag, Db,
147                    Group#spatial_group.current_seq, HelperFuns),
148        FoldAccInit = {Limit, SkipCount, undefined, ""},
149        % In this case the accumulator consists of the response (which
150        % might be undefined) and the actual accumulator we only care
151        % about in spatiallist functions)
152        {ok, {_, _, Resp, _}} = couch_spatial:fold(
153            Index, FoldFun, FoldAccInit, Bbox, Bounds),
154        finish_spatial_fold(Req, Resp)
155    end).
156
157% counterpart in couch_httpd_view is make_view_fold/7
158make_spatial_fold_funs(Req, #spatial_query_args{geometry=QueryGeom}, Etag, _Db,
159        UpdateSeq, HelperFuns) ->
160    #spatial_fold_helper_funs{
161        start_response = StartRespFun,
162        send_row = SendRowFun
163    } = HelperFuns,
164
165    % The condition function is always the same for all cases (normal output
166    % and list functions), hence it's not passed in through the
167    % #spatial_fold_helper_funs{} record, but computed here
168    {ConditionFun, QueryGeom2} = case QueryGeom of
169    % simple bounding box comparison
170    nil ->
171        {
172            fun(_, _) -> true end,
173            QueryGeom
174        };
175    % full intersection on geometry level
176    _ ->
177        {
178            fun condition_disjoint/2,
179            erlgeom:to_geom(QueryGeom)
180        }
181    end,
182
183    % The Acc is there to output characters that belong to the previous line,
184    % but only if one line follows (think of a comma separated list which
185    % doesn't have a comma at the last item)
186    fun({{Bbox, DocId}, {Geom, Value}}, {AccLimit, AccSkip, Resp, Acc}) ->
187        % If the condition function returns true the geometry will be part of
188        % the result, else it will be dropped
189        case ConditionFun(QueryGeom2, Geom) of
190        true ->
191            case {AccLimit, AccSkip, Resp} of
192            {0, _, _} ->
193                % we've done "limit" rows, stop foldling
194                {stop, {0, 0, Resp, Acc}};
195            {_, AccSkip, _} when AccSkip > 0 ->
196                % just keep skipping
197                {ok, {AccLimit, AccSkip - 1, Resp, Acc}};
198            {_, _, undefined} ->
199                % rendering the first row, first we start the response
200                {ok, Resp2, BeginBody} = StartRespFun(Req, Etag, UpdateSeq),
201                {Go, Acc2} = SendRowFun(
202                    Resp2, {{Bbox, DocId}, {Geom, Value}}, BeginBody),
203                {Go, {AccLimit - 1, 0, Resp2, Acc2}};
204            {AccLimit, _, Resp} when (AccLimit > 0) ->
205                % rendering all other rows
206                {Go, Acc2} = SendRowFun(Resp, {{Bbox, DocId}, {Geom, Value}}, Acc),
207                {Go, {AccLimit - 1, 0, Resp, Acc2}}
208            end;
209        false ->
210            {ok, {AccLimit, AccSkip, Resp, Acc}}
211        end
212    end.
213
214% counterpart in couch_httpd_view is finish_view_fold/5
215finish_spatial_fold(Req, Resp) ->
216    case Resp of
217    % no response was sent yet
218    undefined ->
219        send_json(Req, 200, {[{"rows", []}]});
220    Resp ->
221        % end the index
222        send_chunk(Resp, "\r\n]}"),
223        end_json_response(Resp)
224    end.
225
226% counterpart in couch_httpd_view is json_view_start_resp/6
227json_spatial_start_resp(Req, Etag, UpdateSeq) ->
228    {ok, Resp} = start_json_response(Req, 200, [{"Etag", Etag}]),
229    BeginBody = io_lib:format(
230            "{\"update_seq\":~w,\"rows\":[\r\n", [UpdateSeq]),
231    {ok, Resp, BeginBody}.
232
233% counterpart in couch_httpd_view is send_json_view_row/5
234send_json_spatial_row(Resp, {{Bbox, DocId}, {Geom, Value}}, RowFront) ->
235    JsonObj = {[
236        {<<"id">>, DocId},
237        {<<"bbox">>, erlang:tuple_to_list(Bbox)},
238        {<<"geometry">>, couch_spatial_updater:geocouch_to_geojsongeom(Geom)},
239        {<<"value">>, Value}]},
240    send_chunk(Resp, RowFront ++  ?JSON_ENCODE(JsonObj)),
241    {ok, ",\r\n"}.
242
243% @doc Include the geometries (hence return true) if they are not disjoint
244% The `QueryGeom` is already converted to an Erlgeom geometry. The `Geom` is
245% still an Erlang term
246condition_disjoint(QueryGeom, Geom) ->
247    Geom2 = erlgeom:to_geom(Geom),
248    case erlgeom:disjoint(QueryGeom, Geom2) of
249        false -> true;
250        true -> false
251    end.
252
253% counterpart in couch_httpd_view is view_group_etag/3 resp. /4
254spatial_etag(Db, Group, Index) ->
255    spatial_etag(Db, Group, Index, nil).
256spatial_etag(_Db, #spatial_group{sig=Sig},
257        #spatial{update_seq=UpdateSeq, purge_seq=PurgeSeq}, Extra) ->
258    couch_httpd:make_etag({Sig, UpdateSeq, PurgeSeq, Extra}).
259
260parse_spatial_params(Req) ->
261    QueryList = couch_httpd:qs(Req),
262    QueryParams = lists:foldl(fun({K, V}, Acc) ->
263        parse_spatial_param(K, V) ++ Acc
264    end, [], QueryList),
265    QueryArgs = lists:foldl(fun({K, V}, Args2) ->
266        validate_spatial_query(K, V, Args2)
267    end, #spatial_query_args{}, lists:reverse(QueryParams)),
268
269    #spatial_query_args{
270        bbox = Bbox,
271        bounds = Bounds,
272        geometry = QueryGeom
273    } = QueryArgs,
274    case {Bbox, Bounds} of
275    % Coordinates of the bounding box are flipped and no bounds for the
276    % cartesian plane were set
277    {{W, S, E, N}, nil} when E < W; N < S ->
278        Msg = <<"Coordinates of the bounding box are flipped, but no bounds "
279                "for the cartesian plane were specified "
280                "(use the `plane_bounds` parameter)">>,
281        throw({query_parse_error, Msg});
282    _ ->
283        ok
284    end,
285    case {Bbox, QueryGeom} of
286    {Bbox, nil} ->
287        QueryArgs;
288    {nil, {GeomType, GeomCoords}} ->
289        % Set the bounding to the bounds of the geometry
290        QueryArgs#spatial_query_args{
291            bbox = erlang:list_to_tuple(couch_spatial_updater:extract_bbox(
292                GeomType, GeomCoords
293            ))
294        };
295    {Bbox, QueryGeom} ->
296        Msg2 = <<"A bounding box *and* a geometry were specified."
297                " Please use either of them.">>,
298        throw({query_parse_error, Msg2})
299     end.
300
301parse_spatial_param("bbox", Bbox) ->
302    [{bbox, list_to_tuple(?JSON_DECODE("[" ++ Bbox ++ "]"))}];
303parse_spatial_param("stale", "ok") ->
304    [{stale, ok}];
305parse_spatial_param("stale", "update_after") ->
306    [{stale, update_after}];
307parse_spatial_param("stale", _Value) ->
308    throw({query_parse_error,
309            <<"stale only available as stale=ok or as stale=update_after">>});
310parse_spatial_param("count", "true") ->
311    [{count, true}];
312parse_spatial_param("count", _Value) ->
313    throw({query_parse_error, <<"count only available as count=true">>});
314parse_spatial_param("plane_bounds", Bounds) ->
315    [{bounds, list_to_tuple(?JSON_DECODE("[" ++ Bounds ++ "]"))}];
316parse_spatial_param("limit", Value) ->
317    [{limit, geocouch_duplicates:parse_positive_int_param(Value)}];
318parse_spatial_param("skip", Value) ->
319    [{skip, geocouch_duplicates:parse_int_param(Value)}];
320parse_spatial_param("geometry", Geometry) ->
321    [{geometry, wkt:parse(Geometry)}];
322parse_spatial_param(Key, Value) ->
323    [{extra, {Key, Value}}].
324
325validate_spatial_query(bbox, Value, Args) ->
326    Args#spatial_query_args{bbox=Value};
327validate_spatial_query(stale, ok, Args) ->
328    Args#spatial_query_args{stale=ok};
329validate_spatial_query(stale, update_after, Args) ->
330    Args#spatial_query_args{stale=update_after};
331validate_spatial_query(stale, _, Args) ->
332    Args;
333validate_spatial_query(count, true, Args) ->
334    Args#spatial_query_args{count=true};
335validate_spatial_query(bounds, Value, Args) ->
336    Args#spatial_query_args{bounds=Value};
337validate_spatial_query(limit, Value, Args) ->
338    Args#spatial_query_args{limit=Value};
339validate_spatial_query(skip, Value, Args) ->
340    Args#spatial_query_args{skip=Value};
341validate_spatial_query(geometry, Value, Args) ->
342    Args#spatial_query_args{geometry=Value};
343validate_spatial_query(extra, _Value, Args) ->
344    Args.
345