diff --git a/extensions/jaxb/deployment/pom.xml b/extensions/jaxb/deployment/pom.xml index 30e68d12c9c46..fc665ddc3fe65 100644 --- a/extensions/jaxb/deployment/pom.xml +++ b/extensions/jaxb/deployment/pom.xml @@ -28,7 +28,12 @@ io.quarkus - quarkus-junit5 + quarkus-junit5-internal + test + + + org.assertj + assertj-core test diff --git a/extensions/jaxb/deployment/src/main/java/io/quarkus/jaxb/deployment/FilteredJaxbClassesToBeBoundBuildItem.java b/extensions/jaxb/deployment/src/main/java/io/quarkus/jaxb/deployment/FilteredJaxbClassesToBeBoundBuildItem.java new file mode 100644 index 0000000000000..347e3cf661c6b --- /dev/null +++ b/extensions/jaxb/deployment/src/main/java/io/quarkus/jaxb/deployment/FilteredJaxbClassesToBeBoundBuildItem.java @@ -0,0 +1,70 @@ +package io.quarkus.jaxb.deployment; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import io.quarkus.builder.item.SimpleBuildItem; +import io.quarkus.jaxb.deployment.utils.JaxbType; + +/** + * List of classes to be bound in the JAXB context. Aggregates all classes passed via + * {@link JaxbClassesToBeBoundBuildItem}. All class names excluded via {@code quarkus.jaxb.exclude-classes} are not + * present in this list. + */ +public final class FilteredJaxbClassesToBeBoundBuildItem extends SimpleBuildItem { + + private final List> classes; + + public static Builder builder() { + return new Builder(); + } + + private FilteredJaxbClassesToBeBoundBuildItem(List> classes) { + this.classes = classes; + } + + public List> getClasses() { + return new ArrayList<>(classes); + } + + public static class Builder { + private final Set classNames = new LinkedHashSet<>(); + private final Set classNameExcludes = new LinkedHashSet<>(); + + public Builder classNameExcludes(Collection classNameExcludes) { + for (String className : classNameExcludes) { + this.classNameExcludes.add(className); + } + return this; + } + + public Builder classNames(Collection classNames) { + for (String className : classNames) { + this.classNames.add(className); + } + return this; + } + + public FilteredJaxbClassesToBeBoundBuildItem build() { + final List> classes = classNames.stream() + .filter(className -> !this.classNameExcludes.contains(className)) + .map(FilteredJaxbClassesToBeBoundBuildItem::getClassByName) + .filter(JaxbType::isValidType) + .collect(Collectors.toList()); + + return new FilteredJaxbClassesToBeBoundBuildItem(classes); + } + } + + private static Class getClassByName(String name) { + try { + return Class.forName(name, false, Thread.currentThread().getContextClassLoader()); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + } +} diff --git a/extensions/jaxb/deployment/src/main/java/io/quarkus/jaxb/deployment/JaxbClassesToBeBoundBuildItem.java b/extensions/jaxb/deployment/src/main/java/io/quarkus/jaxb/deployment/JaxbClassesToBeBoundBuildItem.java index 199842f0b7586..dd76cb8f5844f 100644 --- a/extensions/jaxb/deployment/src/main/java/io/quarkus/jaxb/deployment/JaxbClassesToBeBoundBuildItem.java +++ b/extensions/jaxb/deployment/src/main/java/io/quarkus/jaxb/deployment/JaxbClassesToBeBoundBuildItem.java @@ -1,19 +1,24 @@ package io.quarkus.jaxb.deployment; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Objects; import io.quarkus.builder.item.MultiBuildItem; /** - * List of classes to be bound in the JAXB context. + * List of class names to be bound in the JAXB context. Note that some of the class names can be removed via + * {@code quarkus.jaxb.exclude-classes}. + * + * @see FilteredJaxbClassesToBeBoundBuildItem */ public final class JaxbClassesToBeBoundBuildItem extends MultiBuildItem { private final List classes; public JaxbClassesToBeBoundBuildItem(List classes) { - this.classes = Objects.requireNonNull(classes); + this.classes = Objects.requireNonNull(Collections.unmodifiableList(new ArrayList<>(classes))); } public List getClasses() { diff --git a/extensions/jaxb/deployment/src/main/java/io/quarkus/jaxb/deployment/JaxbProcessor.java b/extensions/jaxb/deployment/src/main/java/io/quarkus/jaxb/deployment/JaxbProcessor.java index 4ab8f71950f88..aa6fd86771366 100644 --- a/extensions/jaxb/deployment/src/main/java/io/quarkus/jaxb/deployment/JaxbProcessor.java +++ b/extensions/jaxb/deployment/src/main/java/io/quarkus/jaxb/deployment/JaxbProcessor.java @@ -1,19 +1,16 @@ package io.quarkus.jaxb.deployment; -import static io.quarkus.jaxb.deployment.utils.JaxbType.isValidType; - import java.io.IOError; import java.io.IOException; import java.lang.annotation.Annotation; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; -import java.util.Collection; -import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.stream.Stream; +import jakarta.enterprise.inject.spi.DeploymentException; import jakarta.xml.bind.JAXBContext; import jakarta.xml.bind.JAXBException; import jakarta.xml.bind.annotation.XmlAccessOrder; @@ -58,6 +55,9 @@ import org.jboss.logging.Logger; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.arc.deployment.SynthesisFinishedBuildItem; +import io.quarkus.arc.processor.BeanInfo; +import io.quarkus.arc.processor.BeanResolver; import io.quarkus.deployment.ApplicationArchive; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; @@ -297,29 +297,62 @@ void registerClasses( } @BuildStep - @Record(ExecutionTime.STATIC_INIT) - void setupJaxbContextConfig(JaxbConfig config, - List classesToBeBoundBuildItems, - JaxbContextConfigRecorder jaxbContextConfig) { - Set classNamesToBeBound = new HashSet<>(); - for (JaxbClassesToBeBoundBuildItem classesToBeBoundBuildItem : classesToBeBoundBuildItems) { - classNamesToBeBound.addAll(classesToBeBoundBuildItem.getClasses()); - } + FilteredJaxbClassesToBeBoundBuildItem filterBoundClasses( + JaxbConfig config, + List classesToBeBoundBuildItems) { + + FilteredJaxbClassesToBeBoundBuildItem.Builder builder = FilteredJaxbClassesToBeBoundBuildItem.builder(); + classesToBeBoundBuildItems.stream() + .map(JaxbClassesToBeBoundBuildItem::getClasses) + .forEach(builder::classNames); // remove classes that have been excluded by users if (config.excludeClasses.isPresent()) { - classNamesToBeBound.removeAll(config.excludeClasses.get()); + builder.classNameExcludes(config.excludeClasses.get()); } + return builder.build(); + } + + @BuildStep + @Record(ExecutionTime.STATIC_INIT) + void setupJaxbContextConfig( + FilteredJaxbClassesToBeBoundBuildItem filteredClassesToBeBound, + JaxbContextConfigRecorder jaxbContextConfig) { + jaxbContextConfig.addClassesToBeBound(filteredClassesToBeBound.getClasses()); + } + + @BuildStep + @Record(ExecutionTime.STATIC_INIT) + void validateDefaultJaxbContext( + JaxbConfig config, + FilteredJaxbClassesToBeBoundBuildItem filteredClassesToBeBound, + SynthesisFinishedBuildItem beanContainerState, + JaxbContextConfigRecorder jaxbContextConfig /* Force the build time container to invoke this method */) { - // parse class names to class - Set> classes = getAllClassesFromClassNames(classNamesToBeBound); if (config.validateJaxbContext) { - // validate the context to fail at build time if it's not valid - validateContext(classes); + final BeanResolver beanResolver = beanContainerState.getBeanResolver(); + final Set beans = beanResolver + .resolveBeans(Type.create(DotName.createSimple(JAXBContext.class), org.jboss.jandex.Type.Kind.CLASS)); + if (!beans.isEmpty()) { + final BeanInfo bean = beanResolver.resolveAmbiguity(beans); + if (bean.isDefaultBean()) { + /* + * Validate the default JAXB context at build time and fail early. + * Do this only if the user application actually requires the default JAXBContext bean + */ + try { + JAXBContext.newInstance(filteredClassesToBeBound.getClasses().toArray(new Class[0])); + } catch (JAXBException e) { + /* + * Producing a ValidationErrorBuildItem would perhaps be more natural here, + * but doing so causes a cycle between this and reactive JAXB extension + * Throwing from here works well too + */ + throw new DeploymentException("Failed to create or validate the default JAXBContext", e); + } + } + } } - - // register the classes to be used at runtime - jaxbContextConfig.addClassesToBeBound(classes); } @BuildStep @@ -388,31 +421,4 @@ private void addResourceBundle(BuildProducer resourceBundle.produce(new NativeImageResourceBundleBuildItem(bundle)); } - private void validateContext(Set> classes) { - try { - JAXBContext.newInstance(classes.toArray(new Class[0])); - } catch (JAXBException e) { - throw new IllegalStateException("Failed to configure JAXB context", e); - } - } - - private Set> getAllClassesFromClassNames(Collection classNames) { - Set> classes = new HashSet<>(); - for (String className : classNames) { - Class clazz = getClassByName(className); - if (isValidType(clazz)) { - classes.add(clazz); - } - } - - return classes; - } - - private Class getClassByName(String name) { - try { - return Class.forName(name, false, Thread.currentThread().getContextClassLoader()); - } catch (ClassNotFoundException e) { - throw new RuntimeException(e); - } - } } diff --git a/extensions/jaxb/deployment/src/test/java/io/quarkus/jaxb/deployment/ConflictingModelClassesMarshalerOnlyTest.java b/extensions/jaxb/deployment/src/test/java/io/quarkus/jaxb/deployment/ConflictingModelClassesMarshalerOnlyTest.java new file mode 100644 index 0000000000000..14badfff6285f --- /dev/null +++ b/extensions/jaxb/deployment/src/test/java/io/quarkus/jaxb/deployment/ConflictingModelClassesMarshalerOnlyTest.java @@ -0,0 +1,54 @@ +package io.quarkus.jaxb.deployment; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import jakarta.enterprise.context.control.ActivateRequestContext; +import jakarta.enterprise.inject.spi.DeploymentException; +import jakarta.inject.Inject; +import jakarta.xml.bind.JAXBContext; +import jakarta.xml.bind.Marshaller; + +import org.assertj.core.api.Assertions; +import org.glassfish.jaxb.core.v2.runtime.IllegalAnnotationException; +import org.glassfish.jaxb.runtime.v2.runtime.IllegalAnnotationsException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +/** + * Make sure that the validation of the default JAXB context fails if there conflicting model classes and there is only + * a {@link Marshaller} injection point (which actually requires a {@link JAXBContext} bean to be available too). + */ +public class ConflictingModelClassesMarshalerOnlyTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses( + io.quarkus.jaxb.deployment.one.Model.class, + io.quarkus.jaxb.deployment.two.Model.class)) + .assertException(e -> { + assertThat(e).isInstanceOf(DeploymentException.class); + assertThat(e.getMessage()).isEqualTo("Failed to create or validate the default JAXBContext"); + Throwable cause = e.getCause(); + assertThat(cause).isInstanceOf(IllegalAnnotationsException.class); + assertThat(cause.getMessage()).isEqualTo("1 counts of IllegalAnnotationExceptions"); + List errors = ((IllegalAnnotationsException) cause).getErrors(); + assertThat(errors.size()).isEqualTo(1); + assertThat(errors.get(0).getMessage()).contains("Two classes have the same XML type name \"model\""); + + }); + + @Inject + Marshaller marshaller; + + @Test + @ActivateRequestContext + public void shouldFail() { + Assertions.fail("The application should fail at boot"); + } + +} diff --git a/extensions/jaxb/deployment/src/test/java/io/quarkus/jaxb/deployment/ConflictingModelClassesTest.java b/extensions/jaxb/deployment/src/test/java/io/quarkus/jaxb/deployment/ConflictingModelClassesTest.java new file mode 100644 index 0000000000000..bcded32283401 --- /dev/null +++ b/extensions/jaxb/deployment/src/test/java/io/quarkus/jaxb/deployment/ConflictingModelClassesTest.java @@ -0,0 +1,53 @@ +package io.quarkus.jaxb.deployment; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import jakarta.enterprise.context.control.ActivateRequestContext; +import jakarta.enterprise.inject.spi.DeploymentException; +import jakarta.inject.Inject; +import jakarta.xml.bind.JAXBContext; + +import org.assertj.core.api.Assertions; +import org.glassfish.jaxb.core.v2.runtime.IllegalAnnotationException; +import org.glassfish.jaxb.runtime.v2.runtime.IllegalAnnotationsException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +/** + * Make sure that the validation of the default JAXB context fails if there conflicting model classes and there actually + * is a {@link JAXBContext} injection point. + */ +public class ConflictingModelClassesTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses( + io.quarkus.jaxb.deployment.one.Model.class, + io.quarkus.jaxb.deployment.two.Model.class)) + .assertException(e -> { + assertThat(e).isInstanceOf(DeploymentException.class); + assertThat(e.getMessage()).isEqualTo("Failed to create or validate the default JAXBContext"); + Throwable cause = e.getCause(); + assertThat(cause).isInstanceOf(IllegalAnnotationsException.class); + assertThat(cause.getMessage()).isEqualTo("1 counts of IllegalAnnotationExceptions"); + List errors = ((IllegalAnnotationsException) cause).getErrors(); + assertThat(errors.size()).isEqualTo(1); + assertThat(errors.get(0).getMessage()).contains("Two classes have the same XML type name \"model\""); + + }); + + @Inject + JAXBContext jaxbContext; + + @Test + @ActivateRequestContext + public void shouldFail() { + Assertions.fail("The application should fail at boot"); + } + +} diff --git a/extensions/jaxb/deployment/src/test/java/io/quarkus/jaxb/deployment/InjectJaxbContextTest.java b/extensions/jaxb/deployment/src/test/java/io/quarkus/jaxb/deployment/InjectJaxbContextTest.java index 44041e1533fef..be1f1e5b39b83 100644 --- a/extensions/jaxb/deployment/src/test/java/io/quarkus/jaxb/deployment/InjectJaxbContextTest.java +++ b/extensions/jaxb/deployment/src/test/java/io/quarkus/jaxb/deployment/InjectJaxbContextTest.java @@ -1,10 +1,10 @@ package io.quarkus.jaxb.deployment; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.assertj.core.api.Assertions.assertThat; import java.io.StringWriter; +import jakarta.enterprise.context.control.ActivateRequestContext; import jakarta.inject.Inject; import jakarta.xml.bind.JAXBContext; import jakarta.xml.bind.JAXBException; @@ -12,12 +12,28 @@ import jakarta.xml.bind.Unmarshaller; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.QuarkusUnitTest; -@QuarkusTest +/** + * Make sure that the default JAXBContext passes the validation thanks to + * {@code quarkus.jaxb.exclude-classes=io.quarkus.jaxb.deployment.two.Model} even though there are conflicting classes + * in the application. + */ public class InjectJaxbContextTest { + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses( + io.quarkus.jaxb.deployment.one.Model.class, + io.quarkus.jaxb.deployment.two.Model.class, + Person.class, + CustomJaxbContextCustomizer.class) + .addPackage("io.quarkus.jaxb.deployment.info")) + .overrideConfigKey("quarkus.jaxb.exclude-classes", "io.quarkus.jaxb.deployment.two.Model"); + @Inject JAXBContext jaxbContext; @@ -28,13 +44,23 @@ public class InjectJaxbContextTest { Unmarshaller unmarshaller; @Test + @ActivateRequestContext public void shouldInjectJaxbBeans() { - assertNotNull(jaxbContext); - assertNotNull(marshaller); - assertNotNull(unmarshaller); + assertThat(jaxbContext).isNotNull(); + assertThat(marshaller).isNotNull(); + assertThat(unmarshaller).isNotNull(); + } + + @Test + @ActivateRequestContext + public void packageInfoLoaded() { + /* make sure the package-info.class is present in the test archive */ + assertThat(io.quarkus.jaxb.deployment.info.Foo.class.getPackage() + .getAnnotation(jakarta.xml.bind.annotation.XmlSchema.class)).isNotNull(); } @Test + @ActivateRequestContext public void shouldPersonBeInTheJaxbContext() throws JAXBException { Person person = new Person(); person.setFirst("first"); @@ -43,11 +69,11 @@ public void shouldPersonBeInTheJaxbContext() throws JAXBException { StringWriter sw = new StringWriter(); marshaller.marshal(person, sw); - assertEquals("\n" + assertThat(sw.toString()).isEqualTo("\n" + "\n" + " first\n" + " last\n" - + "\n", sw.toString()); + + "\n"); } } diff --git a/extensions/jaxb/deployment/src/test/java/io/quarkus/jaxb/deployment/NoJaxbContextBeanTest.java b/extensions/jaxb/deployment/src/test/java/io/quarkus/jaxb/deployment/NoJaxbContextBeanTest.java new file mode 100644 index 0000000000000..a86d158c0c6d7 --- /dev/null +++ b/extensions/jaxb/deployment/src/test/java/io/quarkus/jaxb/deployment/NoJaxbContextBeanTest.java @@ -0,0 +1,34 @@ +package io.quarkus.jaxb.deployment; + +import jakarta.enterprise.context.control.ActivateRequestContext; +import jakarta.xml.bind.JAXBContext; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.InstanceHandle; +import io.quarkus.test.QuarkusUnitTest; + +/** + * Make sure that the default JAXB context is not validated at build time as long as there is no injection point for it. + * Conflicting model classes thus won't make the application fail. + */ +public class NoJaxbContextBeanTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses( + io.quarkus.jaxb.deployment.one.Model.class, + io.quarkus.jaxb.deployment.two.Model.class)); + + @Test + @ActivateRequestContext + public void noJaxbContext() { + InstanceHandle contextHandle = Arc.container().instance(JAXBContext.class); + Assertions.assertFalse(contextHandle.isAvailable()); + } + +} diff --git a/extensions/jaxb/deployment/src/test/java/io/quarkus/jaxb/deployment/UserProvidedJaxbContextTest.java b/extensions/jaxb/deployment/src/test/java/io/quarkus/jaxb/deployment/UserProvidedJaxbContextTest.java new file mode 100644 index 0000000000000..b40507bc70911 --- /dev/null +++ b/extensions/jaxb/deployment/src/test/java/io/quarkus/jaxb/deployment/UserProvidedJaxbContextTest.java @@ -0,0 +1,91 @@ +package io.quarkus.jaxb.deployment; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.StringWriter; + +import jakarta.enterprise.context.control.ActivateRequestContext; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import jakarta.xml.bind.JAXBContext; +import jakarta.xml.bind.JAXBException; +import jakarta.xml.bind.Marshaller; +import jakarta.xml.bind.Unmarshaller; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +/** + * If the user provides his own {@link JAXBContext} bean then the validation of the default context should not happen + * and the presence of conflicting classes, such as {@link io.quarkus.jaxb.deployment.one.Model} and + * {@link io.quarkus.jaxb.deployment.two.Model} should not matter. + */ +public class UserProvidedJaxbContextTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses( + io.quarkus.jaxb.deployment.one.Model.class, + io.quarkus.jaxb.deployment.two.Model.class, + UserProvidedJaxbContextTest.JaxbContextProducer.class)); + + @Inject + JAXBContext jaxbContext; + + @Inject + Marshaller marshaller; + + @Inject + Unmarshaller unmarshaller; + + @Test + @ActivateRequestContext + public void shouldInjectJaxbBeans() { + assertThat(jaxbContext).isNotNull(); + assertThat(marshaller).isNotNull(); + assertThat(unmarshaller).isNotNull(); + } + + @Test + @ActivateRequestContext + public void marshalModelOne() throws JAXBException { + io.quarkus.jaxb.deployment.one.Model model = new io.quarkus.jaxb.deployment.one.Model(); + model.setName1("name1"); + + StringWriter sw = new StringWriter(); + marshaller.marshal(model, sw); + + assertThat(sw.toString()).isEqualTo("" + + "name1"); + } + + @Test + @ActivateRequestContext + public void marshalModelTwo() throws JAXBException { + io.quarkus.jaxb.deployment.two.Model model = new io.quarkus.jaxb.deployment.two.Model(); + model.setName2("name2"); + Assertions.assertThatExceptionOfType(JAXBException.class) + .isThrownBy(() -> marshaller.marshal(model, new StringWriter())) + .withMessage("class io.quarkus.jaxb.deployment.two.Model nor any of its super class is known to this context."); + } + + public static class JaxbContextProducer { + + @Produces + @Singleton + JAXBContext produceJaxbContext() { + try { + return JAXBContext.newInstance(new Class[] { io.quarkus.jaxb.deployment.one.Model.class }); + } catch (JAXBException e) { + throw new RuntimeException("Could not create new JAXBContext", e); + } + } + + } + +} diff --git a/extensions/jaxb/deployment/src/test/java/io/quarkus/jaxb/deployment/info/Foo.java b/extensions/jaxb/deployment/src/test/java/io/quarkus/jaxb/deployment/info/Foo.java new file mode 100644 index 0000000000000..3e9a2b5dd5078 --- /dev/null +++ b/extensions/jaxb/deployment/src/test/java/io/quarkus/jaxb/deployment/info/Foo.java @@ -0,0 +1,4 @@ +package io.quarkus.jaxb.deployment.info; + +public class Foo { +} diff --git a/extensions/jaxb/deployment/src/test/resources/application.properties b/extensions/jaxb/deployment/src/test/resources/application.properties deleted file mode 100644 index d5f361123c8c7..0000000000000 --- a/extensions/jaxb/deployment/src/test/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -quarkus.jaxb.exclude-classes=io.quarkus.jaxb.deployment.two.Model \ No newline at end of file