diff --git a/all/pom.xml b/all/pom.xml index a4d6e458d54..c37fc609d7e 100644 --- a/all/pom.xml +++ b/all/pom.xml @@ -1140,6 +1140,10 @@ io.helidon.service.inject helidon-service-inject + + io.helidon.service.inject + helidon-service-inject-maven-plugin + io.helidon.metadata helidon-metadata-hson diff --git a/bom/pom.xml b/bom/pom.xml index 60d895a39ed..42775cd8560 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -1506,6 +1506,11 @@ helidon-service-inject ${helidon.version} + + io.helidon.service.inject + helidon-service-inject-maven-plugin + ${helidon.version} + diff --git a/service/codegen/src/main/java/io/helidon/service/codegen/DescriptorClassCode.java b/service/codegen/src/main/java/io/helidon/service/codegen/DescriptorClassCode.java index 8c340ce7f04..c9a464af0cc 100644 --- a/service/codegen/src/main/java/io/helidon/service/codegen/DescriptorClassCode.java +++ b/service/codegen/src/main/java/io/helidon/service/codegen/DescriptorClassCode.java @@ -25,6 +25,28 @@ * New service descriptor metadata with its class code. */ public interface DescriptorClassCode { + /** + * Create a new instance. + * + * @param classCode class code that contains necessary information for the generated class. + * @param registryType type of registry that generates the descriptor (core, inject) + * @param weight weight of the service this descriptor describes + * @param contracts contracts of the service (i.e. {@code MyContract}) + * @param factoryContracts factory contracts of this service (i.e. {@code Supplier}) + * @return a new class code of service descriptor + */ + static DescriptorClassCode create(ClassCode classCode, + String registryType, + double weight, + Set contracts, + Set factoryContracts) { + return new DescriptorClassCodeImpl(classCode, + registryType, + weight, + contracts, + factoryContracts); + } + /** * New source code information. * diff --git a/service/codegen/src/main/java/io/helidon/service/codegen/GenerateServiceDescriptor.java b/service/codegen/src/main/java/io/helidon/service/codegen/GenerateServiceDescriptor.java index 651ef707ffd..3fcc867a56d 100644 --- a/service/codegen/src/main/java/io/helidon/service/codegen/GenerateServiceDescriptor.java +++ b/service/codegen/src/main/java/io/helidon/service/codegen/GenerateServiceDescriptor.java @@ -47,7 +47,7 @@ /** * Generates a service descriptor. */ -class GenerateServiceDescriptor { +public class GenerateServiceDescriptor { static final TypeName SET_OF_RESOLVED_TYPES = TypeName.builder(TypeNames.SET) .addTypeArgument(TypeNames.RESOLVED_TYPE_NAME) .build(); @@ -89,11 +89,11 @@ private GenerateServiceDescriptor(TypeName generator, * @param service service to create a descriptor for * @return class model builder of the service descriptor */ - static ClassModel.Builder generate(TypeName generator, - RegistryCodegenContext ctx, - RegistryRoundContext roundContext, - Collection allServices, - TypeInfo service) { + public static ClassModel.Builder generate(TypeName generator, + RegistryCodegenContext ctx, + RegistryRoundContext roundContext, + Collection allServices, + TypeInfo service) { return new GenerateServiceDescriptor(generator, ctx, roundContext, diff --git a/service/inject/README.md b/service/inject/README.md index 7a8f87f72a3..f28d1f55c12 100644 --- a/service/inject/README.md +++ b/service/inject/README.md @@ -13,6 +13,7 @@ Helidon Inject includes: - [Aspect Oriented Programming (interceptors)](#interceptors) - [Events](events) - [Programmatic Lookup](#programmatic-lookup) +- [Startup](#startup) - [Other](#other) - [Glossary](#glossary) @@ -361,6 +362,29 @@ Lookup parameter options: - `TypeName` - the same, but using Helidon abstraction of type names (may have type arguments) - `Lookup` - a full search criteria for a registry lookup +# Startup + +The following options are available to start a service registry (and the application): + +1. Use API to create an `io.helidon.service.inject.InjectRegistryManager` +2. Use the Helidon startup class `io.helidon.Main`, which will use the injection main class through service loader +3. Use a generated main class, by default named `ApplicationMain` in the main package of the application (supports customization) + +## Generated Main Class + +To generate a main class, the Helidon Service Inject Maven plugin must be configured. +This is expected to be configured only for an application (i.e. not for library modules) - this is the reason we do not generate it automatically. + +The generated main class will contain full, reflection less configuration of the service registry. It registers all services directly through API, and disables service discovery from classpath. + +The Main class can also be customized; to do this: +1. Create a custom class (let's call it `CustomMain` as an example) +2. The class must extends the injection main class (`public abstract class CustomMain extends InjectionMain`) +3. The class must be annotated with `@Injection.Main`, so it is discovered by annotation processor +4. Implement any desired methods; the generated class will only implement `serviceDescriptors(InjectConfig.Builder configBuilder)` (always), and `discoverServices()` (if created from the Maven plugin) + +For details on how to configure your build, see [Maven Plugin](../maven-plugin/README.md). + # Other ## API types quick reference diff --git a/service/inject/api/src/main/java/io/helidon/service/inject/api/LookupSupport.java b/service/inject/api/src/main/java/io/helidon/service/inject/api/LookupSupport.java index 6f49a8ade71..6dad1fc703e 100644 --- a/service/inject/api/src/main/java/io/helidon/service/inject/api/LookupSupport.java +++ b/service/inject/api/src/main/java/io/helidon/service/inject/api/LookupSupport.java @@ -110,6 +110,18 @@ static void addContract(Lookup.BuilderBase builder, Class contract) { builder.addContract(ResolvedType.create(contract)); } + /** + * The managed services advertised types (i.e., typically its interfaces). + * + * @param builder builder instance + * @param contract contract the service implements + * @see Lookup#contracts() + */ + @Prototype.BuilderMethod + static void addContract(Lookup.BuilderBase builder, TypeName contract) { + builder.addContract(ResolvedType.create(contract)); + } + /** * The managed service implementation type. * diff --git a/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/ApplicationMainGenerator.java b/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/ApplicationMainGenerator.java index 2cbbd847b90..4ba8d39126e 100644 --- a/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/ApplicationMainGenerator.java +++ b/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/ApplicationMainGenerator.java @@ -17,27 +17,35 @@ package io.helidon.service.inject.codegen; import java.util.List; +import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; +import io.helidon.codegen.CodegenException; import io.helidon.codegen.CodegenUtil; +import io.helidon.codegen.ElementInfoPredicates; import io.helidon.codegen.classmodel.ClassModel; import io.helidon.codegen.classmodel.Method; import io.helidon.common.types.AccessModifier; import io.helidon.common.types.Annotations; import io.helidon.common.types.ElementSignature; +import io.helidon.common.types.TypeInfo; import io.helidon.common.types.TypeName; import io.helidon.common.types.TypeNames; +import io.helidon.common.types.TypedElementInfo; import static io.helidon.service.inject.codegen.InjectCodegenTypes.DOUBLE_ARRAY; import static io.helidon.service.inject.codegen.InjectCodegenTypes.INJECT_CONFIG; import static io.helidon.service.inject.codegen.InjectCodegenTypes.INJECT_CONFIG_BUILDER; +import static io.helidon.service.inject.codegen.InjectCodegenTypes.INJECT_MAIN; import static io.helidon.service.inject.codegen.InjectCodegenTypes.INJECT_REGISTRY; import static io.helidon.service.inject.codegen.InjectCodegenTypes.STRING_ARRAY; +import static java.util.function.Predicate.not; /** * Utility for {@value #CLASS_NAME} class generation. */ -final class ApplicationMainGenerator { +public final class ApplicationMainGenerator { /** * Default class name of the generated main class. */ @@ -87,6 +95,7 @@ private ApplicationMainGenerator() { * @param runLevelHandler handler of the run level method * @return class model builder */ + @SuppressWarnings("checkstyle:ParameterNumber") // all parameters are mandatory public static ClassModel.Builder generate(TypeName generator, Set declaredSignatures, TypeName superType, @@ -167,6 +176,67 @@ public static ClassModel.Builder generate(TypeName generator, return classModel; } + /** + * Provides all relevant signatures that may override methods from {@code InjectionMain}. + * + * @param customMain type to analyze + * @return set of method signatures that are non-private, non-static + */ + public static Set declaredSignatures(TypeInfo customMain) { + return customMain.elementInfo() + .stream() + .filter(ElementInfoPredicates::isMethod) + .filter(not(ElementInfoPredicates::isStatic)) + .filter(not(ElementInfoPredicates::isPrivate)) + .map(TypedElementInfo::signature) + .collect(Collectors.toUnmodifiableSet()); + } + + /** + * Validate a type, to make sure it is a valid custom main class. + * + * @param customMain type to validate + */ + public static void validate(TypeInfo customMain) { + Optional superType = customMain.superTypeInfo(); + if (superType.isEmpty()) { + throw new CodegenException("Custom main class must directly extend " + INJECT_MAIN.fqName() + ", but " + + customMain.typeName().fqName() + " does not extend any class", + customMain.originatingElementValue()); + } + if (!superType.get().typeName().equals(INJECT_MAIN)) { + throw new CodegenException("Custom main class must directly extend " + INJECT_MAIN.fqName() + ", but " + + customMain.typeName().fqName() + " extends " + superType.get().typeName(), + customMain.originatingElementValue()); + } + if (customMain.accessModifier() == AccessModifier.PRIVATE) { + throw new CodegenException("Custom main class must be accessible (non-private) class, but " + + customMain.typeName().fqName() + " is private.", + customMain.originatingElementValue()); + } + if (customMain.elementInfo() + .stream() + .filter(ElementInfoPredicates::isMethod) + .filter(ElementInfoPredicates::isStatic) + .filter(not(ElementInfoPredicates::isPrivate)) + .filter(ElementInfoPredicates.elementName("main")) + .anyMatch(ElementInfoPredicates.hasParams(TypeName.create(String[].class)))) { + throw new CodegenException("Custom main class must not declare a static main(String[]) method, as it is code " + + "generated into the ApplicationMain class, but " + + customMain.typeName().fqName() + " declares it.", + customMain.originatingElementValue()); + } + if (customMain.elementInfo() + .stream() + .filter(ElementInfoPredicates::isConstructor) + .filter(not(ElementInfoPredicates::isPrivate)) + .noneMatch(ElementInfoPredicates.hasParams())) { + throw new CodegenException("Custom main class must have an accessible no-argument constructor, but " + + customMain.typeName().fqName() + " does not.", + customMain.originatingElementValue()); + } + } + private static void mainMethodBody(TypeName type, Method.Builder method) { method.addContent("new ") .addContent(type) diff --git a/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/EventEmitterObserverProvider.java b/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/EventEmitterObserverProvider.java index 50ebda400ac..e4c01742ad6 100644 --- a/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/EventEmitterObserverProvider.java +++ b/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/EventEmitterObserverProvider.java @@ -184,7 +184,7 @@ private void addMergeQualifiers(ClassModel.Builder classModel) { .addContentLine("}") .addContent("var qualifierSet = new ") .addContent(HashSet.class) - .addContentLine("(QUALIFIERS);") + .addContentLine("<>(QUALIFIERS);") .addContent("qualifierSet.addAll(") .addContent(Set.class) .addContentLine(".of(qualifiers));") diff --git a/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/InjectCodegenTypes.java b/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/InjectCodegenTypes.java index 016ed707e79..2737c061821 100644 --- a/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/InjectCodegenTypes.java +++ b/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/InjectCodegenTypes.java @@ -140,11 +140,26 @@ public class InjectCodegenTypes { */ public static final TypeName INJECT_REGISTRY = TypeName.create("io.helidon.service.inject.api.InjectRegistry"); + /** + * {@link io.helidon.common.types.TypeName} for {@code io.helidon.service.inject.InjectRegistryManager}. + */ + public static final TypeName INJECT_REGISTRY_MANAGER = + TypeName.create("io.helidon.service.inject.InjectRegistryManager"); /** * {@link io.helidon.common.types.TypeName} for {@code io.helidon.service.inject.InjectionMain}. */ public static final TypeName INJECT_MAIN = TypeName.create("io.helidon.service.inject.InjectionMain"); + /** + * {@link io.helidon.common.types.TypeName} for {@code io.helidon.service.inject.Binding}. + */ + public static final TypeName INJECT_BINDING = + TypeName.create("io.helidon.service.inject.Binding"); + /** + * {@link io.helidon.common.types.TypeName} for {@code io.helidon.service.inject.InjectionPlanBinder}. + */ + public static final TypeName INJECT_PLAN_BINDER = + TypeName.create("io.helidon.service.inject.InjectionPlanBinder"); /** * {@link io.helidon.common.types.TypeName} for {@code io.helidon.service.inject.api.InvocationException}. diff --git a/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/InjectOptions.java b/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/InjectOptions.java index b68e6477276..41cf8b1ddfc 100644 --- a/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/InjectOptions.java +++ b/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/InjectOptions.java @@ -53,6 +53,7 @@ public final class InjectOptions { * Name of the generated Main class for Injection. Defaults to * {@value ApplicationMainGenerator#CLASS_NAME}. * The same property must be provided to the maven plugin, to correctly update the generated class. + * To configure package name, use {@link io.helidon.codegen.CodegenOptions#CODEGEN_PACKAGE} option. */ public static final Option APPLICATION_MAIN_CLASS_NAME = Option.create("helidon.inject.application.main.class.name", @@ -60,17 +61,6 @@ public final class InjectOptions { ApplicationMainGenerator.CLASS_NAME, Function.identity(), GenericType.STRING); - /** - * Package name of the generated Main class for Injection. - * This is only needed if there is no custom main class AND the package name cannot be determined from processed classes, - * OR it was determined wrongly. - */ - public static final Option APPLICATION_MAIN_PACKAGE_NAME = - Option.create("helidon.inject.application.main.package.name", - "Package name of the generated Main class for Helidon Injection.", - ApplicationMainGenerator.CLASS_NAME, - Function.identity(), - GenericType.STRING); /** * Whether to generate main class for Helidon Injection. diff --git a/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/InjectionExtension.java b/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/InjectionExtension.java index dddb3ac3a41..5ce5bee9ce7 100644 --- a/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/InjectionExtension.java +++ b/service/inject/codegen/src/main/java/io/helidon/service/inject/codegen/InjectionExtension.java @@ -137,7 +137,7 @@ class InjectionExtension implements RegistryCodegenExtension { .stream() .map(it -> it.create(codegenContext)) .toList(); - this.packageName = InjectOptions.APPLICATION_MAIN_PACKAGE_NAME.findValue(options) + this.packageName = CodegenOptions.CODEGEN_PACKAGE.findValue(options) .orElse(null); this.mainClassGenerated = !options.enabled(InjectOptions.APPLICATION_MAIN_GENERATE); } @@ -430,7 +430,7 @@ private void generateScopeDescriptor(RegistryRoundContext roundContext, TypeInfo private void generateMain() { if (packageName == null) { throw new CodegenException("Cannot determine package name for the generated main class. " - + "Please use option " + InjectOptions.APPLICATION_MAIN_PACKAGE_NAME.name() + + "Please use option " + CodegenOptions.CODEGEN_PACKAGE.name() + " to specify it"); } // generate main class if it doe not exist @@ -467,13 +467,6 @@ private void generateMain(RegistryRoundContext roundCtx, Collection cu customMain.originatingElementValue()); } - // TODO validate that the custom main class extends InjectionMain - // validate it does not declare `main` method - // validate it has accessible no-arg constructor - // validate it is accessible (at least package local static class) - // add generation to processing over if not generated here (and make sure a conflicting name does not exist) - // - // we always generate the main class, even when there is no Maven plugin mainClassGenerated = true; String className = InjectOptions.APPLICATION_MAIN_CLASS_NAME.value(ctx.options()); @@ -481,19 +474,14 @@ private void generateMain(RegistryRoundContext roundCtx, Collection cu .packageName(customMain.typeName().packageName()) .className(className) .build(); - var declaredSignatures = customMain.elementInfo() - .stream() - .filter(ElementInfoPredicates::isMethod) - .filter(not(ElementInfoPredicates::isStatic)) - .filter(not(ElementInfoPredicates::isPrivate)) - .map(TypedElementInfo::signature) - .collect(Collectors.toUnmodifiableSet()); + ApplicationMainGenerator.validate(customMain); + var declaredSignatures = ApplicationMainGenerator.declaredSignatures(customMain); ClassModel.Builder applicationMain = ApplicationMainGenerator.generate(GENERATOR, declaredSignatures, customMain.typeName(), generatedType, - false, + true, false, (a, b, c) -> { }, diff --git a/service/inject/inject/src/main/java/io/helidon/service/inject/InjectionMain.java b/service/inject/inject/src/main/java/io/helidon/service/inject/InjectionMain.java index bd918d055f2..ac153fe9516 100644 --- a/service/inject/inject/src/main/java/io/helidon/service/inject/InjectionMain.java +++ b/service/inject/inject/src/main/java/io/helidon/service/inject/InjectionMain.java @@ -50,7 +50,7 @@ */ public abstract class InjectionMain { /* - A change in this class requires change in ApplicationMainCodegen! + A change in this class requires change in ApplicationMainGenerator! */ private static final System.Logger LOGGER = System.getLogger(InjectionMain.class.getName()); @@ -204,11 +204,30 @@ protected InjectRegistry init(InjectConfig config) { return registry; } + /** + * Maximal run level to initialize. + * The default startup sequence will go through each + * {@link #runLevels(InjectConfig, io.helidon.service.inject.api.InjectRegistry) run level} up to (and including) the value + * returned by this method. + * + * @param config injection config + * @param registry registry instance + * @return maximal run level to initialize + */ protected double maxRunLevel(InjectConfig config, InjectRegistry registry) { return config.maxRunLevel(); } + /** + * Run levels that should be initialized at startup. + * Default implementation initializes all declared run levels (services with explicit + * {@link io.helidon.service.inject.api.Injection.RunLevel} annotation). + * + * @param config injection config + * @param registry registry instance + * @return array of doubles that represent run levels to initialize, will be used in the order provided here + */ protected double[] runLevels(InjectConfig config, InjectRegistry registry) { // child classes will have this method code generated at build time diff --git a/service/inject/maven-plugin/README.md b/service/inject/maven-plugin/README.md new file mode 100644 index 00000000000..3d3f2260cfc --- /dev/null +++ b/service/inject/maven-plugin/README.md @@ -0,0 +1,67 @@ +Maven Plugin +--- + +The Helidon Service Maven Plugin provides the following goals: + +1. Create application artifacts (`create-application`, `create-test-application`) + +# Create application artifacts Maven goals + +This goal creates artifacts that are only valid for the service (assembled from libraries and its own sources). +This goal generates: + +1. Application Binding - a mapping of services to injection points (to bypass runtime lookups) - generates class `Injection__Binding` +2. Application Main - a generated main class that registers all services (to bypass service discovery) - generates class `ApplicationMain` + +Usage of this plugin goal is not required, yet it is recommended for final application module, it will add +- binding for injection points, to avoid runtime lookups +- explicit registration of all services into `InjectConfig`, to avoid resource lookup and reflection at runtime +- overall speedup of bootstrapping, as all the required tasks to start a service registry are code generated + +This goal should not be used for library modules (i.e. modules that do not have a Main class that bootstraps registry). + +## Usage + +Goal names: + +- `create-application` - for production sources +- `create-test-application` - for test sources (only creates binding, main class not relevant) + +Configuration options: + +| Name | Property | Default | Description | +|--------------------|-------------------------------------------------|------------------------|---------------------------------------------------------------------------------| +| `packageName` | `helidon.codegen.package-name` | Inferred from module | Package to put the generated classes in | +| `moduleName` | `helidon.codegen.module-name` | Inferred from module | Name of the JPMS module | +| `validate` | `helidon.inject.application.validate` | `true` | Whether to validate application | +| `createMain` | `helidon.inject.application.main.generate` | `true` | Whether to create application Main class | +| `mainClassName` | `helidon.inject.application.main.class.name` | `ApplicationMain` | Name of the generated Main class | +| `createBinding` | `helidon.inject.application.binding.generate` | `true` | Whether to create application binding | +| `bindingClassName` | `helidon.inject.application.binding.class.name` | `Application__Binding` | Name of the generated binding class, for test, it is `TestApplication__Binding` | +| `failOnError` | `helidon.inject.fail-on-error` | `true` | Whether to fail when the plugin encounters an error | +| `failOnWarning` | `helidon.inject.fail-on-warning` | `false` | Whether to fail when the plugin encounters a warning | +| `compilerArgs` | | | Arguments of the Java compiler (both classes are compiled by the plugin) | + +Configuration example in `pom.xml`: + +```xml + + + io.helidon.service + helidon-service-maven-plugin + + + create-application + + create-application + + + + + com.example.mypackage + MyInjection__Binding + MyApplicationMain + false + + +``` diff --git a/service/inject/maven-plugin/etc/spotbugs/exclude.xml b/service/inject/maven-plugin/etc/spotbugs/exclude.xml new file mode 100644 index 00000000000..e672fbdf494 --- /dev/null +++ b/service/inject/maven-plugin/etc/spotbugs/exclude.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/service/inject/maven-plugin/pom.xml b/service/inject/maven-plugin/pom.xml new file mode 100644 index 00000000000..8a8ccd063ac --- /dev/null +++ b/service/inject/maven-plugin/pom.xml @@ -0,0 +1,203 @@ + + + + + io.helidon.service.inject + helidon-service-inject-project + 4.2.0-SNAPSHOT + ../pom.xml + + 4.0.0 + + helidon-service-inject-maven-plugin + Helidon Service Maven Plugin + maven-plugin + + + 2.7.0 + 3.3.0 + 3.9.3 + 3.9.0 + 3.9.0 + 2.2.1 + + etc/spotbugs/exclude.xml + + + + + + org.apache.maven.plugins + maven-plugin-plugin + + + + report + + + + + + + + + + org.apache.maven + maven-artifact + ${version.plugin.api} + provided + + + org.apache.maven + maven-model + ${version.plugin.api} + provided + + + + + io.helidon.common + helidon-common + + + io.helidon.common + helidon-common-types + + + io.helidon.codegen + helidon-codegen-class-model + + + io.helidon.service + helidon-service-registry + + + io.helidon.service + helidon-service-codegen + + + io.helidon.service.inject + helidon-service-inject-codegen + + + io.helidon.service + helidon-service-metadata + + + + io.helidon.service.inject + helidon-service-inject-api + + + io.helidon.codegen + helidon-codegen + + + io.helidon.codegen + helidon-codegen-scan + + + io.helidon.codegen + helidon-codegen-compiler + + + io.github.classgraph + classgraph + + + org.apache.maven + maven-plugin-api + ${version.plugin.api} + provided + + + org.codehaus.plexus + plexus-classworlds + + + org.codehaus.plexus + plexus-utils + + + + + org.apache.maven.plugin-tools + maven-plugin-annotations + ${version.plugin.annotations} + provided + + + org.apache.maven + maven-project + ${version.plugin.project} + provided + + + org.apache.maven + maven-model + + + org.codehaus.plexus + plexus-utils + + + org.apache.maven + maven-artifact + + + + + + + + + + org.apache.maven.plugins + maven-plugin-plugin + ${version.plugin.plugin} + + + + + + [3.6.1,) + [${version.java}.0,) + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + -proc:none + + + + + + diff --git a/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/ApplicationValidator.java b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/ApplicationValidator.java new file mode 100644 index 00000000000..6a54a8941b7 --- /dev/null +++ b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/ApplicationValidator.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * 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.helidon.service.inject.maven.plugin; + +import java.lang.System.Logger.Level; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import io.helidon.codegen.CodegenException; +import io.helidon.codegen.CodegenLogger; +import io.helidon.common.Errors; +import io.helidon.common.types.ResolvedType; +import io.helidon.common.types.TypeName; +import io.helidon.service.inject.api.InjectServiceInfo; +import io.helidon.service.inject.api.Injection; +import io.helidon.service.inject.api.Ip; +import io.helidon.service.inject.api.Lookup; + +class ApplicationValidator { + private final MavenCodegenContext scanContext; + private final boolean failOnWarning; + + ApplicationValidator(MavenCodegenContext scanContext, boolean failOnWarning) { + this.scanContext = scanContext; + this.failOnWarning = failOnWarning; + } + + void validate(WrappedServices services) { + Errors.Collector collector = Errors.collector(); + + validate(services, collector); + + Errors errors = collector.collect(); + CodegenLogger logger = scanContext.logger(); + for (Errors.ErrorMessage error : errors) { + Level level = switch (error.getSeverity()) { + case FATAL -> Level.ERROR; + case WARN -> Level.WARNING; + case HINT -> Level.INFO; + }; + logger.log(level, error.getSeverity() + " " + error.getSource() + ": " + error.getMessage()); + } + + if (errors.hasFatal() || failOnWarning && errors.hasWarning()) { + throw new CodegenException("Application validation failed, see log output for details."); + } + } + + private void validate(WrappedServices services, Errors.Collector collector) { + // check all singletons, that they only contain injection points that are singletons, or have a supplier + List requestScopedServices = services.all(Lookup.builder() + .addScope(Injection.PerRequest.TYPE) + .build()); + Set requestScopedContracts = new HashSet<>(); + Map> requestScopedByContracts = new HashMap<>(); + + for (InjectServiceInfo requestScoped : requestScopedServices) { + TypeName serviceType = requestScoped.serviceType(); + Set contracts = requestScoped.contracts(); + + ResolvedType resolvedServiceType = ResolvedType.create(serviceType); + requestScopedContracts.add(resolvedServiceType); + requestScopedByContracts.computeIfAbsent(resolvedServiceType, k -> new HashSet<>()) + .add(serviceType); + + requestScopedContracts.addAll(contracts); + for (ResolvedType contract : contracts) { + requestScopedByContracts.computeIfAbsent(contract, k -> new HashSet<>()) + .add(serviceType); + } + } + + List singletons = services.all(Lookup.builder() + .addScope(Injection.Singleton.TYPE) + .build()); + + boolean requestScopeHinted = false; + + for (InjectServiceInfo singleton : singletons) { + for (Ip dependency : singleton.dependencies()) { + ResolvedType contract = ResolvedType.create(dependency.contract()); + if (requestScopedContracts.contains(contract)) { + // this is an injection of request scope service into a singleton + if (dependency.typeName().isSupplier()) { + // this is correct + if (!requestScopeHinted) { + collector.hint( + "Injection of request scoped service into a singleton (as a supplier). This is correct, " + + "please " + + "make sure you have appropriate request scope library on your module path."); + requestScopeHinted = true; + } + } else { + // this does not have to be an error, if the user decides the whole application has a request + // scope active and they implement their own request scope initialization + collector.warn("Injection of request scoped service into a singleton without a supplier. " + + " Singleton: " + singleton.serviceType() + ", request scoped service(s): " + + requestScopedByContracts.get(contract)); + } + } + } + } + } +} diff --git a/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/BindingGenerator.java b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/BindingGenerator.java new file mode 100644 index 00000000000..fea87bd0624 --- /dev/null +++ b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/BindingGenerator.java @@ -0,0 +1,584 @@ +/* + * Copyright (c) 2023, 2024 Oracle and/or its affiliates. + * + * 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.helidon.service.inject.maven.plugin; + +import java.nio.file.Path; +import java.util.ArrayList; +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.Set; +import java.util.function.Consumer; + +import io.helidon.codegen.CodegenException; +import io.helidon.codegen.CodegenUtil; +import io.helidon.codegen.classmodel.ClassModel; +import io.helidon.codegen.classmodel.ContentBuilder; +import io.helidon.codegen.classmodel.Method; +import io.helidon.codegen.compiler.Compiler; +import io.helidon.codegen.compiler.CompilerOptions; +import io.helidon.common.types.AccessModifier; +import io.helidon.common.types.Annotation; +import io.helidon.common.types.Annotations; +import io.helidon.common.types.ElementKind; +import io.helidon.common.types.ResolvedType; +import io.helidon.common.types.TypeInfo; +import io.helidon.common.types.TypeName; +import io.helidon.service.codegen.DescriptorClassCode; +import io.helidon.service.codegen.GenerateServiceDescriptor; +import io.helidon.service.codegen.HelidonMetaInfServices; +import io.helidon.service.codegen.RegistryCodegenContext; +import io.helidon.service.inject.api.InjectServiceInfo; +import io.helidon.service.inject.api.Interception; +import io.helidon.service.inject.api.Ip; +import io.helidon.service.inject.api.Lookup; +import io.helidon.service.inject.api.Qualifier; +import io.helidon.service.metadata.DescriptorMetadata; +import io.helidon.service.registry.ServiceLoader__ServiceDescriptor; + +import static io.helidon.service.codegen.ServiceCodegenTypes.SERVICE_ANNOTATION_CONTRACT; +import static io.helidon.service.codegen.ServiceCodegenTypes.SERVICE_ANNOTATION_PROVIDER; +import static io.helidon.service.inject.codegen.InjectCodegenTypes.INJECTION_POINT_FACTORY; +import static io.helidon.service.inject.codegen.InjectCodegenTypes.INJECT_BINDING; +import static io.helidon.service.inject.codegen.InjectCodegenTypes.INJECT_PLAN_BINDER; +import static io.helidon.service.inject.codegen.InjectCodegenTypes.INJECT_SERVICE_INSTANCE; + +/** + * The default implementation for {@link BindingGenerator}. + */ +class BindingGenerator { + private static final TypeName GENERATOR = TypeName.create(BindingGenerator.class); + + private final MavenCodegenContext ctx; + private final boolean failOnError; + + BindingGenerator(MavenCodegenContext scanContext, boolean failOnError) { + this.ctx = scanContext; + this.failOnError = failOnError; + } + + /** + * Generates the source and class file for {@code io.helidon.inject.Binding} using the current classpath. + * + * @param injectionServices injection services to use + * @param serviceTypes types to process + * @param typeName generated binding type name + * @param moduleName name of the module of this maven module + * @param compilerOptions compilation options + */ + void createBinding(WrappedServices injectionServices, + Set serviceTypes, + TypeName typeName, + String moduleName, + CompilerOptions compilerOptions) { + Objects.requireNonNull(injectionServices); + Objects.requireNonNull(serviceTypes); + + try { + codegen(injectionServices, serviceTypes, typeName, moduleName, compilerOptions); + } catch (CodegenException ce) { + handleError(ce); + } catch (Throwable te) { + handleError(new CodegenException("Failed to code generate binding class", te)); + } + } + + void codegen(WrappedServices injectionServices, + Set serviceTypes, + TypeName typeName, + String moduleName, + CompilerOptions compilerOptions) { + ClassModel.Builder classModel = ClassModel.builder() + .accessModifier(AccessModifier.PACKAGE_PRIVATE) + .copyright(CodegenUtil.copyright(GENERATOR, + GENERATOR, + typeName)) + .description("Generated Binding to provide explicit bindings for known services.") + .type(typeName) + .addAnnotation(CodegenUtil.generatedAnnotation(GENERATOR, + GENERATOR, + typeName, + "1", + "")) + .addInterface(INJECT_BINDING); + + // deprecated default constructor - binding should always be service loaded + classModel.addConstructor(ctr -> ctr + .accessModifier(AccessModifier.PACKAGE_PRIVATE)); + + // public String name() + classModel.addMethod(nameMethod -> nameMethod + .addAnnotation(Annotations.OVERRIDE) + .returnType(io.helidon.common.types.TypeNames.STRING) + .name("name") + .addContentLine("return \"" + moduleName + "\";")); + + // public void configure(ServiceInjectionPlanBinder binder) + classModel.addMethod(configureMethod -> configureMethod + .addAnnotation(Annotations.OVERRIDE) + .name("configure") + // constructors of services for service loader are usually deprecated in Helidon + .addAnnotation(Annotation.create(SuppressWarnings.class, "deprecation")) + .addParameter(binderParam -> binderParam + .name("binder") + .type(INJECT_PLAN_BINDER)) + .update(it -> createConfigureMethodBody(injectionServices, + serviceTypes, + it))); + + Path generated = ctx.filer() + .writeSourceFile(classModel.build()); + + TypeInfo appTypeInfo = createAppTypeInfo(typeName); + RegistryCodegenContext registryCodegenContext = RegistryCodegenContext.create(ctx); + MavenRoundContext roundContext = new MavenRoundContext(ctx); + GenerateServiceDescriptor.generate(GENERATOR, + registryCodegenContext, + roundContext, + List.of(appTypeInfo), + appTypeInfo); + + List toCompile = new ArrayList<>(); + toCompile.add(generated); + + HelidonMetaInfServices services = HelidonMetaInfServices.create(ctx.filer(), moduleName); + + for (DescriptorClassCode descriptor : roundContext.descriptors()) { + Path path = ctx.filer().writeSourceFile(descriptor.classCode().classModel().build(), + descriptor.classCode().originatingElements()); + toCompile.add(path); + + services.add(DescriptorMetadata.create(descriptor.registryType(), + descriptor.classCode().newType(), + descriptor.weight(), + descriptor.contracts(), + Set.of())); + } + + services.write(); + Compiler.compile(compilerOptions, toCompile.toArray(new Path[0])); + } + + BindingPlan bindingPlan(WrappedServices services, + TypeName serviceTypeName) { + + Lookup lookup = toLookup(serviceTypeName); + InjectServiceInfo sp = services.get(lookup); + TypeName serviceDescriptorType = sp.descriptorType(); + + if (!isQualifiedInjectionTarget(sp)) { + return new BindingPlan(serviceDescriptorType, Set.of()); + } + + List dependencies = sp.dependencies(); + if (dependencies.isEmpty()) { + return new BindingPlan(serviceDescriptorType, Set.of()); + } + + Set bindings = new LinkedHashSet<>(); + for (Ip dependency : dependencies) { + InjectionPlan iPlan = injectionPlan(services, sp, dependency); + List qualified = iPlan.qualifiedProviders(); + List unqualified = iPlan.unqualifiedProviders(); + List usedList; + + if (qualified.isEmpty() && !unqualified.isEmpty()) { + usedList = unqualified; + } else { + usedList = qualified; + } + + bindings.add(new Binding(dependency, + usedList)); + } + + return new BindingPlan(serviceDescriptorType, bindings); + } + + private static Consumer> toContentBuilder(InjectServiceInfo serviceInfo) { + if (serviceInfo.coreInfo() instanceof ServiceLoader__ServiceDescriptor sl) { + // we need to create a specific descriptor for interface and implementation + TypeName providerInterface = sl.providerInterface(); + TypeName providerImpl = sl.serviceType(); + return it -> it.addContent(sl.descriptorType()) + .addContent(".create(") + .addContentCreate(providerInterface) + .addContent(", ") + .addContent(providerImpl) + .addContent(".class, ") + .addContent(providerImpl) + .addContent("::new, ") + .addContent(String.valueOf(sl.weight())) + .addContent(")"); + } else { + // the usual singleton instance + return it -> it.addContent(serviceInfo.descriptorType().fqName()) + .addContent(".INSTANCE"); + } + } + + private static Lookup toLookup(TypeName typeName) { + return Lookup.builder() + .serviceType(typeName) + .build(); + } + + /** + * Determines if the service is valid to receive injections. + * + * @param sp the service provider + * @return true if the service provider can receive injection + */ + private static boolean isQualifiedInjectionTarget(InjectServiceInfo sp) { + Set contractsImplemented = sp.contracts(); + List dependencies = sp.dependencies(); + + if (contractsImplemented.contains(ResolvedType.create(INJECT_BINDING))) { + return false; + } + boolean hasDependencies = !dependencies.isEmpty(); + boolean hasContract = !contractsImplemented.isEmpty(); + + return hasContract || hasDependencies; + } + + private TypeInfo createAppTypeInfo(TypeName typeName) { + return TypeInfo.builder() + .kind(ElementKind.CLASS) + .typeName(typeName) + // to trigger generation of descriptor + .addAnnotation(Annotation.create(SERVICE_ANNOTATION_PROVIDER)) + .addInterfaceTypeInfo(TypeInfo.builder() + .kind(ElementKind.INTERFACE) + .typeName(INJECT_BINDING) + .addAnnotation(Annotation.create(SERVICE_ANNOTATION_CONTRACT)) + .build()) + .build(); + } + + private void handleError(CodegenException ce) { + if (failOnError) { + throw ce; + } else { + ctx.logger().log(ce.toEvent(System.Logger.Level.WARNING)); + } + } + + private InjectionPlan injectionPlan(WrappedServices services, + InjectServiceInfo self, + Ip dependency) { + /* + very similar code is used in ServiceManager.planForIp + make sure this is kept in sync! + */ + Lookup dependencyTo = Lookup.create(dependency); + Set qualifiers = dependencyTo.qualifiers(); + if (self.contracts().containsAll(dependencyTo.contracts()) && self.qualifiers().equals(qualifiers)) { + // criteria must have a single contract for each injection point + // if this service implements the contracts actually required, we must look for services with lower weight + // but only if we also have the same qualifiers + dependencyTo = Lookup.builder(dependencyTo) + .weight(self.weight()) + .build(); + } + + /* + An injection point can be satisfied by: + 1. a service that matches the type or contract, and qualifiers match + 2. a Supplier service, where T matches service type or contract, and qualifiers match + 3. an InjectionPointProvider, where T matches service type or contract, regardless of qualifiers + 4. an InjectionResolver, where the method resolve returns an information if this type can be resolved (config driven) + */ + + List qualifiedProviders = services.all(dependencyTo); + List unqualifiedProviders; + + if (qualifiedProviders.isEmpty()) { + unqualifiedProviders = injectionPointProvidersFor(services, dependency) + .stream() + .filter(it -> !it.serviceType().equals(self.serviceType())) + .toList(); + } else { + unqualifiedProviders = List.of(); + } + + // remove current service provider from matches + qualifiedProviders = qualifiedProviders.stream() + .filter(it -> !it.serviceType().equals(self.serviceType())) + .toList(); + + // the list now contains all providers that match the processed injection points + return new InjectionPlan(unqualifiedProviders, qualifiedProviders); + } + + private List injectionPointProvidersFor(WrappedServices services, Ip injectionPoint) { + if (injectionPoint.qualifiers().isEmpty()) { + return List.of(); + } + Lookup criteria = Lookup.builder(Lookup.create(injectionPoint)) + .qualifiers(Set.of()) // remove qualifier from lookup + .addContract(INJECTION_POINT_FACTORY) // only search for injection point providers + .build(); + return services.all(criteria); + } + + private void createConfigureMethodBody(WrappedServices services, + Set serviceTypes, + Method.Builder method) { + // find all interceptors and bind them + List interceptors = + services.all(Lookup.builder() + .addContract(Interception.Interceptor.class) + .addQualifier(Qualifier.WILDCARD_NAMED) + .build()); + method.addContent("binder.interceptors("); + boolean multiline = interceptors.size() > 2; + if (multiline) { + method.addContentLine("") + .increaseContentPadding(); + } + + Iterator interceptorIterator = interceptors.iterator(); + while (interceptorIterator.hasNext()) { + method.addContent(interceptorIterator.next().descriptorType()) + .addContent(".INSTANCE"); + if (interceptorIterator.hasNext()) { + method.addContent(","); + if (multiline) { + method.addContentLine(""); + } else { + method.addContent(" "); + } + } + } + + if (multiline) { + method.addContentLine("") + .decreaseContentPadding(); + } + method.addContentLine(");") + .addContentLine(""); + + // first collect required dependencies by descriptor + Map> injectionPlan = new LinkedHashMap<>(); + for (TypeName serviceType : serviceTypes) { + BindingPlan plan = bindingPlan(services, serviceType); + if (!plan.bindings.isEmpty()) { + injectionPlan.put(plan.descriptorType(), plan.bindings()); + } + } + + boolean supportNulls = false; + // we group all bindings by descriptor they belong to + injectionPlan.forEach((descriptorType, bindings) -> { + method.addContent("binder.bindTo(") + .addContent(descriptorType.genericTypeName()) + .addContentLine(".INSTANCE)") + .increaseContentPadding(); + + for (Binding binding : bindings) { + Consumer> ipId = content -> content + .addContent(binding.injectionPoint().descriptor().genericTypeName()) + .addContent(".") + .addContent(binding.injectionPoint.descriptorConstant()); + + buildTimeBinding(method, binding, ipId, supportNulls); + } + + /* + Commit the dependencies + */ + method.addContentLine(".commit();") + .decreaseContentPadding() + .addContentLine(""); + }); + } + + /* + Very similar code is used for runtime discovery in ServiceProvider.planForIp + make sure this is doing the same thing! + Here we code generate the calls to the binding class + */ + private void buildTimeBinding(Method.Builder method, + Binding binding, + Consumer> ipId, + boolean supportNulls) { + + Ip injectionPoint = binding.injectionPoint(); + List discovered = binding.descriptors(); + Iterator>> descriptors = discovered.stream() + .map(BindingGenerator::toContentBuilder) + .iterator(); + + TypeName ipType = injectionPoint.typeName(); + + // now there are a few options - optional, list, and single instance + if (ipType.isList()) { + TypeName typeOfList = ipType.typeArguments().getFirst(); + if (typeOfList.isSupplier()) { + // inject List> + method.addContent(".bindListOfSuppliers("); + } else if (typeOfList.equals(INJECT_SERVICE_INSTANCE)) { + method.addContent(".bindServiceInstanceList("); + } else { + // inject List + method.addContent(".bindList("); + } + method.update(ipId::accept); + + if (discovered.isEmpty()) { + method.addContentLine(")"); + } else { + method.addContent(", ") + .update(it -> { + while (descriptors.hasNext()) { + descriptors.next().accept(it); + if (descriptors.hasNext()) { + it.addContent(", "); + } + } + }) + .addContentLine(")"); + } + } else if (ipType.isOptional()) { + TypeName typeOfOptional = ipType.typeArguments().getFirst(); + if (typeOfOptional.isSupplier()) { + // inject Optional> + method.addContent(".bindOptionalOfSupplier("); + } else if (typeOfOptional.equals(INJECT_SERVICE_INSTANCE)) { + // inject Optional> + method.addContent(".bindOptionalOfServiceInstance("); + } else { + // inject Optional + method.addContent(".bindOptional("); + } + method.update(ipId::accept); + + if (discovered.isEmpty()) { + method.addContentLine(")"); + } else { + method.addContent(", "); + descriptors.next().accept(method); + method.addContentLine(")"); + } + } else if (ipType.isSupplier()) { + // one of the supplier options + + TypeName typeOfSupplier = ipType.typeArguments().getFirst(); + if (typeOfSupplier.isOptional()) { + // inject Supplier> + method.addContent(".bindSupplierOfOptional(") + .update(ipId::accept); + if (discovered.isEmpty()) { + method.addContentLine(")"); + } else { + method.addContent(", "); + descriptors.next().accept(method); + method.addContentLine(")"); + } + } else if (typeOfSupplier.isList()) { + // inject Supplier> + method.addContent(".bindSupplierOfList(") + .update(ipId::accept); + if (discovered.isEmpty()) { + method.addContentLine(")"); + } else { + method.addContent(", ") + .update(it -> { + while (descriptors.hasNext()) { + descriptors.next().accept(it); + if (descriptors.hasNext()) { + it.addContent(", "); + } + } + }) + .addContentLine(")"); + } + } else { + // inject Supplier + method.addContent(".bindSupplier(") + .update(ipId::accept); + + if (discovered.isEmpty()) { + // null binding is not supported at runtime + throw new CodegenException("Injection point requires a value, but no provider discovered: " + + injectionPoint); + } + method.addContent(", "); + descriptors.next().accept(method); + method.addContentLine(")"); + } + } else if (ipType.equals(INJECT_SERVICE_INSTANCE)) { + // inject Contract + if (discovered.isEmpty()) { + if (supportNulls) { + method.addContent(".bindNull(") + .update(ipId::accept) + .addContentLine(")"); + } else { + // null binding is not supported at runtime + throw new CodegenException("Injection point requires a value, but no provider discovered: " + + injectionPoint); + } + } else { + method.addContent(".bindServiceInstance(") + .update(ipId::accept) + .addContent(", ") + .update(descriptors.next()::accept) + .addContentLine(")"); + } + } else { + // inject Contract + if (discovered.isEmpty()) { + if (supportNulls) { + method.addContent(".bindNull(") + .update(ipId::accept) + .addContentLine(")"); + } else { + // null binding is not supported at runtime + throw new CodegenException("Injection point requires a value, but no provider discovered: " + + injectionPoint); + } + } else { + method.addContent(".bind(") + .update(ipId::accept) + .addContent(", ") + .update(descriptors.next()::accept) + .addContentLine(")"); + } + } + } + + record InjectionPlan(List unqualifiedProviders, + List qualifiedProviders) { + } + + record BindingPlan(TypeName descriptorType, + Set bindings) { + } + + /** + * @param injectionPoint to bind to + * @param descriptors matching descriptors + */ + record Binding(Ip injectionPoint, + List descriptors) { + } +} diff --git a/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/CodegenAbstractMojo.java b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/CodegenAbstractMojo.java new file mode 100644 index 00000000000..83ea6fe969e --- /dev/null +++ b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/CodegenAbstractMojo.java @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2023, 2024 Oracle and/or its affiliates. + * + * 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.helidon.service.inject.maven.plugin; + +import java.util.List; +import java.util.Optional; + +import io.helidon.codegen.CodegenOptions; + +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.project.MavenProject; + +/** + * Abstract base for all codegen goals. + */ +abstract class CodegenAbstractMojo extends AbstractMojo { + /** + * Tag controlling whether we fail on error. + */ + static final String TAG_FAIL_ON_ERROR = "helidon.inject.fail-on-error"; + + /** + * Tag controlling whether we fail on warnings. + */ + static final String TAG_FAIL_ON_WARNING = "helidon.inject.fail-on-warning"; + + // ---------------------------------------------------------------------- + // Configurables + // ---------------------------------------------------------------------- + /** + * The module name to apply. If not found the module name will be inferred + * from {@code module-info.java} if present, or defined as {@code unnamed/package name}. + * + * @see io.helidon.codegen.CodegenOptions#TAG_CODEGEN_MODULE + */ + @Parameter(property = CodegenOptions.TAG_CODEGEN_MODULE) + private String moduleName; + /** + * The package name to apply. If not found the package name will be inferred. + * + * @see io.helidon.codegen.CodegenOptions#TAG_CODEGEN_PACKAGE + */ + @Parameter(property = CodegenOptions.TAG_CODEGEN_PACKAGE) + private String packageName; + /** + * Indicates whether the build will continue even if there are compilation errors. + */ + @Parameter(property = TAG_FAIL_ON_ERROR, defaultValue = "true") + private boolean failOnError; + /** + * Indicates whether the build will continue even if there are any warnings. + */ + @Parameter(property = TAG_FAIL_ON_WARNING) + private boolean failOnWarning; + /** + * Sets the arguments to be passed to the compiler. + *

+ * Example: + *

+     * <compilerArgs>
+     *   <arg>-Xmaxerrs</arg>
+     *   <arg>1000</arg>
+     *   <arg>-Xlint</arg>
+     *   <arg>-J-Duser.language=en_us</arg>
+     * </compilerArgs>
+     * 
+ */ + @Parameter + private List compilerArgs; + + // ---------------------------------------------------------------------- + // Generic Configurables + // ---------------------------------------------------------------------- + + /** + * The current project instance. This is used for propagating generated-sources paths as + * compile/testCompile source roots. + */ + @Parameter(defaultValue = "${project}", readonly = true, required = true) + private MavenProject project; + + /** + * Default constructor. + */ + CodegenAbstractMojo() { + } + + @Override + public final void execute() throws MojoExecutionException, MojoFailureException { + try { + getLog().info("Started " + getClass().getSimpleName()); + innerExecute(); + } catch (MojoFailureException | MojoExecutionException e) { + if (failOnError) { + throw e; + } + getLog().warn("Failed to process " + getClass().getSimpleName(), e); + } catch (Throwable t) { + if (failOnError) { + throw new MojoExecutionException(t); + } + getLog().warn("Failed to process " + getClass().getSimpleName(), t); + } + } + + /** + * Handle execution of this plugin. The {@link #execute()} method handles exceptions according to + * {@code failOnError} configuration. + * + * @throws org.apache.maven.plugin.MojoExecutionException as needed + * @throws org.apache.maven.plugin.MojoFailureException as needed + */ + abstract void innerExecute() throws MojoExecutionException, MojoFailureException; + + /** + * The target package name. + * + * @return the target package name, if configured + */ + Optional packageNameFromMavenConfig() { + return Optional.ofNullable(packageName); + } + + /** + * The module name of current module. + * + * @return the module name, if configured + */ + Optional moduleNameFromMavenConfig() { + return Optional.ofNullable(moduleName); + } + + /** + * The Maven project. + * + * @return maven project + */ + MavenProject mavenProject() { + return project; + } + + /** + * Whether to fail on error. + * Handled in {@link #execute()} by default. + * + * @return if processing should fail on error + */ + boolean failOnError() { + return failOnError; + } + + /** + * Whether to fail on warning. + * + * @return if processing should fail on warning + */ + boolean failOnWarning() { + return failOnWarning; + } + + /** + * List of compiler arguments (expected to start with {@code -A}). + * + * @return compiler arguments + */ + List getCompilerArgs() { + return compilerArgs == null ? List.of() : compilerArgs; + } +} diff --git a/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/CreateApplicationAbstractMojo.java b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/CreateApplicationAbstractMojo.java new file mode 100644 index 00000000000..07ea515d505 --- /dev/null +++ b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/CreateApplicationAbstractMojo.java @@ -0,0 +1,492 @@ +/* + * Copyright (c) 2023, 2024 Oracle and/or its affiliates. + * + * 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.helidon.service.inject.maven.plugin; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +import io.helidon.codegen.CodegenException; +import io.helidon.codegen.CodegenOptions; +import io.helidon.codegen.CodegenScope; +import io.helidon.codegen.ModuleInfo; +import io.helidon.codegen.ModuleInfoSourceParser; +import io.helidon.codegen.compiler.CompilerOptions; +import io.helidon.common.types.TypeName; +import io.helidon.service.inject.api.InjectServiceInfo; +import io.helidon.service.inject.codegen.ApplicationMainGenerator; + +import io.github.classgraph.ClassGraph; +import io.github.classgraph.ScanResult; +import org.apache.maven.artifact.Artifact; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.project.MavenProject; + +/** + * Abstract base for the Injection {@code maven-plugin} responsible for creating + * {@code Binding}, Test {@code Binding}, and application Main class. + */ +abstract class CreateApplicationAbstractMojo extends CodegenAbstractMojo { + /** + * Class name of the binding class generated by Maven plugin (for end user application). + */ + protected static final String BINDING_CLASS_NAME = "Injection__Binding"; + /** + * Name of the generated main class. + */ + @Parameter(property = "helidon.inject.application.main.class.name", + defaultValue = ApplicationMainGenerator.CLASS_NAME) + private String mainClassName; + /** + * The -source argument for the Java compiler. + * Note: using the same as maven-compiler for convenience and least astonishment. + */ + @Parameter(property = "maven.compiler.source", + defaultValue = "21") + private String source; + /** + * The -target argument for the Java compiler. + * Note: using the same as maven-compiler for convenience and least astonishment. + */ + @Parameter(property = "maven.compiler.target", + defaultValue = "21") + private String target; + /** + * Whether to validate the application when creating its bindings. + */ + @Parameter(property = "helidon.inject.application.validate", + defaultValue = "true") + private boolean validate; + /** + * Whether to generate binding class (provides generated injection plan for all services). + */ + @Parameter(property = "helidon.inject.application.binding.generate", + defaultValue = "true") + private boolean generateBinding; + + /** + * Default constructor. + */ + CreateApplicationAbstractMojo() { + } + + @Override + void innerExecute() { + MavenLogger mavenLogger = MavenLogger.create(getLog(), failOnWarning()); + + boolean hasModuleInfo = hasModuleInfo(); + Set modulepath = hasModuleInfo ? getModulepathElements() : Set.of(); + Set classpath = getClasspathElements(); + ClassLoader prev = Thread.currentThread().getContextClassLoader(); + URLClassLoader loader = createClassLoader(classpath, prev); + getLog().debug("Service registry classpath: " + classpath); + + Optional nonTestModuleInfo = findModuleInfo(nonTestSourceRootPaths()) + .map(ModuleInfoSourceParser::parse); + + /* + We may have module info both in sources and in tests + */ + Optional myModuleInfo = findModuleInfo(sourceRootPaths()) + .map(ModuleInfoSourceParser::parse); + CodegenOptions codegenOptions = MavenOptions.create(toOptions()); + CodegenScope scope = scope(); + codegenOptions.validate(Set.of()); + + // package name to use (should be the same as ModuleComponent package) + String packageName = packageName(codegenOptions, myModuleInfo, nonTestModuleInfo); + // module name to use to define application name (should be the same as ModuleComponent uses for this module) + String moduleName = moduleName(loader, codegenOptions, myModuleInfo, packageName, scope); + + try (ScanResult scan = new ClassGraph() + .overrideClasspath(classpath) + .enableAllInfo() + .scan()) { + MavenCodegenContext scanContext = MavenCodegenContext.create(codegenOptions, + scan, + scope, + generatedSourceDirectory(), + outputDirectory(), + mavenLogger, + myModuleInfo.orElse(null)); + + Thread.currentThread().setContextClassLoader(loader); + + CompilerOptions compilerOptions = CompilerOptions.builder() + .classpath(List.copyOf(classpath)) + .modulepath(List.copyOf(modulepath)) + .sourcepath(sourceRootPaths()) + .source(getSource()) + .target(getTarget()) + .commandLineArguments(getCompilerArgs()) + .outputDirectory(outputDirectory()) + .build(); + + applicationBinding(loader, + mavenLogger, + scanContext, + compilerOptions, + moduleName, + packageName); + + if (createMain()) { + createMain(loader, + mavenLogger, + scanContext, + compilerOptions, + packageName); + } + } finally { + Thread.currentThread().setContextClassLoader(prev); + } + } + + void createMain(ClassLoader loader, + MavenLogger mavenLogger, + MavenCodegenContext scanContext, + CompilerOptions compilerOptions, + String packageName) { + try (WrappedServices services = WrappedServices.create(loader, mavenLogger, false)) { + createMainClass(compilerOptions, + scanContext, + services, + packageName); + } catch (CodegenException e) { + throw e; + } catch (Exception e) { + throw new CodegenException("An error occurred creating the main class in " + getClass().getName(), e); + } + } + + void applicationBinding(ClassLoader loader, + MavenLogger mavenLogger, + MavenCodegenContext scanContext, + CompilerOptions compilerOptions, + String moduleName, + String packageName) { + try (WrappedServices services = WrappedServices.create(loader, mavenLogger, false)) { + applicationBinding(scanContext, + services, + compilerOptions, + moduleName, + packageName); + } catch (CodegenException e) { + throw e; + } catch (Exception e) { + throw new CodegenException("An error occurred creating the binding in " + getClass().getName(), e); + } + } + + void createMainClass(CompilerOptions compilerOptions, + MavenCodegenContext scanContext, + WrappedServices services, + String packageName) { + TypeName generatedType = TypeName.builder() + .packageName(packageName) + .className(mainClassName) + .build(); + + MainClassCreator creator = new MainClassCreator(scanContext, failOnError()); + creator.create(scanContext, compilerOptions, services, generatedType); + } + + void applicationBinding(MavenCodegenContext scanContext, + WrappedServices services, + CompilerOptions compilerOptions, + String moduleName, + String packageName) { + + // retrieves all the services in the registry + Set allServices = services.all() + .stream() + .map(InjectServiceInfo::serviceType) + .collect(Collectors.toCollection(TreeSet::new)); + + if (allServices.isEmpty()) { + warn("Binding generator found no services to process"); + return; + } + + getLog().debug("All services to be processed: " + allServices); + + String className = bindingClassName(); + + if (validate) { + // validate the application + ApplicationValidator validator = new ApplicationValidator(scanContext, failOnWarning()); + validator.validate(services); + } + + if (generateBinding) { + // get the binding generator only after services are initialized (we need to ignore any existing apps) + BindingGenerator creator = new BindingGenerator(scanContext, failOnError()); + + creator.createBinding(services, + allServices, + TypeName.create(packageName + "." + className), + moduleName, + compilerOptions); + } + } + + /** + * Where to generate sources. As this directory differs between production code and test code, it must be provided + * by a subclass. + * + * @return where to generate sources + */ + abstract Path generatedSourceDirectory(); + + /** + * Binding class name to be generated. + * + * @return binding class name + */ + abstract String bindingClassName(); + + /** + * Output directory for this {@link #scope()}. + * + * @return output directory + */ + abstract Path outputDirectory(); + + abstract boolean createMain(); + + /** + * Source roots for this {@link #scope()}. + * + * @return source roots + */ + List sourceRootPaths() { + return nonTestSourceRootPaths(); + } + + /** + * Production source roots for this project. + * + * @return source roots for production code + */ + List nonTestSourceRootPaths() { + MavenProject project = mavenProject(); + List result = new ArrayList<>(project.getCompileSourceRoots().size()); + for (Object a : project.getCompileSourceRoots()) { + result.add(Path.of(a.toString())); + } + return result; + } + + /** + * Test source roots for this project. + * + * @return source roots for test code + */ + protected List testSourceRootPaths() { + MavenProject project = mavenProject(); + List result = new ArrayList<>(project.getTestCompileSourceRoots().size()); + for (Object a : project.getTestCompileSourceRoots()) { + result.add(Path.of(a.toString())); + } + return result; + } + + LinkedHashSet getModulepathElements() { + return getSourceClasspathElements(); + } + + boolean hasModuleInfo() { + return sourceRootPaths() + .stream() + .anyMatch(p -> Files.exists(p.resolve(ModuleInfo.FILE_NAME))); + } + + Optional findModuleInfo(List sourcePaths) { + return sourcePaths.stream() + .map(it -> it.resolve(ModuleInfo.FILE_NAME)) + .filter(Files::exists) + .findFirst(); + } + + void warn(String msg) { + getLog().warn(msg); + + if (failOnWarning()) { + throw new CodegenException(msg); + } + } + + /** + * The scope of the code generation (production, test etc.). + * + * @return codegen scope + */ + abstract CodegenScope scope(); + + /** + * Creates a new classloader. + * + * @param classPath the classpath to use + * @param parent the parent loader + * @return the loader + */ + URLClassLoader createClassLoader(Collection classPath, + ClassLoader parent) { + List urls = new ArrayList<>(classPath.size()); + for (Path dependency : classPath) { + try { + urls.add(dependency.toUri().toURL()); + } catch (MalformedURLException e) { + throw new CodegenException("Unable to build the classpath. Dependency cannot be converted to URL: " + + dependency, + e); + } + } + + if (parent == null) { + parent = Thread.currentThread().getContextClassLoader(); + } + return new URLClassLoader(urls.toArray(new URL[0]), parent); + } + + String getSource() { + return source; + } + + String getTarget() { + return target; + } + + LinkedHashSet getSourceClasspathElements() { + MavenProject project = mavenProject(); + LinkedHashSet result = new LinkedHashSet<>(project.getCompileArtifacts().size()); + result.add(Paths.get(project.getBuild().getOutputDirectory())); + for (Object a : project.getCompileArtifacts()) { + result.add(((Artifact) a).getFile().toPath()); + } + return result; + } + + /** + * Provides a convenient way to handle test scope. Returns the classpath for source files (or test sources) only. + */ + LinkedHashSet getClasspathElements() { + return getSourceClasspathElements(); + } + + // to dot separated path + private static String toDotSeparated(Path relativePath) { + return StreamSupport.stream(relativePath.spliterator(), false) + .map(Path::toString) + .collect(Collectors.joining(".")); + } + + private String packageName(CodegenOptions codegenOptions, + Optional myModuleInfo, + Optional srcModuleInfo) { + return CodegenOptions.CODEGEN_PACKAGE + .findValue(codegenOptions) + .or(() -> myModuleInfo.flatMap(this::exportedPackage)) + .or(() -> srcModuleInfo.flatMap(this::exportedPackage)) + .or(this::firstUsedPackage) + .orElseThrow(() -> new CodegenException("Unable to determine package for binding class.")); + } + + private Optional firstUsedPackage() { + // we expect at least some source code. If none found, try test source, if none found, must be configured + return firstUsedPackage(nonTestSourceRootPaths()) + .or(() -> firstUsedPackage(testSourceRootPaths())); + } + + private Optional firstUsedPackage(List sourceRoots) { + Set found = new TreeSet<>(Comparator.comparing(String::length)); + + for (Path sourceRoot : sourceRoots) { + try { + try (Stream pathStream = Files.walk(sourceRoot)) { + pathStream + .filter(it -> it.getFileName().toString().endsWith(".java")) + .map(it -> packageName(sourceRoot, it)) + .filter(Predicate.not(String::isBlank)) + .forEach(found::add); + } + } catch (IOException e) { + getLog().debug("Failed to walk path tree for source root: " + sourceRoot.toAbsolutePath(), + e); + } + } + return found.stream() + .findFirst(); + } + + private Optional exportedPackage(ModuleInfo moduleInfo) { + Set unqualifiedExports = new TreeSet<>(Comparator.comparing(String::length)); + moduleInfo.exports() + .forEach((export, to) -> { + if (to.isEmpty()) { + unqualifiedExports.add(export); + } + }); + return unqualifiedExports.stream().findFirst(); + } + + private String moduleName(ClassLoader loader, + CodegenOptions codegenOptions, + Optional myModuleInfo, + String packageName, + CodegenScope scope) { + return CodegenOptions.CODEGEN_MODULE + .findValue(codegenOptions) + .or(() -> myModuleInfo.map(ModuleInfo::name)) + .orElseGet(() -> "unnamed/" + + packageName + + (scope.isProduction() ? "" : "/" + scope.name())); + } + + private String packageName(Path rootPath, Path filePath) { + Path parent = filePath.getParent(); + if (parent == null) { + return ""; + } + return toDotSeparated(rootPath.relativize(parent)); + } + + private Set toOptions() { + Set options = new HashSet<>(getCompilerArgs()); + + moduleNameFromMavenConfig().ifPresent(it -> options.add("-A" + CodegenOptions.TAG_CODEGEN_MODULE + "=" + it)); + packageNameFromMavenConfig().ifPresent(it -> options.add("-A" + CodegenOptions.TAG_CODEGEN_PACKAGE + "=" + it)); + + return options; + } +} diff --git a/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/CreateApplicationMojo.java b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/CreateApplicationMojo.java new file mode 100644 index 00000000000..a6bedc507cc --- /dev/null +++ b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/CreateApplicationMojo.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2023, 2024 Oracle and/or its affiliates. + * + * 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.helidon.service.inject.maven.plugin; + +import java.io.File; +import java.nio.file.Path; + +import io.helidon.codegen.CodegenScope; + +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.annotations.ResolutionScope; + +/** + * Maven goal to create application bindings (a mapping of services that satisfy injection points), + * and to create application main class (reflection-free registration of services). + */ +@Mojo(name = "create-application", + defaultPhase = LifecyclePhase.COMPILE, + threadSafe = true, + requiresDependencyResolution = ResolutionScope.COMPILE) +public class CreateApplicationMojo extends CreateApplicationAbstractMojo { + + /** + * Specify where to place generated source files created by annotation processing. + */ + @Parameter(defaultValue = "${project.build.directory}/generated-sources/annotations") + private File generatedSourcesDirectory; + + /** + * The directory for compiled classes. + */ + @Parameter(defaultValue = "${project.build.outputDirectory}", required = true, readonly = true) + private File outputDirectory; + + /** + * Whether to generate main class. Default name is {@code ApplicationMain} in the same package as + * the generated application. + */ + @Parameter(property = "helidon.inject.application.main.generate", + defaultValue = "true") + private boolean generateMain; + + /** + * Name of the generated binding class. + */ + @Parameter(property = "helidon.inject.application.binding.class.name", + defaultValue = BINDING_CLASS_NAME) + private String bindingClassName; + + /** + * Default constructor. + */ + public CreateApplicationMojo() { + } + + @Override + protected Path generatedSourceDirectory() { + return generatedSourcesDirectory.toPath(); + } + + @Override + protected Path outputDirectory() { + return outputDirectory.toPath(); + } + + @Override + protected CodegenScope scope() { + return CodegenScope.PRODUCTION; + } + + @Override + String bindingClassName() { + return bindingClassName; + } + + @Override + boolean createMain() { + return generateMain; + } +} diff --git a/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/CreateTestApplicationMojo.java b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/CreateTestApplicationMojo.java new file mode 100644 index 00000000000..637ef1f1ea3 --- /dev/null +++ b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/CreateTestApplicationMojo.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2023, 2024 Oracle and/or its affiliates. + * + * 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.helidon.service.inject.maven.plugin; + +import java.io.File; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.List; + +import io.helidon.codegen.CodegenScope; + +import org.apache.maven.artifact.Artifact; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.annotations.ResolutionScope; +import org.apache.maven.project.MavenProject; + +/** + * A mojo wrapper to {@link BindingGenerator} for test specific types. + * For test scope, we only generate binding, as main class would not be useful. + */ +@Mojo(name = "test-application-create", defaultPhase = LifecyclePhase.TEST_COMPILE, threadSafe = true, + requiresDependencyResolution = ResolutionScope.TEST) +@SuppressWarnings("unused") +public class CreateTestApplicationMojo extends CreateApplicationAbstractMojo { + + /** + * Name of the generated binding class. + */ + @Parameter(property = "helidon.inject.application.binding.classname", + defaultValue = "Test" + BINDING_CLASS_NAME) + private String bindingClassName; + + /** + * Specify where to place generated source files created by annotation processing. + * Only applies to JDK 1.6+ + */ + @Parameter(defaultValue = "${project.build.directory}/generated-test-sources/test-annotations") + private File generatedTestSourcesDirectory; + + /** + * The directory where compiled test classes go. + */ + @Parameter(defaultValue = "${project.build.testOutputDirectory}", required = true, readonly = true) + private File testOutputDirectory; + + /** + * Default constructor. + */ + public CreateTestApplicationMojo() { + } + + @Override + protected Path generatedSourceDirectory() { + return generatedTestSourcesDirectory.toPath(); + } + + @Override + protected Path outputDirectory() { + return testOutputDirectory.toPath(); + } + + @Override + protected List sourceRootPaths() { + return testSourceRootPaths(); + } + + @Override + protected LinkedHashSet getClasspathElements() { + MavenProject project = mavenProject(); + LinkedHashSet result = new LinkedHashSet<>(project.getTestArtifacts().size()); + result.add(new File(project.getBuild().getTestOutputDirectory()).toPath()); + for (Object a : project.getTestArtifacts()) { + result.add(((Artifact) a).getFile().toPath()); + } + result.addAll(super.getClasspathElements()); + return result; + } + + @Override + LinkedHashSet getModulepathElements() { + return getClasspathElements(); + } + + @Override + protected String bindingClassName() { + return bindingClassName; + } + + @Override + protected CodegenScope scope() { + return new CodegenScope("test"); + } + + @Override + boolean createMain() { + return false; + } +} diff --git a/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MainClassCreator.java b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MainClassCreator.java new file mode 100644 index 00000000000..027b00f67a3 --- /dev/null +++ b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MainClassCreator.java @@ -0,0 +1,289 @@ +/* + * Copyright (c) 2023, 2024 Oracle and/or its affiliates. + * + * 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.helidon.service.inject.maven.plugin; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import io.helidon.codegen.CodegenException; +import io.helidon.codegen.classmodel.ClassModel; +import io.helidon.codegen.classmodel.Method; +import io.helidon.codegen.compiler.Compiler; +import io.helidon.codegen.compiler.CompilerOptions; +import io.helidon.codegen.scan.ScanContext; +import io.helidon.codegen.scan.ScanTypeInfoFactory; +import io.helidon.common.types.AccessModifier; +import io.helidon.common.types.ElementSignature; +import io.helidon.common.types.ResolvedType; +import io.helidon.common.types.TypeInfo; +import io.helidon.common.types.TypeName; +import io.helidon.common.types.TypeNames; +import io.helidon.service.inject.api.InjectServiceInfo; +import io.helidon.service.inject.api.Injection.RunLevel; +import io.helidon.service.inject.codegen.ApplicationMainGenerator; +import io.helidon.service.metadata.DescriptorMetadata; +import io.helidon.service.registry.ServiceDescriptor; +import io.helidon.service.registry.ServiceDiscovery; +import io.helidon.service.registry.ServiceLoader__ServiceDescriptor; + +import io.github.classgraph.ClassGraph; +import io.github.classgraph.ClassInfo; +import io.github.classgraph.ClassInfoList; +import io.github.classgraph.ScanResult; + +import static io.helidon.service.inject.codegen.InjectCodegenTypes.INJECTION_MAIN; +import static io.helidon.service.inject.codegen.InjectCodegenTypes.INJECTION_RUN_LEVEL; +import static io.helidon.service.inject.codegen.InjectCodegenTypes.INJECT_BINDING; +import static io.helidon.service.inject.codegen.InjectCodegenTypes.INJECT_MAIN; + +class MainClassCreator { + private static final TypeName GENERATOR = TypeName.create(MainClassCreator.class); + private static final String INJECTION_MAIN_ANNOTATION = INJECTION_MAIN.packageName() + + "." + + INJECTION_MAIN.classNameWithEnclosingNames().replace('.', '$'); + + private final MavenCodegenContext ctx; + private final boolean failOnError; + + MainClassCreator(MavenCodegenContext scanContext, boolean failOnError) { + this.ctx = scanContext; + this.failOnError = failOnError; + } + + void create(MavenCodegenContext scanContext, + CompilerOptions compilerOptions, + WrappedServices services, + TypeName generatedType) { + try { + codegen(scanContext, compilerOptions, services, generatedType); + } catch (CodegenException ce) { + handleError(ce); + } catch (Throwable te) { + handleError(new CodegenException("Failed to code generate application main class", te)); + } + } + + private void codegen(ScanContext scanContext, + CompilerOptions compilerOptions, + WrappedServices services, + TypeName generatedType) { + // if there is a custom main class present, we must honor it + Optional foundCustomMain = findCustomMain(scanContext, compilerOptions.outputDirectory()); + Set declaredSignatures; + TypeName superType; + + if (foundCustomMain.isPresent()) { + TypeInfo customMain = foundCustomMain.get(); + ApplicationMainGenerator.validate(customMain); + declaredSignatures = ApplicationMainGenerator.declaredSignatures(customMain); + superType = customMain.typeName(); + } else { + declaredSignatures = Set.of(); + superType = INJECT_MAIN; + } + + ClassModel classModel = + ApplicationMainGenerator.generate(GENERATOR, + declaredSignatures, + superType, + generatedType, + false, + true, + (classBuilder, methodModel, paramName) -> serviceDescriptors(classBuilder, + methodModel, + paramName, + services), + (classBuilder, methodModel, paramName) -> runLevels( + methodModel, + services)) + .build(); + + Path generated = ctx.filer() + .writeSourceFile(classModel); + + Compiler.compile(compilerOptions, generated); + } + + private Optional findCustomMain(ScanContext scanContext, Path targetPath) { + // we do not want to search the whole application, just the current module. Main class MUST be in the module that + // uses the maven plugin + try (ScanResult scan = new ClassGraph() + .overrideClasspath(Set.of(targetPath)) + .enableAllInfo() + .scan()) { + ClassInfoList customMainClasses = scan.getClassesWithAnnotation(INJECTION_MAIN_ANNOTATION); + if (customMainClasses.isEmpty()) { + return Optional.empty(); + } + if (customMainClasses.size() > 1) { + String names = customMainClasses.stream() + .map(ClassInfo::getName) + .collect(Collectors.joining(", ")); + throw new CodegenException("There can only be one class annotated with " + INJECTION_MAIN.fqName() + ", " + + "but discovered more than one: " + names); + } + return ScanTypeInfoFactory.create(scanContext, customMainClasses.getFirst()); + } + } + + private void runLevels(Method.Builder method, + WrappedServices services) { + Set runLevels = new TreeSet<>(); + + for (InjectServiceInfo serviceInfo : services.all()) { + if (serviceInfo.runLevel().isPresent()) { + runLevels.add(serviceInfo.runLevel().get()); + } + } + + List runLevelList = List.copyOf(runLevels); + + for (int i = 0; i < runLevelList.size(); i++) { + double current = runLevelList.get(i); + if (Double.compare(RunLevel.STARTUP, current) == 0) { + method.addContent(INJECTION_RUN_LEVEL) + .addContent(".STARTUP"); + } else if (Double.compare(RunLevel.SERVER, current) == 0) { + method.addContent(INJECTION_RUN_LEVEL) + .addContent(".SERVER"); + } else if (Double.compare(RunLevel.NORMAL, current) == 0) { + method.addContent(INJECTION_RUN_LEVEL) + .addContent(".NORMAL"); + } else { + method.addContent(String.valueOf(current)) + .addContent("D"); + } + if (i == runLevelList.size() - 1) { + method.addContentLine(""); + } else { + method.addContentLine(","); + } + } + } + + private void serviceDescriptors(ClassModel.Builder classModel, + Method.Builder method, + String paramName, + WrappedServices services) { + List serviceLoaded = new ArrayList<>(); + List serviceDescriptors = new ArrayList<>(); + + // bindings must be added directly from service discovery, as they are not part of the registry + addBindings(serviceDescriptors); + + // for each discovered service, add it to the configuration + for (InjectServiceInfo serviceInfo : services.all()) { + if (serviceInfo.coreInfo() instanceof ServiceLoader__ServiceDescriptor sl) { + serviceLoaded.add(sl); + } else { + serviceDescriptors.add(serviceInfo.descriptorType()); + } + } + + Map providerConstants = new HashMap<>(); + AtomicInteger constantCounter = new AtomicInteger(); + + serviceLoaded.stream() + .sorted(serviceLoaderComparator()) + .forEach(it -> addServiceLoader(classModel, method, providerConstants, constantCounter, it)); + + if (!serviceLoaded.isEmpty() && !serviceDescriptors.isEmpty()) { + // visually separate service loaded services from service descriptors + method.addContentLine(""); + } + + // config.addServiceDescriptor(ImperativeFeature__ServiceDescriptor.INSTANCE); + serviceDescriptors.stream().sorted() + .forEach(it -> method.addContent(paramName) + .addContent(".addServiceDescriptor(") + .addContent(it) + .addContentLine(".INSTANCE);")); + } + + private void addBindings(List serviceDescriptors) { + ResolvedType binding = ResolvedType.create(INJECT_BINDING); + + ServiceDiscovery.create() + .allMetadata() + .stream() + .filter(it -> it.contracts().contains(binding)) + .map(DescriptorMetadata::descriptorType) + .forEach(serviceDescriptors::add); + } + + private void addServiceLoader(ClassModel.Builder classModel, + Method.Builder main, + Map providerConstants, + AtomicInteger constantCounter, + ServiceLoader__ServiceDescriptor sl) { + // Generated code: + // config.addServiceDescriptor(serviceLoader(PROVIDER_1, + // YamlConfigParser.class, + // () -> new io.helidon.config.yaml.YamlConfigParser(), + // 90.0)); + TypeName providerInterface = sl.providerInterface(); + String constantName = providerConstants.computeIfAbsent(providerInterface, it -> { + int i = constantCounter.getAndIncrement(); + String constant = "PROVIDER_" + i; + classModel.addField(field -> field + .accessModifier(AccessModifier.PRIVATE) + .isStatic(true) + .isFinal(true) + .type(TypeNames.TYPE_NAME) + .name(constant) + .addContentCreate(providerInterface)); + return constant; + }); + + main.addContent("config") + .addContent(".addServiceDescriptor(serviceLoader(") + .addContent(constantName) + .addContentLine(",") + .increaseContentPadding() + .increaseContentPadding() + .increaseContentPadding() + .addContent(sl.serviceType()).addContentLine(".class,") + .addContent("() -> new ").addContent(sl.serviceType()).addContentLine("(),") + .addContent(String.valueOf(sl.weight())) + .addContentLine("));") + .decreaseContentPadding() + .decreaseContentPadding() + .decreaseContentPadding(); + } + + private Comparator serviceLoaderComparator() { + return Comparator.comparing(ServiceLoader__ServiceDescriptor::providerInterface) + .thenComparing(ServiceDescriptor::serviceType); + } + + private void handleError(CodegenException ce) { + if (failOnError) { + throw ce; + } else { + ctx.logger().log(ce.toEvent(System.Logger.Level.WARNING)); + } + } +} diff --git a/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MavenCodegenContext.java b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MavenCodegenContext.java new file mode 100644 index 00000000000..f5bffbf0b37 --- /dev/null +++ b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MavenCodegenContext.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2023, 2024 Oracle and/or its affiliates. + * + * 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.helidon.service.inject.maven.plugin; + +import java.nio.file.Path; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; + +import io.helidon.codegen.CodegenContext; +import io.helidon.codegen.CodegenContextBase; +import io.helidon.codegen.CodegenLogger; +import io.helidon.codegen.CodegenOptions; +import io.helidon.codegen.CodegenScope; +import io.helidon.codegen.ModuleInfo; +import io.helidon.codegen.scan.ScanContext; +import io.helidon.common.types.TypeInfo; +import io.helidon.common.types.TypeName; +import io.helidon.common.types.TypedElementInfo; + +import io.github.classgraph.ScanResult; + +class MavenCodegenContext extends CodegenContextBase implements CodegenContext, ScanContext { + private final ModuleInfo module; + private final MavenFiler filer; + private final ScanResult scanResult; + + protected MavenCodegenContext(CodegenOptions options, + MavenFiler filer, + CodegenLogger logger, + CodegenScope scope, + ScanResult scanResult, + ModuleInfo module) { + super(options, Set.of(), filer, logger, scope); + + this.module = module; + this.filer = filer; + this.scanResult = scanResult; + } + + static MavenCodegenContext create(CodegenOptions options, + ScanResult scanResult, + CodegenScope scope, + Path generatedSourceDir, + Path outputDirectory, + MavenLogger logger, + ModuleInfo module /* may be null*/) { + return new MavenCodegenContext(options, + MavenFiler.create(generatedSourceDir, outputDirectory), + logger, + scope, + scanResult, + module); + } + + @Override + public Optional module() { + return Optional.ofNullable(module); + } + + @Override + public MavenFiler filer() { + return filer; + } + + @Override + public Optional typeInfo(TypeName typeName) { + return Optional.empty(); + } + + @Override + public Optional typeInfo(TypeName typeName, Predicate elementPredicate) { + return Optional.empty(); + } + + @Override + public ScanResult scanResult() { + return scanResult; + } +} diff --git a/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MavenFiler.java b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MavenFiler.java new file mode 100644 index 00000000000..32b2a213ca2 --- /dev/null +++ b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MavenFiler.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2023, 2024 Oracle and/or its affiliates. + * + * 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.helidon.service.inject.maven.plugin; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +import io.helidon.codegen.CodegenException; +import io.helidon.codegen.CodegenFiler; +import io.helidon.codegen.FilerResource; +import io.helidon.codegen.FilerTextResource; +import io.helidon.codegen.classmodel.ClassModel; +import io.helidon.common.types.TypeName; + +class MavenFiler implements CodegenFiler { + private final Path generatedSourceDir; + private final Path outputDirectory; + private boolean generatedSources; + + MavenFiler(Path generatedSourceDir, Path outputDirectory) { + this.generatedSourceDir = generatedSourceDir; + this.outputDirectory = outputDirectory; + } + + static MavenFiler create(Path generatedSourceDir, Path outputDirectory) { + return new MavenFiler(generatedSourceDir, outputDirectory); + } + + @Override + public Path writeSourceFile(TypeName typeName, String content, Object... originatingElements) { + String pathToSourceFile = typeName.packageName().replace('.', '/'); + String fileName = typeName.className() + ".java"; + Path path = generatedSourceDir.resolve(pathToSourceFile) + .resolve(fileName); + Path parentDir = path.getParent(); + if (parentDir != null) { + mkdirs(parentDir); + } + + try (Writer writer = Files.newBufferedWriter(path, + StandardCharsets.UTF_8, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE)) { + writer.write(content); + generatedSources = true; + } catch (IOException e) { + throw new CodegenException("Failed to write new source file: " + path.toAbsolutePath(), e, typeName); + } + return path; + } + + @Override + public Path writeSourceFile(ClassModel classModel, Object... originatingElements) { + TypeName typeName = classModel.typeName(); + String pathToSourceFile = typeName.packageName().replace('.', '/'); + String fileName = typeName.className() + ".java"; + Path path = generatedSourceDir.resolve(pathToSourceFile) + .resolve(fileName); + Path parentDir = path.getParent(); + if (parentDir != null) { + mkdirs(parentDir); + } + + try (Writer writer = Files.newBufferedWriter(path, + StandardCharsets.UTF_8, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE)) { + classModel.write(writer, " "); + generatedSources = true; + } catch (IOException e) { + throw new CodegenException("Failed to write new source file: " + path.toAbsolutePath(), e, typeName); + } + return path; + } + + @Override + public Path writeResource(byte[] resource, String location, Object... originatingElements) { + Path path = outputDirectory.resolve(location); + Path parentDir = path.getParent(); + if (parentDir != null) { + mkdirs(parentDir); + } + try (OutputStream out = Files.newOutputStream(path, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE)) { + out.write(resource); + } catch (IOException e) { + throw new CodegenException("Failed to write new resource file: " + path.toAbsolutePath(), location); + } + return path; + } + + @Override + public FilerTextResource textResource(String location, Object... originatingElements) { + Path resourcePath = outputDirectory.resolve(location); + Path parentDir = resourcePath.getParent(); + if (parentDir != null) { + mkdirs(parentDir); + } + if (Files.exists(resourcePath)) { + try { + return new MavenFilerTextResource(resourcePath, Files.readAllLines(resourcePath)); + } catch (IOException e) { + throw new CodegenException("Failed to read existing text resource: " + resourcePath.toAbsolutePath(), e); + } + } else { + return new MavenFilerTextResource(resourcePath); + } + } + + @Override + public FilerResource resource(String location, Object... originatingElements) { + Path resourcePath = outputDirectory.resolve(location); + Path parentDir = resourcePath.getParent(); + if (parentDir != null) { + mkdirs(parentDir); + } + if (Files.exists(resourcePath)) { + try { + return new MavenFilerResource(resourcePath, Files.readAllBytes(resourcePath)); + } catch (IOException e) { + throw new CodegenException("Failed to read existing resource: " + resourcePath.toAbsolutePath(), e); + } + } else { + return new MavenFilerResource(resourcePath); + } + } + + boolean generatedSources() { + return generatedSources; + } + + private void mkdirs(Path path) { + try { + Files.createDirectories(path); + } catch (IOException e) { + throw new CodegenException("Failed to create directories for: " + path.toAbsolutePath()); + } + } +} diff --git a/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MavenFilerResource.java b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MavenFilerResource.java new file mode 100644 index 00000000000..284ea4ab585 --- /dev/null +++ b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MavenFilerResource.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. + * + * 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.helidon.service.inject.maven.plugin; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; + +import io.helidon.codegen.CodegenException; +import io.helidon.codegen.FilerResource; + +class MavenFilerResource implements FilerResource { + private final Path resourcePath; + + private byte[] currentBytes; + private boolean modified; + + MavenFilerResource(Path resourcePath) { + this(resourcePath, new byte[0]); + } + + MavenFilerResource(Path resourcePath, byte[] bytes) { + this.resourcePath = resourcePath; + this.currentBytes = bytes; + } + + @Override + public byte[] bytes() { + return Arrays.copyOf(currentBytes, currentBytes.length); + } + + @Override + public void bytes(byte[] newBytes) { + currentBytes = Arrays.copyOf(newBytes, newBytes.length); + modified = true; + } + + @Override + public void write() { + if (modified) { + try { + Files.write(resourcePath, currentBytes, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE); + } catch (IOException e) { + throw new CodegenException("Failed to write resource " + resourcePath.toAbsolutePath(), e); + } + } + } +} diff --git a/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MavenFilerTextResource.java b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MavenFilerTextResource.java new file mode 100644 index 00000000000..14a0cbcf7ca --- /dev/null +++ b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MavenFilerTextResource.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * 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.helidon.service.inject.maven.plugin; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.List; + +import io.helidon.codegen.CodegenException; +import io.helidon.codegen.FilerTextResource; + +class MavenFilerTextResource implements FilerTextResource { + private final Path resourcePath; + private final ArrayList currentLines; + + private boolean modified; + + MavenFilerTextResource(Path resourcePath) { + this.resourcePath = resourcePath; + this.currentLines = new ArrayList<>(); + } + + MavenFilerTextResource(Path resourcePath, List lines) { + this.resourcePath = resourcePath; + this.currentLines = new ArrayList<>(lines); + } + + @Override + public List lines() { + return List.copyOf(currentLines); + } + + @Override + public void lines(List newLines) { + currentLines.clear(); + currentLines.addAll(newLines); + modified = true; + } + + @Override + public void write() { + if (modified) { + try { + Files.write(resourcePath, currentLines, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE); + } catch (IOException e) { + throw new CodegenException("Failed to write resource " + resourcePath.toAbsolutePath(), e); + } + } + } +} diff --git a/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MavenLogger.java b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MavenLogger.java new file mode 100644 index 00000000000..334cbb958ad --- /dev/null +++ b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MavenLogger.java @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2023, 2024 Oracle and/or its affiliates. + * + * 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.helidon.service.inject.maven.plugin; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import io.helidon.codegen.CodegenEvent; +import io.helidon.codegen.CodegenLogger; +import io.helidon.common.types.TypeName; + +import io.github.classgraph.ClassInfo; +import io.github.classgraph.FieldInfo; +import io.github.classgraph.MethodInfo; +import org.apache.maven.plugin.logging.Log; + +class MavenLogger implements CodegenLogger { + private final Log log; + private final List warnings = new CopyOnWriteArrayList<>(); + private final List errors = new CopyOnWriteArrayList<>(); + private final Consumer warningConsumer; + + private MavenLogger(Log log, boolean failOnWarning) { + this.log = log; + if (failOnWarning) { + // keep them + warningConsumer = warnings::add; + } else { + // throw away + warningConsumer = it -> { + }; + } + } + + public static MavenLogger create(Log log, boolean failOnWarning) { + return new MavenLogger(log, failOnWarning); + } + + @Override + public void log(CodegenEvent event) { + String message = toMessage(event); + + switch (event.level()) { + case TRACE, DEBUG -> log(log::debug, log::debug, event, message); + case INFO -> log(log::info, log::info, event, message); + case WARNING -> { + warningConsumer.accept(message); + log(log::warn, log::warn, event, message); + } + case ERROR -> { + errors.add(message); + log(log::error, log::error, event, message); + } + default -> { + } + } + } + + boolean hasErrors() { + return !errors.isEmpty() && !warnings.isEmpty(); + } + + List messages() { + return Stream.concat( + errors.stream() + .map(it -> "error: " + it), + warnings.stream() + .map(it -> "warning: " + it) + ) + .toList(); + } + + private void log(Consumer messageLog, + BiConsumer throwableLog, + CodegenEvent event, + String message) { + Optional throwable = event.throwable(); + if (throwable.isPresent()) { + throwableLog.accept(message, throwable.get()); + } else { + messageLog.accept(message); + } + } + + private String toMessage(CodegenEvent event) { + List objects = event.objects(); + if (objects.isEmpty()) { + return event.message(); + } + return event.message() + ", originating in: " + objects.stream() + .map(this::toString) + .collect(Collectors.joining(", ")); + } + + private String toString(Object object) { + if (object instanceof TypeName type) { + return type.fqName(); + } + if (object instanceof ClassInfo ci) { + return ci.getName(); + } + if (object instanceof MethodInfo mi) { + return mi.getClassInfo().getName() + "#" + mi.toStringWithSimpleNames(); + } + if (object instanceof FieldInfo fi) { + return fi.getClassInfo().getName() + "." + fi.getName(); + } + return String.valueOf(object); + } +} diff --git a/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MavenOptions.java b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MavenOptions.java new file mode 100644 index 00000000000..eb393e30fa0 --- /dev/null +++ b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MavenOptions.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2023, 2024 Oracle and/or its affiliates. + * + * 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.helidon.service.inject.maven.plugin; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import io.helidon.codegen.CodegenException; +import io.helidon.codegen.CodegenOptions; +import io.helidon.codegen.Option; + +class MavenOptions implements CodegenOptions { + private final Map options; + + private MavenOptions(Map options) { + this.options = options; + } + + static CodegenOptions create(Set compilerArgs) { + Map options = new HashMap<>(); + + compilerArgs.forEach(it -> addInjectOption(options, it)); + + return new MavenOptions(Map.copyOf(options)); + } + + @Override + public Optional option(String option) { + return Optional.ofNullable(options.get(option)).map(String::trim); + } + + @Override + public void validate(Set> permittedOptions) { + Set helidonOptions = options + .keySet() + .stream() + .filter(it -> it.startsWith("helidon.")) + .collect(Collectors.toSet()); + + // now remove all expected + permittedOptions.stream() + .map(Option::name) + .forEach(helidonOptions::remove); + + helidonOptions.remove(CODEGEN_SCOPE.name()); + helidonOptions.remove(CODEGEN_MODULE.name()); + helidonOptions.remove(CODEGEN_PACKAGE.name()); + helidonOptions.remove(INDENT_TYPE.name()); + helidonOptions.remove(INDENT_COUNT.name()); + helidonOptions.remove(CREATE_META_INF_SERVICES.name()); + + if (!helidonOptions.isEmpty()) { + throw new CodegenException("Unrecognized/unsupported Helidon option configured: " + helidonOptions); + } + } + + private static void addInjectOption(Map options, String option) { + String toProcess = option; + if (toProcess.startsWith("-A")) { + toProcess = toProcess.substring(2); + } + int eq = toProcess.indexOf('='); + if (eq < 0) { + options.put(toProcess, "true"); + return; + } + options.put(toProcess.substring(0, eq), toProcess.substring(eq + 1)); + } +} diff --git a/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MavenRoundContext.java b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MavenRoundContext.java new file mode 100644 index 00000000000..23cfa7802ee --- /dev/null +++ b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/MavenRoundContext.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * 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.helidon.service.inject.maven.plugin; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import io.helidon.codegen.ClassCode; +import io.helidon.codegen.classmodel.ClassModel; +import io.helidon.common.types.ResolvedType; +import io.helidon.common.types.TypeInfo; +import io.helidon.common.types.TypeName; +import io.helidon.common.types.TypedElementInfo; +import io.helidon.service.codegen.DescriptorClassCode; +import io.helidon.service.codegen.RegistryRoundContext; +import io.helidon.service.codegen.ServiceContracts; + +class MavenRoundContext implements RegistryRoundContext { + private final List descriptors = new ArrayList<>(); + private final MavenCodegenContext ctx; + + MavenRoundContext(MavenCodegenContext ctx) { + this.ctx = ctx; + } + + @Override + public void addDescriptor(String registryType, + TypeName serviceType, + TypeName descriptorType, + ClassModel.Builder descriptor, + double weight, + Set contracts, + Set factoryContracts, + Object... originatingElements) { + ClassCode cc = new ClassCode(descriptorType, + descriptor, + serviceType, + originatingElements); + descriptors.add(DescriptorClassCode.create(cc, registryType, weight, contracts, factoryContracts)); + } + + @Override + public ServiceContracts serviceContracts(TypeInfo serviceInfo) { + return ServiceContracts.create(ctx.options(), + this::typeInfo, + serviceInfo); + } + + @Override + public Collection availableAnnotations() { + return List.of(); + } + + @Override + public Collection types() { + return List.of(); + } + + @Override + public Collection annotatedTypes(TypeName annotationType) { + return List.of(); + } + + @Override + public Collection annotatedElements(TypeName annotationType) { + return List.of(); + } + + @Override + public void addGeneratedType(TypeName type, + ClassModel.Builder newClass, + TypeName mainTrigger, + Object... originatingElements) { + + } + + @Override + public Optional generatedType(TypeName type) { + return Optional.empty(); + } + + @Override + public Optional typeInfo(TypeName typeName) { + return ctx.typeInfo(typeName); + } + + public List descriptors() { + return descriptors; + } +} diff --git a/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/WrappedServices.java b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/WrappedServices.java new file mode 100644 index 00000000000..20bebf70b01 --- /dev/null +++ b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/WrappedServices.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * 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.helidon.service.inject.maven.plugin; + +import java.lang.System.Logger.Level; +import java.util.List; + +import io.helidon.codegen.CodegenEvent; +import io.helidon.codegen.CodegenException; +import io.helidon.codegen.CodegenLogger; +import io.helidon.service.inject.api.Activator; +import io.helidon.service.inject.api.InjectServiceInfo; +import io.helidon.service.inject.api.Lookup; + +import static io.helidon.service.inject.codegen.InjectCodegenTypes.INJECT_CONFIG; +import static io.helidon.service.inject.codegen.InjectCodegenTypes.INJECT_REGISTRY; +import static io.helidon.service.inject.codegen.InjectCodegenTypes.INJECT_REGISTRY_MANAGER; + +class WrappedServices implements AutoCloseable { + private final ClassLoader classLoader; + private final CodegenLogger logger; + private final Class injectionServicesType; + private final Class servicesType; + private final Object injectionServices; + private final Object services; + + private WrappedServices(ClassLoader classLoader, + CodegenLogger logger, + Class injectionServicesType, + Class servicesType, + Object injectionServices, + Object services) { + this.classLoader = classLoader; + this.logger = logger; + this.injectionServicesType = injectionServicesType; + this.servicesType = servicesType; + this.injectionServices = injectionServices; + this.services = services; + } + + static WrappedServices create(ClassLoader classLoader, CodegenLogger logger, boolean useBindings) { + try { + /* + Phase.GATHERING_DEPENDENCIES + */ + + Activator.Phase limitPhase = Activator.Phase.ACTIVATION_STARTING; + + /* + InjectionConfig.builder()....build(); + */ + Class injectConfigType = classLoader.loadClass(INJECT_CONFIG.fqName()); + Object injectConfigBuilder = injectConfigType.getMethod("builder") + .invoke(null); + Class injectConfigBuilderType = injectConfigBuilder.getClass(); + + injectConfigBuilderType.getMethod("useBinding", boolean.class) + .invoke(injectConfigBuilder, useBindings); + injectConfigBuilderType.getMethod("limitRuntimePhase", Activator.Phase.class) + .invoke(injectConfigBuilder, limitPhase); + Object injectionConfig = injectConfigBuilderType.getMethod("build") + .invoke(injectConfigBuilder); + + /* + InjectRegistryManager registryManager = InjectRegistryManager.create(injectionConfig) + InjectRegistry services = injectionServices.registry(); + */ + Class injectionServicesType = classLoader.loadClass(INJECT_REGISTRY_MANAGER.fqName()); + Object injectionServices = injectionServicesType.getMethod("create", injectConfigType) + .invoke(null, injectionConfig); + Class servicesType = classLoader.loadClass(INJECT_REGISTRY.fqName()); + Object services = injectionServicesType.getMethod("registry") + .invoke(injectionServices); + + return new WrappedServices(classLoader, + logger, + injectionServicesType, + servicesType, + injectionServices, + services); + } catch (ReflectiveOperationException e) { + throw new CodegenException( + "Failed to invoke Service registry related methods in user's application class loader using reflection", + e); + } + } + + @Override + public void close() { + try { + injectionServicesType.getMethod("shutdown") + .invoke(injectionServices); + } catch (ReflectiveOperationException e) { + logger.log(CodegenEvent.builder() + .level(Level.WARNING) + .message("Failed to shutdown services used from Maven plugin") + .throwable(e) + .build()); + } + } + + List all() { + return all(Lookup.EMPTY); + } + + @SuppressWarnings("unchecked") + List all(Lookup lookup) { + try { + // retrieves all the services in the registry + return (List) servicesType.getMethod("lookupServices", Lookup.class) + .invoke(services, lookup); + } catch (ReflectiveOperationException e) { + throw new CodegenException("Failed to get providers from service registry using reflection", e); + } + } + + InjectServiceInfo get(Lookup lookup) { + List services = all(lookup); + if (services.size() == 1) { + return services.getFirst(); + } + if (services.isEmpty()) { + throw new CodegenException("Expected that service registry contains service: " + lookup + ", yet none was found"); + } + + throw new CodegenException("Expected that service registry contains service: " + lookup + + ", yet more than one was found: " + services); + } +} diff --git a/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/package-info.java b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/package-info.java new file mode 100644 index 00000000000..1f616eb568e --- /dev/null +++ b/service/inject/maven-plugin/src/main/java/io/helidon/service/inject/maven/plugin/package-info.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023, 2024 Oracle and/or its affiliates. + * + * 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. + */ + +/** + * Maven plugin for Helidon Service Inject. + *

+ * This plugin should be used by the application - i.e. the actual microservice that is going to be deployed and started. + * This plugin will not help when used on a library. + *

+ * The plugin generates application binding (mapping of services to injection points they satisfy), and application main class + * to avoid lookups (binding), and reflection and resources discovery from classpath (main class). + */ +package io.helidon.service.inject.maven.plugin; diff --git a/service/inject/maven-plugin/src/main/java/module-info.java b/service/inject/maven-plugin/src/main/java/module-info.java new file mode 100644 index 00000000000..f75f6c19013 --- /dev/null +++ b/service/inject/maven-plugin/src/main/java/module-info.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023, 2024 Oracle and/or its affiliates. + * + * 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. + */ + +/** + * Injection maven-plugin module. + */ +module io.helidon.service.inject.maven.plugin { + + requires io.helidon.builder.api; + requires io.helidon.common; + requires io.helidon.common.config; + requires io.helidon.codegen; + requires io.helidon.codegen.scan; + requires io.helidon.codegen.compiler; + requires io.helidon.service.codegen; + requires io.helidon.service.inject.codegen; + requires io.helidon.service.inject.api; + + requires maven.artifact; + requires maven.model; + requires maven.plugin.annotations; + requires maven.plugin.api; + requires maven.project; + requires io.github.classgraph; + requires io.helidon.service.metadata; + + exports io.helidon.service.inject.maven.plugin; +} diff --git a/service/inject/pom.xml b/service/inject/pom.xml index a246ccb2276..1d863c4a322 100644 --- a/service/inject/pom.xml +++ b/service/inject/pom.xml @@ -40,5 +40,6 @@ codegen api inject + maven-plugin diff --git a/service/pom.xml b/service/pom.xml index 72de3cd13e2..dc67f7c9624 100644 --- a/service/pom.xml +++ b/service/pom.xml @@ -25,6 +25,7 @@ io.helidon helidon-project 4.2.0-SNAPSHOT + ../pom.xml io.helidon.service diff --git a/service/tests/inject/codegen/src/test/java/io/helidon/service/tests/inject/codegen/InjectCodegenTypesTest.java b/service/tests/inject/codegen/src/test/java/io/helidon/service/tests/inject/codegen/InjectCodegenTypesTest.java index ed93979fbcb..5856b589df5 100644 --- a/service/tests/inject/codegen/src/test/java/io/helidon/service/tests/inject/codegen/InjectCodegenTypesTest.java +++ b/service/tests/inject/codegen/src/test/java/io/helidon/service/tests/inject/codegen/InjectCodegenTypesTest.java @@ -24,8 +24,11 @@ import java.util.Set; import io.helidon.common.types.TypeName; +import io.helidon.service.inject.Binding; import io.helidon.service.inject.InjectConfig; +import io.helidon.service.inject.InjectRegistryManager; import io.helidon.service.inject.InjectionMain; +import io.helidon.service.inject.InjectionPlanBinder; import io.helidon.service.inject.api.Event; import io.helidon.service.inject.api.EventManager; import io.helidon.service.inject.api.FactoryType; @@ -104,7 +107,10 @@ void testTypes() { checkField(toCheck, checked, fields, "INJECT_CONFIG", InjectConfig.class); checkField(toCheck, checked, fields, "INJECT_CONFIG_BUILDER", InjectConfig.Builder.class); checkField(toCheck, checked, fields, "INJECT_MAIN", InjectionMain.class); + checkField(toCheck, checked, fields, "INJECT_BINDING", Binding.class); checkField(toCheck, checked, fields, "INJECT_REGISTRY", InjectRegistry.class); + checkField(toCheck, checked, fields, "INJECT_REGISTRY_MANAGER", InjectRegistryManager.class); + checkField(toCheck, checked, fields, "INJECT_PLAN_BINDER", InjectionPlanBinder.class); // api.* interception types checkField(toCheck, checked, fields, "INTERCEPT_EXCEPTION", InterceptionException.class); diff --git a/service/tests/inject/inject/pom.xml b/service/tests/inject/inject/pom.xml index 3d483dca7b4..7768e2efaf7 100644 --- a/service/tests/inject/inject/pom.xml +++ b/service/tests/inject/inject/pom.xml @@ -137,6 +137,22 @@ + + io.helidon.service.inject + helidon-service-inject-maven-plugin + ${helidon.version} + + + create-application + + create-application + + + + + true + + diff --git a/service/tests/inject/lookup/pom.xml b/service/tests/inject/lookup/pom.xml index 4ee6d85a335..1d1be68d387 100644 --- a/service/tests/inject/lookup/pom.xml +++ b/service/tests/inject/lookup/pom.xml @@ -137,6 +137,22 @@ + + io.helidon.service.inject + helidon-service-inject-maven-plugin + ${helidon.version} + + + create-application + + create-application + + + + + true + + diff --git a/service/tests/inject/qualified-providers/pom.xml b/service/tests/inject/qualified-providers/pom.xml index 0772cf44a96..e00b45ef4c1 100644 --- a/service/tests/inject/qualified-providers/pom.xml +++ b/service/tests/inject/qualified-providers/pom.xml @@ -127,6 +127,22 @@ + + io.helidon.service.inject + helidon-service-inject-maven-plugin + ${helidon.version} + + + create-application + + create-application + + + + + true + + diff --git a/service/tests/inject/toolbox/pom.xml b/service/tests/inject/toolbox/pom.xml index 5cc2a7b0322..84c7f654273 100644 --- a/service/tests/inject/toolbox/pom.xml +++ b/service/tests/inject/toolbox/pom.xml @@ -141,6 +141,22 @@ + + io.helidon.service.inject + helidon-service-inject-maven-plugin + ${helidon.version} + + + create-application + + create-application + + + + + true + + diff --git a/service/tests/inject/toolbox/src/test/java/io/helidon/service/tests/inject/toolbox/ToolBoxTest.java b/service/tests/inject/toolbox/src/test/java/io/helidon/service/tests/inject/toolbox/ToolBoxTest.java index 5e9b9a6a023..974c976e4c0 100644 --- a/service/tests/inject/toolbox/src/test/java/io/helidon/service/tests/inject/toolbox/ToolBoxTest.java +++ b/service/tests/inject/toolbox/src/test/java/io/helidon/service/tests/inject/toolbox/ToolBoxTest.java @@ -150,7 +150,6 @@ void hierarchyOfInjections() { * This assumes the presence of module(s) + application(s) to handle all bindings, with effectively no lookups! */ @Test - @Disabled("Disabled, as this required maven plugin, to be added in a later PR") void noServiceActivationRequiresLookupWhenApplicationIsPresent() { Counter counter = lookupCounter(); long initialCount = counter.count();