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.function.*; 023import java.util.regex.*; 024import java.util.stream.*; 025 026import org.apache.juneau.marshall.collections.*; 027import org.apache.juneau.rest.server.*; 028import org.apache.juneau.console.*; 029import org.apache.juneau.rest.server.view.freemarker.*; 030import org.apache.juneau.console.views.*; 031 032import freemarker.template.*; 033 034/** 035 * Java description of one console page: the active tab, toolkits, page CSS and init scripts, per-request header 036 * fields, the stock theme by name, and the page's cards (tables, html and registered custom types) and body attributes. 037 * 038 * <p> 039 * A {@code PageSpec} renders nothing itself. Its values are seeded into the request's {@link PageCapture} before any 040 * directive runs, so a built page goes through the same {@code <@console>} chrome and emits the same 041 * {@code #juneau-page} contract and the same toolkit asset tags as an FTL page. Chrome (brand, nav tree, footer, theme tokens) stays in the app's 042 * chrome template. 043 * 044 * <h5 class='section'>Example:</h5> 045 * <p class='bjava'> 046 * <jk>var</jk> <jv>spec</jv> = PageSpec.<jsm>create</jsm>() 047 * .title(<js>"SLO"</js>) 048 * .tab(<js>"slo"</js>) 049 * .toolkit(<js>"views"</js>) 050 * .css(<js>"/css/slo.css"</js>) 051 * .csrf(<jv>token</jv>, <js>"X-CSRF"</js>) 052 * .html(<js>"intro"</js>, <js>"<p>Queue health</p>"</js>) 053 * .table(<jv>sloTable</jv>); 054 * </p> 055 * 056 * <h5 class='section'>Example:</h5> 057 * <p class='bjava'> 058 * <jk>return</jk> PageSpec.<jsm>create</jsm>() 059 * .tab(<js>"changes"</js>) 060 * .table(ChangesView.<jsm>def</jsm>(), <jv>c</jv> -> <jv>c</jv>.title(<js>"Changes"</js>)) 061 * .table(TasksView.<jsm>def</jsm>(), <jv>c</jv> -> <jv>c</jv>.title(<js>"Tasks for the selected change"</js>) 062 * .subscribes(Subscription.<jsm>to</jsm>(Topics.<jsm>selection</jsm>(<js>"changes"</js>)).as(<js>"params"</js>).map(<js>"changeId"</js>, <js>"ids.0"</js>))) 063 * .card(ChartDef.<jsm>bar</jsm>(<js>"byOwner"</js>).title(<js>"Open changes by owner"</js>).src(<js>"/rest/changes/by-owner?q={q}"</js>) 064 * .x(<js>"owner"</js>).series(<js>"open"</js>, <js>"Open"</js>).toCardSpec() 065 * .subscribes(Subscription.<jsm>to</jsm>(Topics.<jsm>filter</jsm>(<js>"changes"</js>)).as(<js>"params"</js>).map(<js>"q"</js>, <js>"search"</js>))) 066 * .view(<jv>req</jv>); 067 * </p> 068 * 069 * @since 10.0.0 070 */ 071@SuppressWarnings({ 072 "java:S1845", // Fluent-builder setters intentionally mirror field names (Juneau DSL convention) 073 "java:S3776", // Validation and rendering chains many independent guards; splitting them adds no clarity. 074 "javabugs:S2259" // False positive: each card.accept(c) is guarded by nn(card), which Sonar's flow analysis does not track as a null guard. 075}) 076public final class PageSpec { 077 078 /** Data-model attribute that carries the spec into the render. */ 079 public static final String ATTR = "jcPageSpec"; 080 081 private static final Set<String> USER_MENU_KEYS = Set.of("label", "initials", "avatar", "items"); 082 /** 083 * The three reserved types ({@code chart}, {@code job-log}, {@code terminal}) {@link #applyTo} lifts through their registered handler, 084 * with their own {@code <@card>} checks. The other reserved types are seeded as written; adopter types are lifted 085 * through the registry too. 086 */ 087 private static final Set<String> LIFTED_TYPES = Set.of("chart", "job-log", "terminal"); 088 private static final Pattern ATTR_NAME_RE = Pattern.compile("[A-Za-z_:][-A-Za-z0-9_:.]*"); 089 090 /** One {@code header(String,String)} passthrough entry. */ 091 record HeaderEntry(String name, String value) {} 092 093 private String template; 094 private String title; 095 private String tab; 096 String theme; 097 private String scheme; 098 private boolean schemeToggle; 099 final List<String> toolkits = new ArrayList<>(); 100 final List<String> css = new ArrayList<>(); 101 final List<String> init = new ArrayList<>(); 102 final Map<String,Object> attrs = new LinkedHashMap<>(); 103 final List<HeaderEntry> headers = new ArrayList<>(); 104 String headerSubtitle; 105 Map<String,Object> headerUserMenu; 106 final List<CardSpec> cards = new ArrayList<>(); 107 /** Card id to inline markup; an absent key means no markup. */ 108 final Map<String,String> cardMarkup = new LinkedHashMap<>(); 109 /** Card id to the unwrapped datatables body of each {@link #table} card, handed to the card-type registry at seed time. */ 110 final Map<String,Map<String,Object>> tableBodies = new LinkedHashMap<>(); 111 final List<TopicDecl> topics = new ArrayList<>(); 112 private final Map<String,Boolean> topicRetain = new LinkedHashMap<>(); 113 final Map<String,BridgeDecl> bridges = new LinkedHashMap<>(); 114 final Map<String,JsonMap> dataSources = new LinkedHashMap<>(); 115 /** The {@code dashboard} contract object, or <jk>null</jk> (see {@link #dashboard(DashboardSpec)}). */ 116 private Map<String,Object> dashboard; 117 /** Body attribute name to its HTML-attribute-escaped value; the last call for a name wins. */ 118 final Map<String,String> bodyAttrs = new LinkedHashMap<>(); 119 final List<PageCapture.PendingNavAdd> navUnders = new ArrayList<>(); 120 final List<BadgeDef> badges = new ArrayList<>(); 121 Map<String,Object> facts = Map.of(); 122 123 private PageSpec() {} 124 125 /** 126 * Creates a new, empty spec. 127 * 128 * @return A new spec. 129 */ 130 public static PageSpec create() { 131 return new PageSpec(); 132 } 133 134 /** 135 * Sets the page template, overriding the mixin's configured chrome template. 136 * 137 * @param pageTemplateName The template name (no {@code .ftlh}). 138 * @return This object. 139 */ 140 public PageSpec template(String pageTemplateName) { 141 template = pageTemplateName; 142 return this; 143 } 144 145 /** 146 * Adds a passthrough attribute, applied to the view this spec builds. 147 * 148 * @param key The attribute name. 149 * @param value The attribute value. 150 * @return This object. 151 */ 152 public PageSpec attr(String key, Object value) { 153 if (ATTR.equals(key)) 154 throw iaex("PageSpec.attr key '%s' is reserved for the spec itself.", key); 155 attrs.put(key, value); 156 return this; 157 } 158 159 /** 160 * Adds a passthrough HTTP response header, applied to the view this spec builds. 161 * 162 * @param name The header name. 163 * @param value The header value. 164 * @return This object. 165 */ 166 public PageSpec header(String name, String value) { 167 headers.add(new HeaderEntry(name, value)); 168 return this; 169 } 170 171 /** 172 * Sets the page title. The spec always wins over {@code <@console title>}. 173 * 174 * @param v The title. 175 * @return This object. 176 */ 177 public PageSpec title(String v) { 178 title = v; 179 return this; 180 } 181 182 /** 183 * Sets the {@code <@page tab>}-equivalent active-nav path. 184 * 185 * @param idPath A {@code '/'}-separated id path, e.g. {@code "home/skills/core"}. 186 * @return This object. 187 * @throws IllegalArgumentException If blank, or any segment is blank. 188 */ 189 public PageSpec tab(String idPath) { 190 tab = checkIdPath("tab", idPath); 191 return this; 192 } 193 194 /** 195 * Validates a {@code '/'}-separated id path: the whole string and every segment must be non-blank. 196 * 197 * @param method The calling method's name, used in the error message. 198 * @param idPath The path to validate. 199 * @return {@code idPath}, unchanged. 200 * @throws IllegalArgumentException If blank, or any {@code '/'}-separated segment is blank. 201 */ 202 static String checkIdPath(String method, String idPath) { 203 if (ib(idPath)) 204 throw iaex("PageSpec %s path must be a non-empty '/'-separated id path; got '%s'.", method, idPath); 205 for (var segment : idPath.split("/", -1)) 206 if (segment.isBlank()) 207 throw iaex("PageSpec %s path must be a non-empty '/'-separated id path; got '%s'.", method, idPath); 208 return idPath; 209 } 210 211 /** 212 * Selects a stock theme by name. Validated immediately via {@link ConsoleChromeMixin#stockTheme(String)}. 213 * 214 * @param stockName One of {@link ConsoleChromeMixin#BUILTIN_THEME_NAMES}. 215 * @return This object. 216 * @throws IllegalArgumentException If {@code stockName} is not a built-in stock-theme name. 217 */ 218 public PageSpec theme(String stockName) { 219 ConsoleChromeMixin.stockTheme(stockName); 220 theme = stockName; 221 return this; 222 } 223 224 /** 225 * Pins the page's colour scheme. 226 * 227 * <p> 228 * {@code auto} (the default) follows the browser's {@code prefers-color-scheme}; {@code light} and {@code dark} 229 * pin the page. An explicit {@code <@console scheme="…">} on the page template wins over this value. 230 * 231 * @param name One of {@link ConsoleChromeMixin#SCHEMES}. 232 * @return This object. 233 * @throws IllegalArgumentException If {@code name} is not a known scheme. 234 */ 235 public PageSpec scheme(String name) { 236 if (name == null || ! ConsoleChromeMixin.SCHEMES.contains(name)) 237 throw iaex("Unknown scheme '%s'. Schemes: %s.", name, String.join(", ", ConsoleChromeMixin.SCHEMES)); 238 scheme = name; 239 return this; 240 } 241 242 /** 243 * Lets the visitor override the page's colour scheme with a stored choice. 244 * 245 * <p> 246 * An explicit {@code <@console schemeToggle="…">} on the page template wins over this value. 247 * 248 * @param value Whether the stored choice is honoured. 249 * @return This object. 250 */ 251 public PageSpec schemeToggle(boolean value) { 252 schemeToggle = value; 253 return this; 254 } 255 256 /** 257 * Adds toolkit names, appending any not already present. 258 * 259 * @param names The toolkit names. 260 * @return This object. 261 */ 262 public PageSpec toolkit(String...names) { 263 addAllDeduped(toolkits, names); 264 return this; 265 } 266 267 /** 268 * Adds page CSS URLs, appending any not already present. 269 * 270 * @param urls The CSS URLs. 271 * @return This object. 272 */ 273 public PageSpec css(String...urls) { 274 addAllDeduped(css, urls); 275 return this; 276 } 277 278 /** 279 * Adds page init-script URLs, appending any not already present. 280 * 281 * @param urls The script URLs. 282 * @return This object. 283 */ 284 public PageSpec init(String...urls) { 285 addAllDeduped(init, urls); 286 return this; 287 } 288 289 private static void addAllDeduped(List<String> target, String...values) { 290 for (var v : values) 291 if (! target.contains(v)) 292 target.add(v); 293 } 294 295 /** 296 * Sets the header's {@code subtitle} and/or {@code userMenu}. These are the only two header fields this builder 297 * may set; every other field belongs in the chrome template. 298 * 299 * @param c Mutates a scratch {@link PageCapture.HeaderSpec}. 300 * @return This object. 301 * @throws IllegalArgumentException If {@code c} sets any field other than {@code subtitle}/{@code userMenu}, or the 302 * user menu has a key the page contract does not allow. 303 */ 304 public PageSpec header(Consumer<PageCapture.HeaderSpec> c) { 305 var scratch = new PageCapture.HeaderSpec(); 306 c.accept(scratch); 307 checkHeaderScratch(scratch); 308 if (scratch.subtitle != null) 309 headerSubtitle = scratch.subtitle; 310 if (scratch.userMenu != null) 311 for (var k : scratch.userMenu.keySet()) 312 if (! USER_MENU_KEYS.contains(k)) 313 throw iaex("PageSpec.header userMenu key '%s' is not allowed; allowed: label, initials, avatar, items.", k); 314 if (scratch.userMenu != null) 315 headerUserMenu = new LinkedHashMap<>(scratch.userMenu); 316 return this; 317 } 318 319 private static void checkHeaderScratch(PageCapture.HeaderSpec h) { 320 if (h.title != null) 321 throw headerFieldRejected("title"); 322 if (h.logoSrc != null || h.logoHref != null || h.logoAlt != null) 323 throw headerFieldRejected("logo"); 324 if (h.chrome) 325 throw headerFieldRejected("chrome"); 326 if (! h.links.isEmpty()) 327 throw headerFieldRejected("link"); 328 } 329 330 private static IllegalArgumentException headerFieldRejected(String field) { 331 return iaex("PageSpec.header may set only subtitle and userMenu; '%s' belongs in the chrome template.", field); 332 } 333 334 /** 335 * Appends a card, enforcing unique ids across every spec-declared card. 336 * 337 * @param card The card. 338 * @return {@code card}, for fluent chaining by the caller. 339 * @throws IllegalArgumentException If a card with this id was already declared. 340 */ 341 CardSpec addCard(CardSpec card) { 342 for (var c : cards) 343 if (c.id().equals(card.id())) 344 throw iaex("PageSpec card id '%s' is already declared.", card.id()); 345 cards.add(card); 346 return card; 347 } 348 349 /** 350 * Adds a {@code datatables} card for {@code table}, with no title. 351 * 352 * @param table The table. Validated and snapshotted immediately; later changes to it do not affect this page. 353 * @return This object. 354 */ 355 public PageSpec table(TableSpec table) { 356 return table(table, (Consumer<CardSpec>)null); 357 } 358 359 /** 360 * Adds a {@code datatables} card for {@code table}, with a title. 361 * 362 * @param table The table. 363 * @param title The card title. 364 * @return This object. 365 */ 366 public PageSpec table(TableSpec table, String title) { 367 return table(table, c -> c.title(title)); 368 } 369 370 /** 371 * Adds a {@code datatables} card for {@code table}, letting {@code card} adjust the card. 372 * 373 * <p> 374 * The table's CSS class, if set, is carried onto the card wrapper before {@code card} runs, so {@code card} may 375 * override it. The card body is produced by the card-type registry when the spec is seeded into a page. 376 * 377 * @param table The table. 378 * @param card Mutates the card. May be <jk>null</jk>. 379 * @return This object. 380 */ 381 public PageSpec table(TableSpec table, Consumer<CardSpec> card) { 382 table.validate(); 383 var body = table.toCardBody(); 384 var c = CardSpec.of("datatables", table.id()); 385 if (nn(table.cssClass())) 386 c.cssClass(table.cssClass()); 387 if (nn(card)) 388 card.accept(c); 389 if (nn(c.src()) || nn(c.template())) 390 throw iaex("PageSpec.table id '%s' sets src or template; the table supplies the card's data.", table.id()); 391 addCard(c); 392 tableBodies.put(table.id(), body); 393 return this; 394 } 395 396 /** 397 * Adds an {@code html} card whose body is {@code markup}, with no title. 398 * 399 * @param id The card id. 400 * @param markup The markup, emitted as {@code <template data-card=id>}. Must not be <jk>null</jk>. 401 * @return This object. 402 * @throws IllegalArgumentException If {@code markup} is <jk>null</jk>. 403 */ 404 public PageSpec html(String id, String markup) { 405 return html0(id, null, markup, null); 406 } 407 408 /** 409 * Adds an {@code html} card whose body is {@code markup}, with a title. 410 * 411 * @param id The card id. 412 * @param title The card title. 413 * @param markup The markup. Must not be <jk>null</jk>. 414 * @return This object. 415 */ 416 public PageSpec html(String id, String title, String markup) { 417 return html0(id, title, markup, null); 418 } 419 420 /** 421 * Adds an {@code html} card whose body is {@code markup}, letting {@code card} adjust the card (title, CSS 422 * class, and so on). {@code card} must not also set {@code src} or {@code template}. 423 * 424 * @param id The card id. 425 * @param markup The markup. Must not be <jk>null</jk>. 426 * @param card Mutates the card. May be <jk>null</jk>. 427 * @return This object. 428 * @throws IllegalArgumentException If {@code markup} is <jk>null</jk>, or {@code card} sets {@code src} or {@code template}. 429 */ 430 public PageSpec html(String id, String markup, Consumer<CardSpec> card) { 431 return html0(id, null, markup, card); 432 } 433 434 /** 435 * Adds an {@code html} card whose body is fetched from {@code src}, with a title, unsanitized. See 436 * {@link #htmlSrc(String,String,String,boolean)} for the trust boundary. 437 * 438 * @param id The card id. 439 * @param title The card title. May be <jk>null</jk> for no title. 440 * @param src The same-origin path of the HTML fragment endpoint. Must not be blank. 441 * @return This object. 442 * @throws IllegalArgumentException If {@code src} is <jk>null</jk> or blank. 443 */ 444 public PageSpec htmlSrc(String id, String title, String src) { 445 return htmlSrc(id, title, src, false); 446 } 447 448 /** 449 * Adds an {@code html} card whose body is fetched from {@code src}, with a title, optionally rebuilt through the 450 * views attribute-allowlist sanitizer. The equivalent of {@code <@card type="html" src="..." sanitize=true>}. 451 * 452 * <h5 class='section'>Trust boundary:</h5> 453 * <p> 454 * With {@code sanitize} <jk>false</jk> the shell fetches {@code src} and inserts the returned HTML as-is through a 455 * raw HTML-sink assignment, <b>unsanitized</b>, the same trust level as {@code footer.text} (see 456 * {@code HtmlCardType}). The endpoint must therefore be a trusted first-party route, must never be derived from 457 * request data, and must escape any data it embeds in the fragment. {@code src} must be a same-origin path; an 458 * absolute URL, a scheme, {@code //}, {@code ..} or a backslash is refused when the page is built, exactly as for 459 * every other card. 460 * 461 * <p> 462 * With {@code sanitize} <jk>true</jk> the fragment is rebuilt through the views attribute-allowlist sanitizer 463 * instead: script, {@code on*} handlers, {@code javascript:} and {@code data:} URLs and every attribute off the 464 * allowlist are dropped, and benign markup (text, tables, links, images) survives. It needs the views toolkit on 465 * the page; without the toolkit nothing is painted (the shell fails closed rather than inserting the fragment 466 * unsanitized). The sanitizer does not remove the need to escape any data you embed in the fragment. Turn it on when the fragment's content is not fully under your control (it embeds user-supplied or 467 * third-party markup, or comes from an endpoint you do not own); leave it off for a fragment that needs 468 * {@code class}, {@code id}, {@code style}, forms, SVG or iframes, which the allowlist drops. The default stays off. 469 * 470 * @param id The card id. 471 * @param title The card title. May be <jk>null</jk> for no title. 472 * @param src The same-origin path of the HTML fragment endpoint. Must not be blank. 473 * @param sanitize Whether to rebuild the fetched fragment through the sanitizer. 474 * @return This object. 475 * @throws IllegalArgumentException If {@code src} is <jk>null</jk> or blank. 476 */ 477 public PageSpec htmlSrc(String id, String title, String src, boolean sanitize) { 478 if (ib(src)) 479 throw iaex("PageSpec.htmlSrc id '%s' requires a non-blank src.", id); 480 var c = CardSpec.html(id).src(src).sanitize(sanitize); 481 if (nn(title)) 482 c.title(title); 483 addCard(c); 484 return this; 485 } 486 487 private PageSpec html0(String id, String title, String markup, Consumer<CardSpec> card) { 488 if (markup == null) 489 throw iaex("PageSpec.html id '%s' requires a non-null markup.", id); 490 var c = CardSpec.html(id); 491 if (nn(title)) 492 c.title(title); 493 if (nn(card)) { 494 card.accept(c); 495 if (nn(c.src()) || nn(c.template())) 496 throw iaex("PageSpec.html id '%s' sets a markup body; its Consumer<CardSpec> must not also set src or template.", id); 497 } 498 addCard(c); 499 cardMarkup.put(id, markup); 500 return this; 501 } 502 503 /** 504 * Adds a custom-type card with no inline markup. 505 * 506 * <p> 507 * When the page renders, a card of an adopter type (any type that is not reserved) is built as 508 * {@code <@card>} builds it: its body map is the authored body, the type's registered {@link CardTypeHandler} 509 * validates it and returns the fragment the card carries, and an invalid body fails the render with the 510 * {@code <@card>} message. An unregistered type takes the generic passthrough, so its body map is carried as-is. 511 * 512 * @param card The card. Its type must not be a removed type or one with a dedicated method. 513 * @return This object. 514 */ 515 public PageSpec card(CardSpec card) { 516 return card(card, null); 517 } 518 519 /** 520 * Adds a custom-type card that also takes inline markup. 521 * 522 * @param card The card. See {@link #card(CardSpec)}. 523 * @param markupOrNull The markup, or <jk>null</jk>. For an adopter type it is the card's body, as the content of 524 * {@code <@card>} is: the handler parses it (JSON5) or captures it as {@code <template data-card=card.id()>}, and 525 * {@code subscribes} / {@code publishes} keys in JSON5 markup are wired onto the card. A card takes at most one of a 526 * body map, non-blank markup, {@code template} or {@code src}. 527 * @return This object. 528 */ 529 public PageSpec card(CardSpec card, String markupOrNull) { 530 checkCardType(card); 531 addCard(card); 532 if (nn(markupOrNull)) 533 cardMarkup.put(card.id(), markupOrNull); 534 return this; 535 } 536 537 /** 538 * Adds the page's dashboard: a rail of filter sections beside a grid of card panels. 539 * 540 * <p> 541 * The panel and card-section cards built by the dashboard are added to this page as ordinary cards, and the 542 * {@code dashboard} toolkit pack is added (just before {@code charts} when that is listed). A by-id panel 543 * ({@code panel("notes")}) must name a card added <i>before</i> this call, for example with {@link #html(String, String)}. 544 * </p> 545 * 546 * @param dashboard The dashboard. 547 * @return This object. 548 * @throws IllegalArgumentException E-77 on any dashboard problem, or if the page already has a dashboard. 549 */ 550 public PageSpec dashboard(DashboardSpec dashboard) { 551 if (dashboard == null) 552 throw iaex("PageSpec.dashboard requires a dashboard."); 553 if (this.dashboard != null) 554 throw Section.e77(dashboard.id(), "a page has at most one dashboard"); 555 var byId = new LinkedHashMap<String,CardSpec>(); 556 for (var c : cards) 557 byId.put(c.id(), c); 558 var json = dashboard.toJson(byId); 559 for (var c : dashboard.ownedCards()) 560 card(c); 561 var pack = ToolkitPackRegistry.PACK_DASHBOARD; 562 if (! toolkits.contains(pack)) { 563 var charts = toolkits.indexOf(ToolkitPackRegistry.PACK_CHARTS); 564 if (charts >= 0) 565 toolkits.add(charts, pack); 566 else 567 toolkits.add(pack); 568 } 569 this.dashboard = json; 570 return this; 571 } 572 573 private static void checkCardType(CardSpec card) { 574 if (card == null) 575 throw iaex("PageSpec.card requires a non-null card."); 576 var type = card.type(); 577 if (ib(type)) 578 throw iaex("PageSpec.card id '%s' requires a non-blank type.", card.id()); 579 if (CardDirectiveModel.REMOVED_TYPES.contains(type)) 580 throw iaex("PageSpec.card type '%s' was removed in 10.0.0; use PageSpec.html(...) with a markup/template, or a registered card type.", type); 581 if ("html".equals(type)) 582 throw iaex("PageSpec.card type '%s' has a dedicated method; use PageSpec.%s(...).", type, "html"); 583 if ("datatables".equals(type)) 584 throw iaex("PageSpec.card type '%s' has a dedicated method; use PageSpec.%s(...).", type, "table"); 585 var registry = CardTypeRegistry.standard(); 586 if (CardTypeRegistry.RESERVED.contains(type) && ! registry.isRegistered(type)) 587 throw iaex("PageSpec.card type '%s' is reserved and has no card-type handler; it cannot be built with PageSpec.", type); 588 } 589 590 /** 591 * Sets the CSRF body attributes: {@code data-juneau-csrf} and, if {@code headerName} is non-blank, 592 * {@code data-juneau-csrf-header}. 593 * 594 * @param token The CSRF token value. 595 * @param headerName The CSRF header name, or <jk>null</jk>/blank to omit the header attribute. 596 * @return This object. 597 */ 598 public PageSpec csrf(String token, String headerName) { 599 if (token == null) 600 throw iaex("PageSpec.csrf requires a non-null token."); 601 bodyAttrs.put("data-juneau-csrf", attrEscape(token)); 602 if (nn(headerName) && ! headerName.isBlank()) 603 bodyAttrs.put("data-juneau-csrf-header", attrEscape(headerName)); 604 return this; 605 } 606 607 /** 608 * Sets one {@code <body>} attribute. 609 * 610 * @param name The attribute name. 611 * @param value The attribute value; {@code & < > "} are escaped. 612 * @return This object. 613 */ 614 public PageSpec bodyAttr(String name, String value) { 615 if (name == null || ! ATTR_NAME_RE.matcher(name).matches()) 616 throw iaex("PageSpec.bodyAttr name '%s' must match [A-Za-z_:][-A-Za-z0-9_:.]*.", name); 617 bodyAttrs.put(name, attrEscape(value)); 618 return this; 619 } 620 621 private static String attrEscape(String s) { 622 return s == null ? "" : s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); 623 } 624 625 /** @return The declared cards, in declaration order; a snapshot. */ 626 public List<CardSpec> cards() { 627 return List.copyOf(cards); 628 } 629 630 /** 631 * Looks up a declared card by id. 632 * 633 * @param id The card id. 634 * @return The card, or empty if none was declared with this id. 635 */ 636 public Optional<CardSpec> card(String id) { 637 return cards.stream().filter(c -> c.id().equals(id)).findFirst(); 638 } 639 640 /** 641 * Adds a header badge. The Java twin of {@code <@badge>}. 642 * 643 * @param def The badge. Must not be <jk>null</jk>. 644 * @return This object. 645 * @throws IllegalArgumentException If {@link BadgeDef#validate()} rejects it. 646 */ 647 public PageSpec badge(BadgeDef def) { 648 def.validate(); 649 badges.add(def); 650 return this; 651 } 652 653 /** 654 * Seeds page-level facts, which {@code visibleWhen} rules test on the client. Repeated calls merge, last wins, on 655 * this side only; the strict check against chrome-supplied facts runs once, at {@code </@console>}. 656 * 657 * @param v The facts. Leaves must be strings, numbers, booleans, string lists or maps. 658 * @return This object. 659 * @throws IllegalArgumentException On a leaf of any other type. 660 */ 661 public PageSpec facts(Map<String,Object> v) { 662 checkFactTypes("", v); 663 facts = PageCapture.mergeFactsLastWins(facts, copyFacts(v)); 664 return this; 665 } 666 667 // Deep, unmodifiable copy, so a caller that later mutates its maps or lists cannot change the page. 668 private static Map<String,Object> copyFacts(Map<String,Object> m) { 669 var out = new LinkedHashMap<String,Object>(); 670 for (var e : m.entrySet()) 671 out.put(e.getKey(), copyFact(e.getValue())); 672 return u(out); 673 } 674 675 @SuppressWarnings({ 676 "unchecked" // Narrows a JSON-shaped Map<?,?> to Map<String,Object>; its keys are always strings. 677 }) 678 private static Object copyFact(Object v) { 679 if (v instanceof Map<?,?> nested) 680 return copyFacts((Map<String,Object>)nested); 681 if (v instanceof List<?> list) 682 return List.copyOf(list); 683 return v; 684 } 685 686 static void checkFactTypes(String prefix, Map<?,?> m) { 687 for (var e : m.entrySet()) { 688 var path = prefix.isEmpty() ? String.valueOf(e.getKey()) : prefix + "." + e.getKey(); 689 var v = e.getValue(); 690 if (v instanceof Map<?,?> nested) { 691 checkFactTypes(path, nested); 692 } else if (v instanceof List<?> list) { 693 for (var item : list) 694 if (! (item instanceof String)) 695 throw badFact(path, item); 696 } else if (! (v instanceof String || v instanceof Boolean || isPlainNumber(v))) { 697 throw badFact(path, v); 698 } 699 } 700 } 701 702 private static boolean isPlainNumber(Object v) { 703 return v instanceof Integer || v instanceof Long || v instanceof Short || v instanceof Byte || v instanceof Float 704 || v instanceof Double || v instanceof java.math.BigInteger || v instanceof java.math.BigDecimal; 705 } 706 707 private static IllegalArgumentException badFact(String path, Object v) { 708 return iaex("Fact '%s' has unsupported value type '%s'; use string, number, boolean, string list or map.", 709 path, v == null ? "null" : v.getClass().getSimpleName()); 710 } 711 712 /** 713 * Java twin of the {@code </@console>} card-reference check, run early when this spec's own card set is final. The 714 * nav-node check needs the resolved chrome, so it only runs at {@code </@console>}. 715 */ 716 private void checkBadgesAgainstOwnCards() { 717 var cardIds = cards.stream().map(CardSpec::id).collect(Collectors.toSet()); 718 for (var b : badges) { 719 var m = b.toContractMap(); 720 for (var card : PageCapture.badgeCardRefs(m)) 721 if (! cardIds.contains(card)) 722 throw isex(PageCapture.BADGE_CARD_MSG, m.get("id"), card); 723 } 724 } 725 726 /** 727 * Queues a child to add under an existing chrome nav node, resolved once the chrome's own nav tree exists. 728 * 729 * <p> 730 * The Java twin of {@code <@node under="...">}. 731 * 732 * @param parentIdPath A {@code '/'}-separated id path to the existing ancestor node. 733 * @param adder Receives the resolved ancestor and adds its children to it. 734 * @return This object. 735 * @throws IllegalArgumentException If the path is blank, or any {@code '/'}-separated segment is blank. 736 */ 737 public PageSpec navUnder(String parentIdPath, PageCapture.NavAdder adder) { 738 navUnders.add(new PageCapture.PendingNavAdd(checkIdPath("navUnder", parentIdPath), adder)); 739 return this; 740 } 741 742 /** 743 * Declares a page-level custom topic; repeatable. Unions with FTL {@code <@topic>}. 744 * 745 * @param decl The declaration; needs {@code retain} and {@code publisher}. 746 * @return This object. 747 * @throws IllegalArgumentException E-46 if the topic is already declared with a different {@code retain} or {@code publisher}. 748 */ 749 public PageSpec topic(TopicDecl decl) { 750 var m = decl.toMap(); 751 var retain = (Boolean)m.get("retain"); 752 var prior = topicRetain.putIfAbsent(decl.topic(), retain); 753 if (prior != null && ! prior.equals(retain)) 754 throw iaex("topic '%s' is declared with retain=%s here and retain=%s at %s", decl.topic(), retain, prior, "PageSpec.topic"); 755 if (prior == null) { 756 topics.add(decl); 757 } else { 758 var priorPub = topics.stream().filter(t -> t.topic().equals(decl.topic())).findFirst().get().toMap().get("publisher"); 759 if (! priorPub.equals(m.get("publisher"))) 760 throw iaex("topic '%s' is declared with publisher=%s here and publisher=%s at %s", decl.topic(), m.get("publisher"), priorPub, "PageSpec.topic"); 761 } 762 return this; 763 } 764 765 /** 766 * Declares a server bridge; repeatable. Unions with FTL {@code <@bridge>}. 767 * 768 * @param decl The bridge. 769 * @return This object. 770 * @throws IllegalArgumentException E-51 on a duplicate id, or from {@link BridgeDecl#toMap()}. 771 */ 772 public PageSpec bridge(BridgeDecl decl) { 773 decl.toMap(); // fail at the call site, not at render 774 if (bridges.putIfAbsent(decl.id(), decl) != null) 775 throw iaex("bridge '%s': %s", decl.id(), "duplicate id"); 776 return this; 777 } 778 779 /** 780 * Declares a data source, published on {@code data:<id>}; repeatable. 781 * 782 * @param def The source. 783 * @return This object. 784 * @throws IllegalArgumentException E-76 on a duplicate id or a bad declaration; also on a null definition. 785 */ 786 public PageSpec data(DataSourceDef def) { 787 if (def == null) 788 throw iaex("PageSpec.data requires a non-null definition."); 789 return addData(def.toMap()); // fail at the call site, not at render 790 } 791 792 /** 793 * Declares a derived data source, published on {@code data:<as>}; repeatable. 794 * 795 * @param def The derived source. 796 * @return This object. 797 * @throws IllegalArgumentException E-76 on a duplicate id or a bad declaration; also on a null definition. 798 * @throws IllegalStateException If {@link Derived#as(String)} was not called. 799 */ 800 public PageSpec data(Derived def) { 801 if (def == null) 802 throw iaex("PageSpec.data requires a non-null definition."); 803 return addData(def.toMap()); 804 } 805 806 private PageSpec addData(JsonMap m) { 807 var id = (String)m.get("id"); 808 if (dataSources.putIfAbsent(id, m) != null) 809 throw iaex(DataSourceDef.E76, id, "duplicate id"); 810 return this; 811 } 812 813 /** @return The template name, or <jk>null</jk> if unset (the mixin's chrome template applies). */ 814 public String template() { 815 return template; 816 } 817 818 /** @return The tab path, or {@code ""} if unset. */ 819 public String tab() { 820 return tab == null ? "" : tab; 821 } 822 823 /** @return The toolkit names, in declaration order. */ 824 public List<String> toolkits() { 825 return List.copyOf(toolkits); 826 } 827 828 /** 829 * The per-render holder {@link #view(RestRequest)} puts in the data model. It carries this spec and records 830 * whether the render adopted it, so a spec reused across requests never shares adoption state. 831 */ 832 static final class Seed implements FreemarkerView.MustConsume { 833 834 private final PageSpec spec; 835 private volatile boolean consumed; 836 837 Seed(PageSpec spec) { 838 this.spec = spec; 839 } 840 841 PageSpec spec() { return spec; } 842 843 /** Applies the spec to a fresh capture and marks it adopted. */ 844 void adoptInto(PageCapture cap, CardDirectiveModel cardModel) throws TemplateModelException { 845 spec.applyTo(cap, cardModel); 846 consumed = true; 847 } 848 849 void markConsumed() { consumed = true; } 850 851 @Override /* Overridden from FreemarkerView.MustConsume */ 852 public boolean consumed() { return consumed; } 853 854 @Override /* Overridden from FreemarkerView.MustConsume */ 855 public String notConsumedMessage(String templateName) { 856 return f("Template '%s' was rendered with a PageSpec, but the spec was never adopted; the " 857 + "template must use <@page> or include the console chrome.", templateName); 858 } 859 } 860 861 /** 862 * Builds the view for this spec. 863 * 864 * <p> 865 * The template is {@link #template(String)} when set, else the {@code ConsoleFreemarkerMixin}'s chrome template. 866 * A built spec is not modified by rendering, so it may be cached and reused across requests. Call 867 * {@code spec.view(req)} on each request: a reused {@link FreemarkerView} would share one adoption flag. 868 * 869 * @param req The current REST request. 870 * @return A view for the template, carrying this spec and every {@link #attr(String, Object)} / 871 * {@link #header(String, String)} passthrough. 872 * @throws IllegalStateException If no template was set and the request has no {@code ConsoleFreemarkerMixin} bean. 873 * @throws IllegalArgumentException If this spec has no page template and its bus wiring fails R-10 / R-11. 874 */ 875 public FreemarkerView view(RestRequest req) { 876 if (req == null) 877 throw iaex("PageSpec.view requires a non-null request."); 878 String templateName; 879 if (template != null) { 880 templateName = template; 881 } else { 882 var ccm = resolveConsoleMixin(req); 883 checkWiringEarly(ccm.cardTypes(), req); 884 checkBadgesAgainstOwnCards(); 885 templateName = ccm.chromeTemplate(); 886 } 887 var v = FreemarkerView.of(templateName).attr(ATTR, new Seed(this)); 888 for (var h : headers) 889 v = v.header(h.name(), h.value()); 890 for (var e : attrs.entrySet()) 891 v = v.attr(e.getKey(), e.getValue()); 892 return v; 893 } 894 895 // The exact-type-else-registered-subtype lookup the renderer runs, without its two-beans ambiguity check (the 896 // renderer still reports that itself at render time). 897 @SuppressWarnings({ 898 "resource" // The request context owns its bean store. 899 }) 900 private static ConsoleFreemarkerMixin resolveConsoleMixin(RestRequest req) { 901 var store = req.getContext().getBeanStore(); 902 FreemarkerMixin found = store.getBean(FreemarkerMixin.class).orElse(null); 903 if (found == null) 904 for (var t : FreemarkerMixin.registeredSubtypes()) { 905 var b = store.getBean(t).orElse(null); 906 if (b != null) { 907 found = b; 908 break; 909 } 910 } 911 if (! (found instanceof ConsoleFreemarkerMixin ccm)) 912 throw isex("PageSpec.view needs a ConsoleFreemarkerMixin bean; found '%s'.", 913 found == null ? "none" : found.getClass().getSimpleName()); 914 return ccm; 915 } 916 917 /** 918 * Applies every field this spec has set to {@code cap}, via {@code cap}'s plain (non-merge-aware) setters. 919 * 920 * <p> 921 * Called once per render, on a freshly created capture before any directive has written to it, so these raw 922 * writes can never conflict with anything. This spec is not modified: each card is copied before it is seeded. 923 * A table card, a chart, job-log or terminal card, and a card of any adopter (non-reserved) type are lifted through the 924 * card-type registry of {@code cardModel}, with the {@code <@card>} checks and messages. Every card's required packs are recorded through {@code cardModel}. 925 * 926 * @param cap The freshly created, still-empty capture. 927 * @param cardModel The shared {@code <@card>} directive; may be <jk>null</jk> only when this spec has no cards. 928 * @throws TemplateModelException If a card is rejected. 929 */ 930 void applyTo(PageCapture cap, CardDirectiveModel cardModel) throws TemplateModelException { 931 if (title != null) 932 cap.title(title); 933 if (tab != null) 934 cap.tab(tab); 935 if (theme != null) 936 cap.theme(theme); 937 if (scheme != null) 938 cap.scheme(scheme); 939 if (schemeToggle) 940 cap.schemeToggle(true); 941 if (! toolkits.isEmpty()) 942 cap.toolkit(toolkits); 943 if (! css.isEmpty()) 944 cap.css(css); 945 if (! init.isEmpty()) 946 cap.init(init); 947 if (headerSubtitle != null || headerUserMenu != null) 948 cap.header(h -> { 949 if (headerSubtitle != null) 950 h.subtitle(headerSubtitle); 951 if (headerUserMenu != null) 952 h.userMenu(headerUserMenu); 953 }); 954 if (! cards.isEmpty() && n(cardModel)) 955 throw FtlAttrLists.reject("PageSpec cards need the console card directive; the render has no ConsoleFreemarkerMixin."); 956 for (var card : cards) { 957 var markup = cardMarkup.get(card.id()); 958 Built built; 959 if (tableBodies.containsKey(card.id())) 960 built = new Built(liftTable(card, cardModel), markup); 961 else if (LIFTED_TYPES.contains(card.type())) 962 built = new Built(liftChecked(card, cardModel), markup); 963 else if (CardTypeRegistry.RESERVED.contains(card.type())) 964 built = new Built(card.copy(), markup); 965 else 966 built = liftCustom(card, markup, cardModel); 967 cap.addBuiltCard(built.card(), built.markupOrNull()); 968 cardModel.recordRequirements(cap, built.card().type(), built.card().id(), List.of(), built.card().bodyMap()); 969 } 970 try { 971 for (var t : topics) 972 cap.addTopic(t.toMap(), "PageSpec.topic"); 973 var req = FreemarkerRenderScope.request(); 974 for (var b : bridges.values()) { 975 cap.addBridge(resolvedBridge(b, req)); 976 } 977 for (var d : dataSources.values()) 978 cap.addDataSource(new JsonMap(d)); 979 if (! dataSources.isEmpty()) 980 cap.require(List.of(ToolkitPackRegistry.PACK_DATA)); 981 } catch (IllegalArgumentException e) { 982 throw FtlAttrLists.reject(e.getMessage()); 983 } 984 if (! bodyAttrs.isEmpty()) { 985 var sb = new StringBuilder(); 986 for (var e : bodyAttrs.entrySet()) 987 sb.append(e.getKey()).append("=\"").append(e.getValue()).append("\" "); 988 cap.bodyAttrs = sb.toString().stripTrailing(); 989 } 990 for (var p : navUnders) 991 cap.addPendingNavUnder(p.parentIdPath(), p.adder()); 992 for (var b : badges) 993 cap.addBadge(b); 994 if (dashboard != null) 995 cap.dashboard(dashboard); 996 if (! facts.isEmpty()) 997 cap.seedJavaFacts(facts); 998 } 999 1000 /** A card ready for {@link PageCapture#addBuiltCard}, with the markup it emits as its template, or <jk>null</jk>. */ 1001 private record Built(CardSpec card, String markupOrNull) {} 1002 1003 // sanitize= is refused before any other check, as <@card> refuses it: only an html card with src= takes it. 1004 private static void checkSanitize(CardSpec card) throws TemplateModelException { 1005 if (card.sanitize()) 1006 throw FtlAttrLists.reject(f("<@card id='%s'> sanitize= applies only to an html card with src=.", card.id())); 1007 } 1008 1009 private CardSpec liftTable(CardSpec card, CardDirectiveModel cardModel) throws TemplateModelException { 1010 checkSanitize(card); 1011 return liftVia(card, CardSource.create(card.type(), card.id()).bodyMap(tableBodies.get(card.id())), cardModel); 1012 } 1013 1014 private static CardSpec liftChecked(CardSpec card, CardDirectiveModel cardModel) throws TemplateModelException { 1015 checkSanitize(card); 1016 if (eq(card.type(), "terminal")) 1017 return liftTerminal(card, cardModel); 1018 if (eq(card.type(), "job-log")) 1019 rejectSrcAndTemplate(card); 1020 else if (nn(card.template())) 1021 throw FtlAttrLists.reject(f("<@card id='%s'> type='chart' takes a JSON5 body (plus src= for remote data); template= is not allowed.", card.id())); 1022 var source = CardSource.create(card.type(), card.id()).bodyMap(card.bodyMap()); 1023 if (nn(card.src())) 1024 source.src(card.src()); 1025 return liftVia(card, source, cardModel); 1026 } 1027 1028 /** Rejects {@code src=} and/or {@code template=} on a card whose options are its body, with the directive's message. */ 1029 private static void rejectSrcAndTemplate(CardSpec card) throws TemplateModelException { 1030 var src = nn(card.src()) ? card.src() : ""; 1031 var template = nn(card.template()) ? card.template() : ""; 1032 if (! (src.isEmpty() && template.isEmpty())) 1033 throw FtlAttrLists.reject(f("<@card id='%s'> type='%s' takes its options as the body; %s.", card.id(), card.type(), 1034 CardDirectiveModel.notAllowed(src, template))); 1035 } 1036 1037 /** 1038 * A terminal card: its body map is the {@code {contractVersion: '1', terminal: {...}}} envelope {@code <@card>} takes, 1039 * with {@code contractVersion} implied, so the registered handler validates it exactly as it does for the directive. 1040 */ 1041 private static CardSpec liftTerminal(CardSpec card, CardDirectiveModel cardModel) throws TemplateModelException { 1042 rejectSrcAndTemplate(card); 1043 var envelope = new LinkedHashMap<String,Object>(); 1044 envelope.put("contractVersion", "1"); 1045 envelope.putAll(card.bodyMap()); 1046 return liftVia(card, CardSource.create(card.type(), card.id()).bodyMap(envelope), cardModel); 1047 } 1048 1049 /** 1050 * An adopter card type: the same checks {@code <@card>} runs, then its registered handler (or the generic 1051 * passthrough for an unregistered type) builds the fragment from the card's body map, or from its non-blank markup 1052 * when it has no body map. The markup is emitted as the card's template only when the handler captures it, as 1053 * {@code <@card>} emits it; JSON5 markup a handler parses is the body, not a template. 1054 */ 1055 private static Built liftCustom(CardSpec card, String markupOrNull, CardDirectiveModel cardModel) throws TemplateModelException { 1056 checkSanitize(card); 1057 var body = card.bodyMap(); 1058 var hasMarkup = nn(markupOrNull) && ! markupOrNull.isBlank(); 1059 if ((body.isEmpty() ? 0 : 1) + (hasMarkup ? 1 : 0) + (nn(card.src()) ? 1 : 0) + (nn(card.template()) ? 1 : 0) > 1) 1060 throw FtlAttrLists.reject(f("<@card id='%s'> type='%s' takes at most one of a body, template= or src=.", card.id(), card.type())); 1061 var captured = new boolean[1]; 1062 var source = CardSource.create(card.type(), card.id()).templateSink((id, markup) -> captured[0] = true); 1063 if (nn(card.src())) 1064 source.src(card.src()); 1065 if (nn(card.template())) 1066 source.template(card.template()); 1067 if (! body.isEmpty()) 1068 source.bodyMap(body); 1069 else if (hasMarkup) 1070 source.body(markupOrNull); 1071 var lifted = liftVia(card, source, cardModel); 1072 return new Built(lifted, captured[0] ? markupOrNull : null); 1073 } 1074 1075 private static CardSpec liftVia(CardSpec card, CardSource.Builder source, CardDirectiveModel cardModel) throws TemplateModelException { 1076 if (nn(card.title())) 1077 source.title(card.title()); 1078 var lifted = cardModel.lift(source.build(), card); 1079 if (nn(card.cssClass())) 1080 lifted.cssClass(card.cssClass()); 1081 if (card.bare()) 1082 lifted.bare(true); 1083 return lifted; 1084 } 1085 1086 static Map<String,Object> resolvedBridge(BridgeDecl b, RestRequest req) { 1087 var m = b.toMap(); 1088 var session = (String)m.get("session"); 1089 if (nn(req)) 1090 m.put("session", req.getUriResolver().resolve(session)); 1091 else if (! session.startsWith("/")) 1092 throw iaex("BridgeDecl '%s' session '%s' needs a request to resolve.", b.id(), session); 1093 return m; 1094 } 1095 1096 /** 1097 * R-10 / R-11 over this spec alone, when it has no page template. With a page template the FTL may add 1098 * publishers or cards, so R-10 waits for {@code </@console>}. 1099 * 1100 * @param registry The console mixin's card-type registry. 1101 * @param req The current request, used to resolve {@code servlet:} / {@code context:} bridge sessions; may be <jk>null</jk> 1102 * only when no bridge uses one. 1103 * @throws IllegalArgumentException With the first wiring problem. 1104 */ 1105 void checkWiringEarly(CardTypeRegistry registry, RestRequest req) { 1106 if (template != null) 1107 return; 1108 var early = new PageCapture(); 1109 try { 1110 for (var c : cards) { 1111 var copy = c.copy(); 1112 if (tableBodies.containsKey(c.id())) 1113 copy.body("table", tableBodies.get(c.id())); 1114 early.addCard(copy, null); 1115 } 1116 } catch (TemplateModelException e) { 1117 throw new IllegalArgumentException(e.getMessage(), e); 1118 } 1119 for (var t : topics) 1120 early.addTopic(t.toMap(), "PageSpec.topic"); 1121 for (var b : bridges.values()) 1122 early.addBridge(resolvedBridge(b, req)); 1123 for (var d : dataSources.values()) 1124 early.addDataSource(new JsonMap(d)); 1125 if (dashboard != null) 1126 early.dashboard(dashboard); 1127 early.checkWiring(registry); 1128 } 1129}