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.freemarker; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.io.*; 022import java.util.*; 023 024import org.apache.juneau.marshall.collections.*; 025import org.apache.juneau.marshall.json5.*; 026import org.apache.juneau.console.views.*; 027 028import freemarker.core.*; 029import freemarker.template.*; 030 031/** 032 * The {@code <@badge>} FreeMarker twin of {@link PageSpec#badge(BadgeDef)}: every scalar or enum {@link BadgeDef} 033 * field is a plain attribute (the label templates as {@code labelOne}, {@code labelOther} and {@code labelMine}, the 034 * scope as {@code scopeParam} and {@code scopeBy}, the state filter as {@code stateFilterColumn}). The three 035 * structured fields have no flat shape, so an optional JSON5 body supplies them under the keys 036 * {@code visibleWhen} (a rule list), {@code scopeValues} (a map of nav node id to value list) and {@code params} 037 * (a map). Any other body key is rejected. 038 * 039 * <p> 040 * The FTL scope is stricter than {@link BadgeDef#scope}: {@code scopeParam} and {@code scopeBy} must be given 041 * together, and {@code scopeBy} must be spelled {@code nav} or {@code view}, where the Java form defaults a missing 042 * {@code by} to {@code nav}. 043 * 044 * <h5 class='section'>Example:</h5> 045 * <p class='bcode'> 046 * <@badge id="health" src="/rest/health" refreshMs=30000 tone="warning"> 047 * {"visibleWhen": [{"field": "status", "op": "eq", "value": "degraded"}]} 048 * </@badge> 049 * </p> 050 * 051 * @since 10.0.0 052 */ 053@SuppressWarnings({ 054 "java:S1192", // Repeated literals are contract wire keys; named constants would obscure the contract shape. 055 "java:S3776" // Validation and rendering chains many independent guards; splitting them adds no clarity. 056}) 057public final class BadgeDirectiveModel implements TemplateDirectiveModel { 058 059 /** The shared-variable name this directive registers under. */ 060 public static final String NAME = "badge"; 061 062 static final Set<String> ATTRS = Set.of("id", "src", "refreshMs", "labelOne", "labelOther", "labelMine", "tooltipMax", 063 "href", "refreshes", "refreshesAllowMissing", "placement", "table", "tone", "scopeParam", "scopeBy", "stateFilterColumn"); 064 065 private static final Set<String> BODY_KEYS = Set.of("visibleWhen", "scopeValues", "params"); 066 067 BadgeDirectiveModel() {} 068 069 @Override 070 @SuppressWarnings({ 071 "unchecked" // FreeMarker's raw params Map is String-keyed by contract. 072 }) 073 public void execute(Environment env, @SuppressWarnings("rawtypes") Map params, TemplateModel[] loopVars, 074 TemplateDirectiveBody body) throws TemplateException, IOException { 075 Map<String, TemplateModel> p = params; 076 if (p.containsKey("format")) 077 throw FtlAttrLists.reject("<@badge> has no format= attribute."); 078 FtlAttrLists.rejectUnknown(p, NAME, ATTRS); 079 080 var cap = PageCapture.get(env); 081 if (cap == null || ! cap.consoleOpen) 082 throw FtlAttrLists.reject("<@badge> must be nested inside <@console>."); 083 084 var id = FtlAttrLists.scalar(p, "id"); 085 if (id.isEmpty()) 086 throw FtlAttrLists.reject("<@badge> requires id=."); 087 var src = FtlAttrLists.scalar(p, "src"); 088 if (src.isEmpty()) 089 throw FtlAttrLists.reject("<@badge id='" + id + "'> requires src=."); 090 091 var def = BadgeDef.create(id).src(src); 092 try { 093 var refreshMs = FtlAttrLists.scalar(p, "refreshMs"); 094 if (! refreshMs.isEmpty()) 095 def.refreshMs(Long.parseLong(refreshMs)); 096 var tooltipMax = FtlAttrLists.scalar(p, "tooltipMax"); 097 if (! tooltipMax.isEmpty()) 098 def.tooltipMax(Integer.parseInt(tooltipMax)); 099 } catch (NumberFormatException e) { 100 throw FtlAttrLists.reject("<@badge id='" + id + "'> refreshMs= and tooltipMax= must be integers."); 101 } 102 var label = new LinkedHashMap<String,String>(); 103 for (var key : List.of("One", "Other", "Mine")) { 104 var v = FtlAttrLists.scalar(p, "label" + key); 105 if (! v.isEmpty()) 106 label.put(key.toLowerCase(), v); 107 } 108 if (! label.isEmpty()) 109 def.label(label); 110 var href = FtlAttrLists.scalar(p, "href"); 111 if (! href.isEmpty()) 112 def.href(href); 113 var refreshes = FtlAttrLists.list(p, NAME, "refreshes"); 114 if (! refreshes.isEmpty()) 115 def.refreshes(refreshes.toArray(new String[0])); 116 var refreshesAllowMissing = FtlAttrLists.list(p, NAME, "refreshesAllowMissing"); 117 if (! refreshesAllowMissing.isEmpty()) 118 def.refreshesAllowMissing(refreshesAllowMissing.toArray(new String[0])); 119 var table = FtlAttrLists.scalar(p, "table"); 120 if (! table.isEmpty()) 121 def.table(table); 122 var stateFilterColumn = FtlAttrLists.scalar(p, "stateFilterColumn"); 123 if (! stateFilterColumn.isEmpty()) 124 def.stateFilter(stateFilterColumn); 125 var placement = FtlAttrLists.scalar(p, "placement"); 126 if (! placement.isEmpty()) 127 def.placement(enumOf(id, "placement", BadgeDef.Placement.values(), BadgeDef.Placement::wire, placement)); 128 var tone = FtlAttrLists.scalar(p, "tone"); 129 if (! tone.isEmpty()) 130 def.tone(enumOf(id, "tone", BadgeDef.Tone.values(), BadgeDef.Tone::wire, tone)); 131 132 var scopeParam = FtlAttrLists.scalar(p, "scopeParam"); 133 var scopeBy = FtlAttrLists.scalar(p, "scopeBy"); 134 if (scopeParam.isEmpty() != scopeBy.isEmpty()) 135 throw FtlAttrLists.reject("<@badge id='" + id + "'> scope requires both scopeParam= and scopeBy=."); 136 137 var markup = PageCapture.render(body); 138 Map<String,List<String>> scopeValues = Map.of(); 139 if (! markup.isBlank()) { 140 JsonMap bodyMap; 141 try { 142 bodyMap = Json5Parser.DEFAULT.read(markup.trim(), JsonMap.class); 143 } catch (org.apache.juneau.marshall.parser.ParseException e) { 144 throw FtlAttrLists.reject("<@badge id='" + id + "'> body is not valid JSON5: " + e.getMessage()); 145 } 146 for (var key : bodyMap.keySet()) 147 if (! BODY_KEYS.contains(key)) 148 throw FtlAttrLists.reject(f( 149 "<@badge id='%s'> body only supports 'visibleWhen', 'scopeValues' and 'params' keys; found '%s'.", id, key)); 150 if (bodyMap.get("visibleWhen") != null) 151 def.visibleWhen(parseVisibleWhen(id, bodyMap.get("visibleWhen")).toArray(new VisibilityRule[0])); 152 if (bodyMap.get("scopeValues") != null) 153 scopeValues = parseScopeValues(id, bodyMap.get("scopeValues")); 154 if (bodyMap.get("params") != null) { 155 if (! (bodyMap.get("params") instanceof Map<?,?> pm)) 156 throw FtlAttrLists.reject("<@badge id='" + id + "'> params must be a JSON5 object."); 157 var out = new LinkedHashMap<String,Object>(); 158 pm.forEach((k, v) -> out.put(String.valueOf(k), v)); 159 def.params(out); 160 } 161 } 162 if (! scopeParam.isEmpty()) 163 def.scope(scopeParam, enumOf(id, "scopeBy", BadgeDef.ScopeBy.values(), BadgeDef.ScopeBy::wire, scopeBy), scopeValues); 164 else if (! scopeValues.isEmpty()) 165 throw FtlAttrLists.reject("<@badge id='" + id + "'> scopeValues requires scopeParam= and scopeBy=."); 166 167 try { 168 def.validate(); 169 } catch (IllegalArgumentException | IllegalStateException e) { 170 throw FtlAttrLists.reject(e.getMessage()); 171 } 172 cap.addBadge(def); 173 } 174 175 private static <E> E enumOf(String badgeId, String attr, E[] values, java.util.function.Function<E,String> wire, String v) 176 throws TemplateModelException { 177 for (var e : values) 178 if (wire.apply(e).equals(v)) 179 return e; 180 var allowed = Arrays.stream(values).map(wire).toList(); 181 throw FtlAttrLists.reject(f("<@badge id='%s'> %s= '%s' is not one of '%s'.", badgeId, attr, v, 182 String.join(", ", allowed))); 183 } 184 185 private static Map<String,List<String>> parseScopeValues(String badgeId, Object raw) throws TemplateModelException { 186 if (! (raw instanceof Map<?,?> m)) 187 throw FtlAttrLists.reject("<@badge id='" + badgeId + "'> scopeValues must be a JSON5 object of string lists."); 188 var out = new LinkedHashMap<String,List<String>>(); 189 for (var e : m.entrySet()) { 190 if (! (e.getValue() instanceof List<?> l)) 191 throw FtlAttrLists.reject("<@badge id='" + badgeId + "'> scopeValues must be a JSON5 object of string lists."); 192 var vals = new ArrayList<>(l.stream().map(Objects::toString).toList()); 193 out.put(String.valueOf(e.getKey()), vals); 194 } 195 return out; 196 } 197 198 private static List<VisibilityRule> parseVisibleWhen(String badgeId, Object raw) throws TemplateModelException { 199 if (! (raw instanceof List<?> list)) 200 throw FtlAttrLists.reject("<@badge id='" + badgeId + "'> visibleWhen must be a JSON5 array."); 201 var out = new ArrayList<VisibilityRule>(); 202 for (var item : list) { 203 if (! (item instanceof Map<?,?> m) || m.get("field") == null) 204 throw FtlAttrLists.reject("<@badge id='" + badgeId + "'> each visibleWhen entry must be an object with a field."); 205 var b = VisibilityRule.when(String.valueOf(m.get("field"))); 206 var op = String.valueOf(m.get("op")); 207 var value = m.get("value"); 208 out.add(switch (op) { 209 case "eq" -> b.eq(value); 210 case "ne" -> b.ne(value); 211 case "present" -> b.present(); 212 case "absent" -> b.absent(); 213 case "in" -> { 214 if (! (value instanceof List<?> l)) 215 throw FtlAttrLists.reject("<@badge id='" + badgeId + "'> visibleWhen op 'in' requires a list value."); 216 yield b.in(l.toArray()); 217 } 218 case "contains" -> b.contains(value); 219 default -> throw FtlAttrLists.reject(f( 220 "<@badge id='%s'> visibleWhen op '%s' is not one of 'eq, ne, present, absent, in, contains'.", badgeId, op)); 221 }); 222 } 223 return out; 224 } 225}