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<Pet> 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 * <@card type="datatables" id="pets"> 061 * { dataMode:'server', dataUrl:'/console/pets/query', columns:[{key:'name', label:'Name'}, ...] } 062 * </@card> 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}