java/gust/backend/PageContextManager.java
/*
* Copyright © 2020, The Gust Framework Authors. All rights reserved.
*
* The Gust/Elide framework and tools, and all associated source or object computer code, except where otherwise noted,
* are licensed under the Zero Prosperity license, which is enclosed in this repository, in the file LICENSE.txt. Use of
* this code in object or source form requires and implies consent and agreement to that license in principle and
* practice. Source or object code not listing this header, or unless specified otherwise, remain the property of
* Elide LLC and its suppliers, if any. The intellectual and technical concepts contained herein are proprietary to
* Elide LLC and its suppliers and may be covered by U.S. and Foreign Patents, or patents in process, and are protected
* by trade secret and copyright law. Dissemination of this information, or reproduction of this material, in any form,
* is strictly forbidden except in adherence with assigned license requirements.
*/
package gust.backend;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableSortedSet;
import com.google.common.collect.Sets;
import com.google.common.html.types.TrustedResourceUrlProto;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.protobuf.ByteString;
import com.google.template.soy.msgs.SoyMsgBundle;
import com.google.template.soy.data.SanitizedContent;
import com.google.template.soy.data.UnsafeSanitizedContentOrdainer;
import gust.backend.runtime.AssetManager;
import gust.backend.runtime.AssetManager.ManagedAsset;
import gust.backend.runtime.Logging;
import gust.util.Hex;
import io.micronaut.http.*;
import io.micronaut.http.context.ServerRequestContext;
import io.micronaut.runtime.http.scope.RequestScope;
import io.micronaut.views.soy.SoyContext;
import io.micronaut.views.soy.SoyNamingMapProvider;
import org.slf4j.Logger;
import tools.elide.assets.AssetBundle.StyleBundle.StyleAsset;
import tools.elide.assets.AssetBundle.ScriptBundle.ScriptAsset;
import tools.elide.page.Context;
import tools.elide.page.Context.ClientHint;
import tools.elide.page.Context.FramingPolicy;
import tools.elide.page.Context.ClientHints;
import tools.elide.page.Context.ConnectionHint;
import tools.elide.page.Context.ReferrerPolicy;
import tools.elide.page.Context.Styles.Stylesheet;
import tools.elide.page.Context.Scripts.JavaScript;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.security.MessageDigest;
import java.util.*;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import static java.lang.String.format;
/**
* Manages the process of filling out {@link PageContext} objects before they are sealed, and delivered to Closure/Soy
* to be reduced and rendered into content.
*
* <p>This object may be used from controllers via dependency injection, or used via the base controller classes
* provided as part of the framework.</p>
*/
@RequestScope
@SuppressWarnings("unused")
public class PageContextManager implements Closeable, AutoCloseable, PageRender {
private static final Logger logging = Logging.logger(PageContextManager.class);
private static final int ETAG_LENGTH = 6;
private static final String DPR_HEADER = "DPR";
private static final String ECT_HEADER = "ECT";
private static final String RTT_HEADER = "RTT";
private static final String LINK_HEADER = "Link";
private static final String MEMORY_HEADER = "Device-Memory";
private static final String DOWNLINK_HEADER = "Downlink";
private static final String VIEWPORT_WIDTH_HEADER = "Viewport-Width";
private static final String ACCEPT_CH_HEADER = "Accept-CH";
private static final String ACCEPT_CH_LIFETIME_HEADER = "Accept-CH-Lifetime";
private static final String FEATURE_POLICY_HEADER = "Feature-Policy";
private static final String X_FRAME_OPTIONS_HEADER = "X-Frame-Options";
private static final String X_CONTENT_TYPE_OPTIONS_HEADER = "X-Content-Type-Options";
private static final String X_XSS_PROTECTION_HEADER = "X-XSS-Protection";
private static final String LINK_DNS_PREFETCH_TOKEN = "dns-prefetch";
private static final String LINK_PRECONNECT_TOKEN = "preconnect";
private static final String REFERRER_POLICY_HEADER = "Referrer-Policy";
private static final ConnectionHint DEFAULT_ECT = ConnectionHint.FAST;
private static final String CDN_PREFIX_IJ_PROP = "cdn_prefix";
private static final String LIVE_RELOAD_TARGET_PROP = "live_reload_url";
private static final String LIVE_RELOAD_SWITCH_PROP = "live_reload_enabled";
private static final String LIVE_RELOAD_JS = "http://localhost:35729/livereload.js";
/** Access to the asset manager. */
private final @Nonnull AssetManager assetManager;
/** Page context builder. */
private final @Nonnull Context.Builder context;
/** Content type to serve from this endpoint. */
private @Nonnull String contentTypeValue = MediaType.TEXT_HTML;
/** HTTP request bound to this flow. */
@SuppressWarnings("rawtypes")
private final @Nonnull HttpRequest request;
/** Set of interpreted/immutable client hints. */
private final @Nonnull ClientHints hints;
/** Main properties to apply during Soy render. */
private final @Nonnull ConcurrentMap<String, Object> props;
/** Additional injected values to apply during Soy render. */
private final @Nonnull ConcurrentMap<String, Object> injected;
/** Set of headers that cause this response flow to vary. */
private final @Nonnull SortedSet<String> varySegments;
/** Naming map provider to apply during the Soy render flow. */
private @Nonnull Optional<SoyNamingMapProvider> namingMapProvider;
/** Predicate for selecting a Soy delegate package, if applicable. */
private @Nonnull Optional<Predicate<String>> delegatePredicate;
/** Translation file to apply during the Soy render flow. */
private @Nonnull Optional<File> translationsFile = Optional.empty();
/** Translation resource to apply during the Soy render flow. */
private @Nonnull Optional<URL> translationsResource = Optional.empty();
/** Pre-constructed message bundle to apply during the Soy render flow. */
private @Nonnull Optional<SoyMsgBundle> messageBundle = Optional.empty();
/** Built context: assembled when we "close" the page context manager. */
private @Nullable PageContext builtContext = null;
/** Whether to enable live-reload mode or not. */
private final boolean liveReload;
/** Whether we have closed context building or not. */
private @Nonnull final AtomicBoolean closed = new AtomicBoolean(false);
/** CDN prefix to apply to this HTTP cycle. */
private @Nonnull volatile Optional<String> cdnPrefix = Optional.empty();
/**
* Constructor for page context.
*
* @param assetManager Manager for static embedded application assets.
* @param namingMapProvider Style renaming map provider, if applicable.
* @throws IllegalStateException If an attempt is made to construct context outside of a server-side HTTP flow.
*/
PageContextManager(@Nonnull AssetManager assetManager,
@Nonnull Optional<SoyNamingMapProvider> namingMapProvider) {
if (logging.isDebugEnabled()) logging.debug("Initializing `PageContextManager`.");
//noinspection SimplifyOptionalCallChains
if (!ServerRequestContext.currentRequest().isPresent())
throw new IllegalStateException("Cannot construct `PageContext` outside of a server-side HTTP flow.");
this.request = ServerRequestContext.currentRequest().get();
this.context = interpretRequest(request);
this.hints = this.context.getHintsBuilder().build();
this.props = new ConcurrentSkipListMap<>();
this.injected = new ConcurrentSkipListMap<>();
this.varySegments = new ConcurrentSkipListSet<>();
this.namingMapProvider = namingMapProvider;
this.assetManager = assetManager;
// inject live-reload state
this.liveReload = "enabled".equals(System.getProperty("LIVE_RELOAD"));
this.injected.put(LIVE_RELOAD_SWITCH_PROP, this.liveReload);
if (this.liveReload) {
logging.info("Live-reload is currently ENABLED.");
this.injected.put(LIVE_RELOAD_TARGET_PROP, UnsafeSanitizedContentOrdainer.ordainAsSafe(
LIVE_RELOAD_JS,
SanitizedContent.ContentKind.TRUSTED_RESOURCE_URI)
.toTrustedResourceUrlProto());
}
}
/**
* Interpret the provided HTTP request, reading any properties and hints that are specified. For instance, this is
* where we can load/interpret <i>Client Hints</i> provided by the browser, as applicable.
*
* @param request HTTP request to interpret/read.
* @return Context builder to use, factoring in the provided request.
*/
@VisibleForTesting
@SuppressWarnings({"WeakerAccess", "rawtypes"})
static @Nonnull Context.Builder interpretRequest(@Nonnull HttpRequest request) {
Context.Builder builder = Context.newBuilder();
interpretHints(request, builder);
return builder;
}
/**
* Interpret <i>Client Hints</i> headers from the provided HTTP request, and apply them to the provided context.
*
* @param request HTTP request to interpret hints from.
* @param builder Builder to apply the hints to, if found.
*/
@VisibleForTesting
@SuppressWarnings({"WeakerAccess", "rawtypes"})
static void interpretHints(@Nonnull HttpRequest request, @Nonnull Context.Builder builder) {
HttpHeaders headers = request.getHeaders();
Context.ClientHints.Builder hints = builder.getHintsBuilder();
checkHint(ClientHint.DPR, headers, hints, (value) -> hints.setDevicePixelRatio(Integer.parseUnsignedInt(value)));
checkHint(ClientHint.ECT, headers, hints, (value) ->
hints.setEffectiveConnectionType(connectionHintForECT(value).orElse(DEFAULT_ECT)));
checkHint(ClientHint.RTT, headers, hints, (value) -> hints.setRoundTripTime(Integer.parseUnsignedInt(value)));
checkHint(ClientHint.DOWNLINK, headers, hints, (value) -> hints.setDownlink(Float.parseFloat(value)));
checkHint(ClientHint.DEVICE_MEMORY, headers, hints, (value) -> hints.setDeviceMemory(Float.parseFloat(value)));
checkHint(ClientHint.SAVE_DATA, headers, hints, (value) -> hints.setSaveData(true));
checkHint(ClientHint.WIDTH, headers, hints, (value) -> hints.setWidth(Integer.parseUnsignedInt(value)));
checkHint(ClientHint.VIEWPORT_WIDTH, headers, hints,
(value) -> hints.setViewportWidth(Integer.parseUnsignedInt(value)));
}
/**
* Check the provided set of HTTP headers for the specified client hint. If a value is found, dispatch the provided
* function with the hint, and the discovered header value.
*
* @param hint Client hint to check for in the specified headers.
* @param headers HTTP request headers to check.
* @param callable Callable to dispatch if the header is found.
*/
@VisibleForTesting
@SuppressWarnings("WeakerAccess")
static void checkHint(@Nonnull ClientHint hint,
@Nonnull HttpHeaders headers,
@Nonnull ClientHints.Builder hints,
Consumer<String> callable) {
String headerName = clientHintForEnum(hint);
if (headers.contains(headerName)) {
String headerValue = headers.get(headerName);
if (headerValue != null && !headerValue.isEmpty()) {
try {
hints.addIndicated(hint);
callable.accept(headerValue);
} catch (IllegalArgumentException iae) {
logging.warn(format("Failed to parse client hint '%s': %s.", hint.name(), iae.getMessage()));
}
}
}
}
/**
* Resolve an enumerated connection hint for the provided connection hint name, which was presumably found in a
* <i>Client Hints</i> Effective Connection Type header value.
*
* @param value Value to interpret.
* @return Connection hint type, resolved.
*/
@VisibleForTesting
@SuppressWarnings("WeakerAccess")
static @Nonnull Optional<ConnectionHint> connectionHintForECT(@Nonnull String value) {
switch (value.toLowerCase().trim()) {
case "slow_2g": return Optional.of(ConnectionHint.SLOW_TWO);
case "2g": return Optional.of(ConnectionHint.SLOW);
case "3g": return Optional.of(ConnectionHint.TYPICAL);
case "4g": return Optional.of(ConnectionHint.FAST);
default: return Optional.empty();
}
}
/**
* Produce a string token for the provided client hint.
*
* @param hint Enumerated hint type.
* @return String token matching the hint.
*/
@VisibleForTesting
@SuppressWarnings("WeakerAccess")
static @Nonnull String clientHintForEnum(@Nonnull ClientHint hint) {
switch (hint) {
case DPR: return "DPR";
case ECT: return "ECT";
case RTT: return "RTT";
case DOWNLINK: return "Downlink";
case DEVICE_MEMORY: return "Device-Memory";
case SAVE_DATA: return "Save-Data";
case WIDTH: return "Width";
case VIEWPORT_WIDTH: return "Viewport-Width";
default:
throw new IllegalStateException(format("Unrecognized client hint type: '%s'.", hint.name()));
}
}
/**
* Produce a token for the specified {@code Referrer-Policy} selection.
*
* @param policy Policy to produce a token for.
* @return String token.
*/
@VisibleForTesting
@SuppressWarnings("WeakerAccess")
static @Nonnull Optional<String> tokenForReferrerPolicy(@Nonnull ReferrerPolicy policy) {
switch (policy) {
case NO_REFERRER: return Optional.of("no-referrer");
case NO_REFERRER_WHEN_DOWNGRADE: return Optional.of("no-referrer-when-downgrade");
case ORIGIN: return Optional.of("origin");
case ORIGIN_WHEN_CROSS_ORIGIN: return Optional.of("origin-when-cross-origin");
case SAME: return Optional.of("same-origin");
case STRICT_ORIGIN: return Optional.of("strict-origin");
case STRICT_ORIGIN_WHEN_CROSS_ORIGIN: return Optional.of("strict-origin-when-cross-origin");
case UNSAFE_URL: return Optional.of("unsafe-url");
default: return Optional.empty();
}
}
/**
* Produce a token for the specified {@code X-Frame-Options} policy.
*
* @param policy Policy to produce a token for.
* @return String token.
*/
@VisibleForTesting
@SuppressWarnings("WeakerAccess")
static @Nonnull Optional<String> tokenForFramingPolicy(@Nonnull FramingPolicy policy) {
switch (policy) {
case SAMEORIGIN: return Optional.of("SAMEORIGIN");
case DENY: return Optional.of("DENY");
default: return Optional.empty();
}
}
/**
* Format a hostname for DNS pre-fetching by the browser.
*
* @param hostname Hostname to pre-fetch.
* @param relevance Indicates the type of link being specified.
* @return Formatted {@code Link} header value.
*/
@VisibleForTesting
@SuppressWarnings("WeakerAccess")
static @Nonnull String formatLinkHeader(@Nonnull String hostname, @Nonnull String relevance) {
return "<" + hostname + ">; rel=" + relevance;
}
/** @return The current page context builder. */
public @Nonnull Context.Builder getContext() {
if (this.closed.get())
throw new IllegalStateException("Cannot access mutable context after closing page manager state.");
return context;
}
/** {@inheritDoc} */
@Override
public @Nonnull <T> MutableHttpResponse<T> finalizeResponse(@Nonnull HttpRequest<?> request,
@Nonnull MutableHttpResponse<T> soyResponse,
@Nonnull T body,
@Nullable MessageDigest digester) {
MutableHttpResponse<T> response = soyResponse.body(body);
Optional<Context> pageContext = response.getAttribute("context", Context.class);
if (pageContext.isPresent()) {
if (logging.isDebugEnabled())
logging.debug("Found request context, finalizing headers.");
@Nonnull Context ctx = pageContext.get();
//noinspection ConstantConditions
if (this.contentTypeValue != null
&& !this.contentTypeValue.isEmpty()
&& !this.contentTypeValue.isBlank()) {
// set the content type
soyResponse.contentType(this.contentTypeValue);
}
// process `ETag` first, because if `If-None-Match` processing is enabled, this may kick is out of this response
// flow entirely.
if (digester != null) {
if (ctx.getEtag().hasPreimage()) {
digester.update(ctx.getEtag().getPreimage().getFingerprint().asReadOnlyByteBuffer().array());
}
String contentDigest = Hex.bytesToHex(digester.digest(), ETAG_LENGTH);
if (!Objects.requireNonNull(contentDigest).isEmpty()) {
if (request.getHeaders().contains(HttpHeaders.IF_NONE_MATCH)) {
// we have a potential conditional match
if (contentDigest.equals(Objects.requireNonNull(request.getHeaders()
.get(HttpHeaders.IF_NONE_MATCH))
.replace("\"", "")
.replace("W/", ""))) {
if (logging.isDebugEnabled()) {
logging.debug(format(
"Response matched `If-None-Match` etag value '%s'. Sending 304.", ("W/" + contentDigest)));
}
// drop the body - explicitly truncate so we don't get caught by chunked TE - and reset the status to
// indicate a conditional request match
response.body(null);
response.contentLength(0);
response.status(HttpStatus.NOT_MODIFIED.getCode());
return response;
}
}
response.getHeaders().add(HttpHeaders.ETAG, "W/" + "\"" + contentDigest + "\"");
}
} else if (logging.isTraceEnabled()) {
logging.trace("Dynamic ETags are disabled.");
}
// `Accept-CH`
if (ctx.hasHints() && // we must support hints to emit this header
ctx.getHints().getSupportedCount() > 0 && // there must be hint types to send
(ctx.getHints().getIndicatedCount() == 0 || // we should only send if there are no hints from the client, or
ctx.getHints().getIndicatedList() != ctx.getHints().getSupportedList() || // the lists differ in length, or
!(Sets.difference( // the provided set of hints from the client doesn't match with the supported set
ImmutableSortedSet.copyOf(ctx.getHints().getIndicatedList()),
ImmutableSortedSet.copyOf(ctx.getHints().getSupportedList()))
.isEmpty()))) {
SortedSet<String> tokens = ctx.getHints().getSupportedList().stream()
.map(PageContextManager::clientHintForEnum)
.collect(Collectors.toCollection(TreeSet::new));
String renderedHints = Joiner.on(", ").join(tokens);
if (logging.isDebugEnabled())
logging.debug(format("Indicating `Accept-CH`: '%s'.", renderedHints.toLowerCase()));
response.getHeaders().add(ACCEPT_CH_HEADER, renderedHints.toLowerCase());
// since we've appended `Accept-CH`, check for a lifetime
long lifetime = ctx.getHints().getLifetime();
if (lifetime > 0) {
response.getHeaders().add(ACCEPT_CH_LIFETIME_HEADER, String.valueOf(lifetime));
}
} else if (logging.isTraceEnabled()) {
logging.trace("`Accept-CH` not configured for response.");
}
// `Content-Language`
if (ctx.getLanguage() != null && !ctx.getLanguage().isEmpty())
response.getHeaders().add(HttpHeaders.CONTENT_LANGUAGE, ctx.getLanguage());
else if (logging.isTraceEnabled())
logging.trace("`Content-Language` not configured for response.");
// `Vary`
if (ctx.getVaryCount() > 0)
response.getHeaders().add(
HttpHeaders.VARY,
Joiner.on(", ").join(new TreeSet<>(ctx.getVaryList())));
else if (logging.isTraceEnabled())
logging.trace("`Vary` not configured for response.");
// `Feature-Policy`
if (ctx.getFeaturePolicyCount() > 0) {
// gather policies
SortedSet<String> segments = new TreeSet<>(ctx.getFeaturePolicyList());
if (!segments.isEmpty()) {
String renderedPolicy = Joiner.on(" ").join(segments);
response.getHeaders().add(FEATURE_POLICY_HEADER, renderedPolicy);
}
} else if (logging.isTraceEnabled()) {
logging.trace("`Feature-Policy` not configured for response.");
}
// `Referrer-Policy`
Optional<String> referrerPolicyToken = tokenForReferrerPolicy(ctx.getReferrerPolicy());
if (referrerPolicyToken.isPresent()) {
if (logging.isDebugEnabled())
logging.debug(format("Indicating `Referrer-Policy`: '%s'.", referrerPolicyToken.get()));
response.getHeaders().add(
REFERRER_POLICY_HEADER,
referrerPolicyToken.get());
} else if (logging.isTraceEnabled()) {
logging.trace("`Referrer-Policy` not configured for response.");
}
// `X-Frame-Options`
Optional<String> framingToken = tokenForFramingPolicy(ctx.getFramingPolicy());
if (framingToken.isPresent()) {
if (logging.isDebugEnabled())
logging.debug(format("Indicating `X-Frame-Options`: '%s'.", ctx.getFramingPolicy().name()));
response.getHeaders().add(
X_FRAME_OPTIONS_HEADER,
framingToken.get());
} else if (logging.isTraceEnabled()) {
logging.trace("No `X-Frame-Options` configured for this response.");
}
// `X-Content-Type-Options`
if (ctx.getContentTypeNosniff()) {
if (logging.isDebugEnabled())
logging.debug("Indicating `X-Content-Type-Options`: 'nosniff'.");
response.getHeaders().add(
X_CONTENT_TYPE_OPTIONS_HEADER,
"nosniff");
} else if (logging.isTraceEnabled()) {
logging.trace("No `X-Content-Type-Options` configured for this response.");
}
// `Link` (domain pre-connection)
if (ctx.getPreconnectCount() > 0) {
SortedSet<String> preconnectList = new TreeSet<>(ctx.getPreconnectList());
preconnectList.forEach((preconnect) ->
response.getHeaders().add(LINK_HEADER, formatLinkHeader(preconnect, LINK_PRECONNECT_TOKEN)));
if (logging.isDebugEnabled()) {
logging.debug(format("Indicated (via `Link`) %s domains for pre-connection.", ctx.getPreconnectCount()));
}
if (logging.isTraceEnabled()) {
logging.trace(format("Domains for pre-connection: %s.", Joiner.on(", ").join(preconnectList)));
}
} else if (logging.isTraceEnabled()) {
logging.trace("No domains to apply to pre-connect hints.");
}
// `Link` (DNS pre-fetching)
if (ctx.getDnsPrefetchCount() > 0) {
SortedSet<String> dnsPrefetches = new TreeSet<>(ctx.getDnsPrefetchList());
dnsPrefetches.forEach((prefetch) ->
response.getHeaders().add(LINK_HEADER, formatLinkHeader(prefetch, LINK_DNS_PREFETCH_TOKEN)));
if (logging.isDebugEnabled()) {
logging.debug(format("Indicated (via `Link`) %s domains for DNS prefetching.", ctx.getDnsPrefetchCount()));
}
if (logging.isTraceEnabled()) {
logging.trace(format("DNS domains prefetched: %s.", Joiner.on(", ").join(dnsPrefetches)));
}
} else if (logging.isTraceEnabled()) {
logging.trace("No domains to apply to DNS prefetch hints.");
}
// `X-XSS-Protection`
if (ctx.getXssProtection() != null && !ctx.getXssProtection().isEmpty()) {
if (logging.isDebugEnabled())
logging.debug(format("Applying `X-XSS-Protection` policy: '%s'.", ctx.getXssProtection()));
response.getHeaders().add(X_XSS_PROTECTION_HEADER, ctx.getXssProtection());
} else if (logging.isTraceEnabled()) {
logging.trace("No `X-XSS-Protection` policy configured for this response cycle.");
}
// additional headers
if (ctx.getHeaderCount() > 0)
ctx.getHeaderList().forEach(
(header) -> response.getHeaders().add(header.getName(), header.getValue()));
else if (logging.isTraceEnabled())
logging.trace("No additional headers to apply.");
} else {
logging.warn("Failed to find HTTP cycle context: cannot finalize headers.");
}
return response;
}
/** @return Built context. After calling this method the first time, context may no longer be mutated. */
public @Nonnull PageContext render() {
if (this.closed.get()) {
assert this.builtContext != null;
} else {
this.closed.compareAndSet(false, true);
this.builtContext = PageContext.fromProto(
this.context.build(),
this.props,
this.injected,
this.namingMapProvider.orElse(null),
new SoyContext.SoyI18NContext(
this.messageBundle,
this.translationsFile,
this.translationsResource
),
this.delegatePredicate.orElse(null));
if (logging.isDebugEnabled()) {
logging.debug(format("Exported page context with: %s props, %s injecteds, and proto follows \n%s",
this.props.size(),
this.injected.size(),
this.builtContext.getPageContext()));
}
}
return this.builtContext;
}
// -- Builder Interface (Context) -- //
/**
* Returns the currently-set content type for this response render flow. This value generally defaults to
* {@link MediaType#TEXT_HTML}.
*
* @return Content type set to serve for this render flow.
*/
public @Nonnull String contentType() {
return this.contentTypeValue;
}
/**
* Sets the content type to return for the current render response flow. This value should typically be set from one
* of the options on {@link MediaType}, or as a raw {@code Content-Type} header value.
*
* @param type Content type to set.
* @return Current page context manager (for call chain-ability).
*/
public @Nonnull PageContextManager contentType(@Nonnull String type) {
//noinspection ConstantConditions
if (type == null) throw new IllegalArgumentException("Cannot pass `null` for content type.");
this.contentTypeValue = type;
return this;
}
/**
* Add a `Feature-Policy` entry for the current response render flow. This value will be appended to whatever current
* values are set for the `Feature-Policy` header.
*
* @param policies Policies to add for the current page. Do not pass `null`.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If `null` is passed for the provided policies.
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager addFeaturePolicy(@Nonnull String... policies) {
//noinspection ConstantConditions
if (policies == null) throw new IllegalArgumentException("Cannot pass `null` for feature policies.");
for (String policy : policies) {
this.context.addFeaturePolicy(policy);
}
return this;
}
/**
* Clear the current set of `Feature-Policy` entries. If the app makes use of the framework's built-in page frame and
* response cycle, the value will automatically be used.
*
* @return Current page context manager (for call chain-ability).
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager clearFeaturePolicy() {
this.context.clearFeaturePolicy();
return this;
}
/**
* Overwrite the set of `Feature-Policy` entries for the current render flow. If the app makes use of the framework's
* built-in page frame and response cycle, the value will automatically be used.
*
* @param policies Policies to set for the current page. Do not pass `null`.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If `null` is passed for the policy collection to apply.
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager setFeaturePolicy(@Nonnull Collection<String> policies) {
//noinspection ConstantConditions
if (policies == null) throw new IllegalArgumentException("Cannot pass `null` for `Feature-Policy` set.");
this.clearFeaturePolicy();
if (!policies.isEmpty()) this.context.addAllFeaturePolicy(policies);
return this;
}
/**
* Retrieve the current value for the page title, set in the builder. If there is no value, {@link Optional#empty()}
* is supplied as the return value.
*
* @return Current page title, wrapped in an optional value.
*/
public @Nonnull Optional<String> title() {
final String val = this.context.getMetaBuilder().getTitle();
return "".equals(val) ? Optional.empty() : Optional.of(val);
}
/**
* Set the page title for the current render flow. If the app makes use of the framework's built-in page frame, the
* title will automatically be used.
*
* @param title Title to set for the current page. Do not pass `null`.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If `null` is passed for the title.
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager title(@Nonnull String title) {
//noinspection ConstantConditions
if (title == null) throw new IllegalArgumentException("Cannot pass `null` for page title.");
this.context.getMetaBuilder().setTitle(title);
return this;
}
/**
* Retrieve the current value for the page description, set in the builder. If there is no value set,
* {@link Optional#empty()} is supplied as the return value.
*
* @return Current page description, wrapped in an optional value.
*/
public @Nonnull Optional<String> description() {
final String val = this.context.getMetaBuilder().getDescription();
return "".equals(val) ? Optional.empty() : Optional.of(val);
}
/**
* Set the page description for the current render flow. If the app makes use of the framework's built-in page frame,
* the value will automatically be used.
*
* @param description Description to set for the current page. Do not pass `null`.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If `null` is passed for the description.
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager description(@Nonnull String description) {
//noinspection ConstantConditions
if (description == null) throw new IllegalArgumentException("Cannot pass `null` for page description.");
this.context.getMetaBuilder().setDescription(description);
return this;
}
/**
* Retrieve the current value for the page keywords, set in the builder. If there is no value set,
* {@link Optional#empty()} is supplied as the return value.
*
* @return Current page keywords, wrapped in an optional value.
*/
public @Nonnull Optional<List<String>> keywords() {
final ArrayList<String> val = this.context.getMetaBuilder().getKeywordList().asByteStringList()
.stream()
.map(ByteString::toString)
.collect(Collectors.toCollection(() -> new ArrayList<>(this.context.getMetaBuilder().getKeywordCount())));
return val.isEmpty() ? Optional.empty() : Optional.of(val);
}
/**
* Add the provided page keywords for the current render flow. If the app makes use of the framework's built-in page
* frame, the value will automatically be used.
*
* @param keywords Keywords to set for the current page. Do not pass `null`.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If `null` is passed for the keywords.
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager addKeyword(@Nonnull String... keywords) {
//noinspection ConstantConditions
if (keywords == null) throw new IllegalArgumentException("Cannot pass `null` for page keywords.");
for (String keyword : keywords) {
this.context.getMetaBuilder().addKeyword(keyword);
}
return this;
}
/**
* Clear the current set of page keywords for the current render flow. If the app makes use of the framework's
* built-in page frame, the value will automatically be used.
*
* @return Current page context manager (for call chain-ability).
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager clearKeywords() {
this.context.getMetaBuilder().clearKeyword();
return this;
}
/**
* Overwrite the current set of page keywords for the current render flow. If the app makes use of the framework's
* built-in page frame, the value will automatically be used.
*
* @param keywords Keywords to set for the current page. Do not pass `null`.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If `null` is passed for the keywords.
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager setKeywords(@Nonnull Collection<String> keywords) {
//noinspection ConstantConditions
if (keywords == null) throw new IllegalArgumentException("Cannot pass `null` for page keywords.");
this.clearKeywords();
if (!keywords.isEmpty()) this.context.getMetaBuilder().addAllKeyword(keywords);
return this;
}
/**
* Retrieve the full set of regular HTML metadata links attached to the current render flow. If the app makes use of
* the framework's built-in age frame, these links will automatically be applied.
*
* @return Current set of links that will be listed in page metadata.
*/
public @Nonnull Optional<List<Context.PageLink>> links() {
final List<Context.PageLink> links = this.context.getMetaBuilder().getLinkList();
return links.isEmpty() ? Optional.empty() : Optional.of(links);
}
/**
* Add a regular HTML metadata link to the current render flow, specified by a {@link Context.PageLink} proto record.
* Adding via this method is sufficient for the link to make it into the rendered page, so long as the framework's
* page frame is invoked.
*
* @param link HTML metadata link to add to the page.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If `null` is passed for the link.
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager addLink(@Nonnull Context.PageLink.Builder link) {
//noinspection ConstantConditions
if (link == null) throw new IllegalArgumentException("Cannot pass `null` for page link spec.");
this.context.getMetaBuilder().addLink(link);
return this;
}
/**
* Add a regular HTML metadata link to the current render flow, specified by the provided method parameters. Each
* parameter maps to an attribute specified for the <pre>link</pre> HTML element.
*
* @param relevance HTML "rel" attribute.
* @param href HTML "href" attribute.
* @param type HTML "type" attribute. Wrapped in an optional.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If `null` is passed for any parameter.
*/
@CanIgnoreReturnValue
@SuppressWarnings({"ConstantConditions", "OptionalAssignedToNull"})
public @Nonnull PageContextManager addLink(@Nonnull String relevance,
@Nonnull URI href,
@Nonnull Optional<String> type) {
if (relevance == null) throw new IllegalArgumentException("Cannot pass `null` for page link relevance.");
if (href == null) throw new IllegalArgumentException("Cannot pass `null` for page link href.");
if (type == null) throw new IllegalArgumentException("Cannot pass `null` for page link type.");
final Context.PageLink.Builder builder = Context.PageLink.newBuilder()
.setRelevance(relevance)
.setHref(this.trustedResource(href));
type.ifPresent(builder::setType);
this.context.getMetaBuilder().addLink(builder);
return this;
}
/**
* Clear the set of HTML metadata links assigned to the current render flow.
*
* @return Current page context manager (for call chain-ability).
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager clearLinks() {
this.context.getMetaBuilder().clearLink();
return this;
}
/**
* Overwrite the set of page metadata links for the current render flow. If the app makes use of the framework's
* built-in page frame, the value will automatically be used.
*
* @param links Link directives to set for the current page. Do not pass `null`.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If `null` is passed for the provided links.
*/
@CanIgnoreReturnValue
@SuppressWarnings("ConstantConditions")
public @Nonnull PageContextManager setLinks(@Nonnull Collection<Context.PageLink.Builder> links) {
if (links == null) throw new IllegalArgumentException("Cannot pass `null` for page links.");
this.clearLinks();
links.forEach(this::addLink);
return this;
}
/**
* Overwrite the value specified for the "robots" metadata key in the current render flow. If the app makes use of the
* framework's built-in page frame, the value will automatically be used.
*
* @param value Robots metadata value to use.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If `null` is passed for the provided value.
*/
@CanIgnoreReturnValue
@SuppressWarnings("ConstantConditions")
public @Nonnull PageContextManager setRobots(@Nonnull String value) {
if (value == null) throw new IllegalArgumentException("Cannot pass `null` for robots value.");
this.context.getMetaBuilder().setRobots(value);
return this;
}
/**
* Overwrite the value specified for the "googlebot" metadata key in the current render flow. If the app makes use of
* the framework's built-in page frame, the value will automatically be used.
*
* @param value Googlebot metadata value to use.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If `null` is passed for the provided value.
*/
@CanIgnoreReturnValue
@SuppressWarnings("ConstantConditions")
public @Nonnull PageContextManager setGooglebot(@Nonnull String value) {
if (value == null) throw new IllegalArgumentException("Cannot pass `null` for googlebot value.");
this.context.getMetaBuilder().setGooglebot(value);
return this;
}
/**
* Clear the current value, if any, set for <pre>robots</pre> in the current render flow metadata.
*
* @return Current page context manager (for call chain-ability).
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager clearRobots() {
this.context.getMetaBuilder().clearRobots();
return this;
}
/**
* Clear the current value, if any, set for <pre>googlebot</pre> in the current render flow metadata.
*
* @return Current page context manager (for call chain-ability).
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager clearGooglebot() {
this.context.getMetaBuilder().clearGooglebot();
return this;
}
/**
* Retrieve OpenGraph settings specified in the current page context. This method always returns a builder, to avoid
* re-builds of protocol buffers during page context construction. If no OpenGraph settings are available, an empty
* {@link Optional} is returned instead.
*
* @return {@link Optional}-wrapped OpenGraph settings, or {@link Optional#empty()}.
*/
public @Nonnull Optional<Context.Metadata.OpenGraph.Builder> getOpenGraph() {
return this.context.getMetaBuilder().hasOpenGraph() ?
Optional.of(this.context.getMetaBuilder().getOpenGraphBuilder()) :
Optional.empty();
}
/**
* Overwrite the OpenGraph metadata configuration for the current render flow, with the provided OpenGraph metadata
* configuration. If the rendered page uses the framework's page template, the values will be serialized and rendered
* into the page head.
*
* @param content OpenGraph content to render.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If `null` is passed for the provided content.
*/
@CanIgnoreReturnValue
@SuppressWarnings("ConstantConditions")
public @Nonnull PageContextManager setOpenGraph(@Nonnull Context.Metadata.OpenGraph.Builder content) {
if (content == null) throw new IllegalArgumentException("Cannot pass `null` for OpenGraph content.");
this.clearOpenGraph();
this.context.getMetaBuilder().setOpenGraph(content);
return this;
}
/**
* Apply the provided OpenGraph metadata configuration to the <i>current</i> OpenGraph metadata configuration, if any.
* If no OpenGraph metadata configuration is set, this method effectively overwrites it.
*
* @param content OpenGraph content to merge and apply.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If `null` is passed for the provided content.
*/
@CanIgnoreReturnValue
@SuppressWarnings("ConstantConditions")
public @Nonnull PageContextManager applyOpenGraph(@Nonnull Context.Metadata.OpenGraph.Builder content) {
if (content == null) throw new IllegalArgumentException("Cannot pass `null` for OpenGraph content.");
Context.Metadata.OpenGraph.Builder ogContent = this.context.getMetaBuilder().getOpenGraphBuilder();
this.setOpenGraph(ogContent.mergeFrom(content.build()));
return this;
}
/**
* Clear any OpenGraph metadata configuration attached to the current render flow. If there is no such configuration,
* this method is a no-op.
*
* @return Current page context manager (for call chain-ability).
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager clearOpenGraph() {
this.context.getMetaBuilder().clearOpenGraph();
return this;
}
/**
* Return the set of RDFa prefixes affixed to the current render flow, if any. If none are found,
* {@link Optional#empty()} is returned.
*
* @return Set of prefixes available on the current render flow, if any.
*/
public @Nonnull Optional<List<Context.RDFPrefix>> prefixes() {
if (this.context.getMetaBuilder().getPrefixCount() > 0) {
return Optional.of(this.context.getMetaBuilder().getPrefixList());
}
return Optional.empty();
}
/**
* Overwrite the full set of RDFa prefixes for the current render flow. If the rendered page uses the framework's page
* template, the values will be serialized and rendered into the page head.
*
* @return Current page context manager (for call chain-ability).
*/
public @Nonnull PageContextManager setPrefixes(@Nonnull Optional<List<Context.RDFPrefix>> prefixes) {
if (prefixes.isPresent()) {
List<Context.RDFPrefix> prefixList = prefixes.get();
prefixList.forEach((prefix) -> {
this.context.getMetaBuilder().addPrefix(Context.RDFPrefix.newBuilder()
.setPrefix(prefix.getPrefix())
.setTarget(prefix.getTarget()));
});
return this;
}
this.context.getMetaBuilder().clearPrefix();
return this;
}
/**
* Include the specified JavaScript resource in the rendered page, according to the specified settings. The module is
* expected to exist and be included in the application's asset bundle.
*
* @param name Name of the script module to load into the page.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If `null` is passed for the module name, or it cannot be located, or is invalid.
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager script(@Nonnull String name) {
// sensible defaults for script embedding
return this.script(
name, null, true, false, false, false, false, false);
}
/**
* Include the specified JavaScript resource in the rendered page, according to the specified settings. The module is
* expected to exist and be included in the application's asset bundle. This variant allows specification of the most
* frequent attributes used with scripts.
*
* @param name Name of the script module to load into the page.
* @param defer Whether to add the {@code defer} attribute to the script tag.
* @param async Whether to add the {@code async} attribute to the script tag.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If `null` is passed for the module name, or it cannot be located, or is invalid.
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager script(@Nonnull String name, @Nonnull Boolean defer, @Nonnull Boolean async) {
return this.script(
name, null, defer, async, false, false, false, false);
}
/**
* Include the specified JavaScript resource in the rendered page, according to the specified settings. The module is
* expected to exist and be included in the application's asset bundle.
*
* <p><b>Behavior:</b> Script assets included in this manner are always loaded in the document head, so be judicious
* with {@code defer} if you are loading a significant amount of JavaScript. There are no default script assets.
* Scripts are emitted in the order in which they are attached to the page context (i.e. via this method).</p>
*
* <p><b>Optimization:</b> Activating the {@code preload} flag causes the script asset to be mentioned in a
* {@code Link} header, which makes supporting browsers aware of it before loading the DOM. For more aggressive
* circumstances, the {@code push} flag proactively pushes the asset to the browser (where supported), unless the
* framework knows the client has seen the asset already. Where HTTP/2 is not supported, special triggering
* {@code Link} headers may be used.</p>
*
* @param name Name of the script module to load into the page.
* @param id ID to assign the script block in the DOM, so it may be located dynamically.
* @param defer Whether to add the {@code defer} attribute to the script tag.
* @param async Whether to add the {@code async} attribute to the script tag.
* @param module Whether to add the {@code module} attribute to the script tag.
* @param nomodule Whether to add the {@code nomodule} attribute to the script tag.
* @param preload Whether to link/hint about the asset in response headers.
* @param push Whether to pro-actively push the asset, if we think the client doesn't have it.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If `null` is passed for the module name, or it cannot be located, or is invalid.
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager script(@Nonnull String name,
@Nullable String id,
@Nonnull Boolean defer,
@Nonnull Boolean async,
@Nonnull Boolean module,
@Nonnull Boolean nomodule,
@Nonnull Boolean preload,
@Nonnull Boolean push) {
Optional<ManagedAsset<ScriptAsset>> maybeAsset = (
this.assetManager.assetMetadataByModule(Objects.requireNonNull(name)));
// fail if not present
if (maybeAsset.isEmpty())
throw new IllegalArgumentException(format("Failed to locate script module '%s'.", name));
ManagedAsset<ScriptAsset> asset = maybeAsset.get();
if (!asset.getType().equals(AssetManager.ModuleType.JS))
throw new IllegalArgumentException(format("Cannot include asset '%s' as %s, it is of type JS.",
name,
asset.getType().name()));
// resolve constituent scripts
for (ScriptAsset script : asset.getAssets()) {
// pre-filled URI js record
JavaScript.Builder js = JavaScript.newBuilder(script.getScript())
.setDefer(defer)
.setAsync(async)
.setModule(module)
.setNoModule(nomodule)
.setPreload(preload)
.setPush(push);
if (id != null) js.setId(id);
this.script(js);
}
return this;
}
/**
* Include the specified JavaScript resource in the rendered page, according to enclosed settings (i.e. respecting
* {@code defer}, {@code async}, and other attributes). If the script asset has an ID, it will <b>not</b> be
* passed through ID rewriting before being rendered.
*
* <p><b>Behavior:</b> Script assets included in this manner are always loaded in the document head, so be judicious
* with {@code defer} if you are loading a significant amount of JavaScript. There are no default script assets.
* Scripts are emitted in the order in which they are attached to the page context (i.e. via this method).</p>
*
* @param script Script asset to load in the rendered page output. Do not pass `null`.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If `null` is passed for the module name, or it cannot be located, or is invalid.
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager script(@Nonnull Context.Scripts.JavaScript.Builder script) {
//noinspection ConstantConditions
if (script == null) throw new IllegalArgumentException("Cannot pass `null` for script.");
this.context.getScriptsBuilder().addLink(script);
return this;
}
/**
* Include the specified CSS stylesheet in the rendered page with default settings. The module is expected to exist
* and be included in the application's asset bundle.
*
* @param name Name of the module to load.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If `null` is passed for the module name, or it cannot be located, or is invalid.
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager stylesheet(@Nonnull String name) {
return stylesheet(name, null);
}
/**
* Include the specified CSS stylesheet in the rendered page, along with the specified media setting. The module is
* expected to exist and be included in the application's asset bundle.
*
* @param name Name of the module to load.
* @param media Media assignment for the stylesheet.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If `null` is passed for the module name, or the module cannot be located.
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager stylesheet(@Nonnull String name, @Nullable String media) {
return stylesheet(name, null, null, false, false);
}
/**
* Include the specified CSS stylesheet in the rendered page, according to the specified settings. The module is
* expected to exist and be included in the application's asset bundle.
*
* <p><b>Optimization:</b> Activating the {@code preload} flag causes the style asset to be mentioned in a
* {@code Link} header, which makes supporting browsers aware of it before loading the DOM. For more aggressive
* circumstances, the {@code push} flag proactively pushes the asset to the browser (where supported), unless the
* framework knows the client has seen the asset already. Where HTTP/2 is not supported, special triggering
* {@code Link} headers may be used.</p>
*
* @param name Name of the module to load.
* @param id ID to assign the link tag in the DOM.
* @param media Media assignment for the stylesheet.
* @param preload Whether to link/hint about the asset in response headers.
* @param push Whether to pro-actively push the asset, if we think the client doesn't have it.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If `null` is passed for the module name, or it cannot be located, or is invalid.
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager stylesheet(@Nonnull String name,
@Nullable String id,
@Nullable String media,
@Nonnull Boolean preload,
@Nonnull Boolean push) {
Optional<ManagedAsset<StyleAsset>> maybeAsset = (
this.assetManager.assetMetadataByModule(Objects.requireNonNull(name)));
// fail if not present
if (maybeAsset.isEmpty())
throw new IllegalArgumentException(format("Failed to locate style module '%s'.", name));
ManagedAsset<StyleAsset> asset = maybeAsset.get();
if (!asset.getType().equals(AssetManager.ModuleType.CSS))
throw new IllegalArgumentException(format("Cannot include asset '%s' as %s, it is of type CSS.",
name,
asset.getType().name()));
// resolve constituent stylesheets
for (StyleAsset styleBundle : asset.getAssets()) {
// pre-filled URI js record
Stylesheet.Builder styles = Stylesheet.newBuilder(styleBundle.getStylesheet())
.setPush(push)
.setPreload(preload);
if (id != null) styles.setId(id);
if (media != null) styles.setMedia(media);
this.stylesheet(styles);
}
return this;
}
/**
* Include the specified CSS stylesheet resource in the rendered page, according to the enclosed settings (i.e.
* respecting properties like <pre>media</pre>). If the stylesheet has an ID, it will <b>not</b> be passed through ID
* rewriting before being rendered.
*
* <p>Stylesheets included in this manner are always loaded in the head, via a link tag. If you want to defer loading
* of styles, you'll have to do so from JS. Stylesheet links are emitted in the order in which they are attached to
* the page context (i.e. via this method).</p>
*
* @param stylesheet Stylesheet asset to load in the rendered page output. Do not pass `null`.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If `null` is passed for the stylesheet.
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager stylesheet(@Nonnull Context.Styles.Stylesheet.Builder stylesheet) {
//noinspection ConstantConditions
if (stylesheet == null) throw new IllegalArgumentException("Cannot pass `null` for stylesheet.");
this.context.getStylesBuilder().addLink(stylesheet);
return this;
}
// -- Map-like Interface (Props) -- //
/**
* Install a regular context value, at the named key provided. This will make the value available in any bound Soy
* render flow via a <pre>@param</pre> declaration on the subject template to be rendered.
*
* @param key Key at which to make this available as a param.
* @param value Value to provide for the parameter.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If the provided <pre>key</pre> is <pre>null</pre>, or a disallowed value, like
* <pre>context</pre> (which cannot be overridden).
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager put(@Nonnull String key, @Nonnull Object value) {
//noinspection ConstantConditions
if (key == null)
throw new IllegalArgumentException("Must provide a key to put a Soy context property. Got `null` for key.");
this.props.put(key, value);
return this;
}
/**
* Safely retrieve a value from the current render context properties. If no property is found at the provided key,
* an empty {@link Optional} is returned. Otherwise, an {@link Optional} is returned wrapping whatever value was
* found.
*
* @param key Key at which to retrieve the desired render context property.
* @return Optional-wrapped context property value.
*/
public @Nonnull Optional<Object> get(@Nonnull String key) {
return this.get(key, false);
}
/**
* Safely retrieve a value from either the current render context properties, or the current injected values. If no
* value is found in whatever context we're looking in, an empty {@link Optional} is returned. Otherwise, an
* {@link Optional} is returned wrapping whatever value was found.
*
* @param key Key at which to retrieve the desired render property or injected value.
* @param injected Whether to look in the injected values, or regular property values.
* @return Empty optional if nothing was found, otherwise, the found value wrapped in an optional.
*/
public @Nonnull Optional<Object> get(@Nonnull String key, boolean injected) {
final ConcurrentMap<String, Object> base = injected ? this.injected : this.props;
if (base.containsKey(key))
return Optional.of(base.get(key));
return Optional.empty();
}
/**
* Install an injected context value, at the named key provided. This will make the value available in any bound Soy
* render flow via the <pre>@inject</pre> declaration, on any template in the render flow.
*
* @param key Key at which to make this available as an injected value.
* @param value Value to provide for the parameter.
* @return Current page context manager (for call chain-ability).
* @throws IllegalArgumentException If the provided <pre>key</pre> is <pre>null</pre>, or a disallowed value, like
* <pre>context</pre> (which cannot be overridden).
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager inject(@Nonnull String key, @Nonnull Object value) {
//noinspection ConstantConditions
if (key == null)
throw new IllegalArgumentException("Must provide a key to put a Soy context property. Got `null` for key.");
if ("context".equals(key.toLowerCase()))
throw new IllegalArgumentException("Cannot use key 'context' for injected property.");
this.injected.put(key, value);
return this;
}
/**
* Install, or uninstall, the request-scoped renaming map provider. This object will be used to lookup style classes
* and IDs for render-time rewriting. To disable an existing naming map provider override, simply pass an empty
* {@link Optional}.
*
* <p>If no renaming map provider is set here, but a global one is, and renaming is enabled, the global map will be
* used. If a renaming map is set here, but renaming is <i>not</i> enabled, no renaming takes place.</p>
*
* @param namingMapProvider Renaming map provider to install, or {@link Optional#empty()} to uninstall any existing
* overriding renaming map provider.
* @return Current page context manager (for call chain-ability).
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager rewrite(@Nonnull Optional<SoyNamingMapProvider> namingMapProvider) {
this.namingMapProvider = namingMapProvider;
return this;
}
/**
* Indicate whether message translation is enabled for the rendering layer. This checks the presence of either a
* translations file, or a translations URL resource. Additionally, a check is performed for any pre-fabricated Soy
* messages bundle. If any of those are present, `true` is returned.
*
* @return Whether translations will be enabled during render.
*/
@Override
public boolean translate() {
return (
this.translationsFile.isPresent() ||
this.translationsResource.isPresent() ||
this.messageBundle.isPresent()
);
}
/**
* Mount a loaded XLIFF file for use during render. Messages mentioned in the XLIFF file and in the corresponding
* template are replaced as the renderer proceeds through the template. Passing {@link Optional#empty()} clears any
* active translation file.
*
* @param xliffFile Translations file to apply.
* @return Current page context manager (for call chain-ability).
*/
public @Nonnull PageContextManager messagesFile(@Nonnull Optional<File> xliffFile) {
this.translationsFile = xliffFile;
return this;
}
/**
* Return the current translation file that will be applied during the next render routine, if available.
*
* @return Translation file, or {@link Optional#empty()}.
*/
@Override
public @Nonnull Optional<File> messagesFile() {
return this.translationsFile;
}
/**
* Load and mount a referenced XLIFF resource for use during render. Messages mentioned in the XLIFF resource and in
* the corresponding template are replaced as the renderer proceeds through the template.
*
* @param xliffData Translations data to apply.
* @return Current page context manager (for call chain-ability).
*/
public @Nonnull PageContextManager messagesResource(@Nonnull Optional<URL> xliffData) {
this.translationsResource = xliffData;
return this;
}
/**
* Return the current translation resource that will be applied during the next render routine, if available.
*
* @return Translation resource, or {@link Optional#empty()}.
*/
@Override
public @Nonnull Optional<URL> messagesResource() {
return this.translationsResource;
}
/**
* Mount a pre-fabricated Soy message bundle for translation use during render. Messages mentioned in the bundle and
* in the corresponding template are replaced as the renderer proceeds through the template.
*
* @param soyMsgBundle Soy message bundle to apply.
* @return Current page context manager (for call chain-ability).
*/
public @Nonnull PageContextManager messageBundle(@Nonnull Optional<SoyMsgBundle> soyMsgBundle) {
this.messageBundle = soyMsgBundle;
return this;
}
/**
* Return the current pre-fabricated Soy message bundle that will be applied during the next render routine, if
* available.
*
* @return Soy message bundle, or {@link Optional#empty()}.
*/
@Override
public @Nonnull Optional<SoyMsgBundle> messageBundle() throws IOException {
if (this.messageBundle.isPresent()) {
return this.messageBundle;
}
return PageRender.super.messageBundle();
}
// -- Delegate Rendering -- //
/**
* Fetch the active delegate package, or return {@link Optional#empty()}. Either a {@link Predicate} is returned which
* is invoked to match the delegate package, or {@link Optional#empty()} indicates that no package should be available
* (or that defaults should be used, where available).
*
* @return Returns the active predicate, as applicable.
*/
@Override
public @Nonnull Optional<Predicate<String>> delegatePackage() {
return this.delegatePredicate;
}
/**
* Set the active delegate package name, wrapped in an implied predicate which filters explicitly against the provided
* name value. If the referenced Soy package is included in the build, it should be selected explicitly as the active
* package during render.
*
* <p>Calling this method overwrites any current delegate package.</p>
*
* @param packageName Package name to match for delegation.
* @return Current page context manager (for call chain-ability).
*/
public @Nonnull PageContextManager delegatePackage(@Nonnull String packageName) {
this.delegatePredicate = Optional.of(packageName::equals);
return this;
}
/**
* Set the active delegate package predicate directly. This predicate is invoked to match a delegate package at
* runtime, if and when one is needed.
*
* <p>Calling this method overwrites any current delegate package.</p>
*
* @param packagePredicate Predicate to match packages.
* @return Current page context manager (for call chain-ability).
*/
public @Nonnull PageContextManager delegatePackage(@Nonnull Predicate<String> packagePredicate) {
this.delegatePredicate = Optional.of(packagePredicate);
return this;
}
/**
* Clear any current delegate package.
*
* @return Current page context manager (for call chain-ability).
*/
public @Nonnull PageContextManager clearDelegatePackage() {
this.delegatePredicate = Optional.empty();
return this;
}
// -- Builder Interface (Response) -- //
/**
* Affix an arbitrary HTTP header to the response eventually produced by this page context, assuming no errors occur.
* If an error occurs while rendering, an error page is served <b>without</b> the additional header (unless
* {@code force} is passed via the companion method to this one).
*
* @param name Name of the header value to affix to the response.
* @param value Value of the header to affix to the response, at {@code name}.
* @return Current page context manager (for call chain-ability).
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager header(@Nonnull String name, @Nonnull String value) {
return this.header(name, value, false);
}
/**
* Affix an arbitrary HTTP header to the response eventually produced by this page context, assuming no errors occur.
* If an error occurs while rendering, an error page is served <b>without</b> the additional header (unless
* {@code force} is passed via the companion method to this one).
*
* @param name Name of the header value to affix to the response.
* @param value Value of the header to affix to the response, at {@code name}.
* @param force Whether to force the header to be applied, even when an error occurs.
* @return Current page context manager (for call chain-ability).
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager header(@Nonnull String name, @Nonnull String value, @Nonnull Boolean force) {
if (HttpHeaders.CONTENT_LANGUAGE.equals(name))
throw new IllegalArgumentException("Please use `language()` instead of setting a `Content-Language` header.");
if (HttpHeaders.VARY.equals(name))
throw new IllegalArgumentException("Please use `vary()` instead of setting a `Vary` header.");
if (HttpHeaders.ETAG.equals(name))
throw new IllegalArgumentException("Please use `enableETags()` instead of setting an `ETag` header.");
if (ACCEPT_CH_HEADER.equals(name))
throw new IllegalArgumentException("Please use `clientHints()` instead of setting an `Accept-CH` header.");
this.context.addHeader(Context.ResponseHeader.newBuilder()
.setName(name)
.setValue(value)
.setForce(force));
return this;
}
/**
* Enable a dynamic {@code ETag} header value, which is computed from the rendered content produced by this page
* context record.
*
* @param enableETags Whether to enable the {@code ETag} header.
* @return Current page context manager (for call chain-ability).
*/
@CanIgnoreReturnValue
@SuppressWarnings("WeakerAccess")
public @Nonnull PageContextManager enableETags(@Nonnull Boolean enableETags) {
this.context.setEtag(Context.DynamicETag.newBuilder()
.setEnabled(enableETags)
.setStrong(true));
return this;
}
/**
* Enable the provided set of server-indicated client hint types. If the client supports any of the indicated types,
* it will enclose matching client-hints accordingly, on subsequent requests for resources. If an empty optional
* ({@link Optional#empty()}) is passed, the current set of client hints are cleared. This method is additive.
*
* @param hints Client hints to indicate as supported by the server.
* @return Current page context manager (for call chain-ability).
*/
@CanIgnoreReturnValue
@SuppressWarnings("WeakerAccess")
public @Nonnull PageContextManager supportedClientHints(Optional<Iterable<ClientHint>> hints,
@Nonnull Optional<Long> ttl) {
if (hints.isPresent()) {
// add hints to indicated set
this.context.getHintsBuilder().addAllSupported(hints.get());
ttl.ifPresent(aLong -> this.context.getHintsBuilder().setLifetime(aLong));
} else {
// if an empty optional is passed, clear the current set
this.context.getHintsBuilder().clearIndicated();
}
return this;
}
/**
* Enable the provided set of server-indicated client hint types. This method is additive. If the client supports any
* of the indicated types, it will enclose matching client-hints accordingly, on subsequent requests for resources.
*
* @param hints Client hints to indicate as supported by the server.
* @return Current page context manager (for call chain-ability).
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager supportedClientHints(ClientHint... hints) {
return supportedClientHints(Optional.of(Arrays.asList(hints)), Optional.empty());
}
/**
* Set the value to send back in this response's {@code Content-Language} header. If an {@link Optional#empty()}
* instance is passed, no header is sent.
*
* @param language Language, or empty value, to send.
* @return Current page context manager (for call chain-ability).
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager language(@Nonnull Optional<String> language) {
if (language.isPresent()) {
this.context.setLanguage(language.get());
} else {
this.context.clearLanguage();
}
return this;
}
/**
* Return the language value set for the current render routine - i.e. bound to the current request cycle. This is
* often driven by the user's browser settings.
*
* @return Current language for this request cycle.
*/
public @Nonnull Optional<String> language() {
if (this.context.getLanguage().length() > 0) {
return Optional.of(this.context.getLanguage());
}
return Optional.empty();
}
/**
* Append an HTTP request header considered as part of the {@code Vary} header in the response. These values are de-
* duplicated before joining and affixing.
*
* @param variance HTTP request header which causes the associated response to vary.
* @return Current page context manager (for call chain-ability).
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager vary(@Nonnull String variance) {
return this.vary(Collections.singleton(variance));
}
/**
* Append an HTTP request header considered as part of the {@code Vary} header in the response. These values are de-
* duplicated before joining and affixing.
*
* @param variance HTTP request header which causes the associated response to vary.
* @return Current page context manager (for call chain-ability).
*/
@CanIgnoreReturnValue
public @Nonnull PageContextManager vary(@Nonnull String... variance) {
return this.vary(Arrays.asList(variance));
}
/**
* Append an HTTP request header considered as part of the {@code Vary} header in the response. These values are de-
* duplicated before joining and affixing.
*
* @param variance HTTP request header which causes the associated response to vary.
* @return Current page context manager (for call chain-ability).
*/
@CanIgnoreReturnValue
@SuppressWarnings("WeakerAccess")
public @Nonnull PageContextManager vary(@Nonnull Iterable<String> variance) {
variance.forEach((segment) -> {
if (this.varySegments.add(segment))
this.context.addVary(segment);
});
return this;
}
/**
* Set the specified {@code prefix} as the Content Distribution Network hostname prefix to use when rendering asset
* links for this HTTP cycle. <b>Do not use a user-provided value for this.</b> If no prefix is set, or one is cleared
* by passing {@link Optional#empty()}, configuration will be read and used instead.
*
* @param prefix Prefix to apply as a CDN hostname for static assets.
* @return Current page context manager (for call chain-ability).
*/
@CanIgnoreReturnValue
@SuppressWarnings("WeakerAccess")
public @Nonnull PageContextManager cdnPrefix(@Nonnull Optional<String> prefix) {
this.cdnPrefix = prefix;
if (prefix.isPresent()) {
TrustedResourceUrlProto proto = this.trustedResource(URI.create(prefix.get()));
this.context.setCdnPrefix(proto);
this.injected.put(CDN_PREFIX_IJ_PROP, proto);
} else {
this.context.clearCdnPrefix();
this.injected.remove(CDN_PREFIX_IJ_PROP);
}
return this;
}
/**
* Retrieve the currently-configured CDN prefix value, if one exists. If none can be located, return an empty optional
* via {@link Optional#empty()}.
*
* @return Current CDN prefix value.
*/
@SuppressWarnings("WeakerAccess")
public @Nonnull Optional<String> getCdnPrefix() {
return this.cdnPrefix;
}
/**
* Inject the specified list of hosts as DNS records to prefetch from the browser. There is no guarantee made by the
* browser that the records will be fetched, it's just a performance hint.
*
* @param hosts Hosts to add to the DNS prefetch list.
* @return Current page context manager (for call chain-ability).
*/
@CanIgnoreReturnValue
@SuppressWarnings("WeakerAccess")
public @Nonnull PageContextManager dnsPrefetch(Iterable<String> hosts) {
hosts.forEach(this.context::addDnsPrefetch);
return this;
}
/**
* Inject the specified DNS hostname(s) as records to prefetch from the browser. There is no guarantee made by the
* browser that the records will be fetched, it's just a performance hint.
*
* @param hosts Hosts to add to the DNS prefetch list.
* @return Current page context manager (for call chain-ability).
*/
@CanIgnoreReturnValue
@SuppressWarnings("WeakerAccess")
public @Nonnull PageContextManager dnsPrefetch(String... hosts) {
return dnsPrefetch(Arrays.asList(hosts));
}
/**
* Inject the specified list of hosts as pre-connect hints for the browser. There is no guarantee made by the browser
* that the connections will be established, it's just a performance hint.
*
* @param hosts Hosts to add to the server pre-connection list.
* @return Current page context manager (for call chain-ability).
*/
@CanIgnoreReturnValue
@SuppressWarnings("WeakerAccess")
public @Nonnull PageContextManager preconnect(Iterable<String> hosts) {
hosts.forEach(this.context::addPreconnect);
return this;
}
/**
* Inject the specified list of hosts as pre-connect hints for the browser. There is no guarantee made by the browser
* that the connections will be established, it's just a performance hint.
*
* @param hosts Hosts to add to the server pre-connection list.
* @return Current page context manager (for call chain-ability).
*/
@CanIgnoreReturnValue
@SuppressWarnings("WeakerAccess")
public @Nonnull PageContextManager preconnect(String... hosts) {
return preconnect(Arrays.asList(hosts));
}
// -- API: Trusted Resources -- //
/**
* Generate a trusted resource URL for the provided Java URL.
*
* @param url Pre-ordained trusted resource URL.
* @return Trusted resource URL specification proto.
*/
@SuppressWarnings("WeakerAccess")
public @Nonnull TrustedResourceUrlProto trustedResource(@Nonnull URL url) {
return UnsafeSanitizedContentOrdainer.ordainAsSafe(
url.toString(),
SanitizedContent.ContentKind.TRUSTED_RESOURCE_URI)
.toTrustedResourceUrlProto();
}
/**
* Generate a trusted resource URL for the provided Java URI.
*
* @param uri Pre-ordained trusted resource URI.
* @return Trusted resource URL specification proto.
*/
@SuppressWarnings("WeakerAccess")
public @Nonnull TrustedResourceUrlProto trustedResource(@Nonnull URI uri) {
return UnsafeSanitizedContentOrdainer.ordainAsSafe(
uri.toString(),
SanitizedContent.ContentKind.TRUSTED_RESOURCE_URI)
.toTrustedResourceUrlProto();
}
// -- Interface: Client Hints -- //
/**
* Attempt to retrieve an interpreted <i>Client Hints</i> client-indicated value from the current HTTP request. If no
* value can be found, or no valid value can be decoded for the hint type specified, {@link Optional#empty()} is
* returned.
*
* @param hint Hint type to look for on the current request.
* @param <V> Value type to return for this hint. If incorrect, a warning is logged and an empty value returned.
* @return Optional-wrapped found value, or {@link Optional#empty()} if no value could be located.
*/
@SuppressWarnings("unchecked")
public @Nonnull <V> Optional<V> hint(@Nonnull ClientHint hint) {
if (this.hints.getIndicatedCount() > 0 && this.hints.getIndicatedList().contains(hint)) {
// we found the hint - try to decode the value
try {
final V value;
switch (hint) {
case DPR: value = (V)(Integer.valueOf(this.hints.getDevicePixelRatio())); break;
case ECT: value = (V)(this.hints.getEffectiveConnectionType()); break;
case RTT: value = (V)(Integer.valueOf(this.hints.getRoundTripTime())); break;
case DOWNLINK: value = (V)(Float.valueOf(this.hints.getDownlink())); break;
case DEVICE_MEMORY: value = (V)(Float.valueOf(this.hints.getDevicePixelRatio())); break;
case SAVE_DATA: value = (V)(Boolean.valueOf(this.hints.getSaveData())); break;
case WIDTH: value = (V)(Integer.valueOf(this.hints.getWidth())); break;
case VIEWPORT_WIDTH: value = (V)(Integer.valueOf(this.hints.getViewportWidth())); break;
default:
logging.warn(format("Unrecognized client hint: '%s'.", hint.name()));
return Optional.empty();
}
return Optional.of(value);
} catch (ClassCastException cce) {
logging.warn(format("Failed to cast client hint value '%s'.", hint.name()));
}
}
return Optional.empty();
}
// -- Interface: HTTP Request -- //
/**
* Return the currently-active HTTP request object, to which the current render/controller flow is bound.
*
* @return Active HTTP request object.
*/
@SuppressWarnings("rawtypes")
public @Nonnull HttpRequest getRequest() {
return this.request;
}
/**
* Return the set of interpreted <i>Client Hints</i> headers for the current request.
*
* @return Client hints configuration.
*/
public @Nonnull ClientHints getHints() {
return this.hints;
}
// -- Interface: HTTP `ETag`s -- //
/** {@inheritDoc} */
@Override
public boolean enableETags() {
return this.builtContext != null ?
this.builtContext.getPageContext().getEtag().getEnabled() :
this.context.getEtag().getEnabled();
}
/** {@inheritDoc} */
@Override
public boolean strongETags() {
return this.builtContext != null ?
this.builtContext.getPageContext().getEtag().getStrong() :
this.context.getEtag().getStrong();
}
// -- Interface: Closeable -- //
/**
* Closes this stream and releases any system resources associated with it. If the stream is already closed then
* invoking this method has no effect.
*
* <p>As noted in {@link AutoCloseable#close()}, cases where the close may fail require careful attention. It is
* strongly advised to relinquish the underlying resources and to internally <em>mark</em> the {@code Closeable} as
* closed, prior to throwing the {@code IOException}.
*/
@Override
public void close() {
if (!this.closed.get()) {
this.render();
}
}
// -- Interface: Delegated Context -- //
/**
* Retrieve serializable server-side-rendered page context, which should be assigned to the render flow bound to this
* context mediator.
*
* @return Server-side rendered page context.
*/
@Override
public @Nonnull Context getPageContext() {
this.close();
if (this.builtContext == null) throw new IllegalStateException("Unable to read page context.");
return this.builtContext.getPageContext();
}
/**
* Retrieve properties which should be made available via regular, declared `@param` statements.
*
* @return Map of regular template properties.
*/
@Nonnull
@Override
public Map<String, Object> getProperties() {
this.close();
if (this.builtContext == null) throw new IllegalStateException("Unable to read page context.");
return this.builtContext.getProperties();
}
/**
* Retrieve properties and values that should be made available via `@inject`.
*
* @param framework Framework-injected properties.
* @return Map of injected properties and their values.
*/
@Nonnull
@Override
public Map<String, Object> getInjectedProperties(@Nonnull Map<String, Object> framework) {
this.close();
if (this.builtContext == null) throw new IllegalStateException("Unable to read page context.");
return this.builtContext.getInjectedProperties(framework);
}
/**
* Specify a Soy renaming map which overrides the globally-installed map, if any. Renaming must still be activated via
* config, or manually, for the return value of this method to have any effect.
*
* @return {@link SoyNamingMapProvider} that should be used for this render routine.
*/
@Nonnull
@Override
public Optional<SoyNamingMapProvider> overrideNamingMap() {
this.close();
if (this.builtContext == null) throw new IllegalStateException("Unable to read page context.");
return this.builtContext.overrideNamingMap();
}
/**
* Indicate whether live-reload mode is enabled or not, which is governed by the built toolchain (i.e. a Bazel
* condition, activated by the Makefile, which injects a JDK system property). Live-reload features additionally
* require dev mode to be active.
*
* @return Whether live-reload is enabled.
*/
public boolean isLiveReload() {
return liveReload;
}
}