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}