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}