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}