/*
* Copyright (C) 2011 eXo Platform SAS.
*
* This is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* This software is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this software; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
* 02110-1301 USA, or see the FSF site: http://www.fsf.org.
*/
package org.gatein.portal.controller.resource.script;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import org.exoplatform.portal.resource.InvalidResourceException;
import org.exoplatform.services.log.ExoLogger;
import org.exoplatform.services.log.Log;
import org.exoplatform.web.application.javascript.DependencyDescriptor;
import org.exoplatform.web.application.javascript.DuplicateResourceKeyException;
import org.exoplatform.web.application.javascript.Javascript;
import org.exoplatform.web.application.javascript.JavascriptConfigParser;
import org.exoplatform.web.application.javascript.JavascriptConfigService;
import org.exoplatform.web.application.javascript.ScriptResourceDescriptor;
import org.gatein.portal.controller.resource.ResourceId;
import org.gatein.portal.controller.resource.ResourceScope;
import org.gatein.portal.controller.resource.script.ScriptGroup.ScriptGroupBuilder;
import org.gatein.portal.controller.resource.script.ScriptResource.ScriptResourceBuilder;
/**
* Scripts and the dependencies among them.
*
* @author <a href="mailto:julien.viet@exoplatform.com">Julien Viet</a>
* @author <a href="mailto:ppalaga@redhat.com">Peter Palaga</a>
*/
public class ScriptGraph {
static class ScriptGraphBuilder {
private final Map<ResourceScope, Map<String, ScriptResourceBuilder>> resourceBuilders;
private final Map<String, ScriptGroupBuilder> loadGroupBuilders;
private ScriptGraphBuilder(Map<ResourceScope, Map<String, ScriptResource>> resources, Map<String, ScriptGroup> loadGroups) {
int groupsCount = loadGroups.size();
this.loadGroupBuilders = new HashMap<String, ScriptGroupBuilder>(groupsCount + groupsCount/2 + 1);
for (ScriptGroup group : loadGroups.values()) {
loadGroupBuilders.put(group.getId().getName(), group.newBuilder(this));
}
this.resourceBuilders =
new EnumMap<ResourceScope, Map<String, ScriptResourceBuilder>>(ResourceScope.class);
for (ResourceScope scope : ResourceScope.values()) {
Map<String, ScriptResource> scopeMap = resources.get(scope);
int resourcesCount = scopeMap.size();
Map<String, ScriptResourceBuilder> newScopeMap = new HashMap<String, ScriptResourceBuilder>(resourcesCount + resourcesCount/2 + 1);
for (ScriptResource scriptResource : scopeMap.values()) {
ScriptGroup group = scriptResource.getGroup();
ScriptGroupBuilder groupBuilder = group != null ? loadGroupBuilders.get(group.getId().getName()) : null;
ScriptResourceBuilder newBuilder = scriptResource.newBuilder(this, groupBuilder);
newScopeMap.put(scriptResource.getId().getName(), newBuilder);
}
resourceBuilders.put(scope, newScopeMap);
}
}
ScriptResourceBuilder getResource(ResourceId id) {
return resourceBuilders.get(id.getScope()).get(id.getName());
}
void addResource(String contextPath, ScriptResourceDescriptor desc) throws InvalidResourceException {
ResourceId id = desc.getId();
FetchMode fetchMode = desc.getFetchMode();
String alias = desc.getAlias();
String groupName = desc.getGroup();
boolean nativeAmd = desc.isNativeAmd();
if (id == null) {
throw new NullPointerException("No null resource accepted");
}
if (fetchMode == null) {
throw new NullPointerException("No null fetch mode accepted");
}
//
if (ResourceScope.SHARED.equals(id.getScope())) {
for (Map<String, ScriptResourceBuilder> mp : resourceBuilders.values()) {
for (ScriptResourceBuilder rs : mp.values()) {
rs.checkDependencyFetchMode(id, fetchMode);
}
}
if (JavascriptConfigService.RESERVED_MODULE.contains(id.getName())) {
throw new InvalidResourceException("Cannot add " + id + ". The name " + id.getName() + " is a reserved name");
}
}
//
Map<String, ScriptResourceBuilder> map = resourceBuilders.get(id.getScope());
String name = id.getName();
ScriptResourceBuilder resourceBuilder = map.get(name);
if (resourceBuilder == null) {
ScriptGroupBuilder group = null;
if (groupName != null && contextPath != null) {
group = loadGroupBuilders.get(groupName);
if (group == null) {
ResourceId grpId = new ResourceId(ResourceScope.GROUP, groupName);
group = new ScriptGroupBuilder(this, grpId, contextPath);
group.addDependency(id);
loadGroupBuilders.put(groupName, group);
} else if (!group.getContextPath().equals(contextPath)) {
log.warn("Cannot add resource {} from context {} to group {} in another context {}.", id, contextPath, groupName, group, group.getContextPath());
group = null;
} else {
group.addDependency(id);
}
}
resourceBuilder = new ScriptResourceBuilder(this, id, contextPath, fetchMode, alias, group, nativeAmd);
map.put(name, resourceBuilder);
} else if (!(id.getScope().equals(ResourceScope.SHARED) && JavascriptConfigParser.LEGACY_JAVA_SCRIPT.equals(name))) {
throw new DuplicateResourceKeyException("Duplicate ResourceId : " + id + ", later resource definition will be ignored");
}
for (Javascript module : desc.getModules()) {
if (module instanceof Javascript.Local) {
Javascript.Local localModule = (Javascript.Local) module;
resourceBuilder.addLocalModule(contextPath, localModule.getContents(), localModule.getResourceBundle(), localModule.getPriority());
} else if (module instanceof Javascript.Remote) {
Javascript.Remote remoteModule = (Javascript.Remote) module;
resourceBuilder.addRemoteModule(remoteModule.getUri(), remoteModule.getPriority());
} else {
throw new IllegalStateException("Unexpected type "+ module.getClass().getName());
}
}
for (Locale locale : desc.getSupportedLocales()) {
resourceBuilder.addSupportedLocale(locale);
}
for (DependencyDescriptor dependency : desc.getDependencies()) {
resourceBuilder.addDependency(contextPath, dependency.getResourceId(), dependency.getAlias(), dependency.getPluginResource());
}
}
void dependencyAdded(ResourceId id, Set<ResourceId> closure) {
for (Map<String, ScriptResourceBuilder> scopeMap : resourceBuilders.values()) {
for (ScriptResourceBuilder resource : scopeMap.values()) {
resource.dependencyAdded(id, closure);
}
}
}
ScriptGraphBuilder removeResourcesByContextPath(String contextPath) {
HashSet<ResourceId> removedIds = new HashSet<ResourceId>();
for (Map<String, ScriptResourceBuilder> scopeMap : resourceBuilders.values()) {
for (Iterator<ScriptResourceBuilder> it = scopeMap.values().iterator(); it.hasNext();) {
ScriptResourceBuilder resource = it.next();
if (resource.getContextPath().equals(contextPath)) {
it.remove();
removedIds.add(resource.getId());
}
}
}
for (Map<String, ScriptResourceBuilder> scopeMap : resourceBuilders.values()) {
for (ScriptResourceBuilder resource : scopeMap.values()) {
resource.resourcesRemoved(contextPath, removedIds);
}
}
for (Iterator<ScriptGroupBuilder> it = loadGroupBuilders.values().iterator(); it.hasNext();) {
ScriptGroupBuilder group = it.next();
if (group.getContextPath().equals(contextPath)) {
it.remove();
}
}
return this;
}
ScriptGraph build() {
int groupsCount = loadGroupBuilders.size();
Map<String, ScriptGroup> loadGroups = new HashMap<String, ScriptGroup>(groupsCount + groupsCount/2 + 1);
for (Entry<String, ScriptGroupBuilder> en : loadGroupBuilders.entrySet()) {
ScriptGroupBuilder groupBuilder = en.getValue();
if (groupBuilder.hasDependencies()) {
/* do not add empty groups */
loadGroups.put(en.getKey(), groupBuilder .build());
}
}
EnumMap<ResourceScope, Map<String, ScriptResource>> resources =
new EnumMap<ResourceScope, Map<String, ScriptResource>>(ResourceScope.class);
for (Entry<ResourceScope, Map<String, ScriptResourceBuilder>> en : resourceBuilders.entrySet()) {
ResourceScope scope = en.getKey();
Map<String, ScriptResourceBuilder> scopeBuilders = en.getValue();
int resourcesCount = scopeBuilders.size();
HashMap<String, ScriptResource> scopeMap = new HashMap<String, ScriptResource>(resourcesCount + resourcesCount/2 + 1);
for (ScriptResourceBuilder resourceBuilder : scopeBuilders.values()) {
ScriptResource resource = resourceBuilder.build();
scopeMap.put(resource.getId().getName(), resource);
}
resources.put(scope, scopeMap);
}
return new ScriptGraph(Collections.unmodifiableMap(resources), Collections.unmodifiableMap(loadGroups));
}
/**
* @return
* @throws InvalidResourceException
*/
public ScriptGraphBuilder validateDependencies(ResourceId id) throws InvalidResourceException {
getResource(id).validateDependencies();
return this;
}
}
/** . */
private final Map<ResourceScope, Map<String, ScriptResource>> resources;
/** . */
private final Map<String, ScriptGroup> loadGroups;
/** . */
private static final Log log = ExoLogger.getExoLogger(ScriptGraph.class);
/** An empty singleton. */
private static final ScriptGraph EMPTY = new ScriptGraph();
/**
* Returns an empty {@link ScriptGraph}.
* @return an empty {@link ScriptGraph}
*/
public static ScriptGraph empty() {
return EMPTY;
}
/**
* Returns a new {@link ScriptGraphBuilder} based on this {@link ScriptGraph} thus providing a
* way to get a new {@link ScriptGraph} instance that is a modification of {@code this}.
* @return
*/
ScriptGraphBuilder newBuilder() {
return new ScriptGraphBuilder(this.resources, this.loadGroups);
}
/**
* Creates an empty {@link ScriptGraph}.
*/
private ScriptGraph() {
EnumMap<ResourceScope, Map<String, ScriptResource>> resources = new EnumMap<ResourceScope, Map<String, ScriptResource>>(
ResourceScope.class);
for (ResourceScope scope : ResourceScope.values()) {
resources.put(scope, Collections.<String, ScriptResource> emptyMap());
}
this.resources = Collections.unmodifiableMap(resources);
this.loadGroups = Collections.emptyMap();
}
/**
* Creates a new {@link ScriptGraph} with provided {@code resources} and {@code loadGroups}.
* Make sure you call this only with deeply immutable {@code resources} and {@code loadGroups}.
* @param resources
* @param loadGroups
*/
private ScriptGraph(Map<ResourceScope, Map<String, ScriptResource>> resources, Map<String, ScriptGroup> loadGroups) {
super();
this.resources = resources;
this.loadGroups = loadGroups;
}
/**
* Resolve a collection of pair of resource id and fetch mode, each entry of the map will be processed in the order
* specified by the iteration of the {@link java.util.Map#entrySet()}. For a given pair the fetch mode may be null or not.
* When the fetch mode is null, the default fetch mode of the resource is used. When the fetch mode is not null, this fetch
* mode may override the resource fetch mode if it implies this particular fetch mode. This algorithm tolerates the absence
* of resourceBuilders, for instance if a resource is specified (among the pairs or by a transitive dependency) and does not exist,
* the resource will be skipped.
*
* @param pairs the pairs to resolve
* @return the resourceBuilders sorted
*/
public Map<ScriptResource, FetchMode> resolve(Map<ResourceId, FetchMode> pairs) {
// Build a fetch graph
Map<ResourceId, ScriptFetch> determined = new HashMap<ResourceId, ScriptFetch>();
for (Map.Entry<ResourceId, FetchMode> pair : pairs.entrySet()) {
traverse(determined, pair.getKey(), pair.getValue());
}
// We remove one by one the nodes of the fetch graph having no dependencies
// each node will build the dependency list
LinkedHashMap<ScriptResource, FetchMode> result = new LinkedHashMap<ScriptResource, FetchMode>();
LinkedList<ScriptFetch> all = new LinkedList<ScriptFetch>(determined.values());
while (all.size() > 0) {
ScriptFetch next = null;
for (Iterator<ScriptFetch> i = all.iterator(); i.hasNext();) {
ScriptFetch fetch = i.next();
if (fetch.dependencies.size() == 0) {
i.remove();
next = fetch;
for (ScriptFetch dependent : fetch.dependsOnMe) {
dependent.dependencies.remove(fetch);
}
break;
}
}
if (next == null) {
// This should not happen:
// we have an DAG, on each iteration we must have at least one node that has no dependencies
// we remove it from the graph and update its dependencies
// (unless the graph is not correctly constructed above)
throw new AssertionError("This is a bug");
} else {
result.put(next.resource, next.mode);
}
}
//
return result;
}
private ScriptFetch traverse(Map<ResourceId, ScriptFetch> map, ResourceId id, FetchMode mode) {
ScriptResource resource = getResource(id);
if (resource != null) {
if (mode != null && !resource.getFetchMode().equals(mode)) {
return null;
} else {
mode = resource.getFetchMode();
ScriptFetch fetch = map.get(id);
if (fetch == null) {
fetch = new ScriptFetch(resource, mode);
if (!resource.isEmpty() || ResourceScope.SHARED.equals(resource.getId().getScope())) {
map.put(id, fetch);
}
// Recursively add the dependencies
if (FetchMode.IMMEDIATE.equals(mode) || resource.isEmpty()) {
for (ResourceId dependencyId : resource.getDependencies()) {
ScriptFetch dependencyFetch = traverse(map, dependencyId, mode);
if (dependencyFetch != null) {
dependencyFetch.dependsOnMe.add(fetch);
fetch.dependencies.add(dependencyFetch);
}
}
}
}
return fetch;
}
} else {
return null;
}
}
public ScriptResource getResource(ResourceId id) {
return getResource(id.getScope(), id.getName());
}
public ScriptResource getResource(ResourceScope scope, String name) {
return resources.get(scope).get(name);
}
public Collection<ScriptResource> getResources(ResourceScope scope) {
return resources.get(scope).values();
}
public ScriptGroup getLoadGroup(String groupName) {
return loadGroups.get(groupName);
}
/**
* Creates a deep copy of this {@link ScriptGraph} then adds the given {@code scriptResourceDescriptors}
* to the copy and finally returns the copy.
*
* {@link InvalidResourceException} is thrown if the addition of the given {@code scriptResourceDescriptors}
* would break the internal consistency of the newly created {@link ScriptGraph}. Illegal Conditions include:
* <ul>
* <li>Adding a resource with {@link ResourceId} that is available in this {@link ScriptGraph} already
* <li>Adding a resource with {@link ResourceId#getName()} containing {@value JavascriptConfigService.RESERVED_MODULE}
* <li>Adding a resource with circular dependencies
* <li>Adding a resource depending on another resource with an incompatible {@link FetchMode}
* <li>Adding a resource depending on another resource that is not available in this {@link ScriptGraph}
* <li>...
* </ul>
*
* @param contextPath the servlet context path the {@code scriptResourceDescriptors} come from
* @param scriptResourceDescriptors
* @return see above
* @throws InvalidResourceException see above
*/
public ScriptGraph add(String contextPath, List<ScriptResourceDescriptor> scriptResourceDescriptors) throws InvalidResourceException {
if (scriptResourceDescriptors == null || scriptResourceDescriptors.isEmpty()) {
return this;
} else {
ScriptGraphBuilder graphBuilder = newBuilder();
for (ScriptResourceDescriptor desc : scriptResourceDescriptors) {
graphBuilder.addResource(contextPath, desc);
}
/* Check if the dependencies of resources just added are available */
for (ScriptResourceDescriptor desc : scriptResourceDescriptors) {
graphBuilder.validateDependencies(desc.getId());
}
return graphBuilder.build();
}
}
/**
* Creates a deep copy of this {@link ScriptGraph} then removes all resources from the copy
* having the context path equal to the given {@code contextPath} and finally returns the copy.
*
* @param contextPath
* @return see above
*/
public ScriptGraph remove(String contextPath) {
return newBuilder().removeResourcesByContextPath(contextPath).build();
}
/**
* Performs several consistency checks on this {@code ScriptGraph}, such as:
* <ul>
* <li>Whether all {@link ScriptGroup}s registered in {@link #loadGroups} contain only
* {@link ScriptResource}s available in {@link #resources}
* <li>Whether all {@link ScriptGroup}s registered in {@link #loadGroups} are non-empty
* <li>Whether all {@link ScriptResource}s registered in {@link #resources} refer to
* {@link ScriptGroup}s available in {@link #loadGroups}
* <li>Whether all {@link ScriptResource}s registered in {@link #resources} contain
* only {@link ScriptResource}s in their dependencies and closures that are registered in
* {@link #resources}
* </ul>
* This method actually contains test code called from {@link ScriptGraph}'s test cases.
* This is the reason why it has default visibility. There is no need to call this method
* from production code as no transformation doable through public methods of {@link ScriptGraph}
* should lead to an invalid state.
*
* @return returns {@code this}
* @throws InvalidResourceException if this {@link ScriptGraph} is inconsistent
*/
ScriptGraph validate() throws InvalidResourceException {
for (ScriptGroup group : loadGroups.values()) {
Set<ResourceId> deps = group.getDependencies();
for (ResourceId depId : deps) {
if (!resources.get(depId.getScope()).containsKey(depId.getName())) {
throw new InvalidResourceException("Inconsistent ScriptGraph: ScriptGroup '"+ group.getId() +"' contains unavailable resource '"+ depId +"'.");
}
}
if (deps.isEmpty()) {
throw new InvalidResourceException("Inconsistent ScriptGraph: ScriptGroup '"+ group.getId() +"' is empty.");
}
}
for (ResourceScope scope : ResourceScope.values()) {
for (ScriptResource resource : resources.get(scope).values()) {
ScriptGroup resourceGroup = resource.getGroup();
if (resourceGroup != null) {
ScriptGroup loadGroup = loadGroups.get(resourceGroup.getId().getName());
if (loadGroup == null) {
throw new InvalidResourceException("Inconsistent ScriptGraph: ScriptGroup '"+ resourceGroup.getId() +"' of resource '"+ resource.getId() +"' is not registered in loadGroups map.");
}
if (!loadGroup.getDependencies().contains(resource.getId())) {
throw new InvalidResourceException("Inconsistent ScriptGraph: ScriptGroup '"+ resourceGroup.getId() +"' does not contain '"+ resource.getId() +"'. It definitely should because '"+ resourceGroup.getId() +"' refers to group '"+ resourceGroup.getId() +"' in its group field.");
}
}
/* deps */
for (ResourceId depId : resource.getDependencies()) {
if (!resources.get(depId.getScope()).containsKey(depId.getName()) && !JavascriptConfigService.RESERVED_MODULE.contains(depId.getName())) {
/* This is was finally decided to be legal. See org.gatein.portal.controller.resource.script.ScriptResource.ScriptResourceBuilder.resourcesRemoved(String, HashSet<ResourceId>) */
if (log.isWarnEnabled()) {
log.warn("Inconsistent ScriptGraph: ScriptResource '"+ resource.getId() +"' depends on a non-existent resource '"+ depId +"'.");
}
}
}
/* closure */
for (ResourceId depId : resource.getClosure()) {
if (!resources.get(depId.getScope()).containsKey(depId.getName())) {
throw new InvalidResourceException("Inconsistent ScriptGraph: ScriptResource '"+ resource.getId() +"' contains on a non-existent resource '"+ depId +"' in its closure.");
}
}
}
}
return this;
}
}