/*
* Copyright (c) 2013-2015 the original author or authors
*
* 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.werval.runtime;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.HttpCookie;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import io.werval.api.Application;
import io.werval.api.ApplicationExecutors;
import io.werval.api.Config;
import io.werval.api.Crypto;
import io.werval.api.Errors;
import io.werval.api.Global;
import io.werval.api.MetaData;
import io.werval.api.Mode;
import io.werval.api.cache.Cache;
import io.werval.api.context.Context;
import io.werval.api.context.ThreadContextHelper;
import io.werval.api.exceptions.ParameterBinderException;
import io.werval.api.exceptions.ParameterBindingException;
import io.werval.api.exceptions.PassivationException;
import io.werval.api.exceptions.WervalException;
import io.werval.api.exceptions.RouteNotFoundException;
import io.werval.api.filters.FilterChain;
import io.werval.api.http.Cookies.Cookie;
import io.werval.api.http.FormUploads;
import io.werval.api.http.ProtocolVersion;
import io.werval.api.http.Request;
import io.werval.api.http.RequestHeader;
import io.werval.api.http.Session;
import io.werval.api.http.Status;
import io.werval.api.i18n.Langs;
import io.werval.api.mime.MimeTypes;
import io.werval.api.outcomes.DefaultErrorOutcomes;
import io.werval.api.outcomes.Outcome;
import io.werval.api.outcomes.OutcomeBuilder;
import io.werval.api.outcomes.Outcomes;
import io.werval.api.routes.ParameterBinder;
import io.werval.api.routes.ParameterBinders;
import io.werval.api.routes.ReverseRoutes;
import io.werval.api.routes.Route;
import io.werval.api.routes.Routes;
import io.werval.api.templates.Templates;
import io.werval.util.Reflectively;
import io.werval.util.Stacktraces;
import io.werval.spi.ApplicationSPI;
import io.werval.spi.dev.DevShellSPI;
import io.werval.spi.http.HttpBuildersSPI;
import io.werval.spi.events.EventsSPI;
import io.werval.runtime.context.ContextInstance;
import io.werval.runtime.exceptions.BadRequestException;
import io.werval.runtime.filters.FilterChainFactory;
import io.werval.runtime.http.HttpBuildersInstance;
import io.werval.runtime.events.EventsInstance;
import io.werval.runtime.http.ResponseHeaderInstance;
import io.werval.runtime.http.SessionInstance;
import io.werval.runtime.i18n.LangsInstance;
import io.werval.runtime.mime.MimeTypesInstance;
import io.werval.runtime.outcomes.OutcomesInstance;
import io.werval.runtime.routes.ParameterBindersInstance;
import io.werval.runtime.routes.ReverseRoutesInstance;
import io.werval.runtime.routes.RoutesConfProvider;
import io.werval.runtime.routes.RoutesInstance;
import io.werval.runtime.routes.RoutesProvider;
import io.werval.runtime.util.TypeResolver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static io.werval.api.http.Headers.Names.CONNECTION;
import static io.werval.api.http.Headers.Names.COOKIE;
import static io.werval.api.http.Headers.Names.RETRY_AFTER;
import static io.werval.api.http.Headers.Names.SET_COOKIE;
import static io.werval.api.http.Headers.Names.X_WERVAL_REQUEST_ID;
import static io.werval.api.http.Headers.Values.CLOSE;
import static io.werval.api.http.Headers.Values.KEEP_ALIVE;
import static io.werval.api.mime.MimeTypes.TEXT_HTML;
import static io.werval.runtime.BuildVersion.COMMIT;
import static io.werval.runtime.BuildVersion.DATE;
import static io.werval.runtime.BuildVersion.DIRTY;
import static io.werval.runtime.BuildVersion.VERSION;
import static io.werval.runtime.ConfigKeys.APP_BANNER;
import static io.werval.runtime.ConfigKeys.APP_GLOBAL;
import static io.werval.runtime.ConfigKeys.APP_LANGS;
import static io.werval.runtime.ConfigKeys.APP_SECRET;
import static io.werval.runtime.ConfigKeys.APP_SESSION_COOKIE_NAME;
import static io.werval.runtime.ConfigKeys.APP_SESSION_COOKIE_ONLYIFCHANGED;
import static io.werval.runtime.ConfigKeys.WERVAL_CHARACTER_ENCODING;
import static io.werval.runtime.ConfigKeys.WERVAL_HTTP_FORMS_MULTIVALUED;
import static io.werval.runtime.ConfigKeys.WERVAL_HTTP_HEADERS_MULTIVALUED;
import static io.werval.runtime.ConfigKeys.WERVAL_HTTP_QUERYSTRING_MULTIVALUED;
import static io.werval.runtime.ConfigKeys.WERVAL_HTTP_UPLOADS_MULTIVALUED;
import static io.werval.runtime.ConfigKeys.WERVAL_MIMETYPES_SUPPLEMENTARY;
import static io.werval.runtime.ConfigKeys.WERVAL_MIMETYPES_TEXTUAL;
import static io.werval.runtime.ConfigKeys.WERVAL_ROUTES_PARAMETERBINDERS;
import static io.werval.runtime.ConfigKeys.WERVAL_SHUTDOWN_RETRYAFTER;
import static io.werval.runtime.ConfigKeys.WERVAL_TMPDIR;
import static io.werval.util.IllegalArguments.ensureNotNull;
import static io.werval.util.InputStreams.BUF_SIZE_4K;
import static io.werval.util.InputStreams.transferTo;
import static io.werval.util.Strings.NEWLINE;
import static io.werval.util.Strings.hasText;
import static io.werval.util.Strings.indentTwoSpaces;
/**
* An Application Instance.
*
* Application Mode defaults to {@link Mode#TEST}.
* <p>
* Application Config behaviour is the very same whatever the Mode is.
* <p>
* Application ClassLoader defaults to the ClassLoader that defined the ApplicationInstance class.
* <p>
* Application Routes are fetched from a given RoutesProvider.
* <p>
* Others are based on Application Config and created by Application instances.
*/
@Reflectively.Loaded( by = "DevShell" )
public final class ApplicationInstance
implements Application, ApplicationSPI
{
private static final Logger LOG = LoggerFactory.getLogger( ApplicationInstance.class );
static
{
io.werval.runtime.util.Versions.ensureComponentsVersions();
}
private volatile boolean activated = false;
private volatile boolean activatingOrPassivating = false;
private final Mode mode;
private ConfigInstance config;
private PluginsInstance plugins;
private Global global;
private Crypto crypto;
private Langs langs;
private Charset defaultCharset;
private File tmpdir;
private ClassLoader classLoader;
private final RoutesProvider routesProvider;
private Routes routes;
private ReverseRoutes reverseRoutes;
private ParameterBinders parameterBinders;
private MimeTypes mimeTypes;
private HttpBuildersSPI httpBuilders;
private ApplicationExecutorsInstance executors;
private final MetaData metaData;
private final EventsInstance events;
private final Errors errors;
private final DevShellSPI devSpi;
/**
* Create a new Application instance in {@link Mode#PROD} mode.
*
* Routes are loaded from the {@literal routes.conf} file.
* <p>
* Use the ClassLoader that loaded the {@link ApplicationInstance} class as Application ClassLoader.
*/
public ApplicationInstance()
{
this( Mode.PROD, new RoutesConfProvider() );
}
/**
* Create a new Application instance in given {@link Mode}.
*
* Routes are loaded from the {@literal routes.conf} file.
* <p>
* Use the ClassLoader that loaded the {@link ApplicationInstance} class as Application ClassLoader.
*
* @param mode Application Mode, must be not null
*/
public ApplicationInstance( Mode mode )
{
this( mode, new RoutesConfProvider() );
}
/**
* Create a new Application instance in given {@link Mode}.
*
* Use the ClassLoader that loaded the {@link ApplicationInstance} class as Application ClassLoader.
*
* @param mode Application Mode, must be not null
* @param routesProvider Routes provider, must be not null
*/
public ApplicationInstance( Mode mode, RoutesProvider routesProvider )
{
this(
mode,
new ConfigInstance( ApplicationInstance.class.getClassLoader() ),
ApplicationInstance.class.getClassLoader(),
routesProvider,
null
);
}
/**
* Create a new Application instance in given {@link Mode}.
*
* @param mode Application Mode, must be not null
* @param config Application config, must be not null
* @param classLoader Application ClassLoader, must be not null
* @param routesProvider Routes provider, must be not null
*/
public ApplicationInstance(
Mode mode,
ConfigInstance config,
ClassLoader classLoader,
RoutesProvider routesProvider
)
{
this( mode, config, classLoader, routesProvider, null );
}
/**
* Create a new Application instance in given {@link Mode}.
*
* @param mode Application Mode, must be not null
* @param config Application config, must be not null
* @param classLoader Application ClassLoader, must be not null
* @param devSpi DevShell SPI, can be null
*/
@Reflectively.Invoked( by = "DevShell" )
public ApplicationInstance(
Mode mode,
ConfigInstance config,
ClassLoader classLoader,
DevShellSPI devSpi
)
{
this( mode, config, classLoader, new RoutesConfProvider(), devSpi );
}
private ApplicationInstance(
Mode mode,
ConfigInstance config,
ClassLoader classLoader,
RoutesProvider routesProvider,
DevShellSPI devSpi
)
{
ensureNotNull( "Application Mode", mode );
ensureNotNull( "Application Config", config );
ensureNotNull( "Application ClassLoader", classLoader );
ensureNotNull( "Application RoutesProvider", routesProvider );
if( mode == Mode.DEV )
{
// Disable TypeResolver caching in Development Mode
TypeResolver.disableCache();
}
this.mode = mode;
this.config = config;
this.routesProvider = routesProvider;
this.reverseRoutes = new ReverseRoutesInstance( this );
this.classLoader = classLoader;
this.metaData = new MetaData();
this.events = new EventsInstance( this );
this.errors = new ErrorsInstance( config );
this.devSpi = devSpi;
if( mode == Mode.DEV && LOG.isDebugEnabled() )
{
LOG.debug( "Runtime classpath: {}", Arrays.toString( devSpi.runtimeClassPath() ) );
LOG.debug( "Application classpath: {}", Arrays.toString( devSpi.applicationClassPath() ) );
}
configure();
showBanner();
}
@Override
public synchronized void activate()
{
if( activated )
{
throw new IllegalStateException( "Application already activated." );
}
ClassLoader previousLoader = Thread.currentThread().getContextClassLoader();
Thread.currentThread().setContextClassLoader( classLoader );
activatingOrPassivating = true;
try
{
// Clear Errors and Events
errors.clear();
events.unregisterAll();
// Executors
executors = new ApplicationExecutorsInstance( this );
executors.activate();
// Global
String globalClassName = config.string( APP_GLOBAL );
this.global = executors.supplyAsync(
() ->
{
try
{
return (Global) classLoader.loadClass( globalClassName ).newInstance();
}
catch( ClassNotFoundException | ClassCastException |
InstantiationException | IllegalAccessException ex )
{
throw new WervalException( "Invalid Global class: " + globalClassName, ex );
}
}
).join();
// Application Routes
List<Route> resolvedRoutes = new ArrayList<>();
resolvedRoutes.addAll( executors.supplyAsync( () -> routesProvider.routes( this ) ).join() );
// Activate Plugins
plugins = new PluginsInstance();
executors.runAsync( () -> plugins.onActivate( this ) ).join();
// Plugin contributed Routes
resolvedRoutes.addAll( 0, executors.supplyAsync( () -> plugins.firstRoutes( this ) ).join() );
resolvedRoutes.addAll( executors.supplyAsync( () -> plugins.lastRoutes( this ) ).join() );
routes = new RoutesInstance( resolvedRoutes );
// Activated
activated = true;
executors.runAsync( () -> global.onActivate( this ) ).join();
}
finally
{
Thread.currentThread().setContextClassLoader( previousLoader );
activatingOrPassivating = false;
}
if( LOG.isInfoEnabled() )
{
StringBuilder runtimeSummary = new StringBuilder( "Runtime Summary\n\n" );
String header = String.format(
"Werval v%s\n"
+ " Git commit: %s%s, built on: %s\n"
+ " Java version: %s, vendor: %s\n"
+ " Java home: %s\n"
+ " Default locale: %s, platform encoding: %s\n"
+ " OS name: %s, version: %s, arch: %s\n",
VERSION,
COMMIT,
( DIRTY ? " (DIRTY)" : "" ),
DATE,
System.getProperty( "java.version" ),
System.getProperty( "java.vendor" ),
System.getProperty( "java.home" ),
Locale.getDefault().toString(),
System.getProperty( "file.encoding" ),
System.getProperty( "os.name" ),
System.getProperty( "os.version" ),
System.getProperty( "os.arch" )
);
runtimeSummary.append( indentTwoSpaces( header, 1 ) ).append( NEWLINE ).append( NEWLINE );
String configLocation = config.location().toStringShort();
if( hasText( configLocation ) )
{
runtimeSummary
.append( indentTwoSpaces( "Configuration", 1 ) )
.append( NEWLINE )
.append( indentTwoSpaces( configLocation, 2 ) )
.append( NEWLINE )
.append( NEWLINE );
}
String allRoutes = routes.toString();
if( hasText( allRoutes ) )
{
runtimeSummary
.append( indentTwoSpaces( "Routes", 1 ) )
.append( NEWLINE )
.append( indentTwoSpaces( allRoutes, 2 ) )
.append( NEWLINE )
.append( NEWLINE );
}
runtimeSummary
.append( indentTwoSpaces( "Executors", 1 ) )
.append( NEWLINE )
.append( indentTwoSpaces( executors.toString(), 2 ) )
.append( NEWLINE );
String allPlugins = plugins.toString();
if( hasText( allPlugins ) )
{
runtimeSummary
.append( NEWLINE )
.append( indentTwoSpaces( "Plugins", 1 ) )
.append( NEWLINE )
.append( indentTwoSpaces( allPlugins, 2 ) )
.append( NEWLINE );
}
LOG.info( runtimeSummary.toString() );
}
LOG.debug( "Application Activated ({} mode)", mode );
}
@Override
public synchronized void passivate()
{
if( !activated )
{
throw new IllegalStateException( "Application already passivated." );
}
ClassLoader previousLoader = Thread.currentThread().getContextClassLoader();
Thread.currentThread().setContextClassLoader( classLoader );
activatingOrPassivating = true;
try
{
List<Exception> passivationErrors = new ArrayList<>();
try
{
executors.runAsync( () -> global.onPassivate( this ) ).join();
}
catch( Exception ex )
{
passivationErrors.add(
new PassivationException( "Exception(s) on Global::onPassivate(): " + ex.getMessage(), ex )
);
}
try
{
executors.runAsync( () -> plugins.onPassivate( this ) ).join();
}
catch( Exception ex )
{
passivationErrors.add(
new PassivationException( "Exception(s) on Plugins::onPassivate(): " + ex.getMessage(), ex )
);
}
try
{
executors.passivate();
}
catch( Exception ex )
{
passivationErrors.add(
new PassivationException( "Exception(s) on Executors::onPassivate(): " + ex.getMessage(), ex )
);
}
if( !passivationErrors.isEmpty() )
{
PassivationException ex = new PassivationException(
"There were errors during Application passivation"
);
for( Exception err : passivationErrors )
{
ex.addSuppressed( err );
}
throw ex;
}
}
finally
{
Thread.currentThread().setContextClassLoader( previousLoader );
activated = false;
activatingOrPassivating = false;
}
LOG.debug( "Application Passivated" );
}
@Override
public boolean isActive()
{
return activated;
}
@Override
public Mode mode()
{
return mode;
}
@Override
public Config config()
{
return config;
}
@Override
public <T> T plugin( Class<T> pluginApiType )
{
ensureActive();
return plugins.plugin( pluginApiType );
}
@Override
public <T> Iterable<T> plugins( Class<T> pluginApiType )
{
ensureActive();
return plugins.plugins( pluginApiType );
}
@Override
public Crypto crypto()
{
return crypto;
}
@Override
public Langs langs()
{
return langs;
}
@Override
public Charset defaultCharset()
{
return defaultCharset;
}
@Override
public File tmpdir()
{
return tmpdir;
}
@Override
public ClassLoader classLoader()
{
return classLoader;
}
@Override
public Routes routes()
{
ensureActive();
return routes;
}
@Override
public ReverseRoutes reverseRoutes()
{
ensureActive();
return reverseRoutes;
}
@Override
public ParameterBinders parameterBinders()
{
return parameterBinders;
}
@Override
public MimeTypes mimeTypes()
{
return mimeTypes;
}
@Override
public MetaData metaData()
{
return metaData;
}
@Override
public Cache cache()
{
return plugin( Cache.class );
}
@Override
public Templates templates()
{
return plugin( Templates.class );
}
@Override
public Errors errors()
{
return errors;
}
@Override
public Stacktraces.FileURLGenerator sourceFileURLGenerator()
{
if( devSpi != null )
{
return devSpi::sourceURL;
}
return new Stacktraces.NullFileURLGenerator();
}
// SPI
@Override
public Global global()
{
ensureActive();
return global;
}
// SPI
@Override
@Reflectively.Invoked( by = "DevShell" )
public void reload( ClassLoader newClassLoader )
{
if( activated )
{
passivate();
}
this.classLoader = newClassLoader;
this.config = new ConfigInstance( newClassLoader, config.location() );
configure();
activate();
}
@Override
public ApplicationExecutors executors()
{
ensureActive();
return executors;
}
// API/SPI
@Override
public EventsSPI events()
{
return events;
}
// API/SPI
@Override
public HttpBuildersSPI httpBuilders()
{
return httpBuilders;
}
// SPI
@Override
public CompletableFuture<Outcome> handleRequest( Request request )
{
ensureActive();
return executors.supplyAsync(
() ->
{
// Prepare Controller Context
ThreadContextHelper contextHelper = new ThreadContextHelper();
try
{
// Validates incoming request
validatesRequestHeader( request );
validatesRequestBody( request );
// Route the request
final Route route = routes().route( request );
LOG.debug( "Routing to: {}", route );
// Bind parameters
request.bind( parameterBinders(), route );
// Parse Session Cookie
Session session = new SessionInstance(
config,
crypto(),
request.cookies().get( config.string( APP_SESSION_COOKIE_NAME ) )
);
// Prepare Response Header
ResponseHeaderInstance responseHeader = new ResponseHeaderInstance( request.version() );
// Set Controller Context
Context context = new ContextInstance(
this,
session, route, request,
responseHeader,
executors.defaultExecutor()
);
contextHelper.setOnCurrentThread( context );
// Plugins beforeInteraction
plugins.beforeInteraction( context );
try
{
// Invoke Controller FilterChain, ended by Controller Method Invokation
// TODO Handle Timeout when invoking Controller FilterChain!
LOG.trace( "Invoking interaction method: {}", route.controllerMethod() );
FilterChain chain = new FilterChainFactory().buildFilterChain( this, global, context );
CompletableFuture<Outcome> interaction = chain.next( context );
Outcome outcome = interaction.get( 30, TimeUnit.SECONDS );
// Apply Session to ResponseHeader
if( !config.bool( APP_SESSION_COOKIE_ONLYIFCHANGED ) || session.hasChanged() )
{
outcome.responseHeader().cookies().set( session.signedCookie() );
}
// Add Set-Cookie headers
for( Cookie cookie : outcome.responseHeader().cookies() )
{
HttpCookie jCookie = new HttpCookie( cookie.name(), cookie.value() );
jCookie.setVersion( cookie.version() );
jCookie.setPath( cookie.path() );
jCookie.setDomain( cookie.domain() );
jCookie.setMaxAge( cookie.maxAge() );
jCookie.setSecure( cookie.secure() );
jCookie.setHttpOnly( cookie.httpOnly() );
jCookie.setComment( cookie.comment().isPresent() ? cookie.comment().get() : null );
jCookie.setCommentURL( cookie.commentUrl().isPresent() ? cookie.commentUrl().get() : null );
outcome.responseHeader().headers().with( SET_COOKIE, jCookie.toString() );
}
// Finalize!
finalizeOutcome( request, outcome );
// Done!
LOG.trace( "Interaction outcome: {}", outcome );
return outcome;
}
finally
{
// Plugins afterInteraction
plugins.afterInteraction( context );
}
}
catch( Throwable cause )
{
// Handle error
return handleError( request, cause );
}
finally
{
// Clean up Controller Context
contextHelper.clearCurrentThread();
}
}
);
}
private void validatesRequestHeader( RequestHeader requestHeader )
{
// Multi-valued QueryString parameters
if( !config.bool( WERVAL_HTTP_QUERYSTRING_MULTIVALUED ) )
{
for( List<String> values : requestHeader.queryString().allValues().values() )
{
if( values.size() > 1 )
{
throw new BadRequestException( "Multi-valued query string parameters are not allowed" );
}
}
}
// Multi-valued Headers
if( !config.bool( WERVAL_HTTP_HEADERS_MULTIVALUED ) )
{
for( List<String> values : requestHeader.headers().allValues().values() )
{
if( values.size() > 1 )
{
throw new BadRequestException( "Multi-valued headers are not allowed" );
}
}
}
// Some are prohibited anyway
if( requestHeader.headers().values( COOKIE ).size() > 1 )
{
throw new BadRequestException(
"RFC 6265 - 5.4. The Cookie Header - When the user agent generates an HTTP request, "
+ "the user agent MUST NOT attach more than one Cookie header field."
);
}
// TODO Multi-valued Cookies
}
private void validatesRequestBody( Request request )
{
// Multi-valued Form Attributes
if( !config.bool( WERVAL_HTTP_FORMS_MULTIVALUED ) )
{
for( List<String> values : request.body().formAttributes().allValues().values() )
{
if( values.size() > 1 )
{
throw new BadRequestException( "Multi-valued form attributes are not allowed" );
}
}
}
// Multi-valued Form Uploads
if( !config.bool( WERVAL_HTTP_UPLOADS_MULTIVALUED ) )
{
for( List<FormUploads.Upload> values : request.body().formUploads().allValues().values() )
{
if( values.size() > 1 )
{
throw new BadRequestException( "Multi-valued form uploads are not allowed" );
}
}
}
}
// SPI
@Override
public Outcome handleError( RequestHeader request, Throwable cause )
{
// Clean-up stacktrace
final Throwable rootCause = ErrorHandling.cleanUpStackTrace( this, LOG, cause );
// Outcomes
Outcomes outcomes = new OutcomesInstance(
config,
mimeTypes,
new ResponseHeaderInstance( request.version() )
);
// Handle contingencies
if( rootCause instanceof RouteNotFoundException )
{
LOG.trace( rootCause.getMessage() + " will return 404." );
StringBuilder details = new StringBuilder();
if( mode == Mode.DEV )
{
if( TEXT_HTML.equals( request.preferredMimeType() ) )
{
details.append( "<p>Tried:</p>\n<pre>\n" );
}
else
{
details.append( "Tried:\n\n" );
}
for( Route route : routes )
{
if( !route.path().startsWith( "/@" ) )
{
details.append( route.toString() ).append( NEWLINE );
}
}
if( TEXT_HTML.equals( request.preferredMimeType() ) )
{
details.append( "</pre>\n" );
}
}
return finalizeOutcome(
request,
DefaultErrorOutcomes.errorOutcome(
request,
Status.NOT_FOUND,
"404 Route Not Found",
details.toString(),
outcomes
).build()
);
}
else if( rootCause instanceof ParameterBindingException )
{
if( mode == Mode.PROD )
{
LOG.trace( "ParameterBindingException, will return 404.", rootCause );
}
else
{
LOG.warn( "ParameterBindingException, will return 404.", rootCause );
}
return finalizeOutcome(
request,
DefaultErrorOutcomes.errorOutcome(
request,
Status.NOT_FOUND,
"404 Route Not Found",
rootCause.getMessage(),
outcomes
).build()
);
}
else if( rootCause instanceof BadRequestException )
{
if( mode == Mode.PROD )
{
LOG.trace( "BadRequestException, will return 400.", rootCause );
}
else
{
LOG.warn( "BadRequestException, will return 400.", rootCause );
}
return finalizeOutcome(
request,
DefaultErrorOutcomes.errorOutcome(
request,
Status.BAD_REQUEST,
Status.BAD_REQUEST.reasonPhrase(),
rootCause.getMessage(),
outcomes
).build()
);
}
// Handle faults
Outcome outcome;
try
{
// Delegates Outcome generation to Global object
if( executors.inDefaultExecutor() )
{
outcome = global.onRequestError( this, request, outcomes, rootCause );
}
else
{
outcome = executors.supplyAsync(
() -> global.onRequestError( this, request, outcomes, rootCause )
).join();
}
}
catch( Exception ex )
{
// Add as suppressed and replay Global default behaviour. This serve as a fault barrier
rootCause.addSuppressed( ex );
if( executors.inDefaultExecutor() )
{
outcome = new Global().onRequestError( this, request, outcomes, rootCause );
}
else
{
outcome = executors.supplyAsync(
() -> new Global().onRequestError( this, request, outcomes, rootCause )
).join();
}
}
// Record error
errors.record( request.identity(), rootCause.getMessage(), rootCause );
// Done!
return finalizeOutcome( request, outcome );
}
private Outcome finalizeOutcome( RequestHeader request, Outcome outcome )
{
// Apply Keep-Alive
applyKeepAlive( request, outcome );
// Add X-Werval-Request-ID
outcome.responseHeader().headers().withSingle( X_WERVAL_REQUEST_ID, request.identity() );
return outcome;
}
/**
* Apply Keep-Alive headers if needed.
*
* Do nothing if the Outcome already has a {@literal Connection} header.
* <p>
* If status is an error or unknown status, {@literal Connection} is set to {@literal Close} if protocol version
* use Keep-Alive by default or if request is not Keep-Alive.
* <p>
* Otherwise, set {@literal Connection} to {@link Keep-Alive} if protocol version use Keep-Alive by default
* or if request is Keep-Alive.
*/
private void applyKeepAlive( RequestHeader request, Outcome outcome )
{
Optional<String> connection = outcome.responseHeader().headers().singleValueOptional( CONNECTION );
if( !connection.isPresent() )
{
if( outcome.responseHeader().status().statusClass().isForceClose() || !request.isKeepAlive() )
{
outcome.responseHeader().headers().withSingle( CONNECTION, CLOSE );
}
else
{
outcome.responseHeader().headers().withSingle( CONNECTION, KEEP_ALIVE );
}
}
}
// SPI
@Override
public void onHttpRequestComplete( RequestHeader requestHeader )
{
executors.runAsync(
() -> global.onHttpRequestComplete( this, requestHeader )
).exceptionally(
ex ->
{
LOG.error( "An error occured in Global::onHttpRequestComplete(): {}", ex.getMessage(), ex );
return null;
}
);
}
// SPI
@Override
public CompletableFuture<Outcome> shuttingDownOutcome( ProtocolVersion version, String requestIdentity )
{
return executors.supplyAsync(
() ->
{
// Outcomes
Outcomes outcomes = new OutcomesInstance(
config,
mimeTypes,
new ResponseHeaderInstance( version )
);
// Return 503 to incoming requests while shutting down
OutcomeBuilder builder = DefaultErrorOutcomes.errorOutcome(
null,
Status.SERVICE_UNAVAILABLE,
Status.SERVICE_UNAVAILABLE.reasonPhrase(),
"Service is shutting down",
outcomes
);
builder.withHeader( CONNECTION, CLOSE ).withHeader( X_WERVAL_REQUEST_ID, requestIdentity );
// By default, no Retry-After, only if defined in configuration
if( config.has( WERVAL_SHUTDOWN_RETRYAFTER ) )
{
builder.withHeader( RETRY_AFTER, String.valueOf( config.seconds( WERVAL_SHUTDOWN_RETRYAFTER ) ) );
}
// Build!
return builder.build();
}
);
}
private void ensureActive()
{
if( !activated && !activatingOrPassivating )
{
throw new IllegalStateException( "Application is not active." );
}
}
private void configure()
{
configureDefaultCharset();
configureCrypto();
configureLangs();
configureTmpdir();
configureParameterBinders();
configureMimeTypes();
configureHttpBuilders();
}
private void configureDefaultCharset()
{
this.defaultCharset = config.charset( WERVAL_CHARACTER_ENCODING );
}
private void configureCrypto()
{
this.crypto = new CryptoInstance( config.string( APP_SECRET ), defaultCharset );
}
private void configureLangs()
{
this.langs = new LangsInstance( config.stringList( APP_LANGS ) );
}
private void configureTmpdir()
{
File tmpdirFile = config.file( WERVAL_TMPDIR );
if( tmpdirFile.isFile() )
{
throw new WervalException( "tmpdir already exist but is a file: " + tmpdirFile );
}
if( !tmpdirFile.exists() && !tmpdirFile.mkdirs() )
{
throw new WervalException( "Unable to create non existant tmpdir: " + tmpdirFile );
}
tmpdir = tmpdirFile;
}
private void configureParameterBinders()
{
List<ParameterBinder<?>> list = new ArrayList<>();
for( String parameterBinderClassName : config.stringList( WERVAL_ROUTES_PARAMETERBINDERS ) )
{
try
{
ParameterBinder<?> binder = (ParameterBinder<?>) classLoader
.loadClass( parameterBinderClassName )
.newInstance();
binder.init( this );
list.add( binder );
}
catch( ClassNotFoundException | InstantiationException | IllegalAccessException | ClassCastException ex )
{
throw new ParameterBinderException(
"Unable to instanciate ParameterBinders, failed at: " + parameterBinderClassName,
ex
);
}
}
parameterBinders = new ParameterBindersInstance( list );
}
private void configureMimeTypes()
{
// Load textuals mime-types
Map<String, Charset> textuals = new LinkedHashMap<>();
for( Map.Entry<String, String> textConfig : config.stringMap( WERVAL_MIMETYPES_TEXTUAL ).entrySet() )
{
String mimetype = textConfig.getKey();
String charsetString = textConfig.getValue().trim();
Charset charset = "default".equals( charsetString )
? defaultCharset
: Charset.forName( charsetString );
textuals.put( mimetype, charset );
}
// Load supplementary mime-types
if( config.has( WERVAL_MIMETYPES_SUPPLEMENTARY ) )
{
Map<String, String> supplementaryMimetypes = config.stringMap( WERVAL_MIMETYPES_SUPPLEMENTARY );
mimeTypes = new MimeTypesInstance( defaultCharset, supplementaryMimetypes, textuals );
}
else
{
mimeTypes = new MimeTypesInstance( defaultCharset, textuals );
}
}
private void configureHttpBuilders()
{
httpBuilders = new HttpBuildersInstance( config, defaultCharset, langs );
}
private void showBanner()
{
try( InputStream input = classLoader.getResourceAsStream( config.string( APP_BANNER ) ) )
{
if( input != null )
{
transferTo( input, System.out, BUF_SIZE_4K );
}
}
catch( IOException ex )
{
throw new UncheckedIOException( ex );
}
}
}