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:&lt;id&gt;</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:&lt;id&gt;</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}