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.UnaryOperator; 023 024import org.apache.juneau.rest.server.*; 025import org.apache.juneau.console.*; 026import org.apache.juneau.rest.server.staticfile.WebJarResolver; 027import org.apache.juneau.rest.server.view.freemarker.*; 028 029import freemarker.cache.*; 030import freemarker.template.*; 031 032/** 033 * A {@link FreemarkerMixin} that additionally makes the reserved-classpath admin-console chrome template 034 * ({@code org/apache/juneau/console/base.ftlh}) — and the {@code <@tag>} macro it defines — resolvable 035 * from any consumer template, without shadowing the consumer's own {@code basePath}-rooted template tree. 036 * 037 * <h5 class='section'>Used INSTEAD OF {@link FreemarkerMixin}, not beside it:</h5> 038 * <p> 039 * Register it typed as either {@link FreemarkerMixin} or {@code ConsoleFreemarkerMixin} — both declared 040 * return types work. {@code FreemarkerViewRenderer} finds the exact-type {@code FreemarkerMixin} bean when one is 041 * registered, and otherwise also looks for a bean declared under any registered subtype, which this class 042 * registers via a static initializer: 043 * 044 * <p class='bjava'> 045 * <ja>@Bean</ja> <jk>public</jk> FreemarkerMixin freemarker() { 046 * <jk>return</jk> ConsoleFreemarkerMixin.<jsm>create</jsm>().basePath(<js>"/templates/"</js>).build(); 047 * } 048 * </p> 049 * 050 * <p> 051 * or equivalently: 052 * 053 * <p class='bjava'> 054 * <ja>@Bean</ja> <jk>public</jk> ConsoleFreemarkerMixin freemarker() { 055 * <jk>return</jk> ConsoleFreemarkerMixin.<jsm>create</jsm>().basePath(<js>"/templates/"</js>).build(); 056 * } 057 * </p> 058 * 059 * <h5 class='section'>Consumer {@code /templates} is never shadowed:</h5> 060 * <p> 061 * The augmented {@link Configuration}'s {@code TemplateLoader} tries the consumer's own {@code basePath}-derived 062 * loader <b>first</b>; the reserved template is addressed by its full classpath-root-relative path 063 * ({@link #BASE_TEMPLATE_PATH}) on a second, classpath-<b>root</b>-rooted {@link ClassTemplateLoader}, so it cannot 064 * collide with a consumer path such as {@code /templates/base.ftlh}. Consumer templates include it with a 065 * loader-root-absolute path: 066 * 067 * <p class='bftl'> 068 * <#include "/org/apache/juneau/console/base.ftlh"> 069 * </p> 070 * 071 * <h5 class='section'>Consumer-supplied {@code Configuration} is the same instance, then fill-missing stamped:</h5> 072 * <p> 073 * When the request's {@code BeanStore} already has a {@code Configuration} bean (Spring/Spring-Boot autoconfig, or 074 * a microservice {@code BasicBeanStore.put}), {@link #resolveConfiguration} still returns that <b>same instance</b> 075 * ({@code ==}) so consumer settings win (encoding, {@code ObjectWrapper}, {@code exposeFields}, template-update 076 * delay, output format). It then fill-missing-stamps the reserved console shared variables 077 * ({@code page}, {@code card}, {@code navigation}, {@code node}, {@code theme}, {@code token}, {@code console}, 078 * {@code main}, the {@code <@console>} slot names {@code head}/{@code scripts}/{@code brand}/{@code actions}/ 079 * {@code title}/{@code footer}/{@code body}, and {@code jcTagHtml}) and splices the 080 * reserved-path console loader only when {@link #BASE_TEMPLATE_PATH} would not already resolve. A name the 081 * consumer already set is left alone. There is no opt-out flag on this class — opt out by registering 082 * {@link FreemarkerMixin} instead of {@code ConsoleFreemarkerMixin}. 083 * 084 * @since 10.0.0 085 */ 086@SuppressWarnings({ 087 "java:S3776" // Validation and rendering chains many independent guards; splitting them adds no clarity. 088}) 089public class ConsoleFreemarkerMixin extends FreemarkerMixin { 090 091 static { 092 FreemarkerMixin.registerSubtype(ConsoleFreemarkerMixin.class); 093 } 094 095 /** 096 * The reserved classpath-root-relative location of the shipped console chrome template 097 * (defines the {@code <@tag>} macro). Load-bearing for the collision-free-namespacing argument in the class 098 * javadoc — do not shorten. 099 */ 100 public static final String BASE_TEMPLATE_PATH = "org/apache/juneau/console/base.ftlh"; 101 102 /** Default consumer chrome template included by {@code <@page>} when none is configured. */ 103 public static final String DEFAULT_CHROME_TEMPLATE = "base.ftlh"; 104 105 private final String chromeTemplate; 106 private final ToolkitPackRegistry packs; 107 private final CardRequirements cardRequirements; 108 private final CardTypeRegistry cardTypes; 109 private final boolean devMode; 110 private final ClassLoader adopterLoader; 111 private final String adopterRoot; 112 113 /** 114 * No-arg constructor — mirrors {@link FreemarkerMixin#FreemarkerMixin()} so the mixin walk's 115 * {@code BeanInstantiator} is not forced to depend on builder-detection alone. 116 */ 117 public ConsoleFreemarkerMixin() { 118 this(create()); 119 } 120 121 /** 122 * Builder constructor. 123 * 124 * @param builder The builder. Must not be {@code null}. 125 * @throws IllegalArgumentException If the toolkit packs, provided packs or card requirements don't validate. 126 * @throws IllegalStateException E-70 if a required pack reaches a WebJar that is not on the classpath 127 * (see {@link Builder#requirePacks(String...)}). 128 */ 129 protected ConsoleFreemarkerMixin(Builder builder) { 130 super(builder); 131 this.chromeTemplate = builder.chromeTemplate; 132 this.packs = new ToolkitPackRegistry(); 133 builder.extraPacks.forEach(packs::register); 134 packs.provide(builder.providedPacks).validate(); 135 packs.requireKnown(builder.requiredPacks, "requirePacks"); 136 var roots = new LinkedHashSet<>(builder.requiredPacks); 137 for (var p : builder.extraPacks) 138 if (packs.reaches(p.name(), ToolkitPackRegistry.PACK_ECHARTS)) 139 roots.add(p.name()); 140 packs.checkWebJars(roots, builder.webJarProbe); 141 this.cardRequirements = builder.cardRequirements.build(packs); 142 this.cardTypes = builder.cardTypes.build(); 143 this.devMode = builder.devMode; 144 this.adopterLoader = builder.adopterLoader; 145 this.adopterRoot = builder.adopterRoot; 146 } 147 148 /** @return The configured consumer chrome template name; read by {@code PageSpec.view()} when no template is set. */ 149 String chromeTemplate() { return chromeTemplate; } 150 151 /** @return The card-type registry, including custom {@code Builder.cardType(...)} handlers. */ 152 CardTypeRegistry cardTypes() { return cardTypes; } 153 154 /** 155 * Creates a new builder. 156 * 157 * @return A new builder. 158 */ 159 @SuppressWarnings({ 160 "java:S9149" // Intentional per-subclass builder-factory override matching FreemarkerMixin.create()'s own convention; each mixin subclass returns its own nested Builder type. 161 }) 162 public static Builder create() { 163 return new Builder(); 164 } 165 166 // Double-checked-locking identity cache: resolveConfiguration is called once per request, and 167 // super.resolveConfiguration(req) returns the SAME Configuration object on every call for a given mixin 168 // instance (consumer bean or FreemarkerDispatcher's lazy default cache). Tracking that object's identity 169 // here means "have I already stamped THIS exact object" is answered in O(1). Presence checks on the loader 170 // and shared-variable names still run on a cache miss so N Rest resources sharing one Spring singleton 171 // do not nest Multi(Multi(base, console), console). 172 @SuppressWarnings({ 173 "java:S3077" // volatile is required here for correct double-checked-locking safe-publication; the reference is publish-once (fully constructed/wrapped before assignment) and never compound-mutated. 174 }) 175 private volatile Configuration wrappedConfiguration; 176 177 /** 178 * Resolves the active {@link Configuration} and fill-missing-stamps console shared variables onto it. 179 * 180 * <p> 181 * Always delegates to {@code super.resolveConfiguration(req)} first so a consumer {@code Configuration} bean 182 * wins for settings. Then: 183 * <ol class='spaced-list'> 184 * <li><b>Fill-missing names:</b> {@code page}, {@code card}, {@code navigation}, {@code node}, 185 * {@code theme}, {@code token}, {@code console}, {@code main}, the {@code <@console>} slot names 186 * ({@code head}, {@code scripts}, {@code brand}, {@code actions}, {@code title}, {@code footer}, 187 * {@code body}), and {@code jcTagHtml} are set only when {@code getSharedVariable(name)} is unset. A 188 * consumer value is kept. 189 * <li><b>Loader splice:</b> the classpath-root console {@link ClassTemplateLoader} is wrapped in only when 190 * {@link #BASE_TEMPLATE_PATH} would not already resolve through the current loader. 191 * <li><b>Wrap-once + presence:</b> identity-cached per mixin instance so a second call does not 192 * re-wrap; presence checks so N Rest resources sharing one Spring singleton {@code Configuration} do 193 * not nest {@link MultiTemplateLoader}. 194 * </ol> 195 * 196 * @param req The current REST request. 197 * @return The active FreeMarker configuration. Never {@code null}. 198 */ 199 @Override 200 public Configuration resolveConfiguration(RestRequest req) { 201 var base = super.resolveConfiguration(req); 202 if (base == wrappedConfiguration) 203 return base; 204 synchronized (this) { 205 if (base != wrappedConfiguration) { 206 spliceConsoleLoaderIfNeeded(base); 207 fillMissingConsoleSharedVariables(base); 208 wrappedConfiguration = base; 209 } 210 } 211 return base; 212 } 213 214 /** 215 * Wraps the current {@link TemplateLoader} with a classpath-root console loader only when 216 * {@link #BASE_TEMPLATE_PATH} would not resolve. Presence-checked so a second mixin instance sharing the 217 * same {@code Configuration} does not nest {@link MultiTemplateLoader}. 218 */ 219 private void spliceConsoleLoaderIfNeeded(Configuration cfg) { 220 if (reservedTemplateResolves(cfg)) 221 return; 222 var console = new ClassTemplateLoader(getClass().getClassLoader(), ""); 223 var existing = cfg.getTemplateLoader(); 224 if (n(existing)) 225 cfg.setTemplateLoader(console); 226 else 227 cfg.setTemplateLoader(new MultiTemplateLoader(new TemplateLoader[]{ existing, console })); 228 } 229 230 /** 231 * Whether the reserved console chrome template is already visible on {@code cfg}'s loader. Uses 232 * {@link TemplateLoader#findTemplateSource(String)} so a miss is not cached as a failed 233 * {@link Configuration#getTemplate(String)}. 234 */ 235 private static boolean reservedTemplateResolves(Configuration cfg) { 236 var loader = cfg.getTemplateLoader(); 237 if (n(loader)) 238 return false; 239 try { 240 var source = loader.findTemplateSource(BASE_TEMPLATE_PATH); 241 if (n(source)) 242 return false; 243 loader.closeTemplateSource(source); 244 return true; 245 } catch (@SuppressWarnings("unused") Exception e) { 246 return false; // Loader probe failed; treat as unresolved and splice. 247 } 248 } 249 250 /** 251 * Sets each reserved console shared variable only when that name is unset on {@code cfg}. 252 */ 253 private void fillMissingConsoleSharedVariables(Configuration cfg) { 254 if (n(cfg.getSharedVariable(TagMethodModel.NAME))) 255 cfg.setSharedVariable(TagMethodModel.NAME, new TagMethodModel()); 256 if (n(cfg.getSharedVariable(PageDirectiveModel.NAME))) { 257 cfg.setSharedVariable(PageDirectiveModel.NAME, new PageDirectiveModel(chromeTemplate, packs)); 258 } 259 if (n(cfg.getSharedVariable(CardDirectiveModel.NAME))) 260 cfg.setSharedVariable(CardDirectiveModel.NAME, new CardDirectiveModel(cardRequirements, cardTypes)); 261 if (n(cfg.getSharedVariable(NavigationDirectiveModel.NAME))) 262 cfg.setSharedVariable(NavigationDirectiveModel.NAME, new NavigationDirectiveModel()); 263 if (n(cfg.getSharedVariable(NodeDirectiveModel.NAME))) 264 cfg.setSharedVariable(NodeDirectiveModel.NAME, new NodeDirectiveModel()); 265 if (n(cfg.getSharedVariable(ThemeDirectiveModel.NAME))) 266 cfg.setSharedVariable(ThemeDirectiveModel.NAME, new ThemeDirectiveModel()); 267 if (n(cfg.getSharedVariable(TokenDirectiveModel.NAME))) 268 cfg.setSharedVariable(TokenDirectiveModel.NAME, new TokenDirectiveModel()); 269 if (n(cfg.getSharedVariable(ConsoleDirectiveModel.NAME))) 270 cfg.setSharedVariable(ConsoleDirectiveModel.NAME, new ConsoleDirectiveModel(devMode, cardTypes, packs)); 271 if (n(cfg.getSharedVariable(MainDirectiveModel.NAME))) 272 cfg.setSharedVariable(MainDirectiveModel.NAME, new MainDirectiveModel()); 273 if (n(cfg.getSharedVariable(HasToolkitMethodModel.NAME))) 274 cfg.setSharedVariable(HasToolkitMethodModel.NAME, new HasToolkitMethodModel()); 275 if (nn(adopterLoader) && n(cfg.getSharedVariable(AssetUrlMethodModel.NAME))) 276 cfg.setSharedVariable(AssetUrlMethodModel.NAME, new AssetUrlMethodModel(adopterLoader, adopterRoot, devMode)); 277 // The seven capture-only slot directives share one parameterized class, one instance registered per slot name. 278 for (var slot : ConsoleSlotDirectiveModel.SLOT_NAMES) 279 if (n(cfg.getSharedVariable(slot))) 280 cfg.setSharedVariable(slot, new ConsoleSlotDirectiveModel(slot)); 281 if (n(cfg.getSharedVariable(TopicDirectiveModel.NAME))) 282 cfg.setSharedVariable(TopicDirectiveModel.NAME, new TopicDirectiveModel()); 283 if (n(cfg.getSharedVariable(BridgeDirectiveModel.NAME))) 284 cfg.setSharedVariable(BridgeDirectiveModel.NAME, new BridgeDirectiveModel()); 285 if (n(cfg.getSharedVariable(BadgeDirectiveModel.NAME))) 286 cfg.setSharedVariable(BadgeDirectiveModel.NAME, new BadgeDirectiveModel()); 287 if (n(cfg.getSharedVariable(FactsDirectiveModel.NAME))) 288 cfg.setSharedVariable(FactsDirectiveModel.NAME, new FactsDirectiveModel()); 289 } 290 291 /** 292 * Builder for {@link ConsoleFreemarkerMixin}. 293 */ 294 public static class Builder extends FreemarkerMixin.Builder { 295 296 String chromeTemplate = DEFAULT_CHROME_TEMPLATE; 297 final List<ToolkitPack> extraPacks = new ArrayList<>(); 298 final List<String> providedPacks = new ArrayList<>(); 299 final List<String> requiredPacks = new ArrayList<>(); 300 UnaryOperator<String> webJarProbe = WebJarResolver::resolvePath; 301 final CardRequirements.Builder cardRequirements = CardRequirements.create(); 302 final CardTypeRegistry.Builder cardTypes = CardTypeRegistry.standard().copy(); 303 boolean devMode = Boolean.getBoolean("juneau.console.devMode"); 304 ClassLoader adopterLoader; 305 String adopterRoot = ""; 306 307 /** Constructor — package access for {@link ConsoleFreemarkerMixin#create()}. */ 308 protected Builder() {} 309 310 /** 311 * {@inheritDoc} 312 * 313 * <p> 314 * Covariantly narrowed to this {@code Builder} so a fluent chain can mix inherited setters 315 * (e.g. {@link #basePath(String)}) with the console-specific {@link #chromeTemplate(String)} / 316 * {@link #registerToolkitPack(ToolkitPack)} in any order. 317 */ 318 @Override 319 public Builder basePath(String value) { 320 super.basePath(value); 321 return this; 322 } 323 324 /** 325 * Sets the consumer chrome template that {@code <@page>} includes after capturing its body. 326 * 327 * <p> 328 * Resolved on the consumer's own {@code basePath}-rooted loader. Defaults to 329 * {@link ConsoleFreemarkerMixin#DEFAULT_CHROME_TEMPLATE}. 330 * 331 * @param chromeTemplate The chrome template name. Must not be {@code null}. 332 * @return This object. 333 */ 334 public Builder chromeTemplate(String chromeTemplate) { 335 this.chromeTemplate = chromeTemplate; 336 return this; 337 } 338 339 /** 340 * Registers an app-supplied {@link ToolkitPack.Kind#RUNTIME} toolkit pack with no dependencies. 341 * 342 * <p> 343 * Same as {@link #registerToolkitPack(ToolkitPack)} with {@code kind(RUNTIME)} and the default resolver. 344 * 345 * @param name The pack name. 346 * @param cssPaths Ordered CSS asset paths. 347 * @param jsPaths Ordered JS asset paths. 348 * @return This object. 349 */ 350 public Builder registerToolkitPack(String name, List<String> cssPaths, List<String> jsPaths) { 351 return registerToolkitPack(ToolkitPack.create(name).css(cssPaths).js(jsPaths).kind(ToolkitPack.Kind.RUNTIME).build()); 352 } 353 354 /** 355 * Registers an app-supplied toolkit pack. 356 * 357 * <p> 358 * A pack with the name of a built-in or earlier pack replaces it completely, including its 359 * {@code dependsOn}. {@link #build()} validates the pack graph. 360 * 361 * @param pack The pack. 362 * @return This object. 363 */ 364 public Builder registerToolkitPack(ToolkitPack pack) { 365 extraPacks.add(rnn(pack)); 366 return this; 367 } 368 369 /** 370 * Names packs the app's chrome already loads. A provided pack contributes no URLs; its dependencies still load 371 * unless they are provided too. 372 * 373 * @param names Registered pack names. 374 * @return This object. 375 */ 376 public Builder providedPacks(String...names) { 377 providedPacks.addAll(Arrays.asList(names)); 378 return this; 379 } 380 381 /** 382 * Opts in to the startup WebJar check: the named packs (for example {@code "charts"}) are expanded through 383 * {@code dependsOn} and every WebJar they reach is probed when the mixin is built, so a missing 384 * {@code org.webjars.npm:echarts} fails at startup (E-70) instead of on the first chart page. An app pack 385 * registered with {@link #registerToolkitPack(ToolkitPack)} that depends on {@code "echarts"} opts in too. 386 * 387 * @param names Registered pack names. 388 * @return This object. 389 */ 390 public Builder requirePacks(String...names) { 391 requiredPacks.addAll(Arrays.asList(names)); 392 return this; 393 } 394 395 // Package-private: tests simulate an absent WebJar (the jar is on every test classpath). 396 Builder webJarProbe(UnaryOperator<String> probe) { 397 webJarProbe = rnn(probe); 398 return this; 399 } 400 401 /** 402 * Adds packs every card of a type requires, after the built-in one 403 * ({@code datatables} → {@code "datatables-glue"}). 404 * 405 * @param type The card type. 406 * @param packs Registered pack names. 407 * @return This object. 408 */ 409 public Builder cardRequires(String type, String...packs) { 410 cardRequirements.add(type, packs); 411 return this; 412 } 413 414 /** 415 * Registers a server-side card type handler for this mixin's {@code <@card>} directive, on top of 416 * {@link CardTypeRegistry#standard()}. 417 * 418 * <p> 419 * Most custom cards need only a JS handler; register a {@link CardTypeHandler} here when the server must 420 * validate or enrich the body. To also load toolkit packs for the type, use {@link #cardRequires(String, String...)}. 421 * 422 * @param handler The handler. Must not be <jk>null</jk>. 423 * @return This object. 424 * @throws IllegalArgumentException E-21 bad type name; E-22 reserved type; E-20 already registered. 425 */ 426 public Builder cardType(CardTypeHandler handler) { 427 cardTypes.add(handler); 428 return this; 429 } 430 431 /** 432 * Validates every page contract against {@code juneau-page.schema.json} when {@code <@console>} closes, and 433 * fails the render with E-14 on any finding. 434 * 435 * <p> 436 * Defaults to the {@code juneau.console.devMode} system property. Intended for development and tests; it parses 437 * and validates the contract on every render. 438 * 439 * <h5 class='section'>Example:</h5> 440 * <p class='bjava'> 441 * <ja>@Bean</ja> <jk>public</jk> FreemarkerMixin freemarker() { 442 * <jk>return</jk> ConsoleFreemarkerMixin.<jsm>create</jsm>().devMode(<jk>true</jk>).basePath(<js>"/templates/"</js>).build(); 443 * } 444 * </p> 445 * 446 * @param value Whether to validate. 447 * @return This object. 448 */ 449 public Builder devMode(boolean value) { 450 devMode = value; 451 return this; 452 } 453 454 /** 455 * Enables the {@code assetUrl(path)} template function that versions the adopter's own static assets. 456 * 457 * <p> 458 * {@code assetUrl('/js/app.js')} returns {@code /js/app.js?v=<crc32 of the bundled bytes>}, where the bytes are 459 * read once (and the token cached) from {@code resourceRoot + path} on {@code loader}. This is the adopter-side 460 * counterpart of Juneau's own {@code viewAssetUrl}/{@code consoleJsUrl}/{@code chromeCssUrl}/{@code themeAssetUrl} 461 * cache-busters, for files the servlet container's static handler serves. A path with a query string, a 462 * non-root-absolute path (e.g. an external URL), or a path naming no bundled resource is returned unchanged 463 * (the last also logs a warning in {@link #devMode(boolean) dev mode}). The URL path itself is never rewritten, 464 * so any context-path prefix must be added by the template. 465 * 466 * <h5 class='section'>Example:</h5> 467 * <p class='bjava'> 468 * <ja>@Bean</ja> <jk>public</jk> FreemarkerMixin freemarker() { 469 * <jk>return</jk> ConsoleFreemarkerMixin.<jsm>create</jsm>() 470 * .basePath(<js>"/templates/"</js>) 471 * .adopterAssets(getClass().getClassLoader(), <js>"static"</js>) <jc>// /js/app.js -> classpath static/js/app.js</jc> 472 * .build(); 473 * } 474 * </p> 475 * <p class='bftl'> 476 * <script src="${assetUrl('/js/app.js')}"></script> 477 * </p> 478 * 479 * @param loader The class loader that holds the assets. Must not be {@code null}. 480 * @param resourceRoot The classpath directory the URL paths are relative to (e.g. {@code "static"}); a leading 481 * and trailing slash are optional. Must not be {@code null}. 482 * @return This object. 483 */ 484 public Builder adopterAssets(ClassLoader loader, String resourceRoot) { 485 this.adopterLoader = rnn(loader); 486 this.adopterRoot = rnn(resourceRoot); 487 return this; 488 } 489 490 /** 491 * Builds the {@link ConsoleFreemarkerMixin}. 492 * 493 * <p> 494 * Overrides {@link org.apache.juneau.rest.server.view.freemarker.FreemarkerMixin.Builder#build() FreemarkerMixin.Builder#build()} — 495 * without this override, the inherited method would silently return a plain {@link FreemarkerMixin}, not 496 * a {@link ConsoleFreemarkerMixin}. 497 * 498 * @return A new {@link ConsoleFreemarkerMixin} instance. 499 */ 500 @Override 501 public ConsoleFreemarkerMixin build() { 502 return new ConsoleFreemarkerMixin(this); 503 } 504 } 505}