Description
openedon Jul 30, 2024
Description
I would like to generate dynamically my endpoints from CommandHandler. One CommandHandler need to produce an Endpoint.
Here is a sample:
public interface AdminCommandHandler<C extends Command> {
void execute(C command) throws ExecutionException;
}
public interface Command {
}
And an implementation likes this one
@ApplicationScoped
public class ClearCacheAdminCommandHandlerWithCommandToEnhance implements AdminCommandHandler<ClearCacheCommandToEnhance> {
private final ClearCacheExecutor clearCacheExecutor;
public ClearCacheAdminCommandHandlerWithCommandToEnhance(final ClearCacheExecutor clearCacheExecutor) {
this.clearCacheExecutor = Objects.requireNonNull(clearCacheExecutor);
}
@Override
public void execute(final ClearCacheCommandToEnhance command) throws ExecutionException {
clearCacheExecutor.clear(command.cacheName);
}
}
public class ClearCacheCommandToEnhance implements Command {
@FormParam("cacheName")
public String cacheName;
}
@Path("/admin")
public class ClearCacheAdminCommandHandlerEndpointToGenerate {
private final ClearCacheAdminCommandHandlerWithCommandToEnhance adminCommandHandler;
public ClearCacheAdminCommandHandlerEndpointToGenerate(
final ClearCacheAdminCommandHandlerWithCommandToEnhance clearCacheAdminCommandHandlerWithCommandToEnhance) {
this.adminCommandHandler = clearCacheAdminCommandHandlerWithCommandToEnhance;
}
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Path("/clearCacheAdminCommandHandler")
public void execute(@BeanParam final ClearCacheCommandToEnhance command) {
adminCommandHandler.execute(command);
}
}
The generated part must be done following a convention. Each endpoint should be generated following each CommandHandler implementation and the Command must be enhanced by adding a @FormParam on each fields.
So everything should be generated from theses classes:
@ApplicationScoped
public class ClearCacheAdminCommandHandler implements AdminCommandHandler<ClearCacheCommand> {
private final ClearCacheExecutor clearCacheExecutor;
public ClearCacheAdminCommandHandlerWithCommand(final ClearCacheExecutor clearCacheExecutor) {
this.clearCacheExecutor = Objects.requireNonNull(clearCacheExecutor);
}
@Override
public void execute(final ClearCacheCommand command) throws ExecutionException {
clearCacheExecutor.clear(command.cacheName);
}
}
public class ClearCacheCommandToEnhance implements Command {
public String cacheName;
}
To do it I've done it this way:
@BuildStep
List<CommandHandlerDiscoveredBuildItem> discoverCommandHandlers(final ApplicationIndexBuildItem applicationIndexBuildItem) {
final Class<?> adminCommandHandlerClazz = AdminCommandHandler.class;
final Index index = applicationIndexBuildItem.getIndex();
return index
.getAllKnownImplementors(adminCommandHandlerClazz)
.stream()
.map(implementor -> {
final List<DotName> interfaceDotNames = implementor.interfaceNames();
final int position = IntStream.range(0, interfaceDotNames.size())
.filter(idx -> adminCommandHandlerClazz.getName().equals(interfaceDotNames.get(idx).toString()))
.findFirst()
.orElseThrow(() -> new IllegalStateException("Should not be here"));
final ParameterizedType adminCommandHandlerType = implementor.interfaceTypes().get(position)
.asParameterizedType();
final Type first = adminCommandHandlerType.arguments().getFirst();
final ClassInfo command = index.getClassByName(first.name());
return new CommandHandlerDiscoveredBuildItem(implementor, command);
})
.toList();
}
@BuildStep
void generateAdminEndpoints(final List<CommandHandlerDiscoveredBuildItem> commandHandlerDiscoveredBuildItems,
final BuildProducer<GeneratedJaxRsResourceBuildItem> generatedJaxRsResourceBuildItemsProducer) {
commandHandlerDiscoveredBuildItems.forEach(commandHandlerDiscoveredBuildItem -> {
final String className = commandHandlerDiscoveredBuildItem.commandHandler().simpleName() + "EndpointGenerated";
try (final ClassCreator beanClassCreator = ClassCreator.builder()
.classOutput(new GeneratedJaxRsResourceGizmoAdaptor(generatedJaxRsResourceBuildItemsProducer))
.className(className)
.setFinal(false)
.build()) {
beanClassCreator.addAnnotation(Path.class).add("value", "/admin");
final FieldCreator adminCommandHandlerField = beanClassCreator
.getFieldCreator("adminCommandHandler", AdminCommandHandler.class)
.setModifiers(ACC_PRIVATE | ACC_FINAL);
final ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
final Class<?> commandHandlerClazz = contextClassLoader
.loadClass(commandHandlerDiscoveredBuildItem.commandHandler().name().toString());
final Class<?> commandClazz = contextClassLoader
.loadClass(commandHandlerDiscoveredBuildItem.command().name().toString());
// constructor
try (final MethodCreator constructorMethod = beanClassCreator.getMethodCreator(MethodDescriptor.INIT,
void.class, commandHandlerClazz)) {
constructorMethod.setModifiers(ACC_PUBLIC);
constructorMethod.invokeSpecialMethod(MethodDescriptor.ofConstructor(Object.class),
constructorMethod.getThis());
constructorMethod.writeInstanceField(adminCommandHandlerField.getFieldDescriptor(),
constructorMethod.getThis(), constructorMethod.getMethodParam(0));
constructorMethod.returnVoid();
}
// execute method
try (final MethodCreator executeMethod = beanClassCreator.getMethodCreator("execute", void.class,
commandClazz)) {
executeMethod.setModifiers(ACC_PUBLIC);
executeMethod.addAnnotation(POST.class);
executeMethod.addAnnotation(Consumes.class).add("value",
new String[]{MediaType.APPLICATION_FORM_URLENCODED});
executeMethod.addAnnotation(Path.class).add("value",
endpointNaming(commandHandlerDiscoveredBuildItem.commandHandler().simpleName()));
executeMethod.getParameterAnnotations(0).addAnnotation(BeanParam.class);
final ResultHandle commandHandlerResult = executeMethod
.readInstanceField(adminCommandHandlerField.getFieldDescriptor(), executeMethod.getThis());
executeMethod
.invokeVirtualMethod(
MethodDescriptor.ofMethod(commandHandlerClazz, "execute",
void.class, commandHandlerDiscoveredBuildItem.command().name().toString()),
commandHandlerResult, executeMethod.getMethodParam(0));
executeMethod.returnVoid();
}
beanClassCreator.writeTo((name, bytes) -> {
// for debugging purpose: open the file with IntelliJ to see the class generated
final File file = new File(String.format("target/%s.class", name));
try (FileOutputStream outputStream = new FileOutputStream(file)) {
outputStream.write(bytes);
} catch (IOException e) {
throw new RuntimeException(e);
}
});
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
});
}
@BuildStep
AnnotationsTransformerBuildItem bindCommandFieldsWithJaxRSFormParam(final ApplicationIndexBuildItem applicationIndexBuildItem) {
final List<ClassInfo> commandImplementors = applicationIndexBuildItem.getIndex()
.getAllKnownImplementors(Command.class)
.stream().toList();
return new AnnotationsTransformerBuildItem(AnnotationTransformation.forFields()
.whenField(fieldInfo -> commandImplementors.contains(fieldInfo.declaringClass()))
.transform(transformationContext -> {
transformationContext.add(AnnotationInstance.builder(FormParam.class)
.add("value", transformationContext.declaration().asField().name())
.build());
})
);
}
}
public final class CommandHandlerDiscoveredBuildItem extends MultiBuildItem {
private final ClassInfo commandHandler;
private final ClassInfo command;
public CommandHandlerDiscoveredBuildItem(final ClassInfo commandHandler, ClassInfo command) {
this.commandHandler = Objects.requireNonNull(commandHandler);
this.command = Objects.requireNonNull(command);
}
public ClassInfo commandHandler() {
return commandHandler;
}
public ClassInfo command() {
return command;
}
}
@BeanParam with @FormParam is an easy way to do the binding.
However it is not working because the AnnotationsTransformerBuildItem will not change the index used by the ServerEndpointIndexer so the @FormParam is not present and it fails this way
java.lang.RuntimeException: java.lang.RuntimeException: io.quarkus.builder.BuildException: Build failure: Build failed due to errors
[error]: Build step io.quarkus.resteasy.reactive.server.deployment.ResteasyReactiveProcessor#setupEndpoints threw an exception: java.lang.RuntimeException: java.lang.RuntimeException: Failed to process method 'ClearCacheAdminCommandHandlerEndpointGenerated#execute'
at org.jboss.resteasy.reactive.common.processor.EndpointIndexer.createEndpoints(EndpointIndexer.java:333)
at io.quarkus.resteasy.reactive.server.deployment.ResteasyReactiveProcessor.setupEndpoints(ResteasyReactiveProcessor.java:665)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at io.quarkus.deployment.ExtensionLoader$3.execute(ExtensionLoader.java:849)
at io.quarkus.builder.BuildContext.run(BuildContext.java:256)
at org.jboss.threads.ContextHandler$1.runWith(ContextHandler.java:18)
at org.jboss.threads.EnhancedQueueExecutor$Task.doRunWith(EnhancedQueueExecutor.java:2516)
at org.jboss.threads.EnhancedQueueExecutor$Task.run(EnhancedQueueExecutor.java:2495)
at org.jboss.threads.EnhancedQueueExecutor$ThreadBody.run(EnhancedQueueExecutor.java:1521)
at java.base/java.lang.Thread.run(Thread.java:1583)
at org.jboss.threads.JBossThread.run(JBossThread.java:483)
Caused by: java.lang.RuntimeException: Failed to process method 'ClearCacheAdminCommandHandlerEndpointGenerated#execute'
at org.jboss.resteasy.reactive.common.processor.EndpointIndexer.createResourceMethod(EndpointIndexer.java:781)
at org.jboss.resteasy.reactive.common.processor.EndpointIndexer.createEndpoints(EndpointIndexer.java:422)
at org.jboss.resteasy.reactive.common.processor.EndpointIndexer.createEndpoints(EndpointIndexer.java:300)
... 11 more
Caused by: jakarta.enterprise.inject.spi.DeploymentException: No annotations found on fields at 'com.lodh.arte.quarkus.loadmin.deployment.bean.ClearCacheCommand'. Annotations like `@QueryParam` should be used in fields, not in methods.
at org.jboss.resteasy.reactive.server.processor.ServerEndpointIndexer.handleBeanParam(ServerEndpointIndexer.java:198)
at org.jboss.resteasy.reactive.common.processor.EndpointIndexer.createResourceMethod(EndpointIndexer.java:634)
... 13 more
Implementation ideas
When using AnnotationsTransformerBuildItem we should rely on AnnotationStore to get changes.
ServerEndpointIndexer is already relying on it.
Is it possible to apply changes on this method
@Override
protected boolean handleBeanParam(ClassInfo actualEndpointInfo, Type paramType, MethodParameter[] methodParameters, int i,
Set<String> fileFormNames) {
ClassInfo beanParamClassInfo = index.getClassByName(paramType.name());
InjectableBean injectableBean = scanInjectableBean(beanParamClassInfo,
actualEndpointInfo,
existingConverters, additionalReaders, injectableBeans, hasRuntimeConverters);
if ((injectableBean.getFieldExtractorsCount() == 0) && !injectableBean.isInjectionRequired()) {
throw new DeploymentException(String.format("No annotations found on fields at '%s'. "
+ "Annotations like `@QueryParam` should be used in fields, not in methods.",
beanParamClassInfo.name()));
}
fileFormNames.addAll(injectableBean.getFileFormNames());
return injectableBean.isFormParamRequired();
}
to use the AnnotationStore here instead of the immutable index.
ClassInfo beanParamClassInfo = index.getClassByName(paramType.name());