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.*;
020import static org.apache.juneau.terminal.report.TerminalViewChecks.clip;
021
022import java.util.*;
023import org.apache.juneau.terminal.report.*;
024
025/**
026 * The author-facing declaration of a terminal-view region, compiled to a {@link RegionDef}.
027 *
028 * <p>
029 * {@link #toRegion()} produces a region with {@code populate("terminal-view")}, opts that populator in with
030 * {@code allowPopulators("terminal-view")}, and carries every setting as a flat {@code params} scalar.  It never sets
031 * {@code dataUrl} or {@code refreshMs} on the region: the terminal view owns its own polling loop.
032 *
033 * <h5 class='section'>Example:</h5>
034 * <p class='bjava'>
035 *    RegionDef <jv>region</jv> = TerminalViewDef.<jsm>create</jsm>(<js>"run"</js>)
036 *       .eventsUrl(<js>"/runs/42/events"</js>)
037 *       .title(<js>"Run 42"</js>)
038 *       .toRegion();
039 * </p>
040 */
041@SuppressWarnings({
042   "java:S1192", // Repeated literals are contract wire keys; named constants would obscure the contract shape.
043   "java:S1845" // Fluent-builder setters intentionally mirror field names (Juneau DSL convention)
044})
045public class TerminalViewDef {
046
047   /** The populator name. */
048   public static final String POPULATOR = "terminal-view";
049
050   /** The refresh floor in milliseconds. */
051   public static final long MIN_REFRESH_MS = TerminalViewChecks.MIN_REFRESH_MS;
052
053   /** The default poll interval in milliseconds. */
054   public static final long DEFAULT_REFRESH_MS = 2000;
055
056   /** The path prefix used by {@link #forMixin(String, String, String)}; equals {@link TerminalViewMixin#RUN_VIEW_PREFIX}. */
057   public static final String MIXIN_PREFIX = TerminalViewMixin.RUN_VIEW_PREFIX;
058
059   /** The param keys, in emission order. */
060   public static final List<String> KEYS = List.of("eventsUrl", "refreshMs", "poll", "compact", "title", "rawHref");
061
062   private static final Set<String> TYPES = Set.of(RegionDef.TYPE_CARD_BODY, RegionDef.TYPE_TAB_BODY, RegionDef.TYPE_ROW_DETAIL);
063
064   /** Region id. */
065   public final String id;
066   /** Region type; defaults to {@link RegionDef#TYPE_CARD_BODY}. */
067   public String type = RegionDef.TYPE_CARD_BODY;
068   /** Events endpoint path template; required unless {@link #poll} is <jk>false</jk>. */
069   public String eventsUrl;
070   /** Poll interval in milliseconds. */
071   public long refreshMs = DEFAULT_REFRESH_MS;
072   /** Whether the region polls {@link #eventsUrl}; <jk>false</jk> makes a region that only takes appended events. */
073   public boolean poll = true;
074   /** Compact mode. */
075   public boolean compact;
076   /** Accessible name and heading. */
077   public String title;
078   /** Raw-console link template with one {@code {offset}} placeholder. */
079   public String rawHref;
080
081   private TerminalViewDef(String id) {
082      this.id = id;
083   }
084
085   /**
086    * Creates a declaration.
087    *
088    * @param id The region id.
089    * @return A new declaration.
090    */
091   public static TerminalViewDef create(String id) {
092      return new TerminalViewDef(id);
093   }
094
095   /**
096    * Creates a declaration whose events URL points at a {@link TerminalViewMixin} mounted at {@code mountPath}.
097    *
098    * @param id The region id.
099    * @param mountPath The resource mount path, for example {@code "/petstore"}.
100    * @param runIdTemplate {@code "{id}"} (the client row-id placeholder) or a literal run id.
101    * @return A new declaration.
102    */
103   public static TerminalViewDef forMixin(String id, String mountPath, String runIdTemplate) {
104      if (! ("{id}".equals(runIdTemplate) || TerminalViewChecks.isRunId(runIdTemplate)))
105         throw iaex("TerminalViewDef '%s' runIdTemplate must be \"{id}\" or match [A-Za-z0-9_-]{1,128}; got '%s'.", id, clip(runIdTemplate));
106      var mount = mountPath == null ? "" : mountPath;
107      while (mount.endsWith("/"))
108         mount = mount.substring(0, mount.length() - 1);
109      return create(id).eventsUrl(mount + MIXIN_PREFIX + "/" + runIdTemplate + "/events");
110   }
111
112   /**
113    * Creates a declaration from a parsed map (the console-ui card body).
114    *
115    * @param id The card id.
116    * @param map The {@code terminalView} object; keys must be a subset of {@link #KEYS}.
117    * @return A new declaration (not yet validated).
118    */
119   public static TerminalViewDef fromMap(String id, Map<String,?> map) {
120      var d = create(id);
121      for (var e : map.entrySet()) {
122         var k = e.getKey();
123         var v = e.getValue();
124         switch (k) {
125            case "eventsUrl" -> d.eventsUrl = string(id, k, v);
126            case "refreshMs" -> d.refreshMs = integer(id, k, v);
127            case "poll" -> d.poll = bool(id, k, v);
128            case "compact" -> d.compact = bool(id, k, v);
129            case "title" -> d.title = string(id, k, v);
130            case "rawHref" -> d.rawHref = string(id, k, v);
131            default -> throw iaex("TerminalViewDef '%s' unknown key '%s'; allowed: %s.", id, clip(k), KEYS);
132         }
133      }
134      return d;
135   }
136
137   private static String string(String id, String k, Object v) {
138      if (! (v instanceof String s))
139         throw iaex("TerminalViewDef '%s' %s must be a string.", id, k);
140      return s;
141   }
142
143   private static long integer(String id, String k, Object v) {
144      if (v instanceof Integer || v instanceof Long || v instanceof Short || v instanceof Byte)
145         return ((Number)v).longValue();
146      if (v instanceof Number n && n.doubleValue() == Math.rint(n.doubleValue()) && Math.abs(n.doubleValue()) < 1e15)
147         return n.longValue();
148      throw iaex("TerminalViewDef '%s' %s must be an integer.", id, k);
149   }
150
151   private static boolean bool(String id, String k, Object v) {
152      if (! (v instanceof Boolean b))
153         throw iaex("TerminalViewDef '%s' %s must be a boolean.", id, k);
154      return b;
155   }
156
157   /** @param value The region type. @return This object. */
158   public TerminalViewDef type(String value) { type = value; return this; }
159   /** @param value The events endpoint path template. @return This object. */
160   public TerminalViewDef eventsUrl(String value) { eventsUrl = value; return this; }
161   /** @param value The poll interval; clamped to {@link #MIN_REFRESH_MS}. @return This object. */
162   public TerminalViewDef refreshMs(long value) { refreshMs = value; return this; }
163   /** @param value Whether to poll. @return This object. */
164   public TerminalViewDef poll(boolean value) { poll = value; return this; }
165   /** @param value Compact mode. @return This object. */
166   public TerminalViewDef compact(boolean value) { compact = value; return this; }
167   /** @param value The accessible name and heading. @return This object. */
168   public TerminalViewDef title(String value) { title = value; return this; }
169   /** @param value The raw-console link template, with one {@code {offset}}. @return This object. */
170   public TerminalViewDef rawHref(String value) { rawHref = value; return this; }
171
172   /**
173    * Validates this declaration (fail-closed).
174    *
175    * @return This object.
176    * @throws IllegalArgumentException If a rule is broken.
177    */
178   public TerminalViewDef validate() {
179      if (ib(id))
180         throw iaex("TerminalViewDef id must not be blank.");
181      if (! TYPES.contains(type))
182         throw iaex("TerminalViewDef '%s' type must be one of card-body|tab-body|row-detail; got '%s'.", id, clip(type));
183      if (eventsUrl == null) {
184         if (poll)
185            throw iaex("TerminalViewDef '%s' eventsUrl is required unless poll is false.", id);
186      } else {
187         if (! RegionDef.isSafeDetailEndpoint(eventsUrl))
188            throw iaex("TerminalViewDef '%s' eventsUrl must be a same-origin path (no scheme, no leading '//', no '..' segment); got '%s'.",
189               id, clip(eventsUrl));
190         if (eventsUrl.contains("{id}") && ! RegionDef.TYPE_ROW_DETAIL.equals(type))
191            throw iaex("TerminalViewDef '%s' eventsUrl: {id} is only allowed on a row-detail region.", id);
192      }
193      if (refreshMs <= 0)
194         throw iaex("TerminalViewDef '%s' refreshMs must be > 0; got %s.", id, refreshMs);
195      if (rawHref != null && ! TerminalViewChecks.isRawHrefTemplate(rawHref))
196         throw iaex("TerminalViewDef '%s' rawHref must contain exactly one {offset} and be a same-origin path or fragment; got '%s'.", id, clip(rawHref));
197      return this;
198   }
199
200   /**
201    * Returns the set params in {@link #KEYS} order, with {@code refreshMs} clamped.
202    *
203    * @return A new ordered map.
204    */
205   public Map<String,Object> toMap() {
206      var m = new LinkedHashMap<String,Object>();
207      put(m, "eventsUrl", eventsUrl);
208      put(m, "refreshMs", Math.max(MIN_REFRESH_MS, refreshMs));
209      put(m, "poll", poll);
210      put(m, "compact", compact);
211      put(m, "title", title);
212      put(m, "rawHref", rawHref);
213      return m;
214   }
215
216   private static void put(Map<String,Object> m, String k, Object v) {
217      if (v != null)
218         m.put(k, v);
219   }
220
221   /**
222    * Validates and compiles this declaration.
223    *
224    * @return A validated region.
225    */
226   public RegionDef toRegion() {
227      validate();
228      var r = RegionDef.create(id).type(type).populate(POPULATOR).allowPopulators(POPULATOR).params(toMap());
229      r.validate();
230      return r;
231   }
232}