From 082aed0c092e62f1054a32caf8937b347cfc7198 Mon Sep 17 00:00:00 2001 From: Martin Kouba Date: Fri, 10 Jan 2025 11:45:57 +0100 Subject: [PATCH] Qute: introduce the TemplateContents annotation - that can be used to specify the contents of a type-safe template directly in Java code --- docs/src/main/asciidoc/qute-reference.adoc | 38 +++++ .../io/quarkus/qute/deployment/Names.java | 2 + .../qute/deployment/QuteProcessor.java | 151 ++++++++++++------ .../contents/TemplateContentsCheckedTest.java | 41 +++++ .../contents/TemplateContentsRecordTest.java | 52 ++++++ .../io/quarkus/qute/TemplateContents.java | 34 ++++ 6 files changed, 271 insertions(+), 47 deletions(-) create mode 100644 extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/contents/TemplateContentsCheckedTest.java create mode 100644 extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/contents/TemplateContentsRecordTest.java create mode 100644 independent-projects/qute/core/src/main/java/io/quarkus/qute/TemplateContents.java diff --git a/docs/src/main/asciidoc/qute-reference.adoc b/docs/src/main/asciidoc/qute-reference.adoc index c7f926b09365e..11dca07e839d2 100644 --- a/docs/src/main/asciidoc/qute-reference.adoc +++ b/docs/src/main/asciidoc/qute-reference.adoc @@ -2095,6 +2095,44 @@ class ItemService { NOTE: You can specify `@CheckedTemplate#ignoreFragments=true` in order to disable this feature, i.e. a dollar sign `$` in the method name will not result in a checked fragment method. +[[template_contents]] +==== Template Contents + +It is also possible to specify the contents for a type-safe template directly in your Java code. +A `static native` method of a class annotated with `@CheckedTemplate` or a Java record that implements `TemplateInstance` may be annotated with `@io.quarkus.qute.TemplateContents`. +The annotation value is used as the template contents. +The template id/path is derived from the type-safe template. + +.Template Contents Example +[source,java] +---- +package org.acme.quarkus.sample; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import io.quarkus.qute.TemplateContents; +import io.quarkus.qute.TemplateInstance; + +@Path("hello") +public class HelloResource { + + @TemplateContents("Hello {name}!") <1> + record Hello(String name) implements TemplateInstance {} + + @GET + @Produces(MediaType.TEXT_PLAIN) + public TemplateInstance get(@QueryParam("name") String name) { + return new Hello(name); + } +} +---- +<1> Defines the contents for the type-safe template represented by the `Hello` record. The derived template id is `HelloResource/Hello`. + [[template_extension_methods]] === Template Extension Methods diff --git a/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/Names.java b/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/Names.java index e0d2afbf4a999..23b9caaf32964 100644 --- a/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/Names.java +++ b/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/Names.java @@ -17,6 +17,7 @@ import io.quarkus.qute.ParserHook; import io.quarkus.qute.SectionHelperFactory; import io.quarkus.qute.Template; +import io.quarkus.qute.TemplateContents; import io.quarkus.qute.TemplateEnum; import io.quarkus.qute.TemplateInstance; import io.quarkus.qute.TemplateLocator; @@ -54,6 +55,7 @@ final class Names { static final DotName VALUE_RESOLVER = DotName.createSimple(ValueResolver.class.getName()); static final DotName NAMESPACE_RESOLVER = DotName.createSimple(NamespaceResolver.class.getName()); static final DotName PARSER_HOOK = DotName.createSimple(ParserHook.class); + static final DotName TEMPLATE_CONTENTS = DotName.createSimple(TemplateContents.class); private Names() { } diff --git a/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/QuteProcessor.java b/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/QuteProcessor.java index f69aab1b19df1..ae41dc3ee4c79 100644 --- a/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/QuteProcessor.java +++ b/extensions/qute/deployment/src/main/java/io/quarkus/qute/deployment/QuteProcessor.java @@ -325,53 +325,41 @@ List collectCheckedTemplates(BeanArchiveIndexBuildItem if (annotation.target().kind() != Kind.CLASS) { continue; } - ClassInfo classInfo = annotation.target().asClass(); - if (classInfo.isRecord()) { + ClassInfo targetClass = annotation.target().asClass(); + if (targetClass.isRecord()) { // Template records are processed separately continue; } NativeCheckedTemplateEnhancer enhancer = new NativeCheckedTemplateEnhancer(); - for (MethodInfo methodInfo : classInfo.methods()) { + for (MethodInfo method : targetClass.methods()) { // only keep native static methods - if (!Modifier.isStatic(methodInfo.flags()) - || !Modifier.isNative(methodInfo.flags())) { + if (!Modifier.isStatic(method.flags()) + || !Modifier.isNative(method.flags())) { continue; } // check its return type - if (methodInfo.returnType().kind() != Type.Kind.CLASS) { - throw new TemplateException("Incompatible checked template return type: " + methodInfo.returnType() + if (method.returnType().kind() != Type.Kind.CLASS) { + throw new TemplateException("Incompatible checked template return type: " + method.returnType() + " only " + supportedAdaptors); } - DotName returnTypeName = methodInfo.returnType().asClassType().name(); + DotName returnTypeName = method.returnType().asClassType().name(); CheckedTemplateAdapter adaptor = null; // if it's not the default template instance, try to find an adapter if (!returnTypeName.equals(Names.TEMPLATE_INSTANCE)) { adaptor = adaptors.get(returnTypeName); if (adaptor == null) - throw new TemplateException("Incompatible checked template return type: " + methodInfo.returnType() + throw new TemplateException("Incompatible checked template return type: " + method.returnType() + " only " + supportedAdaptors); } - String fragmentId = getCheckedFragmentId(methodInfo, annotation); - StringBuilder templatePathBuilder = new StringBuilder(); - AnnotationValue basePathValue = annotation.value(CHECKED_TEMPLATE_BASE_PATH); - if (basePathValue != null && !basePathValue.asString().equals(CheckedTemplate.DEFAULTED)) { - templatePathBuilder.append(basePathValue.asString()); - } else if (classInfo.enclosingClass() != null) { - ClassInfo enclosingClass = index.getIndex().getClassByName(classInfo.enclosingClass()); - templatePathBuilder.append(enclosingClass.simpleName()); - } - if (templatePathBuilder.length() > 0 && templatePathBuilder.charAt(templatePathBuilder.length() - 1) != '/') { - templatePathBuilder.append('/'); - } - String templatePath = templatePathBuilder - .append(getCheckedTemplateName(methodInfo, annotation, fragmentId != null)).toString(); + String fragmentId = getCheckedFragmentId(method, annotation); + String templatePath = getCheckedTemplatePath(index.getIndex(), annotation, fragmentId, targetClass, method); String fullPath = templatePath + (fragmentId != null ? "$" + fragmentId : ""); - AnnotationTarget checkedTemplate = checkedTemplates.putIfAbsent(fullPath, methodInfo); + AnnotationTarget checkedTemplate = checkedTemplates.putIfAbsent(fullPath, method); if (checkedTemplate != null) { throw new TemplateException( String.format( "Multiple checked templates exist for the template path %s:\n\t- %s: %s\n\t- %s", - fullPath, methodInfo.declaringClass().name(), methodInfo, + fullPath, method.declaringClass().name(), method, checkedTemplate)); } if (!filePaths.contains(templatePath) @@ -387,7 +375,7 @@ && isNotLocatedByCustomTemplateLocator(locatorPatternsBuildItem.getLocationPatte if (startsWith.isEmpty()) { throw new TemplateException( "No template matching the path " + templatePath + " could be found for: " - + classInfo.name() + "." + methodInfo.name()); + + targetClass.name() + "." + method.name()); } else { throw new TemplateException( startsWith + " match the path " + templatePath @@ -396,24 +384,24 @@ && isNotLocatedByCustomTemplateLocator(locatorPatternsBuildItem.getLocationPatte } Map bindings = new HashMap<>(); - List parameters = methodInfo.parameterTypes(); + List parameters = method.parameterTypes(); List parameterNames = new ArrayList<>(parameters.size()); for (int i = 0; i < parameters.size(); i++) { Type type = parameters.get(i); - String name = methodInfo.parameterName(i); + String name = method.parameterName(i); if (name == null) { - throw new TemplateException("Parameter names not recorded for " + classInfo.name() + throw new TemplateException("Parameter names not recorded for " + targetClass.name() + ": compile the class with -parameters"); } bindings.put(name, getCheckedTemplateParameterTypeName(type)); parameterNames.add(name); } AnnotationValue requireTypeSafeExpressions = annotation.value(CHECKED_TEMPLATE_REQUIRE_TYPE_SAFE); - ret.add(new CheckedTemplateBuildItem(templatePath, fragmentId, bindings, methodInfo, null, + ret.add(new CheckedTemplateBuildItem(templatePath, fragmentId, bindings, method, null, requireTypeSafeExpressions != null ? requireTypeSafeExpressions.asBoolean() : true)); - enhancer.implement(methodInfo, templatePath, fragmentId, parameterNames, adaptor); + enhancer.implement(method, templatePath, fragmentId, parameterNames, adaptor); } - transformers.produce(new BytecodeTransformerBuildItem(classInfo.name().toString(), + transformers.produce(new BytecodeTransformerBuildItem(targetClass.name().toString(), enhancer)); } @@ -443,21 +431,8 @@ && isNotLocatedByCustomTemplateLocator(locatorPatternsBuildItem.getLocationPatte AnnotationInstance checkedTemplateAnnotation = recordClass.declaredAnnotation(Names.CHECKED_TEMPLATE); String fragmentId = getCheckedFragmentId(recordClass, checkedTemplateAnnotation); - StringBuilder templatePathBuilder = new StringBuilder(); - AnnotationValue basePathValue = checkedTemplateAnnotation != null - ? checkedTemplateAnnotation.value(CHECKED_TEMPLATE_BASE_PATH) - : null; - if (basePathValue != null && !basePathValue.asString().equals(CheckedTemplate.DEFAULTED)) { - templatePathBuilder.append(basePathValue.asString()); - } else if (recordClass.enclosingClass() != null) { - ClassInfo enclosingClass = index.getIndex().getClassByName(recordClass.enclosingClass()); - templatePathBuilder.append(enclosingClass.simpleName()); - } - if (templatePathBuilder.length() > 0 && templatePathBuilder.charAt(templatePathBuilder.length() - 1) != '/') { - templatePathBuilder.append('/'); - } - String templatePath = templatePathBuilder - .append(getCheckedTemplateName(recordClass, checkedTemplateAnnotation, fragmentId != null)).toString(); + String templatePath = getCheckedTemplatePath(index.getIndex(), checkedTemplateAnnotation, fragmentId, + recordClass); String fullPath = templatePath + (fragmentId != null ? "$" + fragmentId : ""); AnnotationTarget checkedTemplate = checkedTemplates.putIfAbsent(fullPath, recordClass); if (checkedTemplate != null) { @@ -518,6 +493,42 @@ && isNotLocatedByCustomTemplateLocator(locatorPatternsBuildItem.getLocationPatte return ret; } + private String getCheckedTemplatePath(IndexView index, AnnotationInstance annotation, String fragmentId, + ClassInfo classInfo, MethodInfo method) { + StringBuilder templatePathBuilder = new StringBuilder(); + AnnotationValue basePathValue = annotation.value(CHECKED_TEMPLATE_BASE_PATH); + if (basePathValue != null && !basePathValue.asString().equals(CheckedTemplate.DEFAULTED)) { + templatePathBuilder.append(basePathValue.asString()); + } else if (classInfo.enclosingClass() != null) { + ClassInfo enclosingClass = index.getClassByName(classInfo.enclosingClass()); + templatePathBuilder.append(enclosingClass.simpleName()); + } + if (templatePathBuilder.length() > 0 && templatePathBuilder.charAt(templatePathBuilder.length() - 1) != '/') { + templatePathBuilder.append('/'); + } + return templatePathBuilder + .append(getCheckedTemplateName(method, annotation, fragmentId != null)).toString(); + } + + private String getCheckedTemplatePath(IndexView index, AnnotationInstance annotation, String fragmentId, + ClassInfo recordClass) { + StringBuilder templatePathBuilder = new StringBuilder(); + AnnotationValue basePathValue = annotation != null + ? annotation.value(CHECKED_TEMPLATE_BASE_PATH) + : null; + if (basePathValue != null && !basePathValue.asString().equals(CheckedTemplate.DEFAULTED)) { + templatePathBuilder.append(basePathValue.asString()); + } else if (recordClass.enclosingClass() != null) { + ClassInfo enclosingClass = index.getClassByName(recordClass.enclosingClass()); + templatePathBuilder.append(enclosingClass.simpleName()); + } + if (templatePathBuilder.length() > 0 && templatePathBuilder.charAt(templatePathBuilder.length() - 1) != '/') { + templatePathBuilder.append('/'); + } + return templatePathBuilder + .append(getCheckedTemplateName(recordClass, annotation, fragmentId != null)).toString(); + } + private String getCheckedTemplateName(AnnotationTarget target, AnnotationInstance checkedTemplateAnnotation, boolean checkedFragment) { AnnotationValue nameValue = checkedTemplateAnnotation != null @@ -2456,6 +2467,52 @@ public boolean test(TypeCheck check) { } } + @BuildStep + void collecTemplateContents(BeanArchiveIndexBuildItem index, List templateAdaptors, + BuildProducer templatePaths) { + + Set recordInterfaces = new HashSet<>(); + recordInterfaces.add(Names.TEMPLATE_INSTANCE); + templateAdaptors.stream().map(ta -> DotName.createSimple(ta.adapter.templateInstanceBinaryName().replace('/', '.'))) + .forEach(recordInterfaces::add); + + for (AnnotationInstance annotation : index.getImmutableIndex().getAnnotations(Names.TEMPLATE_CONTENTS)) { + AnnotationValue suffixValue = annotation.value("suffix"); + String suffix = suffixValue != null ? "." + suffixValue.asString() : ".txt"; + if (annotation.target().kind() == Kind.CLASS) { + ClassInfo target = annotation.target().asClass(); + if (target.isRecord() && target.interfaceNames().stream().anyMatch(recordInterfaces::contains)) { + AnnotationInstance checkedTemplateAnnotation = target.declaredAnnotation(Names.CHECKED_TEMPLATE); + String fragmentId = getCheckedFragmentId(target, checkedTemplateAnnotation); + templatePaths.produce(TemplatePathBuildItem.builder() + .content(annotation.value().asString()) + .path(getCheckedTemplatePath(index.getIndex(), checkedTemplateAnnotation, fragmentId, target) + + suffix) + .extensionInfo(target.toString()) + .build()); + continue; + } + } else if (annotation.target().kind() == Kind.METHOD) { + MethodInfo method = annotation.target().asMethod(); + if (Modifier.isStatic(method.flags()) + && Modifier.isNative(method.flags()) + && method.declaringClass().hasAnnotation(Names.CHECKED_TEMPLATE)) { + AnnotationInstance checkedTemplateAnnotation = method.declaringClass() + .declaredAnnotation(Names.CHECKED_TEMPLATE); + String fragmentId = getCheckedFragmentId(method, checkedTemplateAnnotation); + templatePaths.produce(TemplatePathBuildItem.builder() + .content(annotation.value().asString()) + .path(getCheckedTemplatePath(index.getIndex(), checkedTemplateAnnotation, fragmentId, + method.declaringClass(), method) + suffix) + .extensionInfo(method.toString()) + .build()); + continue; + } + } + throw new TemplateException("Invalid annotation target for @TemplateContents: " + annotation.target()); + } + } + @BuildStep @Record(value = STATIC_INIT) void initialize(BuildProducer syntheticBeans, QuteRecorder recorder, diff --git a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/contents/TemplateContentsCheckedTest.java b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/contents/TemplateContentsCheckedTest.java new file mode 100644 index 0000000000000..d2e7df2904d9d --- /dev/null +++ b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/contents/TemplateContentsCheckedTest.java @@ -0,0 +1,41 @@ +package io.quarkus.qute.deployment.contents; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.concurrent.ExecutionException; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.qute.CheckedTemplate; +import io.quarkus.qute.Engine; +import io.quarkus.qute.TemplateContents; +import io.quarkus.qute.TemplateInstance; +import io.quarkus.test.QuarkusUnitTest; + +public class TemplateContentsCheckedTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot(root -> root.addClass(Templates.class)); + + @Inject + Engine engine; + + @Test + public void testTemplateContents() throws InterruptedException, ExecutionException { + assertEquals("Hello 42!", Templates.helloInt(42).render()); + assertEquals("Hello 1!", engine.getTemplate("TemplateContentsCheckedTest/helloInt").data("val", 1).render()); + } + + @CheckedTemplate + static class Templates { + + @TemplateContents("Hello {val}!") + static native TemplateInstance helloInt(int val); + + } + +} diff --git a/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/contents/TemplateContentsRecordTest.java b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/contents/TemplateContentsRecordTest.java new file mode 100644 index 0000000000000..42ace0a7f3d2f --- /dev/null +++ b/extensions/qute/deployment/src/test/java/io/quarkus/qute/deployment/contents/TemplateContentsRecordTest.java @@ -0,0 +1,52 @@ +package io.quarkus.qute.deployment.contents; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.concurrent.ExecutionException; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.qute.CheckedTemplate; +import io.quarkus.qute.Engine; +import io.quarkus.qute.TemplateContents; +import io.quarkus.qute.TemplateInstance; +import io.quarkus.test.QuarkusUnitTest; + +public class TemplateContentsRecordTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot(root -> root.addClass(helloInt.class)); + + @Inject + Engine engine; + + @Test + public void testTemplateContents() throws InterruptedException, ExecutionException { + assertEquals("Hello 42!", new helloInt(42).render()); + assertEquals("Hello 1!", engine.getTemplate("TemplateContentsRecordTest/helloInt").data("val", 1).render()); + assertEquals("Hello 42!", new helloLong(42).render()); + assertEquals("Hello 1!", engine.getTemplate("foo/helloLong").data("val", 1).render()); + + assertEquals("

Hello "Martin"!

", new helloHtml("\"Martin\"").render()); + assertEquals("

Hello Lu!

", + engine.getTemplate("TemplateContentsRecordTest/helloHtml.html").data("name", "Lu").render()); + } + + @TemplateContents("Hello {val}!") + record helloInt(int val) implements TemplateInstance { + } + + @CheckedTemplate(basePath = "foo") + @TemplateContents("Hello {val}!") + record helloLong(long val) implements TemplateInstance { + } + + @TemplateContents(value = "

Hello {name}!

", suffix = "html") + record helloHtml(String name) implements TemplateInstance { + } + +} diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/TemplateContents.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/TemplateContents.java new file mode 100644 index 0000000000000..51460b8419b34 --- /dev/null +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/TemplateContents.java @@ -0,0 +1,34 @@ +package io.quarkus.qute; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + *

+ * IMPORTANT: This annotation only works in a fully integrated environment; such as a Quarkus application. + *

+ * + * This annotation can be used to specify the contents for a type-safe template, i.e. for a method of a class annotated with + * {@link CheckedTemplate} or a Java record that implements {@link TemplateInstance}. + */ +@Target({ TYPE, METHOD }) +@Retention(RUNTIME) +public @interface TemplateContents { + + /** + * The contents. + */ + String value(); + + /** + * The suffix is used to determine the content type of a template. This is useful when working with template variants. + *

+ * By default, the {@code txt} value, which corresponds to {@code text/plain}, is used. For the {@code text/html} content + * type the {@code html} suffix should be used. + */ + String suffix() default "txt"; +}