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.rest.server.staticfile;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.io.*;
022import java.util.*;
023import java.util.concurrent.*;
024
025import org.apache.juneau.rest.server.*;
026
027/**
028 * Resolves a WebJar file to a cache-busted URL under the {@link WebJarsMixin} mount.
029 *
030 * <p>
031 * An asset is named {@code groupId:artifactId:path} (see {@link #asset(String, String, String)}).  The path is
032 * relative to {@code META-INF/resources/webjars/} and may contain {@link #VERSION_TOKEN}, which is replaced with the
033 * version read from the WebJar's own {@code META-INF/maven/<groupId>/<artifactId>/pom.properties}.  The URL gets a
034 * {@code ?v=<version>} cache-buster, so a version bump in the app's pom changes every URL.
035 *
036 * <p>
037 * A missing WebJar, or a missing file in a present WebJar, throws {@link IllegalStateException}, so a page never
038 * renders a tag that would 404.  Versions and file checks are cached for the life of the class loader.
039 *
040 * <h5 class='section'>Example:</h5>
041 * <p class='bjava'>
042 *    String <jv>asset</jv> = WebJarResolver.<jsm>asset</jsm>(<js>"org.webjars"</js>, <js>"jquery"</js>, <js>"jquery/{version}/jquery.min.js"</js>);
043 *    String <jv>url</jv> = WebJarResolver.<jsm>resolve</jsm>(<jv>req</jv>, <jv>asset</jv>);  <jc>// .../webjars/jquery/3.7.1/jquery.min.js?v=3.7.1</jc>
044 * </p>
045 *
046 * @since 10.0.0
047 */
048public final class WebJarResolver {
049
050   /** Classpath root of every WebJar's files. */
051   public static final String WEBJARS_ROOT = "META-INF/resources/webjars/";
052
053   /** Placeholder in an asset path that is replaced with the WebJar's version. */
054   public static final String VERSION_TOKEN = "{version}";
055
056   private static final ClassLoader LOADER = WebJarResolver.class.getClassLoader();
057   private static final Map<String,String> VERSIONS = new ConcurrentHashMap<>();
058   private static final Map<String,Boolean> PRESENT = new ConcurrentHashMap<>();
059
060   private WebJarResolver() {}
061
062   /**
063    * Builds an asset id.
064    *
065    * @param groupId The WebJar group id, e.g. {@code org.webjars}.
066    * @param artifactId The WebJar artifact id, e.g. {@code jquery}.
067    * @param path The file path under {@code META-INF/resources/webjars/}, optionally containing {@link #VERSION_TOKEN}.
068    * @return The asset id {@code groupId:artifactId:path}.
069    */
070   public static String asset(String groupId, String artifactId, String path) {
071      return groupId + ":" + artifactId + ":" + path;
072   }
073
074   /**
075    * Returns the version of a WebJar on the classpath.
076    *
077    * @param groupId The WebJar group id.
078    * @param artifactId The WebJar artifact id.
079    * @return The version from the WebJar's {@code pom.properties}.
080    * @throws IllegalStateException If the WebJar isn't on the classpath or names no version.
081    */
082   public static String version(String groupId, String artifactId) {
083      var key = groupId + ":" + artifactId;
084      return VERSIONS.computeIfAbsent(key, k -> readVersion(groupId, artifactId));
085   }
086
087   /**
088    * Resolves an asset id to its file path under {@code META-INF/resources/webjars/}, checking that the file exists.
089    *
090    * @param asset The asset id (see {@link #asset(String, String, String)}).
091    * @return The path with {@link #VERSION_TOKEN} filled in.
092    * @throws IllegalArgumentException If the asset id is malformed.
093    * @throws IllegalStateException If the WebJar or the file isn't on the classpath.
094    */
095   public static String resolvePath(String asset) {
096      var a = parse(asset);
097      var version = version(a[0], a[1]);
098      var path = a[2].replace(VERSION_TOKEN, version);
099      if (! b(PRESENT.computeIfAbsent(path, k -> LOADER.getResource(WEBJARS_ROOT + k) != null)))
100         throw new IllegalStateException(f("needs WebJar file '%s' (%s:%s:%s), which is not on the classpath.", path, a[0], a[1], version));
101      return path;
102   }
103
104   /**
105    * Resolves an asset id to an absolute, cache-busted URL under the {@link WebJarsMixin} mount of the request's servlet.
106    *
107    * @param req The in-flight request.
108    * @param asset The asset id (see {@link #asset(String, String, String)}).
109    * @return The URL.
110    */
111   public static String resolve(RestRequest req, String asset) {
112      var a = parse(asset);
113      var path = resolvePath(asset);
114      return req.getUriResolver().resolve("servlet:" + WebJarsMixin.PATH + "/" + path) + "?v=" + version(a[0], a[1]);
115   }
116
117   private static String readVersion(String groupId, String artifactId) {
118      var props = "META-INF/maven/" + groupId + "/" + artifactId + "/pom.properties";
119      try (var in = LOADER.getResourceAsStream(props)) {
120         if (in == null)
121            throw new IllegalStateException(f("needs %s:%s on the classpath (no %s).", groupId, artifactId, props));
122         var p = new Properties();
123         p.load(in);
124         var v = p.getProperty("version", "").trim();
125         if (v.isEmpty())
126            throw new IllegalStateException(f("needs %s:%s on the classpath (%s has no version).", groupId, artifactId, props));
127         return v;
128      } catch (IOException e) {
129         throw new UncheckedIOException(e);
130      }
131   }
132
133   private static String[] parse(String asset) {
134      var a = asset == null ? new String[0] : asset.split(":", 3);
135      if (a.length != 3 || a[0].isBlank() || a[1].isBlank() || a[2].isBlank())
136         throw new IllegalArgumentException(f("WebJar asset must be 'groupId:artifactId:path'; got '%s'.", asset));
137      return a;
138   }
139}