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.terminal.region;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020import static org.apache.juneau.terminal.report.TerminalViewChecks.*;
021
022import java.util.*;
023
024
025/**
026 * The options of a {@code terminal} card: where its bytes and (optionally) its step events come from.
027 *
028 * <h5 class='section'>Example:</h5>
029 * <p class='bjava'>
030 *    TerminalDef <jv>def</jv> = TerminalDef.<jsm>forMixin</jsm>(<js>"build"</js>, <js>"/runs"</js>, <js>"r42"</js>)
031 *       .eventsUrl(<js>"/runs/juneau-terminal-view/r42/events"</js>)
032 *       .title(<js>"Build"</js>)
033 *       .validate();
034 * </p>
035 *
036 * @since 10.0.0
037 */
038@SuppressWarnings({
039   "java:S1192", // The option keys are protocol literals, spelled out where they are read and written.
040   "java:S1845" // Fluent-builder setters intentionally mirror field names (Juneau DSL convention)
041})
042public class TerminalDef {
043
044   /** The keys {@link #fromMap(String, Map)} accepts, in {@link #toMap()} order. */
045   public static final List<String> KEYS = List.of("bytesUrl", "eventsUrl", "refreshMs", "title", "tailBytes", "input", "inputId", "terminal");
046
047   /** The keys the {@code terminal} option map accepts, in {@link #toMap()} order; they match the client-side xterm allowlist. */
048   public static final List<String> TERMINAL_KEYS = List.of("convertEol", "scrollback", "fontFamily");
049
050   /** The largest {@code terminal.scrollback}, in lines. */
051   public static final int MAX_SCROLLBACK = 1_000_000;
052
053   /** The tail the card replays on its first load when {@link #tailBytes} is not set: 2 MiB. */
054   public static final long DEFAULT_TAIL_BYTES = 2L * 1024 * 1024;
055
056   /** The largest {@link #tailBytes}: 1 GiB. */
057   public static final long MAX_TAIL_BYTES = 1L << 30;
058
059   /** The card id. */
060   public final String id;
061   /** The bytes endpoint path. */
062   public String bytesUrl;
063   /** The terminal-view events endpoint path, or <jk>null</jk> for no steps. */
064   public String eventsUrl;
065   /** The poll interval; clamped to {@link org.apache.juneau.terminal.report.TerminalViewChecks#MIN_REFRESH_MS}. */
066   public Long refreshMs;
067   /** The card header title. */
068   public String title;
069   /** The most bytes replayed on the first load. */
070   public Long tailBytes;
071   /** Whether the card lets the user type into the job; needs {@link #inputId}. */
072   public Boolean input;
073   /** The terminal id sent as the payload {@code id} of a {@code term.input} frame. */
074   public String inputId;
075   /** The xterm options passed to the client terminal, limited to {@link #TERMINAL_KEYS}; <jk>null</jk> for none. */
076   public Map<String,Object> terminal;
077
078   private TerminalDef(String id) {
079      this.id = id;
080   }
081
082   /**
083    * Creates a declaration.
084    *
085    * @param id The card id.
086    * @return A new declaration.
087    */
088   public static TerminalDef create(String id) {
089      return new TerminalDef(id);
090   }
091
092   /**
093    * Creates a declaration whose {@link #bytesUrl} points at a {@link TerminalMixin} mounted at {@code mountPath}.
094    *
095    * @param id The card id.
096    * @param mountPath The resource mount path, for example {@code "/runs"}.
097    * @param terminalId The terminal id; must match {@code [A-Za-z0-9_-]{1,128}}.
098    * @return A new declaration.
099    */
100   public static TerminalDef forMixin(String id, String mountPath, String terminalId) {
101      if (! isRunId(terminalId))
102         throw iaex("TerminalDef '%s' terminalId must match [A-Za-z0-9_-]{1,128}; got '%s'.", id, clip(terminalId));
103      var mount = mountPath == null ? "" : mountPath;
104      while (mount.endsWith("/"))
105         mount = mount.substring(0, mount.length() - 1);
106      return create(id).bytesUrl(mount + TerminalMixin.TERMINAL_PREFIX + "/" + terminalId + "/bytes").inputId(terminalId);
107   }
108
109   /**
110    * Creates a declaration from a parsed map (the console-ui card body).
111    *
112    * @param id The card id.
113    * @param map The {@code terminal} object; keys must be a subset of {@link #KEYS}.
114    * @return A new declaration (not yet validated).
115    */
116   public static TerminalDef fromMap(String id, Map<String,?> map) {
117      var d = create(id);
118      for (var e : map.entrySet()) {
119         var k = e.getKey();
120         var v = e.getValue();
121         switch (k) {
122            case "bytesUrl" -> d.bytesUrl = string(id, k, v);
123            case "eventsUrl" -> d.eventsUrl = string(id, k, v);
124            case "refreshMs" -> d.refreshMs = integer(id, k, v);
125            case "title" -> d.title = string(id, k, v);
126            case "tailBytes" -> d.tailBytes = integer(id, k, v);
127            case "input" -> d.input = bool(id, k, v);
128            case "inputId" -> d.inputId = string(id, k, v);
129            case "terminal" -> d.terminal = terminalOptions(id, v);
130            default -> throw iaex("TerminalDef '%s' unknown key '%s'; allowed: %s.", id, clip(k), KEYS);
131         }
132      }
133      return d;
134   }
135
136   @SuppressWarnings({
137      "java:S3776" // One flat check per option key; splitting it would only scatter them.
138   })
139   private static Map<String,Object> terminalOptions(String id, Object v) {
140      if (! (v instanceof Map<?,?> m))
141         throw iaex("TerminalDef '%s' terminal must be an object.", id);
142      for (var k : m.keySet())
143         if (! (k instanceof String ks) || ! TERMINAL_KEYS.contains(ks))
144            throw iaex("TerminalDef '%s' terminal unknown key '%s'; allowed: %s.", id, clip(String.valueOf(k)), TERMINAL_KEYS);
145      var out = new LinkedHashMap<String,Object>();
146      if (m.containsKey("convertEol")) {
147         if (! (m.get("convertEol") instanceof Boolean b))
148            throw iaex("TerminalDef '%s' terminal.convertEol must be a boolean.", id);
149         out.put("convertEol", b);
150      }
151      if (m.containsKey("scrollback")) {
152         var n = integer(id, "terminal.scrollback", m.get("scrollback"));
153         if (n < 0 || n > MAX_SCROLLBACK)
154            throw iaex("TerminalDef '%s' terminal.scrollback must be 0..%s; got %s.", id, MAX_SCROLLBACK, n);
155         out.put("scrollback", n);
156      }
157      if (m.containsKey("fontFamily")) {
158         if (! (m.get("fontFamily") instanceof String f) || f.isEmpty())
159            throw iaex("TerminalDef '%s' terminal.fontFamily must be a non-empty string.", id);
160         out.put("fontFamily", f);
161      }
162      return out;
163   }
164
165   private static String string(String id, String k, Object v) {
166      if (! (v instanceof String))
167         throw iaex("TerminalDef '%s' %s must be a string.", id, k);
168      return (String)v;
169   }
170
171   private static Boolean bool(String id, String k, Object v) {
172      if (! (v instanceof Boolean))
173         throw iaex("TerminalDef '%s' %s must be a boolean.", id, k);
174      return (Boolean)v;
175   }
176
177   private static Long integer(String id, String k, Object v) {
178      if (v instanceof Integer || v instanceof Long || v instanceof Short || v instanceof Byte)
179         return ((Number)v).longValue();
180      if (v instanceof Number n && n.doubleValue() == Math.rint(n.doubleValue()) && Math.abs(n.doubleValue()) < 1e15)
181         return n.longValue();
182      throw iaex("TerminalDef '%s' %s must be an integer.", id, k);
183   }
184
185   /** @param value The bytes endpoint path. @return This object. */
186   public TerminalDef bytesUrl(String value) { bytesUrl = value; return this; }
187   /** @param value The terminal-view events endpoint path. @return This object. */
188   public TerminalDef eventsUrl(String value) { eventsUrl = value; return this; }
189   /** @param value The poll interval; clamped to {@link org.apache.juneau.terminal.report.TerminalViewChecks#MIN_REFRESH_MS}. @return This object. */
190   public TerminalDef refreshMs(long value) { refreshMs = value; return this; }
191   /** @param value The card header title. @return This object. */
192   public TerminalDef title(String value) { title = value; return this; }
193   /** @param value The most bytes replayed on the first load, 1..{@value #MAX_TAIL_BYTES}. @return This object. */
194   public TerminalDef tailBytes(long value) { tailBytes = value; return this; }
195
196   /** @param value Whether the card lets the user type into the job. @return This object. */
197   public TerminalDef input(boolean value) { input = value; return this; }
198   /** @param value The terminal id sent as the {@code term.input} payload {@code id}. @return This object. */
199   public TerminalDef inputId(String value) { inputId = value; return this; }
200
201   /**
202    * @param value The xterm options, a map of {@code convertEol} (boolean), {@code scrollback} (integer 0..{@value #MAX_SCROLLBACK})
203    *    and {@code fontFamily} (non-empty string); checked by {@link #validate()}.
204    * @return This object.
205    */
206   public TerminalDef terminal(Map<String,?> value) { terminal = value == null ? null : new LinkedHashMap<>(value); return this; }
207
208   /**
209    * Validates this declaration (fail-closed).
210    *
211    * @return This object.
212    * @throws IllegalArgumentException If a rule is broken.
213    */
214   public TerminalDef validate() {
215      if (ib(id))
216         throw iaex("TerminalDef id must not be blank.");
217      if (bytesUrl == null)
218         throw iaex("TerminalDef '%s' bytesUrl is required.", id);
219      checkUrl("bytesUrl", bytesUrl);
220      if (eventsUrl != null)
221         checkUrl("eventsUrl", eventsUrl);
222      if (refreshMs != null && refreshMs <= 0)
223         throw iaex("TerminalDef '%s' refreshMs must be > 0; got %s.", id, refreshMs);
224      if (tailBytes != null && (tailBytes < 1 || tailBytes > MAX_TAIL_BYTES))
225         throw iaex("TerminalDef '%s' tailBytes must be 1..%s; got %s.", id, MAX_TAIL_BYTES, tailBytes);
226      if (inputId != null && ! isRunId(inputId))
227         throw iaex("TerminalDef '%s' inputId must match [A-Za-z0-9_-]{1,128}; got '%s'.", id, clip(inputId));
228      if (Boolean.TRUE.equals(input) && inputId == null)
229         throw iaex("TerminalDef '%s' input needs inputId.", id);
230      if (terminal != null)
231         terminal = terminalOptions(id, terminal);
232      return this;
233   }
234
235   private void checkUrl(String name, String url) {
236      if (! isSameOriginPath(url))
237         throw iaex("TerminalDef '%s' %s must be a same-origin path (no scheme, no leading '//', no '..' segment); got '%s'.",
238            id, name, clip(url));
239      if (url.contains("{"))
240         throw iaex("TerminalDef '%s' %s must not contain a '{' placeholder; got '%s'.", id, name, clip(url));
241   }
242
243   /**
244    * Returns the set options in {@link #KEYS} order, with {@code refreshMs} clamped.
245    *
246    * @return A new ordered map.
247    * @throws IllegalArgumentException If the {@code terminal} options are invalid.
248    */
249   public Map<String,Object> toMap() {
250      var m = new LinkedHashMap<String,Object>();
251      put(m, "bytesUrl", bytesUrl);
252      put(m, "eventsUrl", eventsUrl);
253      put(m, "refreshMs", refreshMs == null ? null : Math.max(MIN_REFRESH_MS, refreshMs));
254      put(m, "title", title);
255      put(m, "tailBytes", tailBytes);
256      put(m, "input", input);
257      put(m, "inputId", inputId);
258      put(m, "terminal", terminal == null ? null : terminalOptions(id, terminal));
259      return m;
260   }
261
262   private static void put(Map<String,Object> m, String k, Object v) {
263      if (v != null)
264         m.put(k, v);
265   }
266}