blob: 83de762a6fd20fedcfd73cdb172d5ab268c68f96 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.sling.feature.builder;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.sling.feature.Artifact;
import org.apache.sling.feature.ArtifactId;
import org.apache.sling.feature.Configuration;
import org.apache.sling.feature.Extension;
import org.apache.sling.feature.ExtensionState;
import org.apache.sling.feature.ExtensionType;
import org.apache.sling.feature.Feature;
import org.apache.sling.feature.Prototype;
import org.osgi.framework.Version;
public abstract class FeatureBuilder {
/** This key is used to track origins while a prototype is merged in */
private static final String TRACKING_KEY = "tracking-key";
/** Pattern for using variables. */
private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\$\\{[a-zA-Z0-9.-_]+\\}");
/**
* Assemble the full feature by processing its prototype.
*
* @param feature The feature to start
* @param context The builder context
* @return The assembled feature.
* @throws IllegalArgumentException If feature or context is {@code null}
* @throws IllegalStateException If a prototype feature can't be provided or merged.
*/
public static Feature assemble(final Feature feature,
final BuilderContext context) {
if ( feature == null || context == null ) {
throw new IllegalArgumentException("Feature and/or context must not be null");
}
return internalAssemble(new ArrayList<>(), feature, context);
}
/**
* Resolve a set of features based on their ids.
*
* @param context The builder context
* @param featureIds The feature ids
* @return An array of features, the array has the same order as the provided ids
* throws IllegalArgumentException If context or featureIds is {@code null}
* throws IllegalStateException If the provided ids are invalid, or the feature can't be provided
*/
public static Feature[] resolve(final BuilderContext context,
final String... featureIds) {
if ( featureIds == null || context == null ) {
throw new IllegalArgumentException("Features and/or context must not be null");
}
final Feature[] features = new Feature[featureIds.length];
int index = 0;
for(final String id : featureIds) {
features[index] = context.getFeatureProvider().provide(ArtifactId.parse(id));
if ( features[index] == null ) {
throw new IllegalStateException("Unable to find prototype feature " + id);
}
index++;
}
return features;
}
/**
* Remove duplicate and prototype features.
* If a feature with the same id but different version is contained several times,
* only the one with the highest version is kept in the result list.
* If a feature has another feature as prototype from the provided set, the prototype feature
* is removed from the set.
*
* @param context The builder context
* @param features A list of features
* @return A list of features without duplicates.
*/
public static Feature[] deduplicate(final BuilderContext context,
final Feature... features) {
if ( features == null || context == null ) {
throw new IllegalArgumentException("Features and/or context must not be null");
}
// Remove duplicate features by selecting the one with the highest version
final List<Feature> featureList = new ArrayList<>();
for(final Feature f : features) {
Feature found = null;
for(final Feature s : featureList) {
if ( s.getId().isSame(f.getId()) ) {
found = s;
break;
}
}
boolean add = true;
// feature with different version found
if ( found != null ) {
if ( f.getId().getOSGiVersion().compareTo(found.getId().getOSGiVersion()) <= 0 ) {
// higher version already included
add = false;
} else {
// remove lower version, higher version will be added
featureList.remove(found);
}
}
if ( add ) {
featureList.add(f);
}
}
// assemble each features
final List<Feature> assembledFeatures = new ArrayList<>();
final Set<ArtifactId> included = new HashSet<>();
for(final Feature f : featureList) {
final Feature assembled = FeatureBuilder.assemble(f, context.clone(new FeatureProvider() {
@Override
public Feature provide(final ArtifactId id) {
included.add(id);
for(final Feature f : features) {
if ( f.getId().equals(id) ) {
return f;
}
}
return context.getFeatureProvider().provide(id);
}
}));
assembledFeatures.add(assembled);
}
// filter out included features
final Iterator<Feature> iter = assembledFeatures.iterator();
while ( iter.hasNext() ) {
final Feature f = iter.next();
if ( included.contains(f.getId())) {
iter.remove();
}
}
return assembledFeatures.toArray(new Feature[assembledFeatures.size()]);
}
/**
* Assemble a feature based on the provided features.
*
* The features are processed in the order they are provided.
* If the same feature is included more than once only the feature with
* the highest version is used. The others are ignored.
*
* @param featureId The feature id to use.
* @param context The builder context
* @param features The features
* @return The application
* throws IllegalArgumentException If featureId, context or featureIds is {@code null}
* throws IllegalStateException If a feature can't be provided
*/
public static Feature assemble(
final ArtifactId featureId,
final BuilderContext context,
final Feature... features) {
if ( featureId == null || features == null || context == null ) {
throw new IllegalArgumentException("Features and/or context must not be null");
}
final Feature target = new Feature(featureId);
final Feature[] assembledFeatures = FeatureBuilder.deduplicate(context, features);
// append feature list in extension
final Extension list = new Extension(ExtensionType.ARTIFACTS, Extension.EXTENSION_NAME_ASSEMBLED_FEATURES,
ExtensionState.TRANSIENT);
for(final Feature feature : assembledFeatures) {
list.getArtifacts().add(new Artifact(feature.getId()));
}
target.getExtensions().add(list);
// assemble feature
boolean targetIsComplete = true;
for(final Feature assembled : assembledFeatures) {
if (!assembled.isComplete()) {
targetIsComplete = false;
}
merge(target, assembled, context, context.getArtifactOverrides(), context.getConfigOverrides(),null);
}
// check complete flag
if (targetIsComplete) {
target.setComplete(true);
}
target.setAssembled(true);
return target;
}
/**
* Resolve variables in the feature.
* Variables are allowed in the values of framework properties and in the values of
* configuration properties.
* @param feature The feature
* @param additionalVariables Optional additional variables
*/
public static void resolveVariables(final Feature feature, final Map<String,String> additionalVariables) {
for(final Configuration cfg : feature.getConfigurations()) {
final Set<String> keys = new HashSet<>(Collections.list(cfg.getProperties().keys()));
for(final String key : keys) {
final Object value = cfg.getProperties().get(key);
cfg.getProperties().put(key, replaceVariables(value, additionalVariables, feature));
}
}
for(final Map.Entry<String, String> entry : feature.getFrameworkProperties().entrySet()) {
// the value is always a string
entry.setValue((String)replaceVariables(entry.getValue(), additionalVariables, feature));
}
}
/**
* Substitute variables in the provided value. The variables must follow the
* syntax ${variable_name} and are looked up in the provided variables and in
* the feature variables. The provided variables are looked up first, potentially
* overwriting variables defined in the feature.
* If the provided value contains no variables, it will be returned as-is.
*
* @param value The value that can contain variables
* @param additionalVariables The optional variables that can be substituted (might be {@code null})
* @param feature The feature containing variables
* @return The value with the variables substituted.
*/
static Object replaceVariables(final Object value, final Map<String,String> additionalVariables, final Feature feature) {
if (!(value instanceof String)) {
return value;
}
final String textWithVars = (String) value;
final Matcher m = VARIABLE_PATTERN.matcher(textWithVars.toString());
final StringBuffer sb = new StringBuffer();
while (m.find()) {
final String var = m.group();
final int len = var.length();
final String name = var.substring(2, len - 1);
if (BuilderUtil.contains(name, feature.getVariables().entrySet())) {
String val = null;
if (additionalVariables != null)
val = BuilderUtil.get(name, additionalVariables.entrySet());
if (val == null) {
val = feature.getVariables().get(name);
}
if (val != null) {
m.appendReplacement(sb, Matcher.quoteReplacement(val));
}
else {
throw new IllegalStateException("Undefined variable: " + name);
}
}
}
m.appendTail(sb);
return sb.toString();
}
private static Feature internalAssemble(final List<String> processedFeatures,
final Feature feature,
final BuilderContext context) {
if ( feature.isAssembled() ) {
return feature;
}
if ( processedFeatures.contains(feature.getId().toMvnId()) ) {
throw new IllegalStateException("Recursive inclusion of " + feature.getId().toMvnId() + " via " + processedFeatures);
}
processedFeatures.add(feature.getId().toMvnId());
// we copy the feature as we set the assembled flag on the result
final Feature result = feature.copy();
if ( result.getPrototype() != null) {
// clear everything in the result, will be added in the process
result.getVariables().clear();
result.getBundles().clear();
result.getFrameworkProperties().clear();
result.getConfigurations().clear();
result.getRequirements().clear();
result.getCapabilities().clear();
result.setPrototype(null);
result.getExtensions().clear();
final Prototype i = feature.getPrototype();
final Feature f = context.getFeatureProvider().provide(i.getId());
if ( f == null ) {
throw new IllegalStateException("Unable to find prototype feature " + i.getId());
}
if (f.isFinal()) {
throw new IllegalStateException(
"Prototype feature " + i.getId() + " is marked as final and can't be used in a prototype.");
}
final Feature prototypeFeature = internalAssemble(processedFeatures, f, context);
// process prototype instructions
processPrototype(prototypeFeature, i);
// and now merge the prototype feature into the result. No overrides should be needed since the result is empty before
merge(result, prototypeFeature, context, Collections.emptyList(), Collections.EMPTY_MAP, TRACKING_KEY);
// and merge the current feature over the prototype feature into the result
merge(result, feature, context, Collections.singletonList(
ArtifactId.parse(BuilderUtil.CATCHALL_OVERRIDE + BuilderContext.VERSION_OVERRIDE_ALL)),
Collections.singletonMap("*", BuilderContext.CONFIG_MERGE_LATEST),
TRACKING_KEY);
for (Artifact a : result.getBundles()) {
a.getMetadata().remove(TRACKING_KEY);
}
for (Extension e : result.getExtensions()) {
if (ExtensionType.ARTIFACTS == e.getType()) {
for (Artifact a : e.getArtifacts()) {
a.getMetadata().remove(TRACKING_KEY);
}
}
}
}
result.setAssembled(true);
processedFeatures.remove(feature.getId().toMvnId());
return result;
}
private static void merge(final Feature target,
final Feature source,
final BuilderContext context,
final List<ArtifactId> artifactOverrides,
final Map<String, String> configOverrides,
final String originKey) {
BuilderUtil.mergeVariables(target.getVariables(), source.getVariables(), context);
BuilderUtil.mergeArtifacts(target.getBundles(), source.getBundles(), source, artifactOverrides, originKey);
BuilderUtil.mergeConfigurations(target.getConfigurations(), source.getConfigurations(), configOverrides);
BuilderUtil.mergeFrameworkProperties(target.getFrameworkProperties(), source.getFrameworkProperties(), context);
BuilderUtil.mergeRequirements(target.getRequirements(), source.getRequirements());
BuilderUtil.mergeCapabilities(target.getCapabilities(), source.getCapabilities());
BuilderUtil.mergeExtensions(target, source, context, artifactOverrides, originKey);
}
/**
* Process all the removals contained in the prototype
*
* @param feature The feature
* @param prototype The prototype
*/
private static void processPrototype(final Feature feature, final Prototype prototype) {
// process bundles removals
for (final ArtifactId a : prototype.getBundleRemovals()) {
boolean removed = false;
final boolean ignoreVersion = a.getOSGiVersion().equals(Version.emptyVersion);
if ( ignoreVersion ) {
// remove any version of that bundle
while (feature.getBundles().removeSame(a)) {
// continue to remove
removed = true;
}
} else {
// remove exact version
removed = feature.getBundles().removeExact(a);
}
if ( !removed ) {
throw new IllegalStateException("Bundle " + a + " can't be removed from feature " + feature.getId()
+ " as it is not part of that feature.");
}
final Iterator<Configuration> iter = feature.getConfigurations().iterator();
while ( iter.hasNext() ) {
final Configuration cfg = iter.next();
final String bundleId = (String)cfg.getProperties().get(Configuration.PROP_ARTIFACT_ID);
if (bundleId != null) {
final ArtifactId bundleArtifactId = ArtifactId.fromMvnId(bundleId);
boolean remove = false;
if ( ignoreVersion ) {
remove = bundleArtifactId.isSame(a);
} else {
remove = bundleArtifactId.equals(a);
}
if ( remove) {
iter.remove();
}
}
}
}
// process configuration removals
for (final String c : prototype.getConfigurationRemovals()) {
final int attrPos = c.indexOf('@');
final String pid = (attrPos == -1 ? c : c.substring(0, attrPos));
final String attr = (attrPos == -1 ? null : c.substring(attrPos + 1));
final Configuration found = feature.getConfigurations().getConfiguration(pid);
if ( found != null ) {
if ( attr == null ) {
feature.getConfigurations().remove(found);
} else {
found.getProperties().remove(attr);
}
}
}
// process framework properties removals
for (final String p : prototype.getFrameworkPropertiesRemovals()) {
feature.getFrameworkProperties().remove(p);
}
// process extensions removals
for (final String name : prototype.getExtensionRemovals()) {
for (final Extension ext : feature.getExtensions()) {
if ( ext.getName().equals(name) ) {
feature.getExtensions().remove(ext);
break;
}
}
}
// process artifact extensions removals
for (final Map.Entry<String, List<ArtifactId>> entry : prototype.getArtifactExtensionRemovals().entrySet()) {
for (final Extension ext : feature.getExtensions()) {
if ( ext.getName().equals(entry.getKey()) ) {
for(final ArtifactId toRemove : entry.getValue() ) {
boolean removed = false;
final boolean ignoreVersion = toRemove.getOSGiVersion().equals(Version.emptyVersion);
final Iterator<Artifact> iter = ext.getArtifacts().iterator();
while ( iter.hasNext() ) {
final Artifact a = iter.next();
boolean remove = false;
if ( ignoreVersion ) {
// remove any version of that bundle
if ( a.getId().isSame(toRemove) ) {
remove = true;
}
} else {
// remove exact version
remove = a.getId().equals(toRemove);
}
if ( remove ) {
iter.remove();
removed = true;
}
if ( remove && !ignoreVersion ) {
break;
}
}
if ( !removed ) {
throw new IllegalStateException("Artifact " + toRemove + " can't be removed from feature "
+ feature.getId() + " as it is not part of that feature.");
}
}
break;
}
}
}
}
}