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 *    &lt;@badge id="health" src="/rest/health" refreshMs=30000 tone="warning"&gt;
047 *      {"visibleWhen": [{"field": "status", "op": "eq", "value": "degraded"}]}
048 *    &lt;/@badge&gt;
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}