io.telicent.smart.cache.server.jaxrs.applications.ServerBuilder Maven / Gradle / Ivy
/**
* Copyright (C) Telicent Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.telicent.smart.cache.server.jaxrs.applications;
import io.telicent.servlet.auth.jwt.PathExclusion;
import io.telicent.servlet.auth.jwt.JwtServletConstants;
import io.telicent.smart.cache.observability.LibraryVersion;
import io.telicent.smart.cache.server.jaxrs.filters.CrossOriginFilter;
import io.telicent.smart.cache.server.jaxrs.init.ServiceLoadedServletContextInitialiser;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.FilterRegistration;
import jakarta.servlet.ServletContextEvent;
import jakarta.servlet.ServletContextListener;
import jakarta.ws.rs.core.Application;
import org.apache.commons.lang3.StringUtils;
import org.glassfish.grizzly.http.HttpServerFilter;
import org.glassfish.grizzly.http.server.HttpServer;
import org.glassfish.grizzly.http.util.MimeHeaders;
import org.glassfish.grizzly.nio.transport.TCPNIOTransport;
import org.glassfish.grizzly.nio.transport.TCPNIOTransportBuilder;
import org.glassfish.grizzly.servlet.ServletRegistration;
import org.glassfish.grizzly.servlet.WebappContext;
import org.glassfish.grizzly.threadpool.ThreadPoolConfig;
import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory;
import org.glassfish.jersey.servlet.ServletContainer;
import org.glassfish.jersey.servlet.ServletProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.*;
import java.util.function.Function;
/**
* Builder for creating server instances, use {@link #create()} as the entrypoint for building a new server e.g.
*
* ServerBuilder builder
* = ServerBuilder.create()
* .port(12345)
* .application(YourApplication.class)
* .displayName("My Cool Application");
*
*
* When you are done building it call {@link #build()} to create a {@link Server} instance which you can then start and
* stop as desired.
*
* Defaults
*
* Some sensible default values are automatically set and need not be explicitly specified, the above example did not
* call {@link #hostname(String)} so the built server will listen on {@code 0.0.0.0}, aka all interfaces, by default.
* Similarly the web application will by default be deployed on the root context i.e. {@code /}, but you could deploy to
* an alternative context by calling {@link #contextPath(String)} to set a desired context e.g. {@code /app/}
*
*/
public class ServerBuilder {
private static final Logger LOGGER = LoggerFactory.getLogger(ServerBuilder.class);
/**
* The default root context path used for built servers unless customised via {@link #contextPath(String)}
*/
public static final String ROOT_CONTEXT = "/";
/**
* The default hostname on which the server should listen, this is {@code 0.0.0.0} which means it listens on all
* interfaces and hostnames on the host upon which it is run by default
*/
public static final String DEFAULT_HOSTNAME = "0.0.0.0";
/**
* The hostname for listening only on localhost, this will prevent connecting to the server from other hosts,
* including when the server is run inside a container
*/
public static final String LOCALHOST = "localhost";
private String hostname = DEFAULT_HOSTNAME, displayName, contextPath = ROOT_CONTEXT;
private int port = Integer.MIN_VALUE;
private Class extends Application> applicationClass;
private final List> listeners = new ArrayList<>();
private final List authExclusions = new ArrayList<>();
private CorsConfigurationBuilder corsBuilder = new CorsConfigurationBuilder();
private Integer maxHttpHeaderSize, maxRequestHeaders, maxResponseHeaders;
private final Map contextAttributes = new LinkedHashMap<>();
private Integer maxThreads;
/**
* Creates a new builder
*
* @return Server builder
*/
public static ServerBuilder create() {
return new ServerBuilder();
}
/**
* Creates a new builder
*/
private ServerBuilder() {
}
/**
* Specifies the hostname/IP address to listen on
*
* @param hostname Hostname/IP Address
* @return Builder
*/
public ServerBuilder hostname(String hostname) {
this.hostname = hostname;
return this;
}
/**
* Specifies that the server should listen on {@code localhost}.
*
* @return Builder
* @deprecated Listening only on {@code localhost} SHOULD be avoided as it will make the server
* inaccessible in some deployment scenarios e.g. running inside a container
*/
@Deprecated(since = "0.22.0", forRemoval = false)
public ServerBuilder localhost() {
return this.hostname(LOCALHOST);
}
/**
* Specifies that the server should listen on {@code 0.0.0.0}, effectively making it listen to all interfaces and
* hostnames
*
* @return Builder
*/
public ServerBuilder allInterfaces() {
return this.hostname(DEFAULT_HOSTNAME);
}
/**
* Specifies the port that the server should listen on
*
* @param port Port number
* @return Builder
*/
public ServerBuilder port(int port) {
if (port <= 0 || port > 65535) {
throw new IllegalArgumentException("Not a valid port in range 1-65535");
}
this.port = port;
return this;
}
/**
* Specifies the JAX-RS Application class for the server
*
* @param appClass Application class
* @return Server
*/
public ServerBuilder application(Class extends Application> appClass) {
this.applicationClass = appClass;
return this;
}
/**
* Specifies the display name for the web application context that will be deployed to the server
*
* @param name Display name
* @return Builder
*/
public ServerBuilder displayName(String name) {
this.displayName = name;
return this;
}
/**
* Specifies the context path for the web application context that will be deployed to the server. The default is
* {@code /} i.e. the web application will be deployed as the root context.
*
* @param path Context path
* @return Builder
*/
public ServerBuilder contextPath(String path) {
if (Objects.equals(path, ROOT_CONTEXT)) {
// A lone / is always a valid context path and indicates that the root context is used
// This branch of the if is needed to avoid the checks in the subsequent branches rejecting this case
} else if (!StringUtils.startsWith(path, ROOT_CONTEXT)) {
throw new IllegalArgumentException("Context path must start with a forward slash e.g. / or /app NOT app");
} else if (StringUtils.endsWith(path, ROOT_CONTEXT)) {
throw new IllegalArgumentException("Context path must not end with a forward slash e.g. /app NOT /app/");
}
this.contextPath = path;
return this;
}
/**
* Specifies that the context path for the web application should be the root context. This is equivalent to
* calling {@link #contextPath(String)} with a value of {@code /}.
*
* @return Builder
*/
public ServerBuilder rootContextPath() {
return contextPath(ROOT_CONTEXT);
}
/**
* Adds servlet context listener classes that will be deployed in the web application context
*
* @param listenerClass Listener class
* @return Builder
*/
public ServerBuilder withListener(Class extends ServletContextListener> listenerClass) {
this.listeners.add(listenerClass);
return this;
}
/**
* Enables automatic configuration initialisation via {@link ServiceLoadedServletContextInitialiser}
*
* @return Builder
*/
public ServerBuilder withAutoConfigInitialisation() {
return withListener(ServiceLoadedServletContextInitialiser.class);
}
/**
* Specifies an authentication exclusion i.e. a path (pattern) to which authentication should not apply, this can be
* used to configure some URLs as not requiring authentication, e.g. {@code /healthz}, even when your application as
* a whole may have authentication configured
*
* @param pathPattern Path pattern
* @return Builder
*/
public ServerBuilder withAuthExclusion(String pathPattern) {
return withAuthExclusion(new PathExclusion(pathPattern));
}
/**
* See {@link #withAuthExclusion(String)}
*
* @param exclusion Path exclusion
* @return Builder
*/
public ServerBuilder withAuthExclusion(PathExclusion exclusion) {
this.authExclusions.add(exclusion);
return this;
}
/**
* Specifies multiple authentication exclusions, see {@link #withAuthExclusion(String)} for more detail
*
* @param pathPatterns Path patterns
* @return Builder
*/
public ServerBuilder withAuthExclusions(String... pathPatterns) {
return withAuthExclusions(Arrays.stream(pathPatterns).map(PathExclusion::new).toList());
}
/**
* See {@link #withAuthExclusions(String...)}
*
* @param exclusions Path exclusions
* @return Builder
*/
public ServerBuilder withAuthExclusions(PathExclusion... exclusions) {
return withAuthExclusions(Arrays.stream(exclusions).toList());
}
/**
* See {@link #withAuthExclusions(String...)}
*
* @param exclusions Path exclusions
* @return Builder
*/
public ServerBuilder withAuthExclusions(List exclusions) {
if (exclusions == null || exclusions.isEmpty()) {
return this;
}
this.authExclusions.addAll(exclusions);
return this;
}
/**
* Tells the Server to attempt to make available the version information for the given library via its
* {@code /version-info} endpoint
*
* @param library Library
* @return Builder
*/
public ServerBuilder withVersionInfo(String library) {
LibraryVersion.getProperties(library);
return this;
}
/**
* Tells the Server to attempt to make available the version information for the given libraries via its
* {@code /version-info} endpoint
*
* @param libraries Libraries
* @return Builder
*/
public ServerBuilder withVersionInfo(String... libraries) {
for (String library : libraries) {
LibraryVersion.getProperties(library);
}
return this;
}
/**
* Configures the server to enable CORS (Cross Origin Resource Sharing) filtering
*
* @param builder CORS Builder
* @return Builder
*/
public ServerBuilder withCors(CorsConfigurationBuilder builder) {
this.corsBuilder = builder;
return this;
}
/**
* Configures the server to enable CORS (Cross Origin Resource Sharing) filtering
*
* @param builderFunction Function that manipulates the CORS Builder as desired
* @return Builder
*/
public ServerBuilder withCors(Function builderFunction) {
if (this.corsBuilder == null) {
this.corsBuilder = new CorsConfigurationBuilder();
}
this.corsBuilder = builderFunction.apply(this.corsBuilder);
return this;
}
/**
* Configures the server to not enable CORS (Cross Origin Resource Sharing) filtering
*
* @return Builder
*/
public ServerBuilder withoutCors() {
this.corsBuilder = null;
return this;
}
/**
* Sets the maximum number of HTTP Headers that are permitted on a request
*
* This defaults to {@value MimeHeaders#MAX_NUM_HEADERS_DEFAULT}, setting it to lower values may restrict valid
* requests. Conversely setting it to higher values, or unlimited ({@code -1}), will make the built server
* vulnerable to Denial of Service attacks from malicious clients so should be done with care.
*
*
* @param max Maximum number of HTTP Request headers, or {@code -1} for unlimited
* @return Builder
*/
public ServerBuilder maxHttpRequestHeaders(int max) {
this.maxRequestHeaders = max;
return this;
}
/**
* Sets the maximum number of HTTP Headers that are permitted on a response
*
* See {@link #maxHttpRequestHeaders(int)} for notes on default value and advice on appropriate values.
*
*
* @param max Maximum number of HTTP Response headers, or {@code -1} for unlimited
* @return Builder
*/
public ServerBuilder maxHttpResponseHeaders(int max) {
this.maxResponseHeaders = max;
return this;
}
/**
* Sets the maximum size in bytes of a single HTTP Header
*
* This defaults to {@value HttpServerFilter#DEFAULT_MAX_HTTP_PACKET_HEADER_SIZE} bytes. You should only adjust
* this value if the server is deployed in an environment where it might reasonably expect to receive larger
* headers. For example some authentication solutions involve sending very large {@code Cookie} headers that can be
* rejected by the server in its default configuration.
*
*
* Similar to the notes on {@link #maxHttpRequestHeaders(int)} changing this value should be done carefully as to
* not restrict valid requests, but also not make the server vulnerable to denial of service attacks. Unlike those
* settings a value of {@code -1} here serves to make the server use its default value of
* {@value HttpServerFilter#DEFAULT_MAX_HTTP_PACKET_HEADER_SIZE} so there is no unlimited setting available.
*
*
* @param bytes Maximum size in bytes, or {@code -1} for server default.
* @return Server builder
*/
public ServerBuilder maxHttpHeaderSize(int bytes) {
this.maxHttpHeaderSize = bytes <= 0 ? -1 : bytes;
return this;
}
/**
* Sets a context attribute that will be injected into the built servers application context
*
* @param name Name
* @param value Value
* @return Server builder
*/
public ServerBuilder withContextAttribute(String name, Object value) {
this.contextAttributes.put(name, value);
return this;
}
/**
* Sets the maximum number of threads that will be configured for the servers thread pool.
*
* Note that the underlying server runtime may use multiple thread pools for different purposes and thus the value
* given here is not an absolute maximum, but rather a maximum for each thread pool.
*
*
* Callers should be careful to set this to an appropriate value for their use case, in most cases the default
* setting, which is automatically detected based upon the number of available processors at runtime is reasonable
* if the server is the primary component of the application. However, a server being built to be a lightweight
* embedded component in a larger application may wish to set a smaller value to constrain its potential resource
* usage.
*
*
* @param max Maximum threads
* @return Server builder
*/
public ServerBuilder withMaxThreads(int max) {
this.maxThreads = max;
return this;
}
/**
* Attempts to build the actual server instance
*
* @return Built server
*/
public Server build() {
// Validate all required parameters are set
if (this.applicationClass == null) {
throw new IllegalStateException("Failed to specify an application class for the server");
}
if (this.port <= 0) {
throw new IllegalStateException("Failed to specify a port for the server");
}
if (StringUtils.isBlank(this.displayName)) {
throw new IllegalStateException("Failed to specify a display name for the server");
}
WebappContext context = new WebappContext(this.displayName, this.contextPath);
// Add the JAX-RS application servlet
ServletRegistration registration =
context.addServlet(ServletContainer.class.getCanonicalName(), ServletContainer.class);
registration.addMapping("/*");
registration.setInitParameter(ServletProperties.JAXRS_APPLICATION_CLASS,
this.applicationClass.getCanonicalName());
// Configure CORS
if (this.corsBuilder != null) {
FilterRegistration corsRegistration = context.addFilter("CORS", CrossOriginFilter.class);
corsRegistration.setInitParameters(this.corsBuilder.buildInitParameters());
corsRegistration.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/*");
} else {
LOGGER.warn(
"ServerBuilder has explicitly disabled CORS, browsers will not be able to interact with this server as a result!");
}
// Add our context listeners that initialize the application
for (Class extends ServletContextListener> listener : this.listeners) {
context.addListener(listener);
}
// Add authentication exclusions (if any)
// We intentionally dynamically configure this because we don't want to rely on configuring authentication
// exclusions from the environment as we do with JwtAuthInitializer rather we want this to be explicit
// configuration on the part of the application developer in knowing what paths should and shouldn't have
// authentication applied.
if (!this.authExclusions.isEmpty()) {
final List builtExclusions = new ArrayList<>(this.authExclusions);
context.addListener(new ServletContextListener() {
@Override
public void contextInitialized(ServletContextEvent sce) {
sce.getServletContext()
.setAttribute(JwtServletConstants.ATTRIBUTE_PATH_EXCLUSIONS, builtExclusions);
}
@Override
public void contextDestroyed(ServletContextEvent sce) {
sce.getServletContext().removeAttribute(JwtServletConstants.ATTRIBUTE_PATH_EXCLUSIONS);
}
});
}
// Any injected context
for (Map.Entry attribute : this.contextAttributes.entrySet()) {
context.setAttribute(attribute.getKey(), attribute.getValue());
}
try {
URI baseUri = new URI(String.format("http://%s:%d", this.hostname, this.port));
HttpServer server = GrizzlyHttpServerFactory.createHttpServer(baseUri, false);
// As most of our APIs are dealing with data that is expressed as RDF knowledge the identifiers will be URIs
// meaning that there's a strong chance we'll see encoded slashes - %2F - in our URIs where they are used
// as path parameters. Therefore, we need to enable this explicitly as the default server behaviour forbids
// this.
server.getHttpHandler().setAllowEncodedSlash(true);
// In some deployment scenarios we can see very large headers, so we expose some controls to customise the
// acceptable header quantities and sizes. These are applied to the server being built now
server.getListeners().forEach(l -> {
if (this.maxHttpHeaderSize != null) {
l.setMaxHttpHeaderSize(this.maxHttpHeaderSize);
}
if (this.maxRequestHeaders != null) {
l.setMaxRequestHeaders(this.maxRequestHeaders);
}
if (this.maxResponseHeaders != null) {
l.setMaxResponseHeaders(this.maxResponseHeaders);
}
});
// Allow for configuring the thread pool
if (this.maxThreads != null) {
final TCPNIOTransportBuilder builder = TCPNIOTransportBuilder.newInstance();
final ThreadPoolConfig config = ThreadPoolConfig.defaultConfig();
config.setCorePoolSize(this.maxThreads).setMaxPoolSize(this.maxThreads).setQueueLimit(-1);
final TCPNIOTransport transport = builder.setWorkerThreadPoolConfig(config)
.setSelectorThreadPoolConfig(config)
.setSelectorRunnersCount(this.maxThreads)
.build();
server.getListeners().forEach(l -> l.setTransport(transport));
}
return new Server(server, baseUri, context, this.displayName);
} catch (URISyntaxException e) {
throw new IllegalStateException(
"Failed to create a server as the hostname and port provided did not produce a valid URI", e);
}
}
}