/*
* Copyright (c) 2013-2014 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.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.URL;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
import java.util.Stack;
import io.werval.api.Application;
import io.werval.api.Mode;
import io.werval.api.Plugin;
import io.werval.api.context.Context;
import io.werval.api.exceptions.PassivationException;
import io.werval.api.exceptions.WervalException;
import io.werval.api.routes.Route;
import io.werval.runtime.routes.RouteBuilderInstance;
import static java.util.Collections.emptyList;
import static java.util.stream.Collectors.toList;
import static io.werval.util.IllegalArguments.ensureNotNull;
import static io.werval.util.Strings.EMPTY;
import static io.werval.util.Strings.NEWLINE;
import static io.werval.util.Strings.rightPad;
/**
* Plugins Instance.
* <p>
* Manage Plugins lifecycle and provide lookup for {@link ApplicationInstance}.
*/
/* package */ class PluginsInstance
{
/**
* Plugin Information.
* <p>
* For internal use only.
* Ties a Plugin to its runtime configuration properties.
*/
private static final class PluginInfo
{
private final Plugin<?> plugin;
private final String routesPrefix;
private PluginInfo( Plugin<?> plugin, String routesPrefix )
{
this.plugin = plugin;
this.routesPrefix = routesPrefix == null ? EMPTY : routesPrefix;
}
@Override
public int hashCode()
{
int hash = 5;
hash = 17 * hash + Objects.hashCode( this.plugin );
hash = 17 * hash + Objects.hashCode( this.routesPrefix );
return hash;
}
@Override
public boolean equals( Object obj )
{
if( this == obj )
{
return true;
}
if( obj == null )
{
return false;
}
if( getClass() != obj.getClass() )
{
return false;
}
final PluginInfo other = (PluginInfo) obj;
if( !Objects.equals( this.plugin, other.plugin ) )
{
return false;
}
return Objects.equals( this.routesPrefix, other.routesPrefix );
}
@Override
public String toString()
{
return plugin.getClass().getName();
}
}
private volatile boolean activated = false;
private boolean activatingOrPassivating = false;
private List<PluginInfo> activePlugins = emptyList();
/**
* Activate Plugins.
*
* @param application Application
*/
/* package */ void onActivate( ApplicationInstance application )
{
activatingOrPassivating = true;
try
{
LinkedHashMap<String, String> pluginsDescriptors = loadPluginsDescriptors( application );
List<PluginInfo> appPlugins = loadApplicationPlugins( application, pluginsDescriptors );
List<PluginInfo> dynamicPlugins = loadDynamicPlugins( application, pluginsDescriptors, appPlugins );
List<PluginInfo> plugins = resolveDependencies( application, appPlugins, dynamicPlugins );
// Activate all plugins, in order
activePlugins = new ArrayList<>( plugins.size() );
for( PluginInfo pluginInfo : plugins )
{
pluginInfo.plugin.onActivate( application );
activePlugins.add( pluginInfo );
}
// Plugins Activated
activated = true;
}
catch( Exception ex )
{
activePlugins = emptyList();
throw ex;
}
finally
{
activatingOrPassivating = false;
}
}
/**
* Passivate Plugins.
*
* @param application Application
*/
/* package */ void onPassivate( ApplicationInstance application )
{
activatingOrPassivating = true;
try
{
Collections.reverse( activePlugins );
Iterator<PluginInfo> it = activePlugins.iterator();
List<Exception> errors = new ArrayList<>();
while( it.hasNext() )
{
try
{
it.next().plugin.onPassivate( application );
}
catch( Exception ex )
{
errors.add( ex );
}
finally
{
it.remove();
}
}
activePlugins = emptyList();
activated = false;
if( !errors.isEmpty() )
{
PassivationException ex = new PassivationException( "There were errors during Plugins passivation" );
for( Exception err : errors )
{
ex.addSuppressed( err );
}
throw ex;
}
}
finally
{
activatingOrPassivating = false;
}
}
/**
* Collect first routes contributed by active Plugins.
*
* @param app Application
*
* @return First routes contributed by active Plugins
*/
/* package */ List<Route> firstRoutes( Application app )
{
List<Route> firstRoutes = new ArrayList<>();
for( PluginInfo pluginInfo : activePlugins )
{
firstRoutes.addAll(
pluginInfo.plugin.firstRoutes( app.mode(), new RouteBuilderInstance( app, pluginInfo.routesPrefix ) )
);
}
return firstRoutes;
}
/**
* Collect last routes contributed by active Plugins.
*
* @param app Application
*
* @return Last routes contributed by active Plugins
*/
/* package */ List<Route> lastRoutes( Application app )
{
List<Route> lastRoutes = new ArrayList<>();
for( PluginInfo pluginInfo : activePlugins )
{
lastRoutes.addAll(
pluginInfo.plugin.lastRoutes( app.mode(), new RouteBuilderInstance( app, pluginInfo.routesPrefix ) )
);
}
return lastRoutes;
}
/**
* Before each interaction hook.
*
* @param context Context
*/
/* package */ void beforeInteraction( Context context )
{
// Fail fast
activePlugins.forEach( cp -> cp.plugin.beforeInteraction( context ) );
}
/**
* After each interaction hook.
*
* @param context Context
*/
/* package */ void afterInteraction( Context context )
{
// Fail safe
List<Exception> errors = new ArrayList<>();
for( PluginInfo pluginInfo : activePlugins )
{
try
{
pluginInfo.plugin.afterInteraction( context );
}
catch( Exception ex )
{
errors.add( ex );
}
}
if( !errors.isEmpty() )
{
WervalException ex = new WervalException( "There were errors during Plugins after interaction hooks" );
for( Exception err : errors )
{
ex.addSuppressed( err );
}
throw ex;
}
}
/**
* Lookup plugins by API type.
*
* @param pluginApiType Plugin API type
*
* @return Plugins matching the given API type, maybe none
*/
/* package */ <T> Iterable<T> plugins( Class<T> pluginApiType )
{
if( !activated && !activatingOrPassivating )
{
throw new IllegalStateException( "Plugins are passivated." );
}
ensureNotNull( "Plugin API Type", pluginApiType );
Set<T> result = new LinkedHashSet<>();
for( PluginInfo pluginInfo : activePlugins )
{
if( pluginInfo.plugin.apiType().equals( pluginApiType ) && pluginInfo.plugin.api() != null )
{
// Type equals
result.add( pluginApiType.cast( pluginInfo.plugin.api() ) );
}
}
for( PluginInfo pluginInfo : activePlugins )
{
if( pluginApiType.isAssignableFrom( pluginInfo.plugin.apiType() ) && pluginInfo.plugin.api() != null )
{
// Type is assignable
result.add( pluginApiType.cast( pluginInfo.plugin.api() ) );
}
}
return result;
}
/**
* Lookup plugin by API type.
*
* @param pluginApiType Plugin API Type
*
* @return First Plugin matching the given API type, never null
*
* @throws IllegalArgumentException if no matching plugin can be found
*/
/* package */ <T> T plugin( Class<T> pluginApiType )
{
if( !activated && !activatingOrPassivating )
{
throw new IllegalStateException( "Plugins are passivated." );
}
ensureNotNull( "Plugin API Type", pluginApiType );
for( PluginInfo pluginInfo : activePlugins )
{
if( pluginInfo.plugin.apiType().equals( pluginApiType ) && pluginInfo.plugin.api() != null )
{
// Type equals
return pluginApiType.cast( pluginInfo.plugin.api() );
}
}
for( PluginInfo pluginInfo : activePlugins )
{
if( pluginApiType.isAssignableFrom( pluginInfo.plugin.apiType() ) && pluginInfo.plugin.api() != null )
{
// Type is assignable
return pluginApiType.cast( pluginInfo.plugin.api() );
}
}
// No Plugin found
throw new IllegalArgumentException(
"API for Plugin<" + pluginApiType.getName() + "> not found. "
+ "Active plugins APIs: " + activePlugins.stream().map( p -> p.plugin.apiType() ).collect( toList() ) + "."
);
}
/**
* Load plugins descriptors from the application classpath.
*
* @param application Application
*
* @return Plugins descriptors as a Map, keys are names, values are FQCNs
*/
private LinkedHashMap<String, String> loadPluginsDescriptors( ApplicationInstance application )
{
try
{
LinkedHashMap<String, String> pluginsDescriptors = new LinkedHashMap<>();
Enumeration<URL> descriptors = application.classLoader().getResources(
"META-INF/werval-plugins.properties"
);
while( descriptors.hasMoreElements() )
{
try( InputStream stream = descriptors.nextElement().openStream() )
{
Properties descriptor = new Properties();
descriptor.load( stream );
for( String name : descriptor.stringPropertyNames() )
{
String fqcn = descriptor.getProperty( name );
pluginsDescriptors.put( name, fqcn );
}
}
}
return pluginsDescriptors;
}
catch( IOException ex )
{
throw new UncheckedIOException( "Unable to load plugins descriptors", ex );
}
}
/**
* Load Application Plugins.
*
* @param application Application
* @param pluginsDescriptors Plugins descriptors
*
* @return Application Plugins
*/
private List<PluginInfo> loadApplicationPlugins(
ApplicationInstance application,
LinkedHashMap<String, String> pluginsDescriptors
)
{
EnumSet<ExtensionPlugin> extensions = EnumSet.allOf( ExtensionPlugin.class );
List<String> enabled = application.config().stringList( "app.plugins.enabled" );
Map<String, String> routesPrefixes = application.config().stringMap( "app.plugins.routes_prefixes" );
if( application.mode() == Mode.DEV )
{
enabled.addAll( application.config().stringList( "werval.devshell.plugins.enabled" ) );
routesPrefixes.putAll( application.config().stringMap( "werval.devshell.plugins.routes_prefixes" ) );
}
try
{
List<PluginInfo> applicationPlugins = new ArrayList<>();
// Application Configured Plugins
for( String pluginNameOrFqcn : enabled )
{
String pluginFqcn = pluginsDescriptors.containsKey( pluginNameOrFqcn )
? pluginsDescriptors.get( pluginNameOrFqcn )
: pluginNameOrFqcn;
Class<?> pluginClass = application.classLoader().loadClass( pluginFqcn );
Plugin<?> plugin = (Plugin<?>) application.global().getPluginInstance( application, pluginClass );
applicationPlugins.add( new PluginInfo( plugin, routesPrefixes.get( pluginNameOrFqcn ) ) );
extensions.removeIf( extension -> extension.satisfiedBy( plugin ) );
}
// Global Extra Plugins
for( Plugin<?> extraPlugin : application.global().extraPlugins() )
{
applicationPlugins.add( new PluginInfo( extraPlugin, null ) );
extensions.removeIf( extension -> extension.satisfiedBy( extraPlugin ) );
}
// Core Extensions Plugins
for( ExtensionPlugin extension : extensions )
{
Plugin<?> extensionPlugin = extension.newDefaultPluginInstance();
applicationPlugins.add( new PluginInfo( extensionPlugin, null ) );
}
return applicationPlugins;
}
catch( ClassNotFoundException ex )
{
throw new WervalException( "Unable to load application plugins", ex );
}
}
/**
* Load dynamic plugins.
*
* @param application Application
* @param pluginsDescriptors Plugins descriptors
* @param appPlugins Already loaded application plugins
*
* @return Dynamic plugins except thoses already loaded by the application
*/
private List<PluginInfo> loadDynamicPlugins(
ApplicationInstance application,
LinkedHashMap<String, String> pluginsDescriptors,
List<PluginInfo> appPlugins
)
{
try
{
List<PluginInfo> dynamicPlugins = new ArrayList<>();
for( String fqcn : pluginsDescriptors.values() )
{
Class<?> pluginClass = application.classLoader().loadClass( fqcn );
if( !appPlugins.stream().anyMatch( p -> p.plugin.getClass().equals( pluginClass ) ) )
{
Plugin<?> plugin = (Plugin<?>) application.global().getPluginInstance( application, pluginClass );
dynamicPlugins.add( new PluginInfo( plugin, null ) );
}
}
return dynamicPlugins;
}
catch( ClassNotFoundException ex )
{
throw new WervalException( "Unable to load dynamic plugins", ex );
}
}
/**
* Resolve and flatten plugins dependency graph.
*
* @return flattenned list of plugins, dependency resolved
*/
private List<PluginInfo> resolveDependencies(
ApplicationInstance application,
List<PluginInfo> appPlugins,
List<PluginInfo> dynamicPlugins
)
{
List<PluginInfo> output = new ArrayList<>( appPlugins.size() );
ArrayDeque<PluginInfo> queue = new ArrayDeque<>( appPlugins );
boolean replay = false;
while( !queue.isEmpty() )
{
PluginInfo pluginInfo = queue.poll();
for( Class<?> dependency : pluginInfo.plugin.dependencies( application.config() ) )
{
// Already resolved?
if( !output.stream().anyMatch(
p -> p.plugin.apiType().equals( dependency ) || dependency.isAssignableFrom( p.plugin.apiType() )
) )
{
// Replay will be needed to discover transitive dependencies
replay = true;
// Same type, then assignable or null
PluginInfo match = appPlugins.stream()
.filter( p -> p.plugin.apiType().equals( dependency ) )
.findFirst()
.orElse(
appPlugins.stream()
.filter( p -> dependency.isAssignableFrom( p.plugin.apiType() ) )
.findFirst()
.orElse( null )
);
if( match == null )
{
// Dynamic plugins, same type, then assignable or null
match = dynamicPlugins.stream()
.filter( p -> p.plugin.apiType().equals( dependency ) )
.findFirst()
.orElse(
appPlugins.stream()
.filter( p -> dependency.isAssignableFrom( p.plugin.apiType() ) )
.findFirst()
.orElse( null )
);
// If no match, throw
if( match == null )
{
throw new WervalException( "Plugin dependency not resolved: " + dependency );
}
// Add to queue so dependencies of dependency gets resolved
queue.addFirst( match );
// Register matched dependency
if( !output.contains( match ) )
{
output.add( 0, match );
}
}
else
{
// Dependency resolved directly from application plugins
queue.remove( match );
if( !output.contains( match ) )
{
output.add( 0, match );
}
}
}
}
if( !output.contains( pluginInfo ) )
{
output.add( pluginInfo );
}
}
if( replay )
{
return resolveDependencies( application, output, dynamicPlugins );
}
Collections.sort(
output,
new Comparator<PluginInfo>()
{
@Override
public int compare( PluginInfo pi1, PluginInfo pi2 )
{
List<Class<?>> pi1Deps = pi1.plugin.dependencies( application.config() );
List<Class<?>> pi2Deps = pi2.plugin.dependencies( application.config() );
if( pi1Deps.isEmpty() && pi2Deps.isEmpty() )
{
// Both have zero dependency
return 0;
}
if( ( pi1Deps.isEmpty() || pi2Deps.isEmpty() ) && pi1Deps.size() != pi2Deps.size() )
{
// One has zero dependency, the other has some
return Integer.compare( pi1Deps.size(), pi2Deps.size() );
}
// Plugin 1 Dependencies
List<PluginInfo> pi1DepsPluginInfos = new ArrayList<>();
Stack<Class<?>> pi1Stack = new Stack<>();
pi1Stack.addAll( pi1Deps );
while( !pi1Stack.empty() )
{
Class<?> pi1Dep = pi1Stack.pop();
PluginInfo match = output.stream()
.filter( pi -> pi.plugin.apiType().equals( pi1Dep ) ).findFirst()
.orElse(
output.stream().filter( pi -> pi1Dep.isAssignableFrom( pi.plugin.apiType() ) )
.findFirst().orElse( null )
);
if( match != null )
{
pi1DepsPluginInfos.add( match );
pi1Stack.addAll( match.plugin.dependencies( application.config() ) );
}
}
if( pi1DepsPluginInfos.contains( pi2 ) )
{
// Plugin 1, or one of its dependencies, depends on Plugin 2
return +1;
}
// Plugin 2 Dependencies
List<PluginInfo> pi2DepsPluginInfos = new ArrayList<>();
Stack<Class<?>> pi2Stack = new Stack<>();
pi2Stack.addAll( pi2Deps );
while( !pi2Stack.empty() )
{
Class<?> pi2Dep = pi2Stack.pop();
PluginInfo match = output.stream()
.filter( pi -> pi.plugin.apiType().equals( pi2Dep ) ).findFirst()
.orElse(
output.stream().filter( pi -> pi2Dep.isAssignableFrom( pi.plugin.apiType() ) )
.findFirst().orElse( null )
);
if( match != null )
{
pi2DepsPluginInfos.add( match );
pi2Stack.addAll( match.plugin.dependencies( application.config() ) );
}
}
if( pi2DepsPluginInfos.contains( pi1 ) )
{
// Plugin 2, or one of its dependencies, depends on Plugin 1
return -1;
}
// No decision to make
return 0;
}
}
);
return output;
}
@Override
public String toString()
{
int apiTypePadLen = 0;
for( PluginInfo pluginInfo : activePlugins )
{
String apiName = pluginInfo.plugin.apiType().getSimpleName();
if( apiName.length() > apiTypePadLen )
{
apiTypePadLen = apiName.length();
}
}
StringBuilder sb = new StringBuilder();
for( Iterator<PluginInfo> it = activePlugins.iterator(); it.hasNext(); )
{
PluginInfo pluginInfo = it.next();
sb.append( rightPad( apiTypePadLen, pluginInfo.plugin.apiType().getSimpleName() ) );
sb.append( " provided by " );
sb.append( pluginInfo.plugin.getClass().getName() );
if( it.hasNext() )
{
sb.append( NEWLINE );
}
}
return sb.toString();
}
}