Skip to content

Commit

Permalink
feat: implement Configuration Injection (#4617)
Browse files Browse the repository at this point in the history
* feat: implement configuration injection

* rebase adaptations

* improve error on missing default CTor

* javadoc

* fix test
  • Loading branch information
paullatzelsperger authored Nov 13, 2024
1 parent fab363c commit 90362c9
Show file tree
Hide file tree
Showing 23 changed files with 1,215 additions and 91 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
Expand Down Expand Up @@ -93,7 +92,8 @@ public List<InjectionContainer<ServiceExtension>> of(List<ServiceExtension> exte

var sort = new TopologicalSort<InjectionContainer<ServiceExtension>>();

var unsatisfiedInjectionPoints = new ArrayList<InjectionPoint<ServiceExtension>>();
// check if all injected fields are satisfied, collect missing ones and throw exception otherwise
var unsatisfiedInjectionPoints = new HashMap<Class<? extends ServiceExtension>, List<InjectionFailure>>();
var unsatisfiedRequirements = new ArrayList<String>();

injectionContainers.forEach(container -> {
Expand All @@ -109,15 +109,13 @@ public List<InjectionContainer<ServiceExtension>> of(List<ServiceExtension> exte

injectionPointScanner.getInjectionPoints(container.getInjectionTarget())
.peek(injectionPoint -> {
var maybeProviders = Optional.of(injectionPoint.getType()).map(dependencyMap::get);

if (maybeProviders.isPresent() || context.hasService(injectionPoint.getType())) {
maybeProviders.ifPresent(l -> l.stream()
.filter(d -> !Objects.equals(d, container)) // remove dependencies onto oneself
.forEach(provider -> sort.addDependency(container, provider)));
var providersResult = injectionPoint.getProviders(dependencyMap, context);
if (providersResult.succeeded()) {
List<InjectionContainer<ServiceExtension>> providers = providersResult.getContent();
providers.stream().filter(d -> !Objects.equals(d, container)).forEach(provider -> sort.addDependency(container, provider));
} else {
if (injectionPoint.isRequired()) {
unsatisfiedInjectionPoints.add(injectionPoint);
unsatisfiedInjectionPoints.computeIfAbsent(injectionPoint.getTargetInstance().getClass(), s -> new ArrayList<>()).add(new InjectionFailure(injectionPoint, providersResult.getFailureDetail()));
}
}

Expand All @@ -130,8 +128,9 @@ public List<InjectionContainer<ServiceExtension>> of(List<ServiceExtension> exte
});

if (!unsatisfiedInjectionPoints.isEmpty()) {
var message = "The following injected fields were not provided:\n";
message += unsatisfiedInjectionPoints.stream().map(InjectionPoint::toString).collect(Collectors.joining("\n"));
var message = "The following injected fields or values were not provided or could not be resolved:\n";
message += unsatisfiedInjectionPoints.entrySet().stream()
.map(entry -> String.format("%s is missing \n --> %s", entry.getKey(), String.join("\n --> ", entry.getValue().stream().map(Object::toString).toList()))).collect(Collectors.joining("\n"));
throw new EdcInjectionException(message);
}

Expand Down Expand Up @@ -169,4 +168,10 @@ private Set<Class<?>> getProvidedFeatures(ServiceExtension ext) {
return allProvides;
}

private record InjectionFailure(InjectionPoint<ServiceExtension> injectionPoint, String failureDetail) {
@Override
public String toString() {
return "%s %s".formatted(injectionPoint.getTypeString(), failureDetail);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/*
* Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)
*
* This program and the accompanying materials are made available under the
* terms of the Apache License, Version 2.0 which is available at
* https://www.apache.org/licenses/LICENSE-2.0
*
* SPDX-License-Identifier: Apache-2.0
*
* Contributors:
* Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation
*
*/

package org.eclipse.edc.boot.system.injection;

import org.eclipse.edc.boot.system.injection.lifecycle.ServiceProvider;
import org.eclipse.edc.runtime.metamodel.annotation.Setting;
import org.eclipse.edc.spi.result.AbstractResult;
import org.eclipse.edc.spi.result.Result;
import org.eclipse.edc.spi.system.ServiceExtensionContext;
import org.jetbrains.annotations.NotNull;

import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import java.util.stream.Stream;

/**
* Injection point for configuration objects. Configuration objects are records or POJOs that contain fields annotated with {@link Setting}.
* Configuration objects themselves must be annotated with {@link org.eclipse.edc.runtime.metamodel.annotation.Settings}.
* Example:
* <pre>
* public class SomeExtension implements ServiceExtension {
* \@Settings
* private SomeConfig someConfig;
* }
*
* \@Settings
* public record SomeConfig(@Setting(key = "foo.bar.baz") String fooValue){ }
* </pre>
*
* @param <T> The type of the declaring class.
*/
public class ConfigurationInjectionPoint<T> implements InjectionPoint<T> {
private final T targetInstance;
private final Field configurationObject;

public ConfigurationInjectionPoint(T instance, Field configurationObject) {
this.targetInstance = instance;
this.configurationObject = configurationObject;
this.configurationObject.setAccessible(true);

}

@Override
public T getTargetInstance() {
return targetInstance;
}

@Override
public Class<?> getType() {
return configurationObject.getType();
}

@Override
public boolean isRequired() {
return Arrays.stream(configurationObject.getType().getDeclaredFields())
.filter(f -> f.getAnnotation(Setting.class) != null)
.anyMatch(f -> f.getAnnotation(Setting.class).required());
}

@Override
public Result<Void> setTargetValue(Object value) {
try {
configurationObject.set(targetInstance, value);
return Result.success();
} catch (IllegalAccessException e) {
return Result.failure("Could not assign value '%s' to field '%s'. Reason: %s".formatted(value, configurationObject, e.getMessage()));
}
}

/**
* Not used here, will always return null
*/
@Override
public ServiceProvider getDefaultServiceProvider() {
return null;
}

/**
* Not used here
*/
@Override
public void setDefaultServiceProvider(ServiceProvider defaultServiceProvider) {

}

@Override
public Object resolve(ServiceExtensionContext context, DefaultServiceSupplier defaultServiceSupplier) {

// all fields annotated with the @Setting annotation
var settingsFields = resolveSettingsFields(context, configurationObject.getType().getDeclaredFields());

// records are treated specially, because they only contain final fields, and must be constructed with a non-default CTOR
// where every constructor arg MUST be named the same as the field value. We can't rely on this with normal classes
if (configurationObject.getType().isRecord()) {
// find matching constructor
var constructor = Stream.of(configurationObject.getType().getDeclaredConstructors())
.filter(constructorFilter(settingsFields))
.findFirst()
.orElseThrow(() -> new EdcInjectionException("No suitable constructor found on record class '%s'".formatted(configurationObject.getType())));

try {
// invoke CTor with the previously resolved config values
constructor.setAccessible(true);
return constructor.newInstance(settingsFields.stream().map(FieldValue::value).toArray());
} catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
throw new EdcInjectionException(e);
}

} else { // all other classes MUST have a default constructor.
try {
var pojoClass = Class.forName(configurationObject.getType().getName());
var defaultCtor = pojoClass.getDeclaredConstructor();
defaultCtor.setAccessible(true);
var instance = defaultCtor.newInstance();

// set the field values on the newly-constructed object instance
settingsFields.forEach(fe -> {
try {
var field = pojoClass.getDeclaredField(fe.fieldName());
field.setAccessible(true);
field.set(instance, fe.value());
} catch (IllegalAccessException | NoSuchFieldException e) {
throw new EdcInjectionException(e);
}
});

return instance;
} catch (NoSuchMethodException e) {
throw new EdcInjectionException("Configuration objects must declare a default constructor, but '%s' does not.".formatted(configurationObject.getType()));
} catch (ClassNotFoundException | InstantiationException | IllegalAccessException |
InvocationTargetException e) {
throw new EdcInjectionException(e);
}
}
}

@Override
public Result<List<InjectionContainer<T>>> getProviders(Map<Class<?>, List<InjectionContainer<T>>> dependencyMap, ServiceExtensionContext context) {
var violators = injectionPointsFrom(configurationObject.getType().getDeclaredFields())
.map(ip -> ip.getProviders(dependencyMap, context))
.filter(Result::failed)
.map(AbstractResult::getFailureDetail)
.toList();
return violators.isEmpty() ? Result.success(List.of()) : Result.failure("%s (%s) --> %s".formatted(configurationObject.getName(), configurationObject.getType().getSimpleName(), violators));
}

@Override
public String getTypeString() {
return "Config object";
}

@Override
public String toString() {
return "Configuration object '%s' of type '%s' in %s"
.formatted(configurationObject.getName(), configurationObject.getType(), targetInstance.getClass());
}

private Predicate<Constructor<?>> constructorFilter(List<FieldValue> args) {
var argNames = args.stream().map(FieldValue::fieldName).toList();
return ctor -> ctor.getParameterCount() == args.size() &&
Arrays.stream(ctor.getParameters()).allMatch(p -> argNames.contains(p.getName()));

}

private @NotNull List<FieldValue> resolveSettingsFields(ServiceExtensionContext context, Field[] fields) {
return injectionPointsFrom(fields)
.map(ip -> {
var val = ip.resolve(context, null /*the default supplier arg is not used anyway*/);
var fieldName = ip.getTargetField().getName();
return new FieldValue(fieldName, val);
})
.toList();
}

private @NotNull Stream<ValueInjectionPoint<T>> injectionPointsFrom(Field[] fields) {
return Arrays.stream(fields)
.filter(f -> f.getAnnotation(Setting.class) != null)
.map(f -> new ValueInjectionPoint<>(null, f, f.getAnnotation(Setting.class), targetInstance.getClass()));
}

private record FieldValue(String fieldName, Object value) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

/**
* Represents one {@link ServiceExtension} with a description of all its auto-injectable fields, which in turn are
* represented by {@link FieldInjectionPoint}s.
* represented by {@link ServiceInjectionPoint}s.
*/
public class InjectionContainer<T> {
private final T injectionTarget;
Expand Down Expand Up @@ -50,8 +50,8 @@ public List<ServiceProvider> getServiceProviders() {
@Override
public String toString() {
return getClass().getSimpleName() + "{" +
"injectionTarget=" + injectionTarget +
'}';
"injectionTarget=" + injectionTarget +
'}';
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,79 @@
package org.eclipse.edc.boot.system.injection;

import org.eclipse.edc.boot.system.injection.lifecycle.ServiceProvider;
import org.eclipse.edc.spi.result.Result;
import org.eclipse.edc.spi.system.ServiceExtensionContext;
import org.jetbrains.annotations.Nullable;

import java.util.List;
import java.util.Map;

/**
* Represents an auto-injectable property. Possible implementors are field injection points, constructor injection points, etc.
*
* @param <T> the type of the target object
*/
public interface InjectionPoint<T> {
T getInstance();
/**
* The fully constructed object instance that contains the injection point. For example an extension class
*/
T getTargetInstance();

/**
* The type (=class) of the injected field. For example, this could be the service class for a {@link ServiceInjectionPoint}, or
* a basic datatype for a {@link ValueInjectionPoint}.
*/
Class<?> getType();

/**
* Whether the injection point must be able to be satisfied from the current runtime. In other words, whether the injected field is nullable.
*/
boolean isRequired();

void setTargetValue(Object service) throws IllegalAccessException;
/**
* Assigns the given value to the injected field.
*
* @param value An object instance that is assigned to the injected field.
* @return a successful result if the assignment could be done, a failure result indicating the reason in other cases.
*/
Result<Void> setTargetValue(Object value);

ServiceProvider getDefaultServiceProvider();
/**
* Some injection points such as service injection points may have a default value provider.
*
* @return the default service provider if any, null otherwise
*/
@Nullable ServiceProvider getDefaultServiceProvider();

/**
* Sets the default service provider.
*
* @param defaultServiceProvider the default service provider
*/
void setDefaultServiceProvider(ServiceProvider defaultServiceProvider);

/**
* Resolves the value for an injected field from either the context or a default service supplier. For some injection points,
* this may also return a (statically declared) default value.
*
* @param context The {@link ServiceExtensionContext} from which the value is resolved.
* @param defaultServiceSupplier Some service dynamically resolve a default value in case the actual value isn't found on the context.
* @return The resolved value, or null if the injected field is not required..
* @throws EdcInjectionException in case the value could not be resolved
*/
Object resolve(ServiceExtensionContext context, DefaultServiceSupplier defaultServiceSupplier);

/**
* Determines whether a particular injection can be resolved by a given map of dependencies or the context.
*
* @param dependencyMap a map containing the current dependency list
* @param context the fully constructed {@link ServiceExtensionContext}
* @return successful result containing a (potentially empty) list of injection containers that can provide this service, a failure otherwise.
*/
Result<List<InjectionContainer<T>>> getProviders(Map<Class<?>, List<InjectionContainer<T>>> dependencyMap, ServiceExtensionContext context);

/**
* A human-readable string indicating the type of injection point, e.g. "Service" or "Config value"
*/
String getTypeString();
}
Loading

0 comments on commit 90362c9

Please sign in to comment.