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.util.*; 022import java.util.regex.*; 023 024import org.apache.juneau.commons.http.*; 025import org.apache.juneau.marshall.collections.*; 026import org.apache.juneau.console.*; 027 028/** 029 * Declares a console data source: a browser loader that fetches rows from a same-origin URL, builds a columnar frame 030 * and publishes it on the topic <c>data:<id></c>, which chart cards read with {@code ChartDef.source(...)}. 031 * 032 * <p> 033 * Add it to a page with {@code PageSpec.data(...)}. The declaration is checked when it is added: a bad URL, mode, 034 * chunking, column type or subscription role is E-76, and a bad parser or builder name is E-74. 035 * 036 * <h5 class='section'>Example:</h5> 037 * <p class='bjava'> 038 * <jv>page</jv>.data(DataSourceDef.<jsm>create</jsm>(<js>"orgRequests"</js>) 039 * .url(<js>"/rest/orgRequests/series"</js>).format(Format.<jsf>CSV</jsf>) 040 * .params(Subscription.<jsm>to</jsm>(Topics.<jsm>filter</jsm>(<js>"range"</js>)).as(<js>"params"</js>) 041 * .map(<js>"start"</js>, <js>"start"</js>).map(<js>"end"</js>, <js>"end"</js>)) 042 * .chunk(Chunking.<jsm>byDay</jsm>(<js>"start"</js>, <js>"end"</js>)).cache(Cache.<jsm>indexedDb</jsm>())); 043 * </p> 044 * 045 * @since 10.0.0 046 */ 047@SuppressWarnings({ 048 "java:S3776" // Validation and rendering chains many independent guards; splitting them adds no clarity. 049}) 050public final class DataSourceDef { 051 052 static final String E76 = "E-76: data source '%s': %s"; 053 static final Set<String> COLUMN_TYPES = Set.of("number", "time", "string", "bool"); 054 static final Set<String> BUCKETS = Set.of("hour", "day", "week", "month", "auto"); 055 static final Pattern FIELD = Pattern.compile("^[^,()\\s]+$"); 056 static final Pattern MEASURE = Pattern.compile("^(count|(sum|avg|min|max)\\([^,()\\s]+\\))$"); 057 static final Pattern DOTTED_PATH = Pattern.compile("^[A-Za-z0-9_-]++(?:\\.[A-Za-z0-9_-]++)*+$"); 058 059 /** The built-in parser for each wire format. */ 060 public enum Format { 061 /** RFC 4180 CSV with a header row; built-in parser <c>csv</c>. */ 062 CSV, 063 /** One JSON object per line; built-in parser <c>ndjson</c>. */ 064 NDJSON, 065 /** An array of rows or <c>{rows, truncated}</c>; built-in parser <c>json</c>. */ 066 JSON 067 } 068 069 /** How the frame is produced. */ 070 public enum Mode { 071 /** The rows as streamed. */ 072 RAW, 073 /** One request to the aggregate URL. */ 074 AGGREGATE, 075 /** RAW, switching to AGGREGATE past {@link DataSourceDef#maxRows(long)} or on a truncated chunk. */ 076 AUTO 077 } 078 079 /** How the window is split into requests; UTC calendar units. */ 080 public static final class Chunking { 081 final String unit; 082 final String start; 083 final String end; 084 085 private Chunking(String unit, String start, String end) { 086 this.unit = unit; 087 this.start = start; 088 this.end = end; 089 } 090 091 /** 092 * One request per UTC hour. 093 * 094 * @param start The query parameter (and params name) carrying the window start. 095 * @param end The query parameter (and params name) carrying the window end. 096 * @return A new chunking. 097 */ 098 public static Chunking byHour(String start, String end) { return new Chunking("hour", start, end); } 099 100 /** 101 * One request per UTC day. 102 * 103 * @param start The window-start parameter name. 104 * @param end The window-end parameter name. 105 * @return A new chunking. 106 */ 107 public static Chunking byDay(String start, String end) { return new Chunking("day", start, end); } 108 109 /** 110 * One request per ISO week (Monday start, UTC). 111 * 112 * @param start The window-start parameter name. 113 * @param end The window-end parameter name. 114 * @return A new chunking. 115 */ 116 public static Chunking byWeek(String start, String end) { return new Chunking("week", start, end); } 117 118 /** @return No chunking: one request. */ 119 public static Chunking none() { return new Chunking(null, null, null); } 120 } 121 122 /** Where closed chunks are kept. */ 123 public static final class Cache { 124 final String kind; 125 126 private Cache(String kind) { this.kind = kind; } 127 128 /** @return IndexedDB, falling back to memory (E-JS-101) when unavailable. */ 129 public static Cache indexedDb() { return new Cache("indexedDb"); } 130 131 /** @return Memory for the page's life (the default). */ 132 public static Cache memory() { return new Cache("memory"); } 133 134 /** @return No cache. */ 135 public static Cache none() { return new Cache("none"); } 136 } 137 138 /** The aggregate request: a bucket unit, up to 3 groupBy dims, and the measures. */ 139 public static final class Aggregate { 140 final String bucket; 141 final List<String> groupBy = new ArrayList<>(); 142 final List<String> measures = new ArrayList<>(); 143 144 private Aggregate(String bucket) { this.bucket = bucket; } 145 146 /** 147 * @param unit <c>hour</c>, <c>day</c>, <c>week</c>, <c>month</c> or <c>auto</c>. 148 * @return A new aggregate spec. 149 */ 150 public static Aggregate bucket(String unit) { return new Aggregate(unit); } 151 152 /** @param fields Up to 3 dims. @return This object. */ 153 public Aggregate groupBy(String...fields) { Collections.addAll(groupBy, fields); return this; } 154 155 /** 156 * @param values <c>count</c>, <c>sum(f)</c>, <c>avg(f)</c>, <c>min(f)</c> or <c>max(f)</c>. 157 * @return This object. 158 */ 159 public Aggregate measure(String...values) { Collections.addAll(measures, values); return this; } 160 } 161 162 private final String id; 163 private String url; 164 private String parser = "csv"; 165 private String builder = "columnar"; 166 private String aggregateUrl; 167 private String epochUrl; 168 private String epochPath; 169 private final List<JsonMap> columns = new ArrayList<>(); 170 private final List<JsonMap> subscribes = new ArrayList<>(); 171 private Chunking chunk = Chunking.none(); 172 private int concurrency = 1; 173 private int debounceMs = 250; 174 private Cache cache = Cache.memory(); 175 private Mode mode = Mode.RAW; 176 private Long maxRows; 177 private Aggregate aggregate; 178 179 private DataSourceDef(String id) { 180 Topics.data(id); 181 this.id = checkedId(id); 182 } 183 184 static String checkedId(String id) { 185 if (! FtlAttrLists.ID_PATTERN.matcher(id).matches()) 186 throw iaex(E76, id, "id must match " + FtlAttrLists.ID_PATTERN.pattern()); 187 return id; 188 } 189 190 /** 191 * @param id The source id; unique across cards, data sources and dashboard sections. The topic is 192 * <c>data:<id></c>. 193 * @return A new declaration. 194 * @throws IllegalArgumentException E-40 if the id is not a valid topic key. 195 */ 196 public static DataSourceDef create(String id) { 197 return new DataSourceDef(id); 198 } 199 200 /** @return The source id. */ 201 public String id() { return id; } 202 203 private IllegalArgumentException e76(String reason, Object...args) { 204 return iaex(E76, id, f(reason, args)); 205 } 206 207 private void required(Object value, String what) { 208 if (value == null) 209 throw e76("%s is null", what); 210 } 211 212 private String sameOrigin(String what, String value) { 213 if (! SafePathTemplate.isSameOriginPath(value)) 214 throw e76("%s '%s' is not a same-origin path", what, value); 215 if (value.indexOf('#') >= 0) 216 throw e76("%s '%s' has a fragment", what, value); 217 return value; 218 } 219 220 /** @param value A same-origin path; <c>{token}</c>s are filled from params. @return This object. */ 221 public DataSourceDef url(String value) { url = sameOrigin("url", value); return this; } 222 223 /** @param value The wire format; sets the built-in parser. @return This object. */ 224 public DataSourceDef format(Format value) { 225 required(value, "format"); 226 parser = value.name().toLowerCase(Locale.ROOT); 227 return this; 228 } 229 230 /** 231 * @param name A registered parser: a bare built-in name or a dotted app name. Only the name's shape is checked 232 * here; a bare name other than the built-ins (<c>csv</c>, <c>ndjson</c>, <c>json</c>) is resolved only in the 233 * browser, where an unknown one is E-JS-92. 234 * @return This object. 235 */ 236 public DataSourceDef parser(String name) { parser = extensionName(name, "parser"); return this; } 237 238 /** 239 * @param name A registered builder: a bare built-in name or a dotted app name. Only the name's shape is checked 240 * here; a bare name other than the built-in <c>columnar</c> is resolved only in the browser, where 241 * an unknown one is E-JS-93. 242 * @return This object. 243 */ 244 public DataSourceDef builder(String name) { builder = extensionName(name, "builder"); return this; } 245 246 static String extensionName(String name, String what) { 247 if (name == null || ! BusWiringValidator.EXTENSION_NAME.matcher(name).matches()) 248 throw new IllegalArgumentException("E-74: " + f(BusWiringValidator.E74, name, what)); 249 return name; 250 } 251 252 /** @param specs Columns as <c>name:type</c>, type one of number, time, string, bool. @return This object. */ 253 public DataSourceDef columns(String...specs) { 254 for (var s : specs) { 255 if (s == null) 256 throw e76("column is null"); 257 var i = s.indexOf(':'); 258 if (i < 1 || s.substring(0, i).isBlank()) 259 throw e76("column '%s' needs the form name:type", s); 260 var type = s.substring(i + 1); 261 if (! COLUMN_TYPES.contains(type)) 262 throw e76("column '%s' type '%s' is not number, time, string or bool", s, type); 263 var name = s.substring(0, i); 264 if (columns.stream().anyMatch(c -> name.equals(c.get("name")))) 265 throw e76("column '%s' is declared twice", name); 266 columns.add(JsonMap.of("name", name, "type", type)); 267 } 268 return this; 269 } 270 271 /** @param subs Subscriptions with role <c>params</c> or <c>refresh</c>. @return This object. */ 272 public DataSourceDef params(Subscription...subs) { 273 for (var s : subs) { 274 required(s, "subscription"); 275 var m = s.toMap(); 276 var role = m.get("as"); 277 if (! "params".equals(role) && ! "refresh".equals(role)) 278 throw e76("subscription to '%s' has role '%s'; a data source accepts params or refresh", 279 s.topic(), role); 280 subscribes.add(m); 281 } 282 return this; 283 } 284 285 /** 286 * @param value The chunking; {@link Chunking#none()} by default. 287 * @return This object. 288 * @throws IllegalArgumentException E-76 if the value is null, or the window param names are blank or equal. 289 */ 290 public DataSourceDef chunk(Chunking value) { 291 required(value, "chunk"); 292 if (value.unit != null && (value.start == null || value.start.isBlank() || value.end == null 293 || value.end.isBlank() || value.start.equals(value.end))) 294 throw e76("chunk window params '%s' and '%s' must be non-blank and different", value.start, value.end); 295 chunk = value; 296 return this; 297 } 298 299 /** @param value Parallel chunk requests, 1..6 (default 1). @return This object. */ 300 public DataSourceDef concurrency(int value) { 301 if (value < 1 || value > 6) 302 throw e76("concurrency %s is outside 1..6", value); 303 concurrency = value; 304 return this; 305 } 306 307 /** @param value The params-change debounce in ms (default 250). @return This object. */ 308 public DataSourceDef debounceMs(int value) { 309 if (value < 0) 310 throw e76("debounceMs %s is negative", value); 311 debounceMs = value; 312 return this; 313 } 314 315 /** @param value The cache; {@link Cache#memory()} by default. @return This object. */ 316 public DataSourceDef cache(Cache value) { required(value, "cache"); cache = value; return this; } 317 318 /** 319 * @param epochUrl A same-origin path probed once per load. 320 * @param path The dotted path of the epoch value in the JSON response. 321 * @return This object. 322 */ 323 public DataSourceDef epoch(String epochUrl, String path) { 324 var checkedUrl = sameOrigin("epoch url", epochUrl); 325 if (path == null || ! DOTTED_PATH.matcher(path).matches()) 326 throw e76("epoch path '%s' is not a dotted path", path); 327 this.epochUrl = checkedUrl; 328 this.epochPath = path; 329 return this; 330 } 331 332 /** @param value The mode; {@link Mode#RAW} by default. @return This object. */ 333 public DataSourceDef mode(Mode value) { required(value, "mode"); mode = value; return this; } 334 335 /** @param value The row count past which AUTO switches to AGGREGATE. @return This object. */ 336 public DataSourceDef maxRows(long value) { 337 if (value <= 0) 338 throw e76("maxRows %s is not greater than 0", value); 339 maxRows = value; 340 return this; 341 } 342 343 /** @param value A same-origin path answering the aggregate request. @return This object. */ 344 public DataSourceDef aggregateUrl(String value) { aggregateUrl = sameOrigin("aggregateUrl", value); return this; } 345 346 /** @param value The aggregate request. @return This object. */ 347 public DataSourceDef aggregate(Aggregate value) { 348 required(value, "aggregate"); 349 if (value.bucket == null || ! BUCKETS.contains(value.bucket)) 350 throw e76("aggregate bucket '%s' is not hour, day, week, month or auto", value.bucket); 351 for (var g : value.groupBy) 352 if (g == null || ! FIELD.matcher(g).matches()) 353 throw e76("aggregate groupBy field '%s' is not a field name", g); 354 for (var m : value.measures) 355 if (m == null || ! MEASURE.matcher(m).matches()) 356 throw e76("aggregate measure '%s' is not count, sum(f), avg(f), min(f) or max(f)", m); 357 if (value.groupBy.size() > 3) 358 throw e76("aggregate has %s groupBy fields; at most 3", value.groupBy.size()); 359 var copy = Aggregate.bucket(value.bucket); 360 copy.groupBy.addAll(value.groupBy); 361 copy.measures.addAll(value.measures); 362 aggregate = copy; 363 return this; 364 } 365 366 /** 367 * The <c>contract.dataSources[]</c> entry; checks the cross-field rules. 368 * 369 * @return A new map. 370 * @throws IllegalArgumentException E-76 if the declaration is incomplete or inconsistent. 371 */ 372 public JsonMap toMap() { 373 if (url == null) 374 throw e76("url is required"); 375 var modeName = mode.name().toLowerCase(Locale.ROOT); 376 if (mode != Mode.RAW && (aggregateUrl == null || aggregate == null)) 377 throw e76("mode %s needs aggregateUrl(...) and aggregate(...)", modeName); 378 if (mode == Mode.AUTO && maxRows == null) 379 throw e76("mode auto needs maxRows(n) greater than 0"); 380 if (chunk.unit != null) { 381 var mapped = new HashSet<String>(); 382 for (var s : subscribes) 383 if ("params".equals(s.get("as")) && s.get("map") instanceof Map<?,?> mm) 384 mm.keySet().forEach(k -> mapped.add(String.valueOf(k))); 385 for (var w : List.of(chunk.start, chunk.end)) 386 if (! mapped.contains(w)) 387 throw e76("chunk window param '%s' is not mapped by any params subscription", w); 388 } 389 var m = JsonMap.of("id", id, "kind", "source", "url", url, "parser", parser, "builder", builder); 390 if (! columns.isEmpty()) 391 m.put("columns", columns.stream().map(JsonMap::new).toList()); 392 m.put("subscribes", subscribes.stream().map(JsonMap::new).toList()); 393 if (chunk.unit != null) 394 m.put("chunk", JsonMap.of("unit", chunk.unit, "start", chunk.start, "end", chunk.end)); 395 m.put("concurrency", concurrency); 396 m.put("debounceMs", debounceMs); 397 m.put("cache", cache.kind); 398 if (epochUrl != null) 399 m.put("epoch", JsonMap.of("url", epochUrl, "path", epochPath)); 400 m.put("mode", modeName); 401 if (maxRows != null) 402 m.put("maxRows", maxRows); 403 if (aggregateUrl != null) 404 m.put("aggregateUrl", aggregateUrl); 405 if (aggregate != null) 406 m.put("aggregate", JsonMap.of("bucket", aggregate.bucket, "groupBy", List.copyOf(aggregate.groupBy), 407 "measures", List.copyOf(aggregate.measures.isEmpty() ? List.of("count") : aggregate.measures))); 408 return m; 409 } 410}