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.service; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.util.*; 022import java.util.concurrent.atomic.*; 023import java.util.function.*; 024 025import org.apache.juneau.petstore.dto.*; 026 027/** 028 * The staging area for pet-detail inline edits: edits are staged here instead of mutating the pet, then applied 029 * (perRow, with retry on failure) or discarded (aggregate) from the Pending changes page. 030 * 031 * <p> 032 * Obtained from {@link PetStore#pendingChanges()}. All methods are synchronized; the store is a demo, not a 033 * transactional system. 034 * 035 * <h5 class='section'>Example:</h5> 036 * <p class='bjava'> 037 * PendingChanges <jv>pc</jv> = <jv>store</jv>.pendingChanges(); 038 * PendingChange <jv>c</jv> = <jv>pc</jv>.stage(1, <js>"price"</js>, <js>"42.50"</js>, <js>"alice"</js>); 039 * <jv>pc</jv>.stage(1, <js>"status"</js>, <js>"SOLD"</js>, <js>"alice"</js>); 040 * <jv>pc</jv>.apply(<jv>c</jv>.getId(), <js>"console:alice"</js>); <jc>// pet 1 now costs 42.50</jc> 041 * PendingChanges.DiscardOutcome <jv>out</jv> = <jv>pc</jv>.discard(List.<jsm>of</jsm>(<js>"7"</js>, <js>"8"</js>), <js>"console:alice"</js>); 042 * </p> 043 */ 044public class PendingChanges { 045 046 /** 047 * Result of an aggregate discard, mapped 1:1 onto the console's {@code BulkResult} by {@code ChangesRest}. 048 * 049 * @param succeeded Discarded change ids. 050 * @param notFound Ids that name no change (including unparseable ids). 051 * @param failed Ids that exist but could not be discarded, with the reason. 052 */ 053 public record DiscardOutcome(List<String> succeeded, List<String> notFound, List<Failure> failed) {} 054 055 /** 056 * One failed id in a {@link DiscardOutcome}. 057 * 058 * @param id The change id. 059 * @param message Why it failed. 060 */ 061 public record Failure(String id, String message) {} 062 063 private static final String PRICE_MSG = "Price '%s' must be a non-negative number"; 064 065 /** Stageable field names, in the order reported by the "cannot be staged" message. */ 066 private static final List<String> FIELDS = List.of("name", "price", "species", "status", "tags"); 067 068 /** Per-field old-value rendering, validation and write. */ 069 private enum StageField { 070 NAME("name", Pet::getName, PendingChanges::validateName, Pet::setName), 071 PRICE("price", p -> String.valueOf(p.getPrice()), PendingChanges::validatePrice, (p, v) -> p.setPrice(Float.parseFloat(v))), 072 SPECIES("species", p -> p.getSpecies() == null ? "" : p.getSpecies().name(), PendingChanges::validateSpecies, (p, v) -> p.setSpecies(Species.valueOf(v))), 073 STATUS("status", p -> p.getStatus() == null ? "" : p.getStatus().name(), PendingChanges::validateStatus, (p, v) -> p.setStatus(PetStatus.valueOf(v))), 074 TAGS("tags", p -> p.getTags() == null ? "" : String.join(",", p.getTags()), PendingChanges::parseTags, (p, v) -> p.setTags(parseTags(v))); 075 076 final String fieldName; 077 final Function<Pet,String> oldValue; 078 final Consumer<String> validator; 079 final BiConsumer<Pet,String> writer; 080 081 StageField(String fieldName, Function<Pet,String> oldValue, Consumer<String> validator, BiConsumer<Pet,String> writer) { 082 this.fieldName = fieldName; 083 this.oldValue = oldValue; 084 this.validator = validator; 085 this.writer = writer; 086 } 087 088 static StageField of(String fieldName) { 089 for (var f : values()) 090 if (f.fieldName.equals(fieldName)) 091 return f; 092 return null; 093 } 094 } 095 096 private final PetStore store; 097 private final Map<Long,PendingChange> changes = new LinkedHashMap<>(); 098 private final AtomicLong nextId = new AtomicLong(); 099 100 PendingChanges(PetStore store) { 101 this.store = store; 102 } 103 104 /** 105 * Stages an edit without touching the pet. 106 * 107 * @param petId The pet id. 108 * @param field One of {@code "name"}, {@code "price"}, {@code "species"}, {@code "status"} or {@code "tags"} 109 * (tags is a comma-separated list; blank clears). 110 * @param newValue The new value (string form). 111 * @param author Who staged it. 112 * @return The new pending change. 113 * @throws PetstoreNotFoundException If the pet does not exist. 114 * @throws IllegalArgumentException If the field is not stageable or the value is invalid. 115 */ 116 public synchronized PendingChange stage(long petId, String field, String newValue, String author) { 117 var pet = store.getPet(petId); 118 if (pet == null) 119 throw new PetstoreNotFoundException(f("Unknown pet '%s'", petId)); 120 var sf = StageField.of(field); 121 if (sf == null) 122 throw iaex("Field '%s' cannot be staged; use one of: %s", field, String.join(", ", FIELDS)); 123 sf.validator.accept(newValue); 124 var old = sf.oldValue.apply(pet); 125 var c = new PendingChange().setId(nextId.incrementAndGet()).setPetId(petId).setField(field) 126 .setOldValue(old).setNewValue(newValue).setAuthor(author) 127 .setSummary(f("%s: %s '%s' -> '%s'", pet.getName(), field, old, newValue)); 128 changes.put(c.getId(), c); 129 return c; 130 } 131 132 /** @return All changes, staging order. Immutable snapshot. */ 133 public synchronized List<PendingChange> list() { 134 return List.copyOf(changes.values()); 135 } 136 137 /** @return Only {@link PendingChange.State#PENDING} changes, staging order. Immutable snapshot. */ 138 public synchronized List<PendingChange> pending() { 139 return changes.values().stream().filter(x -> x.getState() == PendingChange.State.PENDING).toList(); 140 } 141 142 /** 143 * Returns one change. 144 * 145 * @param id The change id. 146 * @return The change. 147 * @throws PetstoreNotFoundException If it does not exist. 148 */ 149 public synchronized PendingChange get(long id) { 150 var c = changes.get(id); 151 if (c == null) 152 throw new PetstoreNotFoundException(f("Unknown change '%s'", id)); 153 return c; 154 } 155 156 /** 157 * Writes a pending change to its pet. If the pet is gone, the change becomes {@code FAILED} instead. 158 * 159 * @param id The change id. 160 * @param actor The audit actor. 161 * @return The change, now {@code APPLIED} or {@code FAILED}. 162 * @throws PetstoreNotFoundException If the change does not exist. 163 * @throws PetstoreConflictException If the change is not pending. 164 */ 165 public synchronized PendingChange apply(long id, String actor) { 166 var c = get(id); 167 if (c.getState() != PendingChange.State.PENDING) 168 throw new PetstoreConflictException("Change '%s' is not pending", id); 169 return write(c, actor); 170 } 171 172 /** 173 * Re-applies a failed change. 174 * 175 * @param id The change id. 176 * @param actor The audit actor. 177 * @return The change, now {@code APPLIED} or still {@code FAILED}. 178 * @throws PetstoreNotFoundException If the change does not exist. 179 * @throws PetstoreConflictException If the change has not failed. 180 */ 181 public synchronized PendingChange retry(long id, String actor) { 182 var c = get(id); 183 if (c.getState() != PendingChange.State.FAILED) 184 throw new PetstoreConflictException("Change '%s' has not failed", id); 185 return write(c, actor); 186 } 187 188 /** 189 * Discards pending changes in one aggregate call. 190 * 191 * @param ids The change ids (string form, as sent by the bulk action). 192 * @param actor The audit actor. 193 * @return The per-id outcome. 194 */ 195 public synchronized DiscardOutcome discard(Collection<String> ids, String actor) { 196 var ok = new ArrayList<String>(); 197 var missing = new ArrayList<String>(); 198 var failed = new ArrayList<Failure>(); 199 for (var sid : ids) { 200 Long id = parse(sid); 201 var c = id == null ? null : changes.get(id); 202 if (c == null) 203 missing.add(sid); 204 else if (c.getState() != PendingChange.State.PENDING) 205 failed.add(new Failure(sid, f("Change '%s' is not pending", sid))); 206 else { 207 changes.remove(id); 208 store.recordAudit(actor, "Change", sid, "DISCARD", c.getSummary()); 209 ok.add(sid); 210 } 211 } 212 return new DiscardOutcome(List.copyOf(ok), List.copyOf(missing), List.copyOf(failed)); 213 } 214 215 private PendingChange write(PendingChange c, String actor) { 216 var pet = store.getPet(c.getPetId()); 217 if (pet == null) 218 return c.setState(PendingChange.State.FAILED).setMessage(f("Unknown pet '%s'", c.getPetId())); 219 StageField.of(c.getField()).writer.accept(pet, c.getNewValue()); 220 store.updatePet(pet, actor); 221 return c.setState(PendingChange.State.APPLIED).setMessage(null); 222 } 223 224 private static void validateName(String value) { 225 if (ib(value)) 226 throw iaex("Name must not be blank"); 227 } 228 229 private static void validatePrice(String value) { 230 if (value == null) 231 throw iaex(PRICE_MSG, value); 232 float p; 233 try { 234 p = Float.parseFloat(value); 235 } catch (NumberFormatException e) { 236 throw iaex(PRICE_MSG, value); 237 } 238 if (p < 0 || Float.isNaN(p) || Float.isInfinite(p)) 239 throw iaex(PRICE_MSG, value); 240 } 241 242 private static void validateSpecies(String value) { 243 for (var x : Species.values()) 244 if (x.name().equals(value)) 245 return; 246 throw iaex("Species '%s' must be one of: %s", value, enumNames(Species.values())); 247 } 248 249 private static void validateStatus(String value) { 250 for (var x : PetStatus.values()) 251 if (x.name().equals(value)) 252 return; 253 throw iaex("Status '%s' must be one of: %s", value, enumNames(PetStatus.values())); 254 } 255 256 private static String enumNames(Enum<?>[] values) { 257 return String.join(", ", Arrays.stream(values).map(Enum::name).toList()); 258 } 259 260 /** Parses a comma-separated tag list; blank means empty; validates as it parses. */ 261 private static List<String> parseTags(String value) { 262 if (value == null) 263 throw iaex("Tags must not be null"); 264 if (value.isBlank()) 265 return new ArrayList<>(); 266 var out = new ArrayList<String>(); 267 for (var t : value.split(",", -1)) { 268 var x = t.trim(); 269 if (x.isEmpty()) 270 throw iaex("Tags '%s' must not contain empty entries", value); 271 out.add(x); 272 } 273 return out; 274 } 275 276 private static Long parse(String s) { 277 try { 278 return Long.valueOf(s); 279 } catch (NumberFormatException e) { 280 return null; 281 } 282 } 283}