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.views;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.util.*;
022import java.util.regex.*;
023
024/**
025 * The shared shape checks of a job-log declaration, used by the console's page-level {@code job-log} card and by a
026 * {@link JobLogRegion}, so the two cannot drift.
027 *
028 * <p>
029 * Every check throws an {@link IllegalArgumentException} whose message names the offending key relative to the
030 * job-log body (for example {@code "submit.fields[0] type must be ..."}); each caller prefixes its own context.
031 *
032 * @since 10.0.0
033 */
034@SuppressWarnings({
035   "java:S1192", // Duplicated key names are wire keys of the job-log body; constants would obscure the schema.
036   "java:S3776", // Validation and rendering chains many independent guards; splitting them adds no clarity.
037   "java:S6353" // Pattern text mirrors the JS-side check and is pinned by tests.
038})
039public final class JobLogChecks {
040
041   /** Where a job-log declaration lives. */
042   public enum Mode {
043      /** The console's page-level {@code job-log} card. */
044      PAGE,
045      /** A row-detail region with {@code populate: "job-log"}. */
046      REGION
047   }
048
049   /** The grammar of a field's {@code source}: {@code row.<identifier>}, nothing nested. */
050   public static final Pattern SOURCE = Pattern.compile("row\\.[A-Za-z_][A-Za-z0-9_]{0,63}");
051
052   /** The grammar of {@code output.from}. */
053   public static final Pattern OUTPUT_FROM = Pattern.compile("message|row\\.[A-Za-z_][A-Za-z0-9_]{0,63}");
054
055   /** The grammar of {@code submit.action}; the page schema's {@code id}. */
056   public static final Pattern ACTION = Pattern.compile("[A-Za-z][A-Za-z0-9_-]{0,63}");
057
058   /** The page card's {@code submit} keys, in message order. */
059   public static final List<String> PAGE_SUBMIT_KEYS = List.of("endpoint", "method", "envelope", "action", "fields", "submitLabel");
060
061   /** A region's {@code submit} keys, in message order. */
062   public static final List<String> REGION_SUBMIT_KEYS = List.of("endpoint", "method", "envelope", "action", "keyUrl", "fields", "submitLabel");
063
064   /** A field's keys, in message order. */
065   public static final List<String> FIELD_KEYS = List.of("name", "label", "type", "placeholder", "required", "default", "source");
066
067   /** The field types. */
068   public static final Set<String> FIELD_TYPES = Collections.unmodifiableSet(new LinkedHashSet<>(List.of("text", "textarea", "number", "hidden")));
069
070   /** The {@code output} keys, in message order. */
071   public static final List<String> OUTPUT_KEYS = List.of("target", "mode", "from", "when");
072
073   /** The {@code detail} keys, in message order. */
074   public static final List<String> DETAIL_KEYS = List.of("renderer", "fields");
075
076   private static final Set<String> METHODS = Set.of("POST", "PUT");
077   private static final Set<String> MODES = Set.of("replace", "append");
078   private static final Set<String> WHENS = Set.of("success", "any");
079   private static final Pattern TOKEN = Pattern.compile("\\{[^}]*\\}");
080
081   // The page card adds a fresh idempotencyKey to every flat submit body, so a field of that name would be overwritten.
082   private static final String RESERVED_FIELD = "idempotencyKey";
083
084   private JobLogChecks() {}
085
086   /**
087    * Checks a {@code submit} object's keys, method, label, envelope, action, key URL and fields.
088    *
089    * <p>
090    * The page card checks its own {@code endpoint} first (it has the console's same-origin rule); a region's
091    * {@code endpoint} and {@code keyUrl} are checked here as same-origin templates whose only token is {@code {id}}.
092    *
093    * @param submit The {@code submit} object.
094    * @param mode Where the declaration lives.
095    * @throws IllegalArgumentException If a rule is broken.
096    */
097   public static void submit(Map<?,?> submit, Mode mode) {
098      var keys = mode == Mode.PAGE ? PAGE_SUBMIT_KEYS : REGION_SUBMIT_KEYS;
099      for (var k : submit.keySet())
100         if (! keys.contains(k))
101            throw iaex("submit unknown key '%s'; allowed: '%s'.", k, String.join(", ", keys));
102      if (mode == Mode.REGION) {
103         template("submit.endpoint", submit.get("endpoint"));
104         template("submit.keyUrl", submit.get("keyUrl"));
105      }
106      var method = submit.get("method");
107      if (nn(method) && ! METHODS.contains(method))
108         throw iaex("submit.method must be POST or PUT; got '%s'.", method);
109      var submitLabel = submit.get("submitLabel");
110      if (nn(submitLabel) && ! (submitLabel instanceof String))
111         throw iaex("submit.submitLabel must be a string; got '%s'.", submitLabel);
112      var envelope = submit.get("envelope");
113      if (mode == Mode.REGION) {
114         if (nn(envelope) && ! "action".equals(envelope))
115            throw iaex("submit.envelope must be 'action' in a row-detail region (the row-bound key is verified against targetId); got '%s'.", envelope);
116      } else if (nn(envelope) && ! ("flat".equals(envelope) || "action".equals(envelope))) {
117         throw iaex("submit.envelope must be 'flat' or 'action'; got '%s'.", envelope);
118      }
119      var actionEnvelope = mode == Mode.REGION || "action".equals(envelope);
120      var action = submit.get("action");
121      if (actionEnvelope && action == null)
122         throw iaex("submit.action is required with envelope 'action'.");
123      if (nn(action) && ! (action instanceof String a && ACTION.matcher(a).matches()))
124         throw iaex("submit.action must match '%s'; got '%s'.", ACTION.pattern(), action);
125      var fields = submit.get("fields");
126      if (nn(fields)) {
127         if (! (fields instanceof List<?> list))
128            throw iaex("submit.fields must be an array; got '%s'.", fields);
129         for (var i = 0; i < list.size(); i++)
130            field(i, list.get(i), mode, actionEnvelope);
131      }
132   }
133
134   private static void template(String name, Object v) {
135      if (! (v instanceof String s) || s.isBlank())
136         throw iaex("%s is required.", name);
137      if (! RegionDef.isSafeDetailEndpoint(s))
138         throw iaex("%s must be a same-origin path (no scheme, no leading '//', no '..' segment); got '%s'.", name, s);
139      var m = TOKEN.matcher(s);
140      while (m.find())
141         if (! "{id}".equals(m.group()))
142            throw iaex("%s may only use the '%s' token; got '%s'.", name, "{id}", m.group());
143   }
144
145   /**
146    * Checks one field.
147    *
148    * @param i The field's index, for the message.
149    * @param f The field.
150    * @param mode Where the declaration lives; {@code source} needs a row, so the page card rejects it.
151    * @param actionEnvelope Whether the fields are nested under {@code fields} (no reserved names) or flat.
152    * @throws IllegalArgumentException If a rule is broken.
153    */
154   public static void field(int i, Object f, Mode mode, boolean actionEnvelope) {
155      if (! (f instanceof Map<?,?> m))
156         throw iaex("submit.fields[%s] must be an object; got '%s'.", i, f);
157      for (var k : m.keySet())
158         if (! FIELD_KEYS.contains(k))
159            throw iaex("submit.fields[%s] unknown key '%s'; allowed: '%s'.", i, k, String.join(", ", FIELD_KEYS));
160      if (! (m.get("name") instanceof String name) || name.isBlank())
161         throw iaex("submit.fields[%s] requires a non-blank 'name'.", i);
162      if (! actionEnvelope && RESERVED_FIELD.equals(name))
163         throw iaex("submit.fields name '%s' is reserved; the runtime sends it with every submit.", RESERVED_FIELD);
164      var type = m.get("type");
165      if (nn(type) && ! FIELD_TYPES.contains(type))
166         throw iaex("submit.fields[%s] type must be 'text', 'textarea', 'number' or 'hidden'; got '%s'.", i, type);
167      for (var k : List.of("label", "placeholder"))
168         if (nn(m.get(k)) && ! (m.get(k) instanceof String))
169            throw iaex("submit.fields[%s] %s must be a string; got '%s'.", i, k, m.get(k));
170      var required = m.get("required");
171      if (nn(required) && ! (required instanceof Boolean))
172         throw iaex("submit.fields[%s] required must be a boolean; got '%s'.", i, required);
173      var source = m.get("source");
174      if (nn(source)) {
175         if (mode == Mode.PAGE)
176            throw iaex("submit.fields[%s] source needs a row; use a row-detail region.", i);
177         if (! (source instanceof String s && SOURCE.matcher(s).matches()))
178            throw iaex("submit.fields[%s] source must match '%s'; got '%s'.", i, SOURCE.pattern(), source);
179      }
180   }
181
182   /**
183    * Checks an {@code output} object.
184    *
185    * @param o The {@code output} value.
186    * @throws IllegalArgumentException If a rule is broken.
187    */
188   public static void output(Object o) {
189      if (! (o instanceof Map<?,?> m))
190         throw iaex("output must be an object; got '%s'.", o);
191      for (var k : m.keySet())
192         if (! OUTPUT_KEYS.contains(k))
193            throw iaex("output unknown key '%s'; allowed: '%s'.", k, String.join(", ", OUTPUT_KEYS));
194      if (! (m.get("target") instanceof Map<?,?> t))
195         throw iaex("output.target must be an object with exactly one of 'field' or 'selector'.");
196      for (var k : t.keySet())
197         if (! ("field".equals(k) || "selector".equals(k)))
198            throw iaex("output.target unknown key '%s'; allowed: 'field, selector'.", k);
199      if (t.size() != 1)
200         throw iaex("output.target must set exactly one of 'field' or 'selector'.");
201      var v = t.values().iterator().next();
202      if (! (v instanceof String s) || s.isBlank())
203         throw iaex("output.target.%s must be a non-blank string; got '%s'.", t.keySet().iterator().next(), v);
204      var mode = m.get("mode");
205      if (nn(mode) && ! MODES.contains(mode))
206         throw iaex("output.mode must be 'replace' or 'append'; got '%s'.", mode);
207      var from = m.get("from");
208      if (nn(from) && ! (from instanceof String fs && OUTPUT_FROM.matcher(fs).matches()))
209         throw iaex("output.from must match '%s'; got '%s'.", OUTPUT_FROM.pattern(), from);
210      var when = m.get("when");
211      if (nn(when) && ! WHENS.contains(when))
212         throw iaex("output.when must be 'success' or 'any'; got '%s'.", when);
213   }
214
215   /**
216    * Checks a region's {@code detail} object: {@code {renderer, fields}}.
217    *
218    * @param o The {@code detail} value.
219    * @throws IllegalArgumentException If a rule is broken.
220    */
221   public static void detail(Object o) {
222      if (! (o instanceof Map<?,?> m))
223         throw iaex("detail must be an object; got '%s'.", o);
224      for (var k : m.keySet())
225         if (! DETAIL_KEYS.contains(k))
226            throw iaex("detail unknown key '%s'; allowed: '%s'.", k, String.join(", ", DETAIL_KEYS));
227      if (! (m.get("renderer") instanceof String r) || r.isBlank())
228         throw iaex("detail.renderer must be a non-blank string.");
229      if (! (m.get("fields") instanceof List<?> l))
230         throw iaex("detail.fields must be an array.");
231      for (var i = 0; i < l.size(); i++)
232         if (! (l.get(i) instanceof Map))
233            throw iaex("detail.fields[%s] must be an object; got '%s'.", i, l.get(i));
234   }
235
236   /**
237    * Checks {@code height}.
238    *
239    * @param height The value.  Can be <jk>null</jk>.
240    * @param min The smallest accepted height, in pixels.
241    * @throws IllegalArgumentException If it is set and not an integer of at least {@code min}.
242    */
243   public static void height(Object height, int min) {
244      if (nn(height) && ! ((height instanceof Integer || height instanceof Long) && ((Number)height).longValue() >= min))
245         throw iaex("height must be an integer of at least '%s'; got '%s'.", min, height);
246   }
247
248   /**
249    * Checks {@code clearOnSubmit}.
250    *
251    * @param clear The value.  Can be <jk>null</jk>.
252    * @throws IllegalArgumentException If it is set and not a boolean.
253    */
254   public static void clearOnSubmit(Object clear) {
255      if (nn(clear) && ! (clear instanceof Boolean))
256         throw iaex("clearOnSubmit must be a boolean; got '%s'.", clear);
257   }
258}