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}) &mdash; and the {@code <@tag>} macro it defines &mdash; 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} &mdash; 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 *    &lt;#include "/org/apache/juneau/console/base.ftlh"&gt;
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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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} &rarr; {@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 -&gt; classpath static/js/app.js</jc>
472       *          .build();
473       *    }
474       * </p>
475       * <p class='bftl'>
476       *    &lt;script src="${assetUrl('/js/app.js')}"&gt;&lt;/script&gt;
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()} &mdash;
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}