/__w/shurbej/shurbej/_build/test/cover/aggregate/shurbej_http_items.html

1 -module(shurbej_http_items).
2 -include_lib("shurbej_store/include/shurbej_records.hrl").
3
4 -export([init/2, generate_key/0, validate_each/2, envelope_for_write/3]).
5
6 -define(MAX_WRITE_ITEMS, 50).
7
8 init(Req0, State) ->
9 130 case shurbej_http_common:authorize(Req0) of
10 {ok, LibRef, _} ->
11 127 Method = cowboy_req:method(Req0),
12 127 Perm = perm_for_method(Method),
13 127 case shurbej_http_common:check_lib_perm(Perm, LibRef) of
14 {error, forbidden} ->
15 4 Req = shurbej_http_common:error_response(403, <<"Access denied">>, Req0),
16 4 {ok, Req, State};
17 ok ->
18 123 handle(Method, Req0, State)
19 end;
20 {error, bad_request, Req} ->
21
:-(
Req2 = shurbej_http_common:error_response(400, <<"Invalid user ID">>, Req),
22
:-(
{ok, Req2, State};
23 {error, Reason, _} ->
24 3 Req = shurbej_http_common:auth_error_response(Reason, Req0),
25 3 {ok, Req, State}
26 end.
27
28 53 perm_for_method(<<"GET">>) -> read;
29
:-(
perm_for_method(<<"HEAD">>) -> read;
30 74 perm_for_method(_) -> write.
31
32 %% GET single item
33 handle(<<"GET">>, Req0, #{scope := single} = State) ->
34 4 LibRef = shurbej_http_common:lib_ref(Req0),
35 4 ItemKey = cowboy_req:binding(item_key, Req0),
36 4 case shurbej_db:get_item(LibRef, ItemKey) of
37 {ok, Item} ->
38 3 {ok, LibVersion} = shurbej_version:get(LibRef),
39 3 ChildrenCounts = cached_children_counts(LibRef, LibVersion),
40 3 Body = shurbej_http_common:envelope_item(LibRef, Item, ChildrenCounts),
41 3 Req = shurbej_http_common:json_response(200, Body, Item#shurbej_item.version, Req0),
42 3 {ok, Req, State};
43 undefined ->
44 1 Req = shurbej_http_common:error_response(404, <<"Item not found">>, Req0),
45 1 {ok, Req, State}
46 end;
47
48 %% GET list — dispatch by scope
49 handle(<<"GET">>, Req0, #{scope := Scope} = State) ->
50 47 LibRef = shurbej_http_common:lib_ref(Req0),
51 47 Since = shurbej_http_common:get_since(Req0),
52 47 {ok, LibVersion} = shurbej_version:get(LibRef),
53 47 case shurbej_http_common:check_304(Req0, LibVersion) of
54 {304, Req} ->
55 1 {ok, Req, State};
56 continue ->
57 46 Items = fetch_items(Scope, LibRef, Since, Req0),
58 46 Items2 = apply_filters(Items, Req0),
59 46 Format = shurbej_http_common:get_format(Req0),
60 46 respond_list(Format, Items2, LibRef, LibVersion, Req0, State)
61 end;
62
63 %% POST — create/update items (with write token idempotency)
64 handle(<<"POST">>, Req0, State) ->
65 62 WriteToken = shurbej_http_common:get_write_token(Req0),
66 62 case shurbej_write_token:check(WriteToken) of
67 {duplicate, {Result, Version}} ->
68 1 Req = shurbej_http_common:json_response(200, Result, Version, Req0),
69 1 {ok, Req, State};
70 in_progress ->
71 %% Concurrent request with same token — proceed without token caching
72
:-(
do_post(Req0, State, undefined);
73 new ->
74 61 do_post(Req0, State, WriteToken)
75 end;
76
77 %% PUT/PATCH single item — update a specific item
78 handle(Method, Req0, #{scope := single} = State) when Method =:= <<"PUT">>; Method =:= <<"PATCH">> ->
79 3 LibRef = shurbej_http_common:lib_ref(Req0),
80 3 ItemKey = cowboy_req:binding(item_key, Req0),
81 3 ExpectedVersion = shurbej_http_common:get_if_unmodified(Req0),
82 3 case shurbej_http_common:read_json_body(Req0) of
83 {error, Reason, Req1} ->
84
:-(
Req = shurbej_http_common:error_response(400, body_error(Reason), Req1),
85
:-(
{ok, Req, State};
86 {ok, Incoming, Req1} ->
87 3 case shurbej_db:get_item(LibRef, ItemKey) of
88 undefined ->
89 1 Req = shurbej_http_common:error_response(404, <<"Item not found">>, Req1),
90 1 {ok, Req, State};
91 {ok, #shurbej_item{data = Existing}} ->
92 2 Merged = case Method of
93 1 <<"PATCH">> -> maps:merge(Existing, Incoming);
94 1 <<"PUT">> -> Incoming
95 end,
96 2 Item = Merged#{<<"key">> => ItemKey},
97 2 case shurbej_validate:item(Item) of
98 {error, Reason2} ->
99
:-(
Req = shurbej_http_common:error_response(400, Reason2, Req1),
100
:-(
{ok, Req, State};
101 ok ->
102 2 case shurbej_version:write(LibRef, ExpectedVersion, fun(NewVersion) ->
103 2 write_item(LibRef, Item, NewVersion)
104 end) of
105 {ok, NewVersion} ->
106 2 Envelope = envelope_for_write(LibRef, Item, NewVersion),
107 2 Req = shurbej_http_common:json_response(200, Envelope, NewVersion, Req1),
108 2 {ok, Req, State};
109 {error, precondition, CurrentVersion} ->
110
:-(
Req = shurbej_http_common:json_response(412,
111 #{<<"message">> => <<"Library has been modified since specified version">>},
112 CurrentVersion, Req1),
113
:-(
{ok, Req, State}
114 end
115 end
116 end
117 end;
118
119 %% DELETE single item
120 handle(<<"DELETE">>, Req0, #{scope := single} = State) ->
121 1 LibRef = shurbej_http_common:lib_ref(Req0),
122 1 ItemKey = cowboy_req:binding(item_key, Req0),
123 1 ExpectedVersion = shurbej_http_common:get_if_unmodified(Req0),
124 1 case shurbej_version:write(LibRef, ExpectedVersion, fun(NewVersion) ->
125 1 cascade_delete(LibRef, ItemKey, NewVersion),
126 1 ok
127 end) of
128 {ok, NewVersion} ->
129 1 Req = cowboy_req:reply(204, #{
130 <<"last-modified-version">> => integer_to_binary(NewVersion)
131 }, Req0),
132 1 {ok, Req, State};
133 {error, precondition, CurrentVersion} ->
134
:-(
Req = shurbej_http_common:json_response(412,
135 #{<<"message">> => <<"Library has been modified since specified version">>},
136 CurrentVersion, Req0),
137
:-(
{ok, Req, State}
138 end;
139
140 %% DELETE multiple — cascade to tags, fulltext, file metadata, children
141 handle(<<"DELETE">>, Req0, State) ->
142 6 LibRef = shurbej_http_common:lib_ref(Req0),
143 6 ExpectedVersion = shurbej_http_common:get_if_unmodified(Req0),
144 6 #{itemKey := KeysParam} = cowboy_req:match_qs([{itemKey, [], <<>>}], Req0),
145 6 Keys = [K || K <- binary:split(KeysParam, <<",">>, [global]), K =/= <<>>],
146 6 case Keys of
147 [] ->
148
:-(
Req = shurbej_http_common:error_response(400, <<"No item keys specified">>, Req0),
149
:-(
{ok, Req, State};
150 _ ->
151 6 case shurbej_version:write(LibRef, ExpectedVersion, fun(NewVersion) ->
152 5 lists:foreach(fun(K) ->
153 5 cascade_delete(LibRef, K, NewVersion)
154 end, Keys),
155 5 ok
156 end) of
157 {ok, NewVersion} ->
158 5 Req = cowboy_req:reply(204, #{
159 <<"last-modified-version">> => integer_to_binary(NewVersion)
160 }, Req0),
161 5 {ok, Req, State};
162 {error, precondition, CurrentVersion} ->
163 1 Req = shurbej_http_common:json_response(412,
164 #{<<"message">> => <<"Library has been modified since specified version">>},
165 CurrentVersion, Req0),
166 1 {ok, Req, State}
167 end
168 end;
169
170 handle(_, Req0, State) ->
171
:-(
Req = shurbej_http_common:error_response(405, <<"Method not allowed">>, Req0),
172
:-(
{ok, Req, State}.
173
174 %% Internal — respond to list GET by format
175
176 respond_list(<<"versions">>, Items, _LibRef, LibVersion, Req0, State) ->
177 7 VersionMap = maps:from_list(
178 84 [{K, V} || #shurbej_item{id = {_, _, K}, version = V} <- Items]),
179 7 Req = shurbej_http_common:json_response(200, VersionMap, LibVersion, Req0),
180 7 {ok, Req, State};
181 respond_list(<<"keys">>, Items, _LibRef, LibVersion, Req0, State) ->
182 1 Keys = [K || #shurbej_item{id = {_, _, K}} <- Items],
183 1 Req = shurbej_http_common:json_response(200, Keys, LibVersion, Req0),
184 1 {ok, Req, State};
185 respond_list(Format, _Items, _LibRef, _LibVersion, Req0, State)
186 when Format =:= <<"atom">>; Format =:= <<"bib">>;
187 Format =:= <<"ris">>; Format =:= <<"mods">> ->
188 1 Req = shurbej_http_common:error_response(400,
189 <<"Export format '", Format/binary, "' is not supported by this server">>, Req0),
190 1 {ok, Req, State};
191 respond_list(_, Items, LibRef, LibVersion, Req0, State) ->
192 37 ChildrenCounts = cached_children_counts(LibRef, LibVersion),
193 37 Sorted = shurbej_http_common:sort_records(Items,
194 shurbej_http_common:get_sort(Req0),
195 shurbej_http_common:get_direction(Req0)),
196 37 Req = shurbej_http_common:list_response(Req0, Sorted, LibVersion,
197 443 fun(I) -> shurbej_http_common:envelope_item(LibRef, I, ChildrenCounts) end),
198 37 {ok, Req, State}.
199
200 %% Internal — fetch items by scope
201
202 fetch_items(all, LibRef, Since, Req) ->
203 43 Base = shurbej_db:list_items(LibRef, Since),
204 43 maybe_include_trashed(Base, LibRef, Since, Req);
205 fetch_items(top, LibRef, Since, Req) ->
206 1 Base = shurbej_db:list_items_top(LibRef, Since),
207 1 maybe_include_trashed(Base, LibRef, Since, Req);
208 fetch_items(trash, LibRef, Since, _Req) ->
209 1 shurbej_db:list_items_trash(LibRef, Since);
210 fetch_items(children, LibRef, Since, Req) ->
211 1 ParentKey = cowboy_req:binding(item_key, Req),
212 1 shurbej_db:list_items_children(LibRef, ParentKey, Since);
213 fetch_items(collection, LibRef, Since, Req) ->
214
:-(
CollKey = cowboy_req:binding(coll_key, Req),
215
:-(
shurbej_db:list_items_in_collection(LibRef, CollKey, Since);
216 fetch_items(collection_top, LibRef, Since, Req) ->
217
:-(
CollKey = cowboy_req:binding(coll_key, Req),
218
:-(
[I || #shurbej_item{data = D} = I
219
:-(
<- shurbej_db:list_items_in_collection(LibRef, CollKey, Since),
220
:-(
maps:get(<<"parentItem">>, D, false) =:= false].
221
222 maybe_include_trashed(Items, LibRef, Since, Req) ->
223 44 case shurbej_http_common:get_include_trashed(Req) of
224 1 true -> Items ++ shurbej_db:list_items_trash(LibRef, Since);
225 43 false -> Items
226 end.
227
228 apply_filters(Items, Req) ->
229 46 Items2 = filter_by_item_keys(Items, shurbej_http_common:get_item_keys(Req)),
230 46 Items3 = shurbej_http_common:filter_by_tag(Items2, shurbej_http_common:get_tag_filter(Req)),
231 46 Items4 = shurbej_http_common:filter_by_item_type(Items3, shurbej_http_common:get_item_type_filter(Req)),
232 46 shurbej_http_common:filter_by_query(Items4, shurbej_http_common:get_query(Req),
233 shurbej_http_common:get_qmode(Req)).
234
235 46 filter_by_item_keys(Items, all) -> Items;
236 filter_by_item_keys(Items, Keys) ->
237
:-(
KeySet = sets:from_list(Keys),
238
:-(
[I || #shurbej_item{id = {_, _, K}} = I <- Items, sets:is_element(K, KeySet)].
239
240 do_post(Req0, State, WriteToken) ->
241 61 LibRef = shurbej_http_common:lib_ref(Req0),
242 61 ExpectedVersion = shurbej_http_common:get_if_unmodified(Req0),
243 61 case shurbej_http_common:read_json_body(Req0) of
244 {error, Reason, Req1} ->
245 1 Req = shurbej_http_common:error_response(400, body_error(Reason), Req1),
246 1 {ok, Req, State};
247 {ok, Items, Req1} when is_list(Items), length(Items) =< ?MAX_WRITE_ITEMS ->
248 60 KeyedItems = [ensure_key(I) || I <- Items],
249 60 {Valid, Failed} = validate_each(KeyedItems, fun shurbej_validate:item/1),
250 60 case Valid of
251 [] when map_size(Failed) > 0 ->
252 6 Result = #{<<"successful">> => #{}, <<"unchanged">> => #{}, <<"failed">> => Failed},
253 6 Req = shurbej_http_common:json_response(400, Result, Req1),
254 6 {ok, Req, State};
255 _ ->
256 54 case shurbej_version:write(LibRef, ExpectedVersion, fun(NewVersion) ->
257 53 lists:foreach(fun({_Idx, Item}) ->
258 66 write_item(LibRef, Item, NewVersion)
259 end, Valid),
260 53 ok
261 end) of
262 {ok, NewVersion} ->
263 53 Successful = maps:from_list(
264 66 [{integer_to_binary(Idx), envelope_for_write(LibRef, Item, NewVersion)}
265 53 || {Idx, Item} <- Valid]),
266 53 Result = #{
267 <<"successful">> => Successful,
268 <<"unchanged">> => #{},
269 <<"failed">> => Failed
270 },
271 53 shurbej_write_token:store(WriteToken, {Result, NewVersion}),
272 53 Req = shurbej_http_common:json_response(200, Result, NewVersion, Req1),
273 53 {ok, Req, State};
274 {error, precondition, CurrentVersion} ->
275 1 Req = shurbej_http_common:json_response(412,
276 #{<<"message">> => <<"Library has been modified since specified version">>},
277 CurrentVersion, Req1),
278 1 {ok, Req, State}
279 end
280 end;
281 {ok, Items, Req1} when is_list(Items) ->
282
:-(
Req = shurbej_http_common:error_response(413,
283 <<"Too many items (max ", (integer_to_binary(?MAX_WRITE_ITEMS))/binary, ")">>, Req1),
284
:-(
{ok, Req, State};
285 {ok, _, Req1} ->
286
:-(
Req = shurbej_http_common:error_response(400, <<"Body must be a JSON array">>, Req1),
287
:-(
{ok, Req, State}
288 end.
289
290 %% Cascade delete — wrapped in Mnesia transaction for atomicity
291 cascade_delete(LibRef, ItemKey, NewVersion) ->
292 6 shurbej_db:mark_item_deleted(LibRef, ItemKey, NewVersion),
293 6 shurbej_db:record_deletion(LibRef, <<"item">>, ItemKey, NewVersion),
294 6 shurbej_db:delete_item_tags(LibRef, ItemKey),
295 6 shurbej_db:delete_item_collections(LibRef, ItemKey),
296 6 shurbej_db:delete_fulltext(LibRef, ItemKey),
297 6 shurbej_db:delete_file_meta(LibRef, ItemKey),
298 %% list_items_children now uses the parent_key secondary index — O(k) not O(n).
299 6 Children = shurbej_db:list_items_children(LibRef, ItemKey, 0),
300 6 lists:foreach(fun(#shurbej_item{id = {_, _, ChildKey}}) ->
301
:-(
cascade_delete(LibRef, ChildKey, NewVersion)
302 end, Children).
303
304 ensure_key(Item) when is_map(Item) ->
305 74 case maps:get(<<"key">>, Item, undefined) of
306 72 undefined -> Item#{<<"key">> => generate_key()};
307 2 _ -> Item
308 end;
309
:-(
ensure_key(_) -> #{<<"key">> => generate_key()}.
310
311 write_item({LT, LI} = LibRef, Item, NewVersion) ->
312 68 Key = maps:get(<<"key">>, Item),
313 68 FullData = Item#{<<"version">> => NewVersion},
314 68 ParentKey = case maps:get(<<"parentItem">>, Item, false) of
315 3 P when is_binary(P) -> P;
316 65 _ -> undefined
317 end,
318 68 shurbej_db:write_item(#shurbej_item{
319 id = {LT, LI, Key},
320 version = NewVersion,
321 data = FullData,
322 deleted = false,
323 parent_key = ParentKey
324 }),
325 68 Collections = maps:get(<<"collections">>, Item, []),
326 68 shurbej_db:set_item_collections(LibRef, Key, Collections),
327 68 Tags = maps:get(<<"tags">>, Item, []),
328 68 TagPairs = [{maps:get(<<"tag">>, T, <<>>), maps:get(<<"type">>, T, 0)} || T <- Tags],
329 68 shurbej_db:set_item_tags(LibRef, Key, TagPairs).
330
331 validate_each(Items, ValidateFn) ->
332 74 {Valid, Failed} = lists:foldl(fun({Idx, Item}, {V, F}) ->
333 90 case ValidateFn(Item) of
334 81 ok -> {[{Idx, Item} | V], F};
335 {error, Reason} ->
336 9 F2 = F#{integer_to_binary(Idx) => #{
337 <<"key">> => maps:get(<<"key">>, Item, <<>>),
338 <<"code">> => 400,
339 <<"message">> => Reason
340 }},
341 9 {V, F2}
342 end
343 end, {[], #{}}, lists:enumerate(0, Items)),
344 74 {lists:reverse(Valid), Failed}.
345
346 envelope_for_write(LibRef, Item, NewVersion) ->
347 80 Key = maps:get(<<"key">>, Item),
348 80 Base = shurbej_http_common:base_url(),
349 80 Prefix = shurbej_http_common:lib_path_prefix(LibRef),
350 80 FullData = Item#{<<"key">> => Key, <<"version">> => NewVersion},
351 80 #{
352 <<"key">> => Key,
353 <<"version">> => NewVersion,
354 <<"library">> => shurbej_http_common:library_obj(LibRef),
355 <<"links">> => #{
356 <<"self">> => #{
357 <<"href">> => <<Base/binary, Prefix/binary, "/items/", Key/binary>>,
358 <<"type">> => <<"application/json">>
359 }
360 },
361 <<"meta">> => #{<<"numChildren">> => 0},
362 <<"data">> => FullData
363 }.
364
365 %% Generate item key using CSPRNG — not rand:uniform
366 generate_key() ->
367 88 Chars = <<"23456789ABCDEFGHIJKLMNPQRSTUVWXYZ">>,
368 88 Len = byte_size(Chars),
369 88 Bytes = crypto:strong_rand_bytes(8),
370 88 list_to_binary([binary:at(Chars, B rem Len) || <<B>> <= Bytes]).
371
372 1 body_error(invalid_json) -> <<"Invalid JSON">>;
373
:-(
body_error(body_too_large) -> <<"Request body too large">>.
374
375 %% One cached {LibVersion, Counts} row per library in a public ETS table
376 %% owned by shurbej_rate_limit. A version mismatch recomputes and overwrites.
377 %% persistent_term would be wrong here — each write bumps LibVersion and
378 %% `persistent_term:put` triggers a global scan across all terms.
379 cached_children_counts(LibRef, LibVersion) ->
380 40 case ets:lookup(shurbej_children_cache, LibRef) of
381 15 [{_, LibVersion, Counts}] -> Counts;
382 _ ->
383 25 Counts = shurbej_db:count_item_children(LibRef),
384 25 ets:insert(shurbej_children_cache, {LibRef, LibVersion, Counts}),
385 25 Counts
386 end.
Line Hits Source