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.orders; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.time.*; 022import java.time.format.DateTimeFormatter; 023import java.util.*; 024 025import org.apache.juneau.commons.beanquery.*; 026import org.apache.juneau.commons.Schema; 027import org.apache.juneau.http.*; 028import org.apache.juneau.http.response.*; 029import org.apache.juneau.marshall.collections.*; 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 Orders page and its endpoints: the server-mode datatable query over BeanQuery, the {@code approve} and 044 * {@code deliver} status transitions, and the cancel dialog form with its {@code DELETE}. 045 * 046 * <h5 class='section'>Example:</h5> 047 * <p class='bjava'> 048 * <jc>// PLACED to APPROVED; any other status is a 409.</jc> 049 * <ja>@RestPost</ja>(path=<js>"/{id}/approve"</js>) 050 * <jk>public</jk> ActionResult approve(RestRequest <jv>req</jv>, <ja>@Path</ja>(<js>"id"</js>) <jk>long</jk> <jv>id</jv>) { 051 * <jk>return</jk> transition(<jv>req</jv>, <jv>id</jv>, <js>"approve"</js>, OrderStatus.<jsf>PLACED</jsf>, OrderStatus.<jsf>APPROVED</jsf>); 052 * } 053 * </p> 054 * <p class='bcode'> 055 * <@card type="datatables" id="orders"> 056 * { dataMode:'server', dataUrl:'/console/orders/query', rowActions:[{id:'approve', endpoint:'/console/orders/{id}/approve', method:'POST'}] } 057 * </@card> 058 * </p> 059 */ 060@Rest(path="/orders", title="Orders") 061@SuppressWarnings({ 062 "java:S110", // Inheritance depth comes from the BasicRestServlet hierarchy, not this page. 063 "java:S1192" // Route paths and field names repeat as inline literals by design. 064}) 065public class OrdersRest extends PetstoreConsolePage { 066 067 private static final long serialVersionUID = 1L; 068 069 /** 070 * The Orders page. 071 * 072 * @param req The request; {@code ?builder} renders the builder twin. 073 * @return The page. 074 */ 075 @RestGet(path="/") 076 public View page(RestRequest req) { 077 return builder(req) ? twin(req, OrdersPageBuilders.orders()) : FreemarkerView.of("orders.ftlh"); 078 } 079 080 /** 081 * Server-mode DataTables query over all orders. 082 * 083 * <p> 084 * The optional {@code day} query parameter ({@code yyyy-mm-dd}) narrows the orders to those whose ship date falls in 085 * that day's window, before the request's own column filters apply, so the two combine. A missing, blank or 086 * malformed value is ignored. 087 * 088 * <p> 089 * The optional {@code start} and {@code end} query parameters (the {@code window} URL-state directive's defaults; 090 * each a {@code yyyy-mm-dd} date or an ISO date-time, whose calendar date is used) keep the orders whose ship date 091 * falls between them, both days included, and either may be given alone. A bound that is blank or malformed is 092 * ignored. An order with no readable ship date is outside any window that has a usable bound. The window and 093 * {@code day} combine with each other and with the request's own column filters. 094 * 095 * @param req The DataTables request. 096 * @param day The day to keep, or {@code null}. 097 * @param start The first ship day to keep, or {@code null}. 098 * @param end The last ship day to keep, or {@code null}. 099 * @return One page of orders, or a 200 {@code error} envelope for a malformed search expression. 100 */ 101 @RestPost(path="/query") 102 public DataTablesResults<Order> query(@Content DataTablesRequest req, @Query("day") @Schema(aev=true) String day, 103 @Query("start") @Schema(aev=true) String start, @Query("end") @Schema(aev=true) String end) { 104 var wanted = parseDay(day); 105 var from = shipDay(start); 106 var to = shipDay(end); 107 var orders = store().getOrders(); 108 if (wanted != null) 109 orders = orders.stream().filter(o -> wanted.equals(shipDay(o.getShipDate()))).toList(); 110 if (from != null || to != null) 111 orders = orders.stream().filter(o -> inWindow(shipDay(o.getShipDate()), from, to)).toList(); 112 try (var s = PetStore.ORDER_QUERY.getSession(orders)) { 113 return DataTablesQuery.run(req, s); 114 } catch (BeanQuerySyntaxException e) { 115 return DataTablesResults.error(req.getDraw(), e.getMessage()); 116 } 117 } 118 119 /** Whether a ship day is inside the window; either bound may be {@code null} (open), and no ship day is never inside. */ 120 private static boolean inWindow(LocalDate shipped, LocalDate from, LocalDate to) { 121 return shipped != null && (from == null || ! shipped.isBefore(from)) && (to == null || ! shipped.isAfter(to)); 122 } 123 124 private static LocalDate parseDay(String value) { 125 if (value == null) 126 return null; 127 try { 128 return LocalDate.parse(value.trim()); 129 } catch (@SuppressWarnings("unused") DateTimeException e) { 130 return null; 131 } 132 } 133 134 /** The calendar date of an order's ship date, which is an ISO date or an ISO date-time; {@code null} if it is neither. */ 135 private static LocalDate shipDay(String value) { 136 var day = parseDay(value); 137 if (day != null || value == null) 138 return day; 139 try { 140 return DateTimeFormatter.ISO_DATE_TIME.parse(value.trim(), LocalDate::from); 141 } catch (@SuppressWarnings("unused") DateTimeException e) { 142 return null; 143 } 144 } 145 146 /** 147 * The new-order wizard, step one: pick a pet that is for sale and an optional ship date. 148 * 149 * <p> 150 * The {@code review} button opens the stacked second step and carries these inputs to it as drafts. That step's 151 * Confirm posts to {@code /console/orders}, as declared on the child action. 152 * 153 * @return The modal, on the compact chrome. 154 */ 155 @RestGet(path="/new-form") 156 public ModalDef newForm() { 157 var pet = FormDef.Input.of("petId", "Pet", "select").required(); 158 store().getPets().stream().filter(p -> p.getStatus() == PetStatus.AVAILABLE) 159 .sorted(Comparator.comparingLong(Pet::getId)) 160 .forEach(p -> pet.option(String.valueOf(p.getId()), p.getName())); 161 var form = FormDef.create() 162 .field(pet) 163 .field(FormDef.Input.of("shipDate", "Ship date (yyyy-mm-dd)", "text")) 164 .field(FormDef.Input.of("review", "Review order", "action").actionId("review")); 165 return ModalDef.create("New order: choose a pet").chrome("compact") 166 .form(form) 167 .childAction(ModalDef.ChildAction.of("review", "Review order").form("/console/orders/review-form").carryDrafts(true) 168 // The stacked step's Confirm submits with the child's own endpoint and method; without them it sends nothing. 169 .endpoint("/console/orders").method("POST").onSuccess("redraw")); 170 } 171 172 /** 173 * The new-order wizard, step two: the order about to be placed, read back from the first step's drafts. 174 * 175 * @param drafts The first step's inputs as JSON, carried by the dialog as {@code juneauDrafts}. 176 * @return The modal, on the compact chrome, whose form re-posts the validated values. 177 * @throws BadRequest If the drafts are missing, not JSON, or carry a bad ship date. 178 * @throws NotFound If the pet does not exist. 179 * @throws Conflict If the pet is not available. 180 */ 181 @RestGet(path="/review-form") 182 public ModalDef reviewForm(@Query("juneauDrafts") String drafts) { 183 if (ib(drafts)) 184 throw new BadRequest("Missing order draft"); 185 JsonMap m; 186 try { 187 m = new JsonMap(drafts); 188 } catch (Exception e) { 189 throw new BadRequest("Invalid order draft: '%s'", e.getMessage()); 190 } 191 var pet = pet(m.getString("petId")); 192 var shipDate = shipDate(m.getString("shipDate")); 193 return ModalDef.create("Review order").chrome("compact") 194 .field("Pet", pet.getName()) 195 .field("Ship date", shipDate == null ? "(none)" : shipDate) 196 .form(FormDef.create() 197 .field(FormDef.Input.of("petId", "Pet", "text").value(String.valueOf(pet.getId()))) 198 .field(FormDef.Input.of("shipDate", "Ship date", "text").value(shipDate == null ? "" : shipDate))); 199 } 200 201 /** 202 * Places a new order. 203 * 204 * @param req The request (audit actor). 205 * @param body The order, as the plain {@code {petId, shipDate}} or the dialog envelope {@code {fields:{...}}}. 206 * @return The result toast, carrying the stored order. 207 * @throws BadRequest On a missing body, non-object {@code fields}, missing pet or a bad ship date. 208 * @throws NotFound If the pet does not exist. 209 * @throws Conflict If the pet is not available. 210 */ 211 @RestPost(path="/") 212 public ActionResult create(RestRequest req, @Content JsonMap body) { 213 var actor = ConsoleWrites.actor(req); 214 if (body == null) 215 throw new BadRequest("Missing order body"); 216 JsonMap fields; 217 try { 218 fields = body.getMap("fields"); 219 } catch (Exception e) { 220 throw new BadRequest("Invalid order fields: '%s'", e.getMessage()); 221 } 222 if (fields == null && body.get("fields") != null) 223 throw new BadRequest("Invalid order fields: '%s'", body.get("fields")); 224 var f = fields != null ? fields : body; 225 var pet = pet(f.getString("petId")); 226 var order = new Order().setPetId(pet.getId()).setShipDate(shipDate(f.getString("shipDate"))).setStatus(OrderStatus.PLACED); 227 var stored = store().createOrder(order, actor); 228 return ActionResult.success(stored).message(f("Placed order '%s' for '%s'", stored.getId(), pet.getName())); 229 } 230 231 private Pet pet(String id) { 232 if (ib(id)) 233 throw new BadRequest("Missing pet"); 234 Pet p = null; 235 try { 236 p = store().getPet(Long.parseLong(id.trim())); 237 } catch (NumberFormatException e) { 238 throw new BadRequest("Invalid pet '%s'", id); 239 } 240 if (p == null) 241 throw new NotFound("Unknown pet '%s'", id); 242 if (p.getStatus() != PetStatus.AVAILABLE) 243 throw new Conflict("Pet '%s' is '%s'; only an available pet can be ordered", id, p.getStatus()); 244 return p; 245 } 246 247 private static String shipDate(String value) { 248 if (ib(value)) 249 return null; 250 try { 251 return LocalDate.parse(value.trim()).toString(); 252 } catch (DateTimeException e) { 253 throw new BadRequest("Invalid ship date '%s'", value); 254 } 255 } 256 257 /** 258 * Approves a placed order. 259 * 260 * @param req The request. 261 * @param id The order id. 262 * @return The result toast, carrying the order. 263 * @throws NotFound If the order does not exist. 264 * @throws Conflict If it is not {@code PLACED}. 265 */ 266 @RestPost(path="/{id}/approve") 267 public ActionResult approve(RestRequest req, @Path("id") long id) { 268 return transition(req, id, "approve", OrderStatus.PLACED, OrderStatus.APPROVED); 269 } 270 271 /** 272 * Delivers an approved order. 273 * 274 * @param req The request. 275 * @param id The order id. 276 * @return The result toast, carrying the order. 277 * @throws NotFound If the order does not exist. 278 * @throws Conflict If it is not {@code APPROVED}. 279 */ 280 @RestPost(path="/{id}/deliver") 281 public ActionResult deliver(RestRequest req, @Path("id") long id) { 282 return transition(req, id, "deliver", OrderStatus.APPROVED, OrderStatus.DELIVERED); 283 } 284 285 /** 286 * The cancel dialog form: a reason and whether to notify the customer. 287 * 288 * @param id The order id. 289 * @return The modal. 290 * @throws NotFound If the order does not exist. 291 */ 292 @RestGet(path="/{id}/cancel-form") 293 public ModalDef cancelForm(@Path("id") long id) { 294 require(id); 295 return ModalDef.create(f("Cancel order '%s'", id)) 296 .form(FormDef.create() 297 .field(FormDef.Input.of("reason", "Reason", "text")) 298 .field(FormDef.Input.of("notify", "Notify the customer", "checkbox"))); 299 } 300 301 /** 302 * Deletes (cancels) an order. 303 * 304 * @param req The request. 305 * @param id The order id. 306 * @return The result toast. 307 * @throws BadRequest If the optional body is not a JSON object. 308 * @throws NotFound If the order does not exist. 309 */ 310 @RestDelete(path="/{id}") 311 public ActionResult cancel(RestRequest req, @Path("id") long id) { 312 var actor = ConsoleWrites.actor(req); 313 require(id); 314 var reason = reason(req); 315 store().deleteOrder(id, actor, reason); 316 return ActionResult.success(null).message(f("Cancelled order '%s'", id)); 317 } 318 319 /** The optional body: the cancel dialog envelope {@code {fields:{reason, notify}}}, or {@code {reason}} from a plain API call. */ 320 private static String reason(RestRequest req) { 321 try { 322 var text = req.getContent().asString(); 323 if (ib(text)) 324 return null; 325 var body = new JsonMap(text); 326 var fields = body.getMap("fields"); 327 return (fields != null ? fields : body).getString("reason"); 328 } catch (Exception e) { 329 throw new BadRequest("Invalid cancel body: '%s'", e.getMessage()); 330 } 331 } 332 333 private ActionResult transition(RestRequest req, long id, String verb, OrderStatus from, OrderStatus to) { 334 var actor = ConsoleWrites.actor(req); 335 var o = require(id); 336 if (o.getStatus() != from) 337 throw new Conflict("Order '%s' is '%s'; cannot '%s'", id, o.getStatus(), verb); 338 // Update a copy: the live order must not change unless the store accepts the write. 339 var next = new Order().setId(o.getId()).setPetId(o.getPetId()).setShipDate(o.getShipDate()).setStatus(to); 340 return ActionResult.success(store().updateOrder(next, actor)).message(f("Order '%s' %s", id, to)); 341 } 342 343 private Order require(long id) { 344 var o = store().getOrder(id); 345 if (o == null) 346 throw new NotFound("Unknown order '%s'", id); 347 return o; 348 } 349}