001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.petstore.console.pets;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.util.*;
022
023import org.apache.juneau.commons.Schema;
024import org.apache.juneau.commons.beanquery.*;
025import org.apache.juneau.marshall.collections.*;
026import org.apache.juneau.marshall.marshaller.*;
027import org.apache.juneau.marshall.parser.*;
028import org.apache.juneau.http.*;
029import org.apache.juneau.http.response.*;
030import org.apache.juneau.petstore.console.*;
031import org.apache.juneau.petstore.console.pages.*;
032import org.apache.juneau.petstore.dto.*;
033import org.apache.juneau.petstore.service.*;
034import org.apache.juneau.rest.server.*;
035import org.apache.juneau.console.datatables.*;
036import org.apache.juneau.console.datatables.adapter.*;
037import org.apache.juneau.rest.server.view.*;
038import org.apache.juneau.rest.server.view.freemarker.*;
039import org.apache.juneau.console.views.*;
040import org.apache.juneau.console.widgets.*;
041
042/**
043 * The pets JSON endpoints (server-mode datatable query over BeanQuery, create/replace/delete, sell, stage, and the
044 * per-pet feeds), the Pets and Sold pages, the add/edit dialog form, and the P4 pet pages: Summary (inline edit),
045 * Photo, Orders (with a row detail, and one order selected at nav depth 4) and History, whose nav nodes the page
046 * appends under {@code pets} as {@code p<petId>} and {@code o<orderId>}.  The Pending changes page is the
047 * {@link ChangesRest} child.
048 *
049 * <h5 class='section'>Example:</h5>
050 * <p class='bjava'>
051 *    <jc>// The BQ#6 endpoint behind the table.</jc>
052 *    <ja>@RestPost</ja>(path=<js>"/query"</js>)
053 *    <jk>public</jk> DataTablesResults&lt;Pet&gt; query(<ja>@Content</ja> DataTablesRequest <jv>req</jv>) {
054 *       <jk>try</jk> (<jk>var</jk> <jv>s</jv> = store().queryPets()) {
055 *          <jk>return</jk> DataTablesQuery.<jsm>run</jsm>(<jv>req</jv>, <jv>s</jv>);
056 *       }
057 *    }
058 * </p>
059 * <p class='bcode'>
060 *    &lt;@card type="datatables" id="pets"&gt;
061 *    { dataMode:'server', dataUrl:'/console/pets/query', columns:[{key:'name', label:'Name'}, ...] }
062 *    &lt;/@card&gt;
063 * </p>
064 * <p class='bjava'>
065 *    <jc>// The same table in the builder twin.</jc>
066 *    TableSpec.<jsm>create</jsm>(<js>"pets"</js>).dataMode(DataMode.<jsf>SERVER</jsf>).dataUrl(<js>"/console/pets/query"</js>)
067 *       .columns(Column.<jsm>create</jsm>(<js>"name"</js>).label(<js>"Name"</js>));
068 * </p>
069 */
070@Rest(path="/pets", title="Pets", children={ChangesRest.class})
071@SuppressWarnings({
072   "java:S110" // Inheritance depth comes from the BasicRestServlet hierarchy, not this page.
073})
074public class PetsRest extends PetstoreConsolePage {
075
076   private static final long serialVersionUID = 1L;
077
078   /** The P4 tabs, in nav order. */
079   private static final List<String> TABS = List.of("summary", "photo", "orders", "history");
080
081   /**
082    * The Pets page.
083    *
084    * @param req The request; {@code ?mixed} renders the half-FTL variant, {@code ?builder} the builder twin.
085    * @return The page.
086    */
087   @RestGet(path="/")
088   public View page(RestRequest req) {
089      var mixed = req.containsQueryParam("mixed");
090      if (! builder(req))
091         return mixed ? PetsPageBuilders.petsMixed().view(req) : FreemarkerView.of("pets.ftlh");
092      return twin(req, mixed ? PetsPageBuilders.petsMixed() : PetsPageBuilders.pets());
093   }
094
095   /**
096    * The Sold pets page.
097    *
098    * @param req The request; {@code ?builder} renders the builder twin.
099    * @return The page.
100    */
101   @RestGet(path="/sold")
102   public View sold(RestRequest req) {
103      return builder(req) ? twin(req, PetsPageBuilders.sold()) : FreemarkerView.of("pets-sold.ftlh");
104   }
105
106   /** @return The client-mode rows for the Sold page: every sold pet, by id. */
107   @RestGet(path="/sold/rows")
108   public List<Pet> soldRows() {
109      return store().getPets().stream().filter(x -> x.getStatus() == PetStatus.SOLD).sorted(Comparator.comparingLong(Pet::getId)).toList();
110   }
111
112   /**
113    * The add/edit dialog form.
114    *
115    * @param id The pet to edit, or <jk>null</jk> to add.
116    * @return The modal.
117    * @throws NotFound If {@code id} names no pet.
118    */
119   @RestGet(path="/form")
120   public ModalDef form(@Query("id") Long id) {
121      var p = id == null ? new Pet().setStatus(PetStatus.AVAILABLE) : require(id);
122      var form = FormDef.create()
123         .field(FormDef.Input.of("name", "Name", "text").required().value(p.getName()));
124      var species = FormDef.Input.of("species", "Species", "select").required();
125      for (var s : Species.values())
126         species.option(s.name(), s.name().charAt(0) + s.name().substring(1).toLowerCase());
127      if (p.getSpecies() != null)
128         species.value(p.getSpecies().name());
129      form.field(species);
130      form.field(FormDef.Input.of("price", "Price (USD)", "text").required().value(id == null ? "" : String.valueOf(p.getPrice())));
131      return ModalDef.create(id == null ? "Add pet" : f("Edit '%s'", p.getName())).form(form);
132   }
133
134   /**
135    * Server-mode DataTables query over all pets.
136    *
137    * <p>
138    * The optional {@code ids} query parameter (comma-separated pet ids, as the {@code ids} URL-state directive sends it)
139    * narrows the pets to those ids, before the request's own column filters apply, so the two combine.  Tokens that are
140    * not numbers are dropped, and a blank value or one with no usable token is ignored.
141    *
142    * @param req The DataTables request.
143    * @param ids The ids to keep, or {@code null}.
144    * @return One page of pets, or a 200 {@code error} envelope for a malformed search expression.
145    */
146   @RestPost(path="/query")
147   public DataTablesResults<Pet> query(@Content DataTablesRequest req, @Query("ids") @Schema(aev=true) String ids) {
148      var wanted = parseIds(ids);
149      var pets = store().getPets();
150      if (wanted != null)
151         pets = pets.stream().filter(p -> wanted.contains(p.getId())).toList();
152      try (var s = PetStore.PET_QUERY.getSession(pets)) {
153         return DataTablesQuery.run(req, s);
154      } catch (BeanQuerySyntaxException e) {
155         // DataTablesQuery maps a bad expression to HTTP 400 by default; the table wants a 200 error envelope it can show.
156         return DataTablesResults.error(req.getDraw(), e.getMessage());
157      }
158   }
159
160   /** The usable ids in a comma-separated value, or {@code null} if there are none (no filter). */
161   @SuppressWarnings({
162      "java:S1168" // null means no filter, distinct from an empty id set
163   })
164   private static Set<Long> parseIds(String value) {
165      if (value == null)
166         return null;
167      var out = new HashSet<Long>();
168      for (var token : value.split(",")) {
169         try {
170            out.add(Long.parseLong(token.trim()));
171         } catch (@SuppressWarnings("unused") NumberFormatException e) {
172            // Not a pet id; dropped.
173         }
174      }
175      return out.isEmpty() ? null : out;
176   }
177
178   /**
179    * Creates a pet.  Status defaults to {@code AVAILABLE}.
180    *
181    * @param req The request (write gate, audit actor).
182    * @param body The new pet, either as the plain pet or as the dialog envelope {@code {fields:{...}}}.
183    * @return The result toast, carrying the stored pet.
184    * @throws BadRequest On a blank name or negative price.
185    */
186   @RestPost(path="/")
187   public ActionResult create(RestRequest req, @Content JsonMap body) {
188      var actor = ConsoleWrites.actor(req);
189      var pet = toPet(body);
190      validate(pet);
191      if (pet.getStatus() == null)
192         pet.setStatus(PetStatus.AVAILABLE);
193      var p = store().createPet(pet, actor);
194      return ActionResult.success(p).message(f("Added '%s'", p.getName()));
195   }
196
197   /**
198    * Replaces a pet.  Name and price are required; a species, tags or photo left out of the body keep their
199    * current values, as does the status.
200    *
201    * @param req The request.
202    * @param id The pet id.
203    * @param body The new state, either as the plain pet or as the dialog envelope {@code {fields:{...}}}.
204    * @return The result toast.
205    * @throws NotFound If the pet does not exist.
206    */
207   @RestPut(path="/{id}")
208   public ActionResult update(RestRequest req, @Path("id") long id, @Content JsonMap body) {
209      // The edit form offers name, species and price but no status, so for a saved edit (for example from the
210      // store treemap) species is the grouping field that can change; status moves only through the sell action.
211      var actor = ConsoleWrites.actor(req);
212      var old = require(id);
213      var pet = toPet(body);
214      validate(pet);
215      pet.setId(id);
216      pet.defaultStatus(old.getStatus()).defaultSpecies(old.getSpecies()).defaultTags(old.getTags()).defaultPhoto(old.getPhoto());
217      return ActionResult.success(store().updatePet(pet, actor)).message(f("Saved '%s'", pet.getName()));
218   }
219
220   /**
221    * Deletes a pet.
222    *
223    * @param req The request.
224    * @param id The pet id.
225    * @return The result toast.
226    * @throws NotFound If the pet does not exist.
227    */
228   @RestDelete(path="/{id}")
229   public ActionResult delete(RestRequest req, @Path("id") long id) {
230      var actor = ConsoleWrites.actor(req);
231      var p = require(id);
232      store().deletePet(id, actor);
233      return ActionResult.success(null).message(f("Deleted '%s'", p.getName()));
234   }
235
236   /**
237    * Marks a pet sold.  The Pets page bulk "Mark sold (N)" action calls this per row.
238    *
239    * @param req The request.
240    * @param id The pet id.
241    * @return The result toast, carrying the updated pet.
242    * @throws NotFound If the pet does not exist.
243    * @throws Conflict If it is already sold.
244    */
245   @RestPost(path="/{id}/sell")
246   public ActionResult sell(RestRequest req, @Path("id") long id) {
247      var actor = ConsoleWrites.actor(req);
248      var p = require(id);
249      if (p.getStatus() == PetStatus.SOLD)
250         throw new Conflict("Pet '%s' is already '%s'", id, p.getStatus());
251      // Update a copy: the live pet must not change unless the store accepts the write.
252      var sold = new Pet().setId(p.getId()).setSpecies(p.getSpecies()).setName(p.getName()).setPrice(p.getPrice())
253         .setTags(p.getTags()).setStatus(PetStatus.SOLD).setPhoto(p.getPhoto());
254      return ActionResult.success(store().updatePet(sold, actor)).message(f("Sold '%s'", p.getName()));
255   }
256
257   /**
258    * Stages an inline edit for review on the Pending changes page.  The pet itself is not changed until the change is applied.
259    *
260    * <p>
261    * {@link PendingChanges#stage} owns which fields are stageable and its messages.  The photo is deliberately not
262    * stageable: the pet detail Photo tab edits it directly through the API's own {@code PUT /petstore/pets/{id}/photo}.
263    *
264    * @param req The request.
265    * @param id The pet id.
266    * @param body {@code {field, value}}, or the inline-edit envelope {@code {fields:{<field>:value}}} with exactly one field.
267    * @return {@code success} carrying the new {@link PendingChange}.
268    * @throws BadRequest If the field is missing or not stageable, or the value is invalid.
269    * @throws NotFound If the pet does not exist.
270    */
271   @RestPost(path="/{id}/stage")
272   public ActionResult stage(RestRequest req, @Path("id") long id, @Content JsonMap body) {
273      var actor = ConsoleWrites.actor(req);
274      String field;
275      String value;
276      // The 'edit' column renderer commits {fields:{<column>:value}} (plus its dialog envelope keys) through the row action.
277      var fields = body.getMap("fields");
278      if (fields != null) {
279         if (fields.size() != 1)
280            throw new BadRequest("Exactly one field can be staged at a time; got '%s'", fields.size());
281         field = fields.keySet().iterator().next();
282         value = fields.getString(field);
283      } else {
284         field = body.getString("field");
285         value = body.getString("value");
286      }
287      if (ib(field))
288         throw new BadRequest("Missing field to stage");
289      try {
290         var c = store().pendingChanges().stage(id, field, value, ConsoleWrites.author(actor));
291         return ActionResult.success(c).message(f("Staged '%s'", c.getSummary()));
292      } catch (PetstoreNotFoundException e) {
293         throw new NotFound(e.getMessage());
294      } catch (IllegalArgumentException e) {
295         throw new BadRequest(e.getMessage());
296      }
297   }
298
299   /**
300    * @param id The pet id.
301    * @return This pet's orders (the related list), by id.
302    * @throws NotFound If the pet does not exist.
303    */
304   @RestGet(path="/{id}/orders/rows")
305   public List<Order> orderRows(@Path("id") long id) {
306      require(id);
307      return store().getOrders().stream().filter(x -> x.getPetId() == id).sorted(Comparator.comparingLong(Order::getId)).toList();
308   }
309
310   /**
311    * @param id The pet id.
312    * @return The audit rows for this pet, oldest first.
313    * @throws NotFound If the pet does not exist.
314    */
315   @RestGet(path="/{id}/history/rows")
316   public List<AuditEntry> historyRows(@Path("id") long id) {
317      require(id);
318      var t = String.valueOf(id);
319      return store().getAudit().stream().filter(x -> "Pet".equals(x.getEntity()) && t.equals(x.getEntityId())).toList();
320   }
321
322   /**
323    * The bare pet URL; lands on the first tab.
324    *
325    * @param req The request.
326    * @param id The pet id.
327    * @return A 303 to {@code /console/pets/{id}/summary}.
328    */
329   @RestGet(path="/{id}")
330   public SeeOther detail(RestRequest req, @Path("id") long id) {
331      return new SeeOther().setLocation(req.getUriResolver().resolve("servlet:/" + id + "/summary"));
332   }
333
334   /**
335    * P4, one tab.
336    *
337    * @param req The request (viewer roles).
338    * @param res The response (404 status for the not-found card).
339    * @param id The pet id.
340    * @param tab One of {@link #TABS}.
341    * @return The tab page, or the not-found card with a 404 status.
342    * @throws NotFound If the tab is unknown.
343    */
344   @RestGet(path="/{id}/{tab}")
345   public View detailTab(RestRequest req, RestResponse res, @Path("id") long id, @Path("tab") String tab) {
346      if (! TABS.contains(tab))
347         throw new NotFound("Unknown tab '%s'", tab);
348      return petPage(req, res, id, tab, null);
349   }
350
351   /**
352    * P4 at nav depth 4: the Orders tab with one of this pet's orders selected.
353    *
354    * @param req The request.
355    * @param res The response.
356    * @param id The pet id.
357    * @param orderId The order id.
358    * @return The Orders tab with the order card, or the not-found card with a 404 status if the pet does not exist.
359    * @throws NotFound If the order does not exist or is not this pet's.
360    */
361   @RestGet(path="/{id}/orders/{orderId}")
362   public View detailOrder(RestRequest req, RestResponse res, @Path("id") long id, @Path("orderId") long orderId) {
363      // An unknown pet gets the not-found card, not the order 404.
364      return petPage(req, res, id, "orders", store().getPet(id) == null ? null : requireOrder(id, orderId));
365   }
366
367   /**
368    * @param id The pet id.
369    * @return The Summary card's single row: this pet, as a one-element list (the card is client-mode).
370    * @throws NotFound If the pet does not exist.
371    */
372   @RestGet(path="/{id}/row")
373   public List<Pet> row(@Path("id") long id) {
374      return List.of(require(id));
375   }
376
377   /**
378    * The Orders tab's row-detail GET.
379    *
380    * @param id The pet id.
381    * @param orderId The order id.
382    * @return {@code {contractVersion, fields}} (see {@link RowDetail#response(Object)}).
383    * @throws NotFound If the order does not exist or is not this pet's.
384    */
385   @RestGet(path="/{id}/orders/{orderId}/detail")
386   public JsonMap orderDetail(@Path("id") long id, @Path("orderId") long orderId) {
387      return RowDetail.response(requireOrder(id, orderId));
388   }
389
390   private View petPage(RestRequest req, RestResponse res, long id, String tab, Order order) {
391      var p = store().getPet(id);
392      if (p == null) {
393         // A View, not a thrown NotFound, so the not-found card renders inside the chrome.
394         res.setStatus(404);
395         return FreemarkerView.of("pet-not-found.ftlh").attr("message", f("Unknown pet '%s'", id));
396      }
397      // Node ids must start with a letter, so the pet node is p<id> and the order node o<id>.
398      var nav = order == null ? f("pets/p%s/%s", id, tab) : f("pets/p%s/orders/o%s", id, order.getId());
399      // The API's CSRF token, published by the console boundary on allowed requests; absent means the upload form renders without one.
400      var servletReq = req.getHttpServletRequest();
401      var apiCsrf = servletReq.getAttribute(PetstoreApiBoundary.TOKEN_ATTRIBUTE) instanceof String s ? s : null;
402      if (builder(req))
403         return twin(req, PetsPageBuilders.pet(p, tab, nav, order, ConsoleWrites.roles(req), apiCsrf));
404      var v = FreemarkerView.of("pet.ftlh").attr("pet", p).attr("tab", tab).attr("activeNav", nav)
405         .attr("pageTitle", p.getName()).attr("roles", ConsoleWrites.roles(req));
406      if (apiCsrf != null)
407         v = v.attr("apiCsrf", apiCsrf);
408      return order == null ? v : v.attr("order", order);
409   }
410
411   private Order requireOrder(long id, long orderId) {
412      var o = store().getOrder(orderId);
413      if (o == null || o.getPetId() != id)
414         throw new NotFound("Unknown order '%s'", orderId);
415      return o;
416   }
417
418   /**
419    * @param id The pet id.
420    * @return The live pet.
421    * @throws NotFound If it does not exist.
422    */
423   protected Pet require(long id) {
424      var p = store().getPet(id);
425      if (p == null)
426         throw new NotFound("Unknown pet '%s'", id);
427      return p;
428   }
429
430   /** Unwraps the dialog envelope ({@code fields}) if present; otherwise the body is the pet itself. */
431   private static Pet toPet(JsonMap body) {
432      var fields = body.getMap("fields");
433      var src = fields != null ? fields : body;
434      try {
435         return Json.to(Json.of(src), Pet.class);
436      } catch (ParseException e) {
437         throw new BadRequest("Invalid pet: '%s'", e.getMessage());
438      }
439   }
440
441   private static void validate(Pet p) {
442      if (p.getName() == null || p.getName().isBlank())
443         throw new BadRequest("Name must not be blank");
444      if (Float.isNaN(p.getPrice()) || Float.isInfinite(p.getPrice()) || p.getPrice() < 0)
445         throw new BadRequest("Price '%s' must be a non-negative number", p.getPrice());
446   }
447}