Repository: avaje/avaje-inject
Pull request #665 (2024-08-18 15:02:40)

Add `@BeanTypes` Annotation to Limit Injection Types
- Creates `@BeanTypes` annotation to define the injectable types associated with a bean. 
- the values provided to the annotation will also override auto-provides generation

Given the class:

```java
@Component
@BeanTypes(SomeInterface2.class)
public class LimitedOtherComponent extends AbstractSuperClass
    implements SomeInterface, SomeInterface2 {}
```

the following will be generated: 

```java
@Generated("io.avaje.inject.generator")
public final class LimitedOtherComponent$DI  {

  /**
   * Create and register LimitedOtherComponent.
   */
  public static void build(Builder builder) {
    if (builder.isAddBeanFor(SomeInterface2.class)) {
      var bean = new LimitedOtherComponent();
      builder.register(bean);
    }
  }
}
```

rbygrave: There are no blackbox tests yet. So it compiles but I don't see tests in blackbox that assert the behaviour. Can we add those tests?
SentryMan: Ran out of time unfortunately, on a road trip without anything more complex than a phone 
SentryMan: Added the tests, please review when you get time

Patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index c48f697b..d1b4d824 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,19 +1,21 @@
 version: 2
 updates:
-- package-ecosystem: maven
-  directory: "/"
-  schedule:
-    interval: "weekly"
-  open-pull-requests-limit: 10
-  ignore:
-      - dependency-name: "jakarta.inject:jakarta.inject-api"
-  groups:
-      dev-deps:
-        dependency-type: "development"
-      prod-deps:
-        dependency-type: "production"
+  - package-ecosystem: "maven"
+    directory: "/"
+    schedule:
+      interval: weekly
+    open-pull-requests-limit: 10
+    ignore:
+       - dependency-name: "jakarta.inject:jakarta.inject-api"
+    groups:
+      dependencies:
+        patterns:
+        - "*"
+    labels:
+      - "dependencies"
+    target-branch: "master"
 
-- package-ecosystem: "github-actions"
+  - package-ecosystem: "github-actions"
     directory: "/"
     schedule:
       interval: "weekly"
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 277cf4df..cf47e041 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -16,14 +16,14 @@ jobs:
         os: [ubuntu-latest]
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - name: Set up Java
-      uses: actions/setup-java@v3
+      uses: actions/setup-java@v4
       with:
         java-version: ${{ matrix.java_version }}
         distribution: 'zulu'
     - name: Maven cache
-      uses: actions/cache@v3
+      uses: actions/cache@v4
       env:
         cache-name: maven-cache
       with:
diff --git a/.github/workflows/dependabot-merge.yml b/.github/workflows/dependabot-merge.yml
index ac02f994..c444bfa4 100644
--- a/.github/workflows/dependabot-merge.yml
+++ b/.github/workflows/dependabot-merge.yml
@@ -12,7 +12,7 @@ jobs:
     steps:
       - name: Dependabot metadata
         id: metadata
-        uses: dependabot/fetch-metadata@v1
+        uses: dependabot/fetch-metadata@v2
         with:
           github-token: "${{ secrets.GITHUB_TOKEN }}"
       - name: Approve a PR
diff --git a/.github/workflows/jdk-ea-stable.yml b/.github/workflows/jdk-ea-stable.yml
index 7e98f064..bc19e66c 100644
--- a/.github/workflows/jdk-ea-stable.yml
+++ b/.github/workflows/jdk-ea-stable.yml
@@ -22,7 +22,7 @@ jobs:
 #        os: [ubuntu-latest]
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - name: Set up Java
       uses: oracle-actions/setup-java@v1
       with:
@@ -30,7 +30,7 @@ jobs:
         release: ea
         version: stable
     - name: Maven cache
-      uses: actions/cache@v3
+      uses: actions/cache@v4
       env:
         cache-name: maven-cache
       with:
diff --git a/.github/workflows/jdk-ea.yml b/.github/workflows/jdk-ea.yml
index b4ac7a5a..34e2519c 100644
--- a/.github/workflows/jdk-ea.yml
+++ b/.github/workflows/jdk-ea.yml
@@ -22,14 +22,14 @@ jobs:
         os: [ubuntu-latest]
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - name: Set up Java
       uses: oracle-actions/setup-java@v1
       with:
         website: jdk.java.net
         release: ${{ matrix.java_version }}
     - name: Maven cache
-      uses: actions/cache@v3
+      uses: actions/cache@v4
       env:
         cache-name: maven-cache
       with:
diff --git a/inject-generator/pom.xml b/inject-generator/pom.xml
index 9907adca..8a2167c7 100644
--- a/inject-generator/pom.xml
+++ b/inject-generator/pom.xml
@@ -34,7 +34,7 @@
     <dependency>
       <groupId>io.avaje</groupId>
       <artifactId>avaje-spi-service</artifactId>
-      <version>2.4</version>
+      <version>2.6</version>
     </dependency>
     <!-- test dependencies -->
     <dependency>
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/AssistBeanReader.java b/inject-generator/src/main/java/io/avaje/inject/generator/AssistBeanReader.java
index 5419e1f1..6e804985 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/AssistBeanReader.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/AssistBeanReader.java
@@ -33,7 +33,13 @@ final class AssistBeanReader {
   AssistBeanReader(TypeElement beanType) {
     this.beanType = beanType;
     this.type = beanType.getQualifiedName().toString();
-    this.typeReader = new TypeReader(UType.parse(beanType.asType()), beanType, importTypes, false);
+    this.typeReader =
+      new TypeReader(
+        Optional.empty(),
+        UType.parse(beanType.asType()),
+        beanType,
+        importTypes,
+        false);
 
     typeReader.process();
     qualifierName = typeReader.name();
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/BeanReader.java b/inject-generator/src/main/java/io/avaje/inject/generator/BeanReader.java
index 6374a7bf..b035859b 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/BeanReader.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/BeanReader.java
@@ -58,7 +58,15 @@ final class BeanReader {
     this.primary = PrimaryPrism.isPresent(beanType);
     this.secondary = !primary && SecondaryPrism.isPresent(beanType);
     this.lazy = !FactoryPrism.isPresent(beanType) && LazyPrism.isPresent(beanType);
-    this.typeReader = new TypeReader(UType.parse(beanType.asType()), beanType, importTypes, factory);
+    final var beantypes = BeanTypesPrism.getOptionalOn(beanType);
+    beantypes.ifPresent(p -> Util.validateBeanTypes(beanType, p.value()));
+    this.typeReader =
+      new TypeReader(
+        beantypes,
+        UType.parse(beanType.asType()),
+        beanType,
+        importTypes,
+        factory);
 
     typeReader.process();
 
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/ExternalProvider.java b/inject-generator/src/main/java/io/avaje/inject/generator/ExternalProvider.java
index fae473c0..4d002761 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/ExternalProvider.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/ExternalProvider.java
@@ -26,6 +26,10 @@ final class ExternalProvider {
     entry("io.avaje.inject.events.spi.ObserverManagerPlugin", of("io.avaje.inject.events.ObserverManager")),
     entry("io.avaje.jsonb.inject.DefaultJsonbProvider", of("io.avaje.jsonb.Jsonb")),
     entry("io.avaje.http.inject.DefaultResolverProvider", of("io.avaje.http.api.context.RequestContextResolver")),
+    entry("io.avaje.htmx.nima.jstache.DefaultTemplateProvider",
+      of(
+        "io.avaje.htmx.nima.TemplateContentCache",
+        "io.avaje.htmx.nima.TemplateRender")),
     entry("io.avaje.nima.provider.DefaultConfigProvider",
       of(
         "io.helidon.webserver.WebServerConfig.Builder",
@@ -51,7 +55,7 @@ final class ExternalProvider {
 
   static void registerModuleProvidedTypes(Set<String> providedTypes) {
     if (!INJECT_AVAILABLE) {
-      if (!pluginExists("avaje-module-provides.txt")) {
+      if (!pluginExists("avaje-module-dependencies.csv")) {
         APContext.logNote("Unable to detect Avaje Inject in Annotation Processor ClassPath, use the Avaje Inject Maven/Gradle plugin for detecting Inject Modules from dependencies");
       }
       return;
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/MethodReader.java b/inject-generator/src/main/java/io/avaje/inject/generator/MethodReader.java
index 1bdff7b2..91235e52 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/MethodReader.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/MethodReader.java
@@ -95,7 +95,9 @@ final class MethodReader {
       this.initMethod = initMethod;
       this.destroyMethod = destroyMethod;
     } else {
-      this.typeReader = new TypeReader(genericType, returnElement, importTypes);
+      final var beantypes = BeanTypesPrism.getOptionalOn(element);
+      beantypes.ifPresent(p -> Util.validateBeanTypes(element, p.value()));
+      this.typeReader = new TypeReader(beantypes, genericType, returnElement, importTypes);
       typeReader.process();
       MethodLifecycleReader lifecycleReader = new MethodLifecycleReader(returnElement, initMethod, destroyMethod);
       this.initMethod = lifecycleReader.initMethod();
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/TypeReader.java b/inject-generator/src/main/java/io/avaje/inject/generator/TypeReader.java
index de8d2018..ab90f310 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/TypeReader.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/TypeReader.java
@@ -3,10 +3,11 @@ package io.avaje.inject.generator;
 import javax.lang.model.element.Element;
 import javax.lang.model.element.TypeElement;
 import javax.lang.model.type.TypeKind;
+import javax.lang.model.type.TypeMirror;
 
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
-
 import static java.util.stream.Collectors.toList;
 
 final class TypeReader {
@@ -18,16 +19,37 @@ final class TypeReader {
   private final TypeAnnotationReader annotationReader;
   private Set<UType> genericTypes;
   private String typesRegister;
-
-  TypeReader(UType genericType, TypeElement beanType, ImportTypeMap importTypes, boolean factory) {
-    this(genericType, true, beanType, importTypes, factory);
-  }
-
-  TypeReader(UType genericType, TypeElement returnElement, ImportTypeMap importTypes) {
-    this(genericType, false, returnElement, importTypes, false);
-  }
-
-  private TypeReader(UType genericType, boolean forBean, TypeElement beanType, ImportTypeMap importTypes, boolean factory) {
+  private final List<UType> injectsTypes;
+
+  TypeReader(
+      Optional<BeanTypesPrism> injectsTypes,
+      UType genericType,
+      TypeElement beanType,
+      ImportTypeMap importTypes,
+      boolean factory) {
+    this(injectsTypes, genericType, true, beanType, importTypes, factory);
+  }
+
+  TypeReader(
+      Optional<BeanTypesPrism> injectsTypes,
+      UType genericType,
+      TypeElement returnElement,
+      ImportTypeMap importTypes) {
+    this(injectsTypes, genericType, false, returnElement, importTypes, false);
+  }
+
+  private TypeReader(
+      Optional<BeanTypesPrism> injectsTypes,
+      UType genericType,
+      boolean forBean,
+      TypeElement beanType,
+      ImportTypeMap importTypes,
+      boolean factory) {
+    this.injectsTypes =
+      injectsTypes.map(BeanTypesPrism::value).stream()
+        .flatMap(List::stream)
+        .map(UType::parse)
+        .collect(toList());
     this.forBean = forBean;
     this.beanType = beanType;
     this.importTypes = importTypes;
@@ -41,14 +63,20 @@ final class TypeReader {
   }
 
   List<String> provides() {
+    if (!injectsTypes.isEmpty()) {
+      return injectsTypes.stream().map(UType::full).collect(toList());
+    }
     return extendsReader.provides().stream().map(UType::full).collect(toList());
   }
 
   List<String> autoProvides() {
+    if (!injectsTypes.isEmpty()) {
+      return injectsTypes.stream().map(UType::full).collect(toList());
+    }
     return extendsReader.autoProvides().stream()
-        .filter(u -> u.componentTypes().stream().noneMatch(p -> p.kind() == TypeKind.TYPEVAR))
-        .map(UType::full)
-        .collect(toList());
+      .filter(u -> u.componentTypes().stream().noneMatch(p -> p.kind() == TypeKind.TYPEVAR))
+      .map(UType::full)
+      .collect(toList());
   }
 
   String providesAspect() {
@@ -120,8 +148,12 @@ final class TypeReader {
 
   private void initRegistrationTypes() {
     TypeAppender appender = new TypeAppender(importTypes);
-    appender.add(extendsReader.baseType());
-    appender.add(extendsReader.provides());
+    if (injectsTypes.isEmpty()) {
+      appender.add(extendsReader.baseType());
+      appender.add(extendsReader.provides());
+    } else {
+      appender.add(injectsTypes);
+    }
     this.genericTypes = appender.genericTypes();
     this.typesRegister = appender.asString();
   }
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/Util.java b/inject-generator/src/main/java/io/avaje/inject/generator/Util.java
index c48ed6a7..f919350c 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/Util.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/Util.java
@@ -2,7 +2,9 @@ package io.avaje.inject.generator;
 
 import javax.lang.model.element.AnnotationMirror;
 import javax.lang.model.element.Element;
+import javax.lang.model.element.ExecutableElement;
 import javax.lang.model.element.Modifier;
+import javax.lang.model.element.TypeElement;
 import javax.lang.model.type.DeclaredType;
 import javax.lang.model.type.TypeKind;
 import javax.lang.model.type.TypeMirror;
@@ -377,4 +379,16 @@ final class Util {
     }
     return "";
   }
+
+  static void validateBeanTypes(Element origin, List<TypeMirror> beanType) {
+    TypeMirror targetType =
+      origin instanceof TypeElement
+        ? origin.asType()
+        : ((ExecutableElement) origin).getReturnType();
+    beanType.forEach(type -> {
+      if (!APContext.types().isAssignable(targetType, type)) {
+        APContext.logError(origin, "%s does not extend type %s", targetType, beanType);
+      }
+    });
+  }
 }
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/package-info.java b/inject-generator/src/main/java/io/avaje/inject/generator/package-info.java
index a1e93e44..1e803d85 100644
--- a/inject-generator/src/main/java/io/avaje/inject/generator/package-info.java
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/package-info.java
@@ -12,6 +12,7 @@
 @GeneratePrism(Generated.class)
 @GeneratePrism(Inject.class)
 @GeneratePrism(InjectModule.class)
+@GeneratePrism(BeanTypes.class)
 @GeneratePrism(Lazy.class)
 @GeneratePrism(Named.class)
 @GeneratePrism(PreDestroy.class)
diff --git a/inject/pom.xml b/inject/pom.xml
index 7a461b9f..5f7d3ab2 100644
--- a/inject/pom.xml
+++ b/inject/pom.xml
@@ -46,7 +46,7 @@
     <dependency>
       <groupId>io.avaje</groupId>
       <artifactId>avaje-spi-service</artifactId>
-      <version>2.4</version>
+      <version>2.6</version>
       <optional>true</optional>
     </dependency>
 
diff --git a/inject/src/main/java/io/avaje/inject/BeanTypes.java b/inject/src/main/java/io/avaje/inject/BeanTypes.java
new file mode 100644
index 00000000..fdcfded5
--- /dev/null
+++ b/inject/src/main/java/io/avaje/inject/BeanTypes.java
@@ -0,0 +1,11 @@
+package io.avaje.inject;
+
+import java.lang.annotation.*;
+
+/** Limits the types exposed by this bean to the given types. */
+@Retention(RetentionPolicy.SOURCE)
+@Target({ElementType.METHOD, ElementType.TYPE})
+public @interface BeanTypes {
+
+  Class<?>[] value();
+}
diff --git a/inject/src/main/java/module-info.java b/inject/src/main/java/module-info.java
index ae9beb14..b39a0ce8 100644
--- a/inject/src/main/java/module-info.java
+++ b/inject/src/main/java/module-info.java
@@ -4,11 +4,11 @@ module io.avaje.inject {
   exports io.avaje.inject.spi;
 
   requires transitive io.avaje.applog;
+  requires transitive jakarta.inject;
   requires static io.avaje.config;
   requires static org.mockito;
   requires static io.avaje.spi;
 
-  requires static transitive jakarta.inject;
   requires static transitive org.jspecify;
 
   uses io.avaje.inject.spi.InjectExtension;


Test patch:
diff --git a/blackbox-test-inject/pom.xml b/blackbox-test-inject/pom.xml
index 1adfae3d..e61431cf 100644
--- a/blackbox-test-inject/pom.xml
+++ b/blackbox-test-inject/pom.xml
@@ -52,7 +52,7 @@
     <dependency>
       <groupId>io.avaje</groupId>
       <artifactId>avaje-spi-service</artifactId>
-      <version>2.4</version>
+      <version>2.6</version>
     </dependency>
 
     <dependency>
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/beantypes/AbstractSuperClass.java b/blackbox-test-inject/src/main/java/org/example/myapp/beantypes/AbstractSuperClass.java
new file mode 100644
index 00000000..0a33c6e0
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/beantypes/AbstractSuperClass.java
@@ -0,0 +1,3 @@
+package org.example.myapp.beantypes;
+
+public abstract class AbstractSuperClass {}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/beantypes/BeanTypeComponent.java b/blackbox-test-inject/src/main/java/org/example/myapp/beantypes/BeanTypeComponent.java
new file mode 100644
index 00000000..96f9d04c
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/beantypes/BeanTypeComponent.java
@@ -0,0 +1,14 @@
+package org.example.myapp.beantypes;
+
+import org.example.myapp.config.AppConfig.SomeInterface;
+import org.other.one.SomeOptionalDep;
+
+import io.avaje.inject.BeanTypes;
+import io.avaje.inject.Component;
+import jakarta.inject.Named;
+
+@Component
+@Named("type")
+@BeanTypes(AbstractSuperClass.class)
+public class BeanTypeComponent extends AbstractSuperClass
+    implements SomeInterface, SomeOptionalDep, LimitedInterface {}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/beantypes/LimitedFactory.java b/blackbox-test-inject/src/main/java/org/example/myapp/beantypes/LimitedFactory.java
new file mode 100644
index 00000000..8e88c14a
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/beantypes/LimitedFactory.java
@@ -0,0 +1,17 @@
+package org.example.myapp.beantypes;
+
+import io.avaje.inject.Bean;
+import io.avaje.inject.BeanTypes;
+import io.avaje.inject.Factory;
+import jakarta.inject.Named;
+
+@Factory
+public class LimitedFactory {
+
+  @Bean
+  @Named("factory")
+  @BeanTypes(LimitedInterface.class)
+  BeanTypeComponent bean() {
+    return new BeanTypeComponent();
+  }
+}
diff --git a/blackbox-test-inject/src/main/java/org/example/myapp/beantypes/LimitedInterface.java b/blackbox-test-inject/src/main/java/org/example/myapp/beantypes/LimitedInterface.java
new file mode 100644
index 00000000..c1a3d78d
--- /dev/null
+++ b/blackbox-test-inject/src/main/java/org/example/myapp/beantypes/LimitedInterface.java
@@ -0,0 +1,3 @@
+package org.example.myapp.beantypes;
+
+public interface LimitedInterface {}
diff --git a/blackbox-test-inject/src/test/java/org/example/myapp/beantypes/BeanTypeComponentTest.java b/blackbox-test-inject/src/test/java/org/example/myapp/beantypes/BeanTypeComponentTest.java
new file mode 100644
index 00000000..b864d5cf
--- /dev/null
+++ b/blackbox-test-inject/src/test/java/org/example/myapp/beantypes/BeanTypeComponentTest.java
@@ -0,0 +1,21 @@
+package org.example.myapp.beantypes;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import org.junit.jupiter.api.Test;
+
+import io.avaje.inject.BeanScope;
+
+class BeanTypesTest {
+
+  @Test
+  void testBeanTypesRestrictingInjection() {
+    try (var scope = BeanScope.builder().build()) {
+
+      assertFalse(scope.contains(BeanTypeComponent.class));
+      assertThat(scope.get(AbstractSuperClass.class)).isNotNull();
+      assertThat(scope.get(LimitedInterface.class)).isNotNull();
+    }
+  }
+}
diff --git a/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/supertypes/LimitedFactory.java b/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/supertypes/LimitedFactory.java
new file mode 100644
index 00000000..183accbf
--- /dev/null
+++ b/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/supertypes/LimitedFactory.java
@@ -0,0 +1,15 @@
+package io.avaje.inject.generator.models.valid.supertypes;
+
+import io.avaje.inject.Bean;
+import io.avaje.inject.BeanTypes;
+import io.avaje.inject.Factory;
+
+@Factory
+public class LimitedFactory {
+
+  @Bean
+  @BeanTypes(SomeInterface.class)
+  OtherComponent bean() {
+    return new OtherComponent();
+  }
+}
diff --git a/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/supertypes/LimitedOtherComponent.java b/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/supertypes/LimitedOtherComponent.java
new file mode 100644
index 00000000..6881a266
--- /dev/null
+++ b/inject-generator/src/test/java/io/avaje/inject/generator/models/valid/supertypes/LimitedOtherComponent.java
@@ -0,0 +1,9 @@
+package io.avaje.inject.generator.models.valid.supertypes;
+
+import io.avaje.inject.BeanTypes;
+import io.avaje.inject.Component;
+
+@Component
+@BeanTypes(SomeInterface2.class)
+public class LimitedOtherComponent extends AbstractSuperClass
+    implements SomeInterface, SomeInterface2 {}