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.console; 018 019import java.util.*; 020import java.util.regex.*; 021 022/** 023 * The model block of one element: handler actions, lifecycle bindings and handler subscriptions. 024 * 025 * <p> 026 * Builders that implement {@link Handled} own one of these. Every mutator validates at once and throws 027 * {@link IllegalArgumentException} naming the element. These checks duplicate the client rules, which are the authority. 028 * 029 * @since 10.0.0 030 */ 031@SuppressWarnings({ 032 "java:S1192" // Contract event and kind names repeat as inline literals by design. 033}) 034public final class ModelBlock { 035 036 private static final Pattern TEMPLATE = Pattern.compile("\\{(rowKey|tabId|cardId)}"); 037 private static final Pattern ANY_BRACE = Pattern.compile("[{}]"); 038 039 private static final Map<String,Set<String>> EVENTS = Map.of( 040 "page", Set.of("loaded"), 041 "card", Set.of("loaded", "rendered"), 042 "table", Set.of("loaded", "rendered"), 043 "row", Set.of("rendered", "expanded", "collapsed"), 044 "rowdetail", Set.of("loaded", "rendered"), 045 "detailtab", Set.of("loaded", "rendered", "activated"), 046 "ribbonitem", Set.of("activated"), 047 "pagetab", Set.of("activated"), 048 "view", Set.of("activated"), 049 "chart", Set.of("loaded", "rendered")); 050 051 private static final Map<String,Set<String>> TEMPLATE_SCOPE = Map.of( 052 "rowKey", Set.of("row", "rowdetail", "detailtab"), 053 "tabId", Set.of("detailtab")); 054 055 private final String kind; 056 private final String elementId; 057 private final Map<String,ModelAction> actions = new LinkedHashMap<>(); 058 private final Map<String,HandlerRef> on = new LinkedHashMap<>(); 059 private final List<Map.Entry<String,HandlerRef>> subscribes = new ArrayList<>(); 060 061 /** 062 * Creates a block. 063 * 064 * @param kind The element kind: page, card, table, row, rowdetail, detailtab, ribbonitem, pagetab, view or chart. 065 * @param elementId The element id, used in messages. 066 * @throws IllegalArgumentException If the kind is unknown. 067 */ 068 public ModelBlock(String kind, String elementId) { 069 if (! EVENTS.containsKey(kind)) 070 throw new IllegalArgumentException("Unknown element kind '" + kind + "': " + new TreeSet<>(EVENTS.keySet())); 071 this.kind = kind; 072 this.elementId = elementId; 073 } 074 075 private IllegalArgumentException err(String msg) { 076 return new IllegalArgumentException(kind + " '" + elementId + "': " + msg); 077 } 078 079 /** 080 * Adds an action. 081 * 082 * @param a The action; must have a handler. 083 * @return This object. 084 * @throws IllegalArgumentException If the kind takes no actions, the id repeats, or no handler is set. 085 */ 086 public ModelBlock action(ModelAction a) { 087 if ("page".equals(kind)) 088 throw err("a page takes no actions."); 089 if (a.handlerRef() == null) 090 throw err("action '" + a.id() + "' has no handler."); 091 if (actions.containsKey(a.id())) 092 throw err("duplicate action id '" + a.id() + "'."); 093 actions.put(a.id(), a); 094 return this; 095 } 096 097 /** 098 * Binds a lifecycle event. 099 * 100 * @param event A documented event of this kind. 101 * @param handler The handler. 102 * @return This object. 103 * @throws IllegalArgumentException If the event is not documented for this kind. 104 */ 105 public ModelBlock on(String event, HandlerRef handler) { 106 if (! EVENTS.get(kind).contains(event)) 107 throw err("event '" + event + "' is not one of " + new TreeSet<>(EVENTS.get(kind)) + "."); 108 on.put(event, Objects.requireNonNull(handler, "handler")); 109 return this; 110 } 111 112 /** 113 * Subscribes a handler to a bus topic. 114 * 115 * @param topic A topic; the key may hold {@code {rowKey}}, {@code {tabId}} or {@code {cardId}} where this kind provides them. 116 * @param handler The handler. 117 * @return This object. 118 * @throws IllegalArgumentException If the topic is invalid or a template is not available here. 119 */ 120 public ModelBlock subscribe(String topic, HandlerRef handler) { 121 if ("page".equals(kind)) 122 throw err("a page takes no subscriptions."); 123 if (topic == null) 124 throw err("topic must not be null."); 125 var m = TEMPLATE.matcher(topic); 126 while (m.find()) { 127 var scope = TEMPLATE_SCOPE.get(m.group(1)); 128 if (scope != null && ! scope.contains(kind)) 129 throw err("template {" + m.group(1) + "} is not available here."); 130 } 131 try { 132 var concrete = TEMPLATE.matcher(topic).replaceAll("k"); 133 if (ANY_BRACE.matcher(concrete).find()) 134 throw new IllegalArgumentException("invalid topic '" + topic + "'"); 135 Topics.checkTopic(concrete); 136 } catch (IllegalArgumentException e) { 137 throw err(e.getMessage()); 138 } 139 subscribes.add(Map.entry(topic, Objects.requireNonNull(handler, "handler"))); 140 return this; 141 } 142 143 /** 144 * Re-checks the whole block. 145 * 146 * @throws IllegalArgumentException If any entry is invalid. 147 */ 148 public void validate() { 149 on.keySet().forEach(e -> { 150 if (! EVENTS.get(kind).contains(e)) 151 throw err("event '" + e + "' is not one of " + new TreeSet<>(EVENTS.get(kind)) + "."); 152 }); 153 actions.values().forEach(a -> { 154 if (a.handlerRef() == null) 155 throw err("action '" + a.id() + "' has no handler."); 156 }); 157 } 158 159 /** 160 * Appends every entry of another block to this one. 161 * 162 * @param other The block to copy from. 163 * @return This object. 164 */ 165 public ModelBlock addAll(ModelBlock other) { 166 other.actions.values().forEach(this::action); 167 other.on.forEach(this::on); 168 other.subscribes.forEach(e -> subscribe(e.getKey(), e.getValue())); 169 return this; 170 } 171 172 /** 173 * Returns whether the block holds nothing. 174 * 175 * @return <jk>true</jk> if empty. 176 */ 177 public boolean isEmpty() { 178 return actions.isEmpty() && on.isEmpty() && subscribes.isEmpty(); 179 } 180 181 /** 182 * Returns whether any entry uses an inline handler. 183 * 184 * @return <jk>true</jk> if an inline reference is present. 185 */ 186 public boolean hasInline() { 187 return on.values().stream().anyMatch(HandlerRef::isInline) 188 || subscribes.stream().anyMatch(e -> e.getValue().isInline()) 189 || actions.values().stream().anyMatch(a -> a.handlerRef().isInline()); 190 } 191 192 /** 193 * Returns the actions in insertion order. 194 * 195 * @return The actions, as contract maps. 196 */ 197 public List<Map<String,Object>> actionMaps() { 198 return actions.values().stream().map(ModelAction::toMap).toList(); 199 } 200 201 /** 202 * Returns the element id. 203 * 204 * @return The id. 205 */ 206 public String elementId() { 207 return elementId; 208 } 209 210 /** 211 * Returns the element kind. 212 * 213 * @return The kind. 214 */ 215 public String kind() { 216 return kind; 217 } 218 219 /** 220 * Serializes to the contract {@code model} object. 221 * 222 * @return The map, or <jk>null</jk> when empty so that builders not using the model emit unchanged JSON. 223 */ 224 @SuppressWarnings({ 225 "java:S1168" // Null is the documented contract so that builders not using the model emit unchanged JSON. 226 }) 227 public Map<String,Object> toMap() { 228 if (isEmpty()) 229 return null; 230 var m = new LinkedHashMap<String,Object>(); 231 if (! actions.isEmpty()) 232 m.put("actions", actionMaps()); 233 if (! on.isEmpty()) { 234 var o = new LinkedHashMap<String,Object>(); 235 on.forEach((k, v) -> o.put(k, v.toJson())); 236 m.put("on", o); 237 } 238 if (! subscribes.isEmpty()) { 239 var l = new ArrayList<Map<String,Object>>(); 240 for (var e : subscribes) { 241 var s = new LinkedHashMap<String,Object>(); 242 s.put("topic", e.getKey()); 243 s.put("handler", e.getValue().toJson()); 244 l.add(s); 245 } 246 m.put("subscribes", l); 247 } 248 return m; 249 } 250}