Skip to content

Commit

Permalink
feat: implement configuration injection
Browse files Browse the repository at this point in the history
  • Loading branch information
paullatzelsperger committed Nov 11, 2024
1 parent c5dbf9d commit 3a9b654
Show file tree
Hide file tree
Showing 23 changed files with 1,144 additions and 85 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ public List<InjectionContainer<ServiceExtension>> of(List<ServiceExtension> exte
var sort = new TopologicalSort<ServiceExtension>();

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

var injectionPoints = extensions.stream()
Expand All @@ -110,9 +110,10 @@ public List<InjectionContainer<ServiceExtension>> of(List<ServiceExtension> exte

return injectionPointScanner.getInjectionPoints(ext)
.peek(injectionPoint -> {
if (!canResolve(dependencyMap, injectionPoint.getType())) {
var result = injectionPoint.isSatisfiedBy(dependencyMap, context);
if (result.failed()) {
if (injectionPoint.isRequired()) {
unsatisfiedInjectionPoints.add(injectionPoint);
unsatisfiedInjectionPoints.computeIfAbsent(injectionPoint.getTargetInstance().getClass(), s -> new ArrayList<>()).add(new InjectionFailure(injectionPoint, result.getFailureDetail()));
}
} else {
// get() would return null, if the feature is already in the context's service list
Expand All @@ -131,8 +132,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 All @@ -148,16 +150,6 @@ public List<InjectionContainer<ServiceExtension>> of(List<ServiceExtension> exte
.toList();
}

private boolean canResolve(Map<Class<?>, List<ServiceExtension>> dependencyMap, Class<?> serviceClass) {
var providers = dependencyMap.get(serviceClass);
if (providers != null) {
return true;
} else {
// attempt to interpret the feature name as class name, instantiate it and see if the context has that service
return context.hasService(serviceClass);
}
}

private Stream<Class<?>> getRequiredFeatures(Class<?> clazz) {
var requiresAnnotation = clazz.getAnnotation(Requires.class);
if (requiresAnnotation != null) {
Expand All @@ -182,4 +174,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,188 @@
/*
* 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.ServiceExtension;
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;
* }
*
* 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 configObject) throws IllegalAccessException {
configurationObject.set(targetInstance, configObject);
return Result.success();
}

@Override
public ServiceProvider getDefaultServiceProvider() {
return null;
}

@Override
public void setDefaultServiceProvider(ServiceProvider defaultServiceProvider) {

}

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

// all fields annotated with the @Value annotation
var valueAnnotatedFields = resolveConfigValueFields(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(valueAnnotatedFields))
.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(valueAnnotatedFields.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
valueAnnotatedFields.forEach(fe -> {
try {
var field = pojoClass.getDeclaredField(fe.fieldName());
field.setAccessible(true);
field.set(instance, fe.value());
} catch (IllegalAccessException | NoSuchFieldException e) {
throw new RuntimeException(e);
}
});

return instance;
} catch (ClassNotFoundException | NoSuchMethodException | InstantiationException | IllegalAccessException |
InvocationTargetException e) {
throw new EdcInjectionException(e);
}
}
}

@Override
public Result<Void> isSatisfiedBy(Map<Class<?>, List<ServiceExtension>> dependencyMap, ServiceExtensionContext context) {
var violators = injectionPointsFrom(configurationObject.getType().getDeclaredFields())
.map(ip -> ip.isSatisfiedBy(dependencyMap, context))
.filter(Result::failed)
.map(AbstractResult::getFailureDetail)
.toList();
return violators.isEmpty() ? Result.success() : 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> resolveConfigValueFields(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<Object>> 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,44 @@
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.ServiceExtension;
import org.eclipse.edc.spi.system.ServiceExtensionContext;

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();
T getTargetInstance();

Class<?> getType();

boolean isRequired();

void setTargetValue(Object service) throws IllegalAccessException;
Result<Void> setTargetValue(Object service) throws IllegalAccessException;

ServiceProvider getDefaultServiceProvider();

void setDefaultServiceProvider(ServiceProvider defaultServiceProvider);

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 success if it can be resolved, a failure otherwise.
*/
Result<Void> isSatisfiedBy(Map<Class<?>, List<ServiceExtension>> dependencyMap, ServiceExtensionContext context);

/**
* A human-readable string indicating the type of injection point, e.g. "Service" or "Config value"
*/
String getTypeString();
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@

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

import org.eclipse.edc.runtime.metamodel.annotation.Configuration;
import org.eclipse.edc.runtime.metamodel.annotation.Inject;
import org.eclipse.edc.runtime.metamodel.annotation.Setting;

import java.util.Arrays;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Stream;

/**
Expand All @@ -30,11 +33,27 @@ public <T> Stream<InjectionPoint<T>> getInjectionPoints(T instance) {

var targetClass = instance.getClass();

return Arrays.stream(targetClass.getDeclaredFields())
// scan service injection points
var fields = Arrays.stream(targetClass.getDeclaredFields())
.filter(f -> f.getAnnotation(Inject.class) != null)
.map(f -> {
var isRequired = f.getAnnotation(Inject.class).required();
return new FieldInjectionPoint<>(instance, f, isRequired);
return new ServiceInjectionPoint<>(instance, f, isRequired);
});

// scan value injection points
var values = Arrays.stream(targetClass.getDeclaredFields())
.filter(f -> f.getAnnotation(Setting.class) != null && !Setting.NULL.equals(f.getAnnotation(Setting.class).key()))
.map(f -> {
var annotation = f.getAnnotation(Setting.class);
return new ValueInjectionPoint<>(instance, f, annotation, targetClass);
});

// scan configuration injection points
var configObjects = Arrays.stream(targetClass.getDeclaredFields())
.filter(f -> f.getAnnotation(Configuration.class) != null)
.map(f -> new ConfigurationInjectionPoint<>(instance, f));

return Stream.of(fields, values, configObjects).flatMap(Function.identity());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public <T> T inject(InjectionContainer<T> container, ServiceExtensionContext con

container.getInjectionPoints().forEach(ip -> {
try {
var service = resolveService(context, ip);
var service = ip.resolve(context, defaultServiceSupplier);
if (service != null) { //can only be if not required
ip.setTargetValue(service);
}
Expand All @@ -54,14 +54,4 @@ public <T> T inject(InjectionContainer<T> container, ServiceExtensionContext con

return container.getInjectionTarget();
}

private Object resolveService(ServiceExtensionContext context, InjectionPoint<?> injectionPoint) {
var serviceClass = injectionPoint.getType();
if (context.hasService(serviceClass)) {
return context.getService(serviceClass, !injectionPoint.isRequired());
} else {
return defaultServiceSupplier.provideFor(injectionPoint, context);
}
}

}
Loading

0 comments on commit 3a9b654

Please sign in to comment.