diff --git a/bom/application/pom.xml b/bom/application/pom.xml index ce7594cee018f..c6d7b2ff64e92 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -1813,6 +1813,16 @@ quarkus-spring-web-resteasy-classic-deployment ${project.version} + + io.quarkus + quarkus-spring-web-resteasy-reactive + ${project.version} + + + io.quarkus + quarkus-spring-web-resteasy-reactive-deployment + ${project.version} + io.quarkus quarkus-spring-data-jpa diff --git a/devtools/bom-descriptor-json/pom.xml b/devtools/bom-descriptor-json/pom.xml index 7b48d90c97e05..539b46999d48e 100644 --- a/devtools/bom-descriptor-json/pom.xml +++ b/devtools/bom-descriptor-json/pom.xml @@ -2684,6 +2684,19 @@ + + io.quarkus + quarkus-spring-web-resteasy-reactive + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-swagger-ui diff --git a/docs/pom.xml b/docs/pom.xml index f591ade57a3f8..696f1b21877e9 100644 --- a/docs/pom.xml +++ b/docs/pom.xml @@ -2645,6 +2645,19 @@ + + io.quarkus + quarkus-spring-web-resteasy-reactive-deployment + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-swagger-ui-deployment diff --git a/extensions/spring-web/core/runtime/src/main/java/io/quarkus/spring/web/runtime/ResponseEntityConverter.java b/extensions/spring-web/core/common-runtime/src/main/java/io.quarkus.spring.web.runtime.common/ResponseEntityConverter.java similarity index 90% rename from extensions/spring-web/core/runtime/src/main/java/io/quarkus/spring/web/runtime/ResponseEntityConverter.java rename to extensions/spring-web/core/common-runtime/src/main/java/io.quarkus.spring.web.runtime.common/ResponseEntityConverter.java index 5095128303196..4980298b480f1 100644 --- a/extensions/spring-web/core/runtime/src/main/java/io/quarkus/spring/web/runtime/ResponseEntityConverter.java +++ b/extensions/spring-web/core/common-runtime/src/main/java/io.quarkus.spring.web.runtime.common/ResponseEntityConverter.java @@ -1,4 +1,4 @@ -package io.quarkus.spring.web.runtime; +package io.quarkus.spring.web.runtime.common; import java.util.Collections; import java.util.HashMap; @@ -21,7 +21,7 @@ public static Response toResponse(ResponseEntity responseEntity, MediaType defau Response.ResponseBuilder responseBuilder = Response.status(responseEntity.getStatusCodeValue()) .entity(responseEntity.getBody()); var jaxRsHeaders = toJaxRsHeaders(responseEntity.getHeaders()); - if (!jaxRsHeaders.containsKey(HttpHeaders.CONTENT_TYPE)) { + if (!jaxRsHeaders.containsKey(HttpHeaders.CONTENT_TYPE) && (defaultMediaType != null)) { jaxRsHeaders.put(HttpHeaders.CONTENT_TYPE, Collections.singletonList(defaultMediaType.toString())); } for (var entry : jaxRsHeaders.entrySet()) { diff --git a/extensions/spring-web/core/deployment/pom.xml b/extensions/spring-web/core/deployment/pom.xml index 774c9a39dba77..b1f3e0bded41f 100644 --- a/extensions/spring-web/core/deployment/pom.xml +++ b/extensions/spring-web/core/deployment/pom.xml @@ -27,10 +27,19 @@ quarkus-spring-web-resteasy-classic-deployment true + + io.quarkus + quarkus-spring-web-resteasy-reactive-deployment + true + io.quarkus quarkus-jaxrs-spi-deployment + + io.quarkus + quarkus-resteasy-reactive-spi-deployment + io.quarkus quarkus-resteasy-common-spi diff --git a/extensions/spring-web/core/deployment/src/main/java/io/quarkus/spring/web/deployment/AbstractExceptionMapperGenerator.java b/extensions/spring-web/core/deployment/src/main/java/io/quarkus/spring/web/deployment/AbstractExceptionMapperGenerator.java index 8c50809a7612a..3333e7501c34f 100644 --- a/extensions/spring-web/core/deployment/src/main/java/io/quarkus/spring/web/deployment/AbstractExceptionMapperGenerator.java +++ b/extensions/spring-web/core/deployment/src/main/java/io/quarkus/spring/web/deployment/AbstractExceptionMapperGenerator.java @@ -26,9 +26,12 @@ abstract class AbstractExceptionMapperGenerator { protected final DotName exceptionDotName; protected final ClassOutput classOutput; - AbstractExceptionMapperGenerator(DotName exceptionDotName, ClassOutput classOutput) { + private final boolean isResteasyClassic; + + AbstractExceptionMapperGenerator(DotName exceptionDotName, ClassOutput classOutput, boolean isResteasyClassic) { this.exceptionDotName = exceptionDotName; this.classOutput = classOutput; + this.isResteasyClassic = isResteasyClassic; } abstract void generateMethodBody(MethodCreator toResponse); @@ -36,8 +39,6 @@ abstract class AbstractExceptionMapperGenerator { String generate() { String generatedClassName = "io.quarkus.spring.web.mappers." + exceptionDotName.withoutPackagePrefix() + "_Mapper_" + HashUtil.sha1(exceptionDotName.toString()); - String generatedSubtypeClassName = "io.quarkus.spring.web.mappers.Subtype" + exceptionDotName.withoutPackagePrefix() - + "Mapper_" + HashUtil.sha1(exceptionDotName.toString()); String exceptionClassName = exceptionDotName.toString(); try (ClassCreator cc = ClassCreator.builder() @@ -64,15 +65,20 @@ String generate() { } } - // additionally generate a dummy subtype to get past the RESTEasy's ExceptionMapper check for synthetic classes - try (ClassCreator cc = ClassCreator.builder() - .classOutput(classOutput).className(generatedSubtypeClassName) - .superClass(generatedClassName) - .build()) { - cc.addAnnotation(Provider.class); - } + if (isResteasyClassic) { + String generatedSubtypeClassName = "io.quarkus.spring.web.mappers.Subtype" + exceptionDotName.withoutPackagePrefix() + + "Mapper_" + HashUtil.sha1(exceptionDotName.toString()); + // additionally generate a dummy subtype to get past the RESTEasy's ExceptionMapper check for synthetic classes + try (ClassCreator cc = ClassCreator.builder() + .classOutput(classOutput).className(generatedSubtypeClassName) + .superClass(generatedClassName) + .build()) { + cc.addAnnotation(Provider.class); + } - return generatedSubtypeClassName; + return generatedSubtypeClassName; + } + return generatedClassName; } protected void preGenerateMethodBody(ClassCreator cc) { @@ -93,6 +99,7 @@ protected int getHttpStatusFromAnnotation(AnnotationInstance responseStatusInsta return 500; // the default value of @ResponseStatus } + @SuppressWarnings({ "rawtypes", "unchecked" }) private int enumValueToHttpStatus(String enumValue) { try { Class httpStatusClass = Class.forName("org.springframework.http.HttpStatus"); diff --git a/extensions/spring-web/core/deployment/src/main/java/io/quarkus/spring/web/deployment/ControllerAdviceAbstractExceptionMapperGenerator.java b/extensions/spring-web/core/deployment/src/main/java/io/quarkus/spring/web/deployment/ControllerAdviceExceptionMapperGenerator.java similarity index 73% rename from extensions/spring-web/core/deployment/src/main/java/io/quarkus/spring/web/deployment/ControllerAdviceAbstractExceptionMapperGenerator.java rename to extensions/spring-web/core/deployment/src/main/java/io/quarkus/spring/web/deployment/ControllerAdviceExceptionMapperGenerator.java index f3c0a5ce9a113..57c525e7f8ebb 100644 --- a/extensions/spring-web/core/deployment/src/main/java/io/quarkus/spring/web/deployment/ControllerAdviceAbstractExceptionMapperGenerator.java +++ b/extensions/spring-web/core/deployment/src/main/java/io/quarkus/spring/web/deployment/ControllerAdviceExceptionMapperGenerator.java @@ -12,7 +12,9 @@ import javax.ws.rs.core.Context; import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Request; import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.DotName; @@ -29,9 +31,9 @@ import io.quarkus.gizmo.MethodCreator; import io.quarkus.gizmo.MethodDescriptor; import io.quarkus.gizmo.ResultHandle; -import io.quarkus.spring.web.runtime.ResponseEntityConverter; +import io.quarkus.spring.web.runtime.common.ResponseEntityConverter; -class ControllerAdviceAbstractExceptionMapperGenerator extends AbstractExceptionMapperGenerator { +class ControllerAdviceExceptionMapperGenerator extends AbstractExceptionMapperGenerator { private static final DotName RESPONSE_ENTITY = DotName.createSimple("org.springframework.http.ResponseEntity"); @@ -54,14 +56,9 @@ class ControllerAdviceAbstractExceptionMapperGenerator extends AbstractException private final boolean isResteasyClassic; - ControllerAdviceAbstractExceptionMapperGenerator(MethodInfo controllerAdviceMethod, DotName exceptionDotName, + ControllerAdviceExceptionMapperGenerator(MethodInfo controllerAdviceMethod, DotName exceptionDotName, ClassOutput classOutput, TypesUtil typesUtil, boolean isResteasyClassic) { - super(exceptionDotName, classOutput); - - // TODO: remove this restriction - if (!isResteasyClassic) { - throw new IllegalStateException("Currently Spring Web can only work with RESTEasy Classic"); - } + super(exceptionDotName, classOutput, isResteasyClassic); this.controllerAdviceMethod = controllerAdviceMethod; this.typesUtil = typesUtil; @@ -80,6 +77,10 @@ class ControllerAdviceAbstractExceptionMapperGenerator extends AbstractException */ @Override protected void preGenerateMethodBody(ClassCreator cc) { + if (!isResteasyClassic) { + return; + } + int notAllowedParameterIndex = -1; for (int i = 0; i < parameterTypes.size(); i++) { Type parameterType = parameterTypes.get(i); @@ -197,14 +198,23 @@ private ResultHandle getResponseContentType(MethodCreator methodCreator, List generatedExceptionMappers, BuildProducer providersProducer, + BuildProducer exceptionMapperProducer, BuildProducer reflectiveClassProducer, + BuildProducer unremovableBeanProducer, Capabilities capabilities) { boolean isResteasyClassicAvailable = capabilities.isPresent(Capability.RESTEASY_JSON_JACKSON); @@ -120,15 +127,18 @@ public void generateExceptionMapperProviders(BeanArchiveIndexBuildItem beanArchi // Look for all exception classes that are annotated with @ResponseStatus - IndexView index = beanArchiveIndexBuildItem.getIndex(); + IndexView indexView = index.getIndex(); ClassOutput classOutput = new GeneratedClassGizmoAdaptor(generatedExceptionMappers, true); - generateMappersForResponseStatusOnException(providersProducer, index, classOutput, typesUtil, + generateMappersForResponseStatusOnException(providersProducer, exceptionMapperProducer, indexView, classOutput, + typesUtil, isResteasyClassicAvailable); - generateMappersForExceptionHandlerInControllerAdvice(providersProducer, reflectiveClassProducer, index, classOutput, + generateMappersForExceptionHandlerInControllerAdvice(providersProducer, exceptionMapperProducer, + reflectiveClassProducer, unremovableBeanProducer, indexView, classOutput, typesUtil, isResteasyClassicAvailable); } private void generateMappersForResponseStatusOnException(BuildProducer providersProducer, + BuildProducer exceptionMapperProducer, IndexView index, ClassOutput classOutput, TypesUtil typesUtil, boolean isResteasyClassic) { Collection responseStatusInstances = index .getAnnotations(RESPONSE_STATUS); @@ -141,19 +151,24 @@ private void generateMappersForResponseStatusOnException(BuildProducer providersProducer, - BuildProducer reflectiveClassProducer, IndexView index, ClassOutput classOutput, + BuildProducer exceptionMapperProducer, + BuildProducer reflectiveClassProducer, + BuildProducer unremovableBeanProducer, IndexView index, ClassOutput classOutput, TypesUtil typesUtil, boolean isResteasyClassic) { AnnotationInstance controllerAdviceInstance = getSingleControllerAdviceInstance(index); @@ -187,12 +202,21 @@ private void generateMappersForExceptionHandlerInControllerAdvice( // we need to generate one JAX-RS ExceptionMapper per Exception type Type[] handledExceptionTypes = exceptionHandlerInstance.value().asClassArray(); for (Type handledExceptionType : handledExceptionTypes) { - String name = new ControllerAdviceAbstractExceptionMapperGenerator(method, handledExceptionType.name(), + String name = new ControllerAdviceExceptionMapperGenerator(method, handledExceptionType.name(), classOutput, typesUtil, isResteasyClassic).generate(); providersProducer.produce(new ResteasyJaxrsProviderBuildItem(name)); + exceptionMapperProducer.produce( + new ExceptionMapperBuildItem(name, handledExceptionType.name().toString(), Priorities.USER, false)); } } + + // allow access to HttpHeaders from Arc.container() + if (!isResteasyClassic) { + unremovableBeanProducer.produce( + UnremovableBeanBuildItem.beanClassNames("org.jboss.resteasy.reactive.server.injection.ContextProducers", + HttpHeaders.class.getName())); + } } private AnnotationInstance getSingleControllerAdviceInstance(IndexView index) { diff --git a/extensions/spring-web/core/runtime/pom.xml b/extensions/spring-web/core/runtime/pom.xml index 6ea9dd5873d21..5a91754456c1e 100644 --- a/extensions/spring-web/core/runtime/pom.xml +++ b/extensions/spring-web/core/runtime/pom.xml @@ -19,6 +19,11 @@ quarkus-spring-web-resteasy-classic true + + io.quarkus + quarkus-spring-web-resteasy-reactive + true + io.quarkus quarkus-spring-di diff --git a/extensions/spring-web/pom.xml b/extensions/spring-web/pom.xml index 4d142216408aa..d9a8941f3e503 100644 --- a/extensions/spring-web/pom.xml +++ b/extensions/spring-web/pom.xml @@ -16,5 +16,6 @@ core resteasy-classic + resteasy-reactive diff --git a/extensions/spring-web/resteasy-reactive/deployment/pom.xml b/extensions/spring-web/resteasy-reactive/deployment/pom.xml new file mode 100644 index 0000000000000..a523fccb6e58a --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/deployment/pom.xml @@ -0,0 +1,59 @@ + + + + quarkus-spring-web-resteasy-reactive-parent + io.quarkus + 999-SNAPSHOT + + + 4.0.0 + + quarkus-spring-web-resteasy-reactive-deployment + Quarkus - Spring Web - RESTEasy Reactive - Deployment + + + + io.quarkus + quarkus-resteasy-reactive-deployment + + + io.quarkus + quarkus-resteasy-server-common-spi + + + + io.quarkus + quarkus-spring-web-resteasy-reactive + + + + io.quarkus + quarkus-junit5-internal + test + + + io.rest-assured + rest-assured + test + + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + diff --git a/extensions/spring-web/resteasy-reactive/deployment/src/main/java/io/quarkus/spring/web/deployment/SpringWebResteasyReactiveProcessor.java b/extensions/spring-web/resteasy-reactive/deployment/src/main/java/io/quarkus/spring/web/deployment/SpringWebResteasyReactiveProcessor.java new file mode 100644 index 0000000000000..4a9b50fbedc87 --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/deployment/src/main/java/io/quarkus/spring/web/deployment/SpringWebResteasyReactiveProcessor.java @@ -0,0 +1,453 @@ +package io.quarkus.spring.web.deployment; + +import static org.jboss.jandex.AnnotationInstance.create; +import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.DEFAULT_VALUE; +import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.REST_COOKIE_PARAM; +import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.REST_MATRIX_PARAM; +import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.REST_PATH_PARAM; +import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.REST_QUERY_PARAM; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.ws.rs.Priorities; +import javax.ws.rs.core.Response; + +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTarget; +import org.jboss.jandex.AnnotationValue; +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.DotName; +import org.jboss.jandex.IndexView; +import org.jboss.jandex.MethodInfo; +import org.jboss.jandex.Type; +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames; +import org.jboss.resteasy.reactive.common.processor.scanning.ResteasyReactiveScanner; +import org.jboss.resteasy.reactive.common.processor.transformation.AnnotationsTransformer; +import org.jboss.resteasy.reactive.common.processor.transformation.Transformation; +import org.jboss.resteasy.reactive.server.model.FixedHandlerChainCustomizer; +import org.jboss.resteasy.reactive.server.model.HandlerChainCustomizer; +import org.jboss.resteasy.reactive.server.processor.scanning.MethodScanner; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.builditem.CombinedIndexBuildItem; +import io.quarkus.resteasy.reactive.server.spi.AnnotationsTransformerBuildItem; +import io.quarkus.resteasy.reactive.server.spi.MethodScannerBuildItem; +import io.quarkus.resteasy.reactive.spi.AdditionalResourceClassBuildItem; +import io.quarkus.resteasy.reactive.spi.ExceptionMapperBuildItem; +import io.quarkus.resteasy.server.common.spi.AdditionalJaxRsResourceMethodParamAnnotations; +import io.quarkus.spring.web.runtime.ResponseEntityHandler; +import io.quarkus.spring.web.runtime.ResponseStatusHandler; +import io.quarkus.spring.web.runtime.common.ResponseStatusExceptionMapper; + +public class SpringWebResteasyReactiveProcessor { + + private static final Logger LOGGER = Logger.getLogger(SpringWebResteasyReactiveProcessor.class.getName()); + + private static final DotName REST_CONTROLLER_ANNOTATION = DotName + .createSimple("org.springframework.web.bind.annotation.RestController"); + + private static final DotName REQUEST_MAPPING = DotName + .createSimple("org.springframework.web.bind.annotation.RequestMapping"); + private static final DotName GET_MAPPING = DotName.createSimple("org.springframework.web.bind.annotation.GetMapping"); + private static final DotName POST_MAPPING = DotName.createSimple("org.springframework.web.bind.annotation.PostMapping"); + private static final DotName PUT_MAPPING = DotName.createSimple("org.springframework.web.bind.annotation.PutMapping"); + private static final DotName DELETE_MAPPING = DotName.createSimple("org.springframework.web.bind.annotation.DeleteMapping"); + private static final DotName PATCH_MAPPING = DotName.createSimple("org.springframework.web.bind.annotation.PatchMapping"); + private static final List MAPPING_ANNOTATIONS = List.of(REQUEST_MAPPING, GET_MAPPING, POST_MAPPING, + PUT_MAPPING, DELETE_MAPPING, PATCH_MAPPING); + + private static final DotName PATH_VARIABLE = DotName.createSimple("org.springframework.web.bind.annotation.PathVariable"); + private static final DotName REQUEST_PARAM = DotName.createSimple("org.springframework.web.bind.annotation.RequestParam"); + private static final DotName REQUEST_HEADER = DotName.createSimple("org.springframework.web.bind.annotation.RequestHeader"); + private static final DotName COOKIE_VALUE = DotName.createSimple("org.springframework.web.bind.annotation.CookieValue"); + private static final DotName MATRIX_VARIABLE = DotName + .createSimple("org.springframework.web.bind.annotation.MatrixVariable"); + + private static final DotName RESPONSE_STATUS = DotName + .createSimple("org.springframework.web.bind.annotation.ResponseStatus"); + private static final DotName EXCEPTION_HANDLER = DotName + .createSimple("org.springframework.web.bind.annotation.ExceptionHandler"); + + private static final DotName REST_CONTROLLER_ADVICE = DotName + .createSimple("org.springframework.web.bind.annotation.RestControllerAdvice"); + + private static final DotName MODEL_AND_VIEW = DotName.createSimple("org.springframework.web.servlet.ModelAndView"); + private static final DotName VIEW = DotName.createSimple("org.springframework.web.servlet.View"); + private static final DotName MODEL = DotName.createSimple("org.springframework.ui.Model"); + + private static final DotName HTTP_ENTITY = DotName.createSimple("org.springframework.http.HttpEntity"); + private static final DotName RESPONSE_ENTITY = DotName.createSimple("org.springframework.http.ResponseEntity"); + + private static final Set DISALLOWED_EXCEPTION_CONTROLLER_RETURN_TYPES = Set.of( + MODEL_AND_VIEW, VIEW, MODEL, HTTP_ENTITY); + + private static final String DEFAULT_NONE = "\n\t\t\n\t\t\n\uE000\uE001\uE002\n\t\t\t\t\n"; // from ValueConstants + + @BuildStep + public AdditionalJaxRsResourceMethodParamAnnotations additionalJaxRsResourceMethodParamAnnotations() { + return new AdditionalJaxRsResourceMethodParamAnnotations( + Arrays.asList(DotName.createSimple("org.springframework.web.bind.annotation.RequestParam"), + PATH_VARIABLE, + DotName.createSimple("org.springframework.web.bind.annotation.RequestBody"), + DotName.createSimple("org.springframework.web.bind.annotation.MatrixVariable"), + DotName.createSimple("org.springframework.web.bind.annotation.RequestHeader"), + DotName.createSimple("org.springframework.web.bind.annotation.CookieValue"))); + } + + @BuildStep + public void registerStandardExceptionMappers(BuildProducer producer) { + producer.produce(new ExceptionMapperBuildItem(ResponseStatusExceptionMapper.class.getName(), + ResponseStatusException.class.getName(), Priorities.USER, false)); + } + + @BuildStep + public void registerAdditionalResourceClasses(CombinedIndexBuildItem index, + BuildProducer additionalResourceClassProducer) { + + validateControllers(index.getIndex()); + + for (AnnotationInstance restController : index.getIndex() + .getAnnotations(REST_CONTROLLER_ANNOTATION)) { + ClassInfo targetClass = restController.target().asClass(); + additionalResourceClassProducer.produce(new AdditionalResourceClassBuildItem(targetClass, + getSinglePathOfInstance(targetClass.classAnnotation(REQUEST_MAPPING), ""))); + } + } + + /** + * Make sure the controllers have the proper annotation and warn if not + */ + private void validateControllers(IndexView index) { + Set classesWithoutRestController = new HashSet<>(); + for (DotName mappingAnnotation : MAPPING_ANNOTATIONS) { + Collection annotations = index.getAnnotations(mappingAnnotation); + for (AnnotationInstance annotation : annotations) { + ClassInfo targetClass; + if (annotation.target().kind() == AnnotationTarget.Kind.CLASS) { + targetClass = annotation.target().asClass(); + } else if (annotation.target().kind() == AnnotationTarget.Kind.METHOD) { + targetClass = annotation.target().asMethod().declaringClass(); + } else { + continue; + } + + if (targetClass.classAnnotation(REST_CONTROLLER_ANNOTATION) == null) { + classesWithoutRestController.add(targetClass.name()); + } + } + } + + if (!classesWithoutRestController.isEmpty()) { + for (DotName dotName : classesWithoutRestController) { + LOGGER.warn("Class '" + dotName + + "' uses a mapping annotation but the class itself was not annotated with '@RestController'. The mappings will therefore be ignored."); + } + } + } + + // meant to be called with instances of MAPPING_ANNOTATIONS + private static String getSinglePathOfInstance(AnnotationInstance instance, String defaultPathValue) { + String[] paths = getPathsOfInstance(instance); + if ((paths != null) && (paths.length > 0)) { + return paths[0]; + } + return defaultPathValue; + } + + // meant to be called with instances of MAPPING_ANNOTATIONS + private static String[] getPathsOfInstance(AnnotationInstance instance) { + if (instance == null) { + return null; + } + AnnotationValue pathValue = instance.value("path"); + if (pathValue != null) { + return pathValue.asStringArray(); + } + AnnotationValue value = instance.value(); + if (value != null) { + return value.asStringArray(); + } + return null; + } + + // meant to be called with instances of MAPPING_ANNOTATIONS and a property name that contains a String array value + private static String[] getStringArrayValueOfInstance(AnnotationInstance instance, String property) { + if (instance == null) { + return null; + } + AnnotationValue pathValue = instance.value(property); + if (pathValue != null) { + return pathValue.asStringArray(); + } + return null; + } + + @BuildStep + public void methodAnnotationsTransformer(BuildProducer producer) { + + producer.produce(new AnnotationsTransformerBuildItem(new AnnotationsTransformer() { + + @Override + public boolean appliesTo(AnnotationTarget.Kind kind) { + return kind == AnnotationTarget.Kind.METHOD; + } + + @Override + public void transform(TransformationContext transformationContext) { + AnnotationTarget target = transformationContext.getTarget(); + if (target.kind() != AnnotationTarget.Kind.METHOD) { + return; + } + MethodInfo methodInfo = target.asMethod(); + Transformation transform = transformationContext.transform(); + DotName jaxRSMethodAnnotation = null; + String path = null; + String[] produces = null; + String[] consumes = null; + + AnnotationInstance mappingAnnotationInstance = methodInfo.annotation(REQUEST_MAPPING); + if (mappingAnnotationInstance != null) { + AnnotationValue methodValue = mappingAnnotationInstance.value("method"); + if (methodValue == null) { + jaxRSMethodAnnotation = ResteasyReactiveDotNames.GET; + } else { + String[] methods = methodValue.asEnumArray(); + if (methods.length > 1) { + throw new IllegalArgumentException( + "Usage of multiple methods using '@RequestMapping' is not allowed. Offending method is '" + + methodInfo.declaringClass().name() + "#" + methodInfo.name() + "'"); + } + DotName methodDotName = ResteasyReactiveScanner.METHOD_TO_BUILTIN_HTTP_ANNOTATIONS.get(methods[0]); + if (methodDotName == null) { + throw new IllegalArgumentException( + "Unsupported HTTP method '" + methods[0] + "' for @RequestMapping. Offending method is '" + + methodInfo.declaringClass().name() + "#" + methodInfo.name() + "'"); + } + jaxRSMethodAnnotation = methodDotName; + } + } else { + if (methodInfo.hasAnnotation(GET_MAPPING)) { + jaxRSMethodAnnotation = ResteasyReactiveDotNames.GET; + mappingAnnotationInstance = methodInfo.annotation(GET_MAPPING); + } else if (methodInfo.hasAnnotation(POST_MAPPING)) { + jaxRSMethodAnnotation = ResteasyReactiveDotNames.POST; + mappingAnnotationInstance = methodInfo.annotation(POST_MAPPING); + } else if (methodInfo.hasAnnotation(PUT_MAPPING)) { + jaxRSMethodAnnotation = ResteasyReactiveDotNames.PUT; + mappingAnnotationInstance = methodInfo.annotation(PUT_MAPPING); + } else if (methodInfo.hasAnnotation(DELETE_MAPPING)) { + jaxRSMethodAnnotation = ResteasyReactiveDotNames.DELETE; + mappingAnnotationInstance = methodInfo.annotation(DELETE_MAPPING); + } else if (methodInfo.hasAnnotation(PATCH_MAPPING)) { + jaxRSMethodAnnotation = ResteasyReactiveDotNames.PATCH; + mappingAnnotationInstance = methodInfo.annotation(PATCH_MAPPING); + } + } + + if (jaxRSMethodAnnotation == null) { + return; + } + + produces = getStringArrayValueOfInstance(mappingAnnotationInstance, "produces"); + consumes = getStringArrayValueOfInstance(mappingAnnotationInstance, "consumes"); + path = getSinglePathOfInstance(mappingAnnotationInstance, null); + + transform.add(jaxRSMethodAnnotation); + addStringArrayValuedAnnotation(transform, target, consumes, ResteasyReactiveDotNames.CONSUMES); + addStringArrayValuedAnnotation(transform, target, produces, ResteasyReactiveDotNames.PRODUCES); + addPathAnnotation(transform, target, path); + + for (AnnotationInstance annotation : methodInfo.annotations()) { + if (annotation.target().kind() == AnnotationTarget.Kind.METHOD_PARAMETER) { + DotName annotationName = annotation.name(); + //TODO: add Cookie and Matrix handling + if (annotationName.equals(REQUEST_PARAM) + || annotationName.equals(REQUEST_HEADER) + || annotationName.equals(COOKIE_VALUE) + || annotationName.equals(MATRIX_VARIABLE)) { + + DotName jaxRsAnnotation; + if (annotationName.equals(REQUEST_PARAM)) { + jaxRsAnnotation = REST_QUERY_PARAM; + } else if (annotationName.equals(REQUEST_HEADER)) { + jaxRsAnnotation = REST_QUERY_PARAM; + } else if (annotationName.equals(COOKIE_VALUE)) { + jaxRsAnnotation = REST_COOKIE_PARAM; + } else { + jaxRsAnnotation = REST_MATRIX_PARAM; + } + + String name = getNameOrDefaultFromParamAnnotation(annotation); + List annotationValues; + if (name == null) { + annotationValues = Collections.emptyList(); + + } else { + annotationValues = Collections.singletonList(AnnotationValue.createStringValue("value", name)); + } + transform.add(create(jaxRsAnnotation, annotation.target(), annotationValues)); + + boolean required = true; // the default value + String defaultValueStr = DEFAULT_NONE; // default value of @RequestMapping#defaultValue + AnnotationValue defaultValue = annotation.value("defaultValue"); + if (defaultValue != null) { + defaultValueStr = defaultValue.asString(); + required = false; // implicitly set according to the javadoc of @RequestMapping#defaultValue + } else { + AnnotationValue requiredValue = annotation.value("required"); + if (requiredValue != null) { + required = requiredValue.asBoolean(); + } + } + if (!required) { + transform.add(create(DEFAULT_VALUE, annotation.target(), + Collections + .singletonList(AnnotationValue.createStringValue("value", defaultValueStr)))); + } + } else if (annotationName.equals(PATH_VARIABLE)) { + String name = getNameOrDefaultFromParamAnnotation(annotation); + List annotationValues = Collections.emptyList(); + if (name != null) { + annotationValues = Collections.singletonList(AnnotationValue.createStringValue("value", name)); + } + transform.add(create(REST_PATH_PARAM, annotation.target(), annotationValues)); + } + } + } + + transform.done(); + } + + private String getNameOrDefaultFromParamAnnotation(AnnotationInstance annotation) { + AnnotationValue nameValue = annotation.value("name"); + if (nameValue != null) { + return nameValue.asString(); + } else { + AnnotationValue value = annotation.value(); + if (value != null) { + return value.asString(); + } + } + return null; + } + + private void addStringArrayValuedAnnotation(Transformation transform, AnnotationTarget target, String[] value, + DotName annotationDotName) { + if ((value != null) && value.length > 0) { + AnnotationValue[] values = new AnnotationValue[value.length]; + for (int i = 0; i < values.length; i++) { + values[i] = AnnotationValue.createStringValue("", value[i]); + } + transform.add(AnnotationInstance.create(annotationDotName, target, + new AnnotationValue[] { AnnotationValue.createArrayValue("value", values) })); + } + } + + private void addPathAnnotation(Transformation transform, AnnotationTarget target, String path) { + if (path == null) { + return; + } + transform.add(AnnotationInstance.create(ResteasyReactiveDotNames.PATH, target, + new AnnotationValue[] { AnnotationValue.createStringValue("value", replaceSpringWebWildcards(path)) })); + } + + private String replaceSpringWebWildcards(String methodPath) { + if (methodPath.contains("/**")) { + methodPath = methodPath.replace("/**", "{unsetPlaceHolderVar:.*}"); + } + if (methodPath.contains("/*")) { + methodPath = methodPath.replace("/*", "/{unusedPlaceHolderVar}"); + } + /* + * Spring Web allows the use of '?' to capture a single character. We support this by + * converting each url path using it to a JAX-RS syntax of variable followed by a regex. + * So '/car?/s?o?/info' would become '/{notusedPlaceHolderVar0:car.}/{notusedPlaceHolderVar1:s.o.}/info' + */ + String[] parts = methodPath.split("/"); + if (parts.length > 0) { + StringBuilder sb = new StringBuilder(methodPath.startsWith("/") ? "/" : ""); + for (int i = 0; i < parts.length; i++) { + String part = parts[i]; + if (part.isEmpty()) { + continue; + } + if (!sb.toString().endsWith("/")) { + sb.append("/"); + } + if ((part.startsWith("{") && part.endsWith("}")) || !part.contains("?")) { + sb.append(part); + } else { + sb.append(String.format("{notusedPlaceHolderVar%s:", i)).append(part.replace('?', '.')).append("}"); + } + } + if (methodPath.endsWith("/")) { + sb.append("/"); + } + methodPath = sb.toString(); + } + return methodPath; + } + + })); + } + + @BuildStep + public MethodScannerBuildItem responseEntitySupport() { + return new MethodScannerBuildItem(new MethodScanner() { + @Override + public List scan(MethodInfo method, ClassInfo actualEndpointClass, + Map methodContext) { + DotName returnTypeName = method.returnType().name(); + if (returnTypeName.equals(RESPONSE_ENTITY)) { + return Collections.singletonList(new FixedHandlerChainCustomizer(new ResponseEntityHandler(), + HandlerChainCustomizer.Phase.AFTER_METHOD_INVOKE)); + } + return Collections.emptyList(); + } + }); + } + + //TODO: replace with RESTEasy Reactive @ResponseStatus support using an annotation transformer + @BuildStep + public MethodScannerBuildItem responseStatusSupport() { + return new MethodScannerBuildItem(new MethodScanner() { + @Override + public List scan(MethodInfo method, ClassInfo actualEndpointClass, + Map methodContext) { + AnnotationInstance responseStatus = method.annotation(RESPONSE_STATUS); + if (responseStatus != null) { + int newStatus = Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(); // default value for @ResponseStatus + AnnotationValue codeValue = responseStatus.value("code"); + if (codeValue != null) { + newStatus = HttpStatus.valueOf(codeValue.asEnum()).value(); + } else { + AnnotationValue value = responseStatus.value(); + if (value != null) { + newStatus = HttpStatus.valueOf(value.asEnum()).value(); + } + } + + ResponseStatusHandler handler = new ResponseStatusHandler(); + handler.setNewResponseCode(newStatus); + handler.setDefaultResponseCode( + method.returnType().kind() != Type.Kind.VOID ? Response.Status.OK.getStatusCode() + : Response.Status.NO_CONTENT.getStatusCode()); + return Collections.singletonList( + new FixedHandlerChainCustomizer(handler, HandlerChainCustomizer.Phase.AFTER_RESPONSE_CREATED)); + } + + return Collections.emptyList(); + } + }); + } +} diff --git a/extensions/spring-web/resteasy-reactive/pom.xml b/extensions/spring-web/resteasy-reactive/pom.xml new file mode 100644 index 0000000000000..9802049d4d966 --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/pom.xml @@ -0,0 +1,23 @@ + + + + quarkus-spring-web-parent-aggregator + io.quarkus + 999-SNAPSHOT + ../pom.xml + + 4.0.0 + + quarkus-spring-web-resteasy-reactive-parent + Quarkus - Spring Web - RESTEasy Reactive - Parent + pom + + deployment + runtime + tests + + + + diff --git a/extensions/spring-web/resteasy-reactive/runtime/pom.xml b/extensions/spring-web/resteasy-reactive/runtime/pom.xml new file mode 100644 index 0000000000000..1b6563b637e5f --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/runtime/pom.xml @@ -0,0 +1,48 @@ + + + + quarkus-spring-web-resteasy-reactive-parent + io.quarkus + 999-SNAPSHOT + + + 4.0.0 + + quarkus-spring-web-resteasy-reactive + Quarkus - Spring Web - RESTEasy Reactive - Runtime + + + + io.quarkus + quarkus-resteasy-reactive + + + io.quarkus + quarkus-spring-web-common + + + + + + + io.quarkus + quarkus-bootstrap-maven-plugin + + + process-resources + + extension-descriptor + + + + io.quarkus:quarkus-resteasy-reactive-jackson + + + + + + + + diff --git a/extensions/spring-web/resteasy-reactive/runtime/src/main/java/io/quarkus/spring/web/runtime/ResponseEntityHandler.java b/extensions/spring-web/resteasy-reactive/runtime/src/main/java/io/quarkus/spring/web/runtime/ResponseEntityHandler.java new file mode 100644 index 0000000000000..a365e4f16079d --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/runtime/src/main/java/io/quarkus/spring/web/runtime/ResponseEntityHandler.java @@ -0,0 +1,19 @@ +package io.quarkus.spring.web.runtime; + +import org.jboss.resteasy.reactive.server.core.ResteasyReactiveRequestContext; +import org.jboss.resteasy.reactive.server.spi.ServerRestHandler; +import org.springframework.http.ResponseEntity; + +import io.quarkus.spring.web.runtime.common.ResponseEntityConverter; + +public class ResponseEntityHandler implements ServerRestHandler { + + @Override + + public void handle(ResteasyReactiveRequestContext requestContext) throws Exception { + Object result = requestContext.getResult(); + if (result instanceof ResponseEntity) { + requestContext.setResult(ResponseEntityConverter.toResponse((ResponseEntity) result, null)); + } + } +} diff --git a/extensions/spring-web/resteasy-reactive/runtime/src/main/java/io/quarkus/spring/web/runtime/ResponseStatusHandler.java b/extensions/spring-web/resteasy-reactive/runtime/src/main/java/io/quarkus/spring/web/runtime/ResponseStatusHandler.java new file mode 100644 index 0000000000000..23a9f3736b46a --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/runtime/src/main/java/io/quarkus/spring/web/runtime/ResponseStatusHandler.java @@ -0,0 +1,36 @@ +package io.quarkus.spring.web.runtime; + +import org.jboss.resteasy.reactive.common.jaxrs.ResponseImpl; +import org.jboss.resteasy.reactive.server.core.ResteasyReactiveRequestContext; +import org.jboss.resteasy.reactive.server.spi.ServerRestHandler; + +public class ResponseStatusHandler implements ServerRestHandler { + + // make mutable to allow for bytecode serialization + private int defaultResponseCode; + private int newResponseCode; + + public int getDefaultResponseCode() { + return defaultResponseCode; + } + + public void setDefaultResponseCode(int defaultResponseCode) { + this.defaultResponseCode = defaultResponseCode; + } + + public int getNewResponseCode() { + return newResponseCode; + } + + public void setNewResponseCode(int newResponseCode) { + this.newResponseCode = newResponseCode; + } + + @Override + public void handle(ResteasyReactiveRequestContext requestContext) throws Exception { + ResponseImpl response = (ResponseImpl) requestContext.getResponse().get(); + if (response.getStatus() == defaultResponseCode) { // only set the status if it has not already been set + response.setStatus(newResponseCode); + } + } +} diff --git a/extensions/spring-web/resteasy-reactive/runtime/src/main/java/io/quarkus/spring/web/runtime/ResteasyReactiveResponseContentTypeResolver.java b/extensions/spring-web/resteasy-reactive/runtime/src/main/java/io/quarkus/spring/web/runtime/ResteasyReactiveResponseContentTypeResolver.java new file mode 100644 index 0000000000000..2324cb923e5ae --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/runtime/src/main/java/io/quarkus/spring/web/runtime/ResteasyReactiveResponseContentTypeResolver.java @@ -0,0 +1,21 @@ +package io.quarkus.spring.web.runtime; + +import java.util.List; + +import javax.ws.rs.core.Variant; + +import org.jboss.resteasy.reactive.server.core.request.ServerDrivenNegotiation; + +import io.quarkus.spring.web.runtime.common.AbstractResponseContentTypeResolver; + +@SuppressWarnings("unused") +public class ResteasyReactiveResponseContentTypeResolver extends AbstractResponseContentTypeResolver { + + @Override + protected Variant negotiateBestMatch(List acceptHeaders, List variants) { + ServerDrivenNegotiation negotiation = new ServerDrivenNegotiation(); + negotiation.setAcceptHeaders(acceptHeaders); + + return negotiation.getBestMatch(variants); + } +} diff --git a/extensions/spring-web/resteasy-reactive/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/spring-web/resteasy-reactive/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 0000000000000..2ecdd23b44ad4 --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,5 @@ +--- +artifact: ${project.groupId}:${project.artifactId}:${project.version} +name: "Spring Web RESTEasy Reactive" +metadata: + unlisted: true diff --git a/extensions/spring-web/resteasy-reactive/tests/pom.xml b/extensions/spring-web/resteasy-reactive/tests/pom.xml new file mode 100644 index 0000000000000..1ebdfb1d54304 --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/tests/pom.xml @@ -0,0 +1,56 @@ + + + + quarkus-spring-web-resteasy-reactive-parent + io.quarkus + 999-SNAPSHOT + + + 4.0.0 + + + quarkus-spring-web-resteasy-reactive-tests + Quarkus - Spring Web - RESTEasy Reactive - Tests + + + true + true + + + + + io.quarkus + quarkus-resteasy-reactive-jackson-deployment + + + io.quarkus + quarkus-spring-web-deployment + + + io.quarkus + quarkus-spring-web-resteasy-reactive-deployment + + + io.quarkus + quarkus-resteasy-common-spi + + + + io.quarkus + quarkus-junit5-internal + test + + + io.rest-assured + rest-assured + test + + + org.assertj + assertj-core + test + + + diff --git a/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/BasicMappingTest.java b/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/BasicMappingTest.java new file mode 100644 index 0000000000000..6bacc2170e5f5 --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/BasicMappingTest.java @@ -0,0 +1,226 @@ +package io.quarkus.spring.web.test; + +import static io.restassured.RestAssured.given; +import static io.restassured.RestAssured.when; +import static org.hamcrest.Matchers.is; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class BasicMappingTest { + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addClasses(SomeClass.class, Greeting.class, TestController.class, ResponseEntityController.class, + ResponseStatusController.class, GreetingControllerWithNoRequestMapping.class)); + + @Test + public void verifyGetWithQueryParam() { + when().get(TestController.CONTROLLER_PATH + "/hello?name=people") + .then() + .statusCode(200) + .contentType("text/plain") + .body(is("hello people")); + } + + @Test + public void verifyRequestMappingWithNoMethod() { + when().get(TestController.CONTROLLER_PATH + "/hello4?name=people") + .then() + .statusCode(200) + .contentType("text/plain") + .body(is("hello people")); + } + + @Test + public void verifyGetToMethodWithoutForwardSlash() { + when().get(TestController.CONTROLLER_PATH + "/yolo") + .then() + .statusCode(200) + .body(is("yolo")); + } + + @Test + public void verifyGetUsingDefaultValue() { + when().get(TestController.CONTROLLER_PATH + "/hello2") + .then() + .statusCode(200) + .body(is("hello world")); + } + + @Test + public void verifyGetUsingNonLatinChars() { + when().get(TestController.CONTROLLER_PATH + "/hello3?name=Γιώργος") + .then() + .statusCode(200) + .body(is("hello Γιώργος")); + } + + @Test + public void verifyPathWithWildcard() { + when().get(TestController.CONTROLLER_PATH + "/wildcard/whatever/world") + .then() + .statusCode(200) + .body(is("world")); + } + + @Test + public void verifyPathWithMultipleWildcards() { + when().get(TestController.CONTROLLER_PATH + "/wildcard2/something/folks/somethingelse") + .then() + .statusCode(200) + .body(is("folks")); + } + + @Test + public void verifyPathWithAntStyleWildCard() { + when().get(TestController.CONTROLLER_PATH + "/antwildcard/whatever/we/want") + .then() + .statusCode(200) + .body(is("ant")); + } + + @Test + public void verifyPathWithCharacterWildCard() { + for (char c : new char[] { 't', 'r' }) { + when().get(TestController.CONTROLLER_PATH + String.format("/ca%cs", c)) + .then() + .statusCode(200) + .body(is("single")); + } + } + + @Test + public void verifyPathWithMultipleCharacterWildCards() { + for (String path : new String[] { "/cars/shop/info", "/cart/show/info" }) { + when().get(TestController.CONTROLLER_PATH + path) + .then() + .statusCode(200) + .body(is("multiple")); + } + } + + @Test + public void verifyPathVariableTypeConversion() { + when().get(TestController.CONTROLLER_PATH + "/int/9") + .then() + .statusCode(200) + .body(is("10")); + } + + @Test + public void verifyJsonGetWithPathParamAndGettingMapping() { + when().get(TestController.CONTROLLER_PATH + "/json/dummy") + .then() + .statusCode(200) + .contentType("application/json") + .body("message", is("dummy")); + } + + @Test + public void verifyJsonOnRequestMappingGetWithPathParamAndRequestMapping() { + when().get(TestController.CONTROLLER_PATH + "/json2/dummy") + .then() + .statusCode(200) + .contentType("application/json") + .body("message", is("dummy")); + } + + @Test + public void verifyJsonPostWithPostMapping() { + given().body("{\"message\": \"hi\"}") + .contentType("application/json") + .when().post(TestController.CONTROLLER_PATH + "/json") + .then() + .statusCode(200) + .contentType("text/plain") + .body(is("hi")); + } + + @Test + public void verifyJsonPostWithRequestMapping() { + given().body("{\"message\": \"hi\"}") + .contentType("application/json") + .when().post(TestController.CONTROLLER_PATH + "/json2") + .then() + .statusCode(200) + .contentType("text/plain") + .body(is("hi")); + } + + @Test + public void verifyMultipleInputAndJsonResponse() { + given().body("{\"message\": \"hi\"}") + .contentType("application/json") + .when().put(TestController.CONTROLLER_PATH + "/json3?suffix=!") + .then() + .statusCode(200) + .contentType("application/json") + .body("message", is("hi!")); + } + + @Test + public void verifyEmptyContentResponseEntity() { + when().get(ResponseEntityController.CONTROLLER_PATH + "/noContent") + .then() + .statusCode(204); + } + + @Test + public void verifyStringContentResponseEntity() { + when().get(ResponseEntityController.CONTROLLER_PATH + "/string") + .then() + .statusCode(200) + .contentType("text/plain") + .body(is("hello world")); + } + + @Test + public void verifyJsonContentResponseEntity() { + when().get(ResponseEntityController.CONTROLLER_PATH + "/json") + .then() + .statusCode(200) + .contentType("application/json") + .body("message", is("dummy")) + .header("custom-header", "somevalue"); + } + + @Test + public void verifyJsonContentResponseEntityWithoutType() { + when().get(ResponseEntityController.CONTROLLER_PATH + "/json2") + .then() + .statusCode(200) + .contentType("application/json") + .body("message", is("dummy")); + } + + @Test + public void verifyEmptyContentResponseStatus() { + when().get(ResponseStatusController.CONTROLLER_PATH + "/noContent") + .then() + .statusCode(200); + } + + @Test + public void verifyStringResponseStatus() { + when().get(ResponseStatusController.CONTROLLER_PATH + "/string") + .then() + .statusCode(202) + .contentType("text/plain") + .body(is("accepted")); + } + + @Test + public void verifyControllerWithoutRequestMapping() { + when().get("/hello") + .then() + .statusCode(200) + .contentType("text/plain") + .body(is("hello world")); + } +} diff --git a/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/Greeting.java b/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/Greeting.java new file mode 100644 index 0000000000000..ebe214bd3ae1e --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/Greeting.java @@ -0,0 +1,14 @@ +package io.quarkus.spring.web.test; + +public class Greeting { + + private final String message; + + public Greeting(final String message) { + this.message = message; + } + + public String getMessage() { + return message; + } +} diff --git a/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/GreetingControllerWithNoRequestMapping.java b/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/GreetingControllerWithNoRequestMapping.java new file mode 100644 index 0000000000000..e79b2bb3e67dc --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/GreetingControllerWithNoRequestMapping.java @@ -0,0 +1,13 @@ +package io.quarkus.spring.web.test; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class GreetingControllerWithNoRequestMapping { + + @GetMapping("/hello") + public String hello() { + return "hello world"; + } +} diff --git a/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/ResponseEntityController.java b/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/ResponseEntityController.java new file mode 100644 index 0000000000000..31bd989f05245 --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/ResponseEntityController.java @@ -0,0 +1,33 @@ +package io.quarkus.spring.web.test; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/" + ResponseEntityController.CONTROLLER_PATH) +public class ResponseEntityController { + + public static final String CONTROLLER_PATH = "re"; + + @GetMapping("/noContent") + public ResponseEntity noContent() { + return ResponseEntity.noContent().build(); + } + + @GetMapping(value = "/string", produces = "text/plain") + public ResponseEntity string() { + return ResponseEntity.ok("hello world"); + } + + @GetMapping(value = "/json", produces = "application/json") + public ResponseEntity jsonPlusHeaders() { + return ResponseEntity.ok().header("custom-header", "somevalue").body(new SomeClass("dummy")); + } + + @GetMapping(value = "/json2", produces = "application/json") + public ResponseEntity responseEntityWithoutType() { + return ResponseEntity.ok().body(new SomeClass("dummy")); + } +} diff --git a/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/ResponseStatusAndExceptionHandlerTest.java b/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/ResponseStatusAndExceptionHandlerTest.java new file mode 100644 index 0000000000000..20c3c8a9cb104 --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/ResponseStatusAndExceptionHandlerTest.java @@ -0,0 +1,70 @@ +package io.quarkus.spring.web.test; + +import static io.restassured.RestAssured.when; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import io.quarkus.test.QuarkusUnitTest; + +public class ResponseStatusAndExceptionHandlerTest { + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addClasses(ExceptionController.class, RestExceptionHandler.class)); + + @Test + public void testRestControllerAdvice() { + when().get("/exception").then().statusCode(400); + } + + @Test + public void testResponseStatusOnException() { + when().get("/exception2").then().statusCode(202); + } + + @RestController + public static class ExceptionController { + + public static final StackTraceElement[] EMPTY_STACK_TRACE = new StackTraceElement[0]; + + @GetMapping("/exception") + @ResponseStatus(HttpStatus.OK) + public String throwRuntimeException() { + RuntimeException runtimeException = new RuntimeException(); + runtimeException.setStackTrace(EMPTY_STACK_TRACE); + throw runtimeException; + } + + @GetMapping("/exception2") + public String throwMyException() { + MyException myException = new MyException(); + myException.setStackTrace(EMPTY_STACK_TRACE); + throw myException; + } + } + + @RestControllerAdvice + public static class RestExceptionHandler { + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity handleException(Exception ex) { + return new ResponseEntity<>(HttpStatus.BAD_REQUEST); + } + } + + @ResponseStatus(HttpStatus.ACCEPTED) + public static class MyException extends RuntimeException { + + } +} diff --git a/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/ResponseStatusController.java b/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/ResponseStatusController.java new file mode 100644 index 0000000000000..4411c4fb134f6 --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/ResponseStatusController.java @@ -0,0 +1,26 @@ +package io.quarkus.spring.web.test; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/" + ResponseStatusController.CONTROLLER_PATH) +public class ResponseStatusController { + + public static final String CONTROLLER_PATH = "rs"; + + @GetMapping(produces = "text/plain", path = "/noContent") + @ResponseStatus(HttpStatus.OK) + public void noValueResponseStatus() { + + } + + @GetMapping(produces = "text/plain", path = "/string") + @ResponseStatus(HttpStatus.ACCEPTED) + public String stringWithResponseStatus() { + return "accepted"; + } +} diff --git a/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/SomeClass.java b/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/SomeClass.java new file mode 100644 index 0000000000000..f3617a19beaaf --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/SomeClass.java @@ -0,0 +1,21 @@ +package io.quarkus.spring.web.test; + +public class SomeClass { + + private String message; + + public SomeClass() { + } + + public SomeClass(final String message) { + this.message = message; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/TestController.java b/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/TestController.java new file mode 100644 index 0000000000000..4793707865885 --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/test/TestController.java @@ -0,0 +1,110 @@ +package io.quarkus.spring.web.test; + +import javax.ws.rs.core.MediaType; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/" + TestController.CONTROLLER_PATH) +public class TestController { + + public static final String CONTROLLER_PATH = "spring"; + + @GetMapping("/hello") + public String string(@RequestParam String name) { + return "hello " + name; + } + + @RequestMapping("/hello4") + public String requestMappingNoMethod(@RequestParam String name) { + return "hello " + name; + } + + @GetMapping("yolo") + public String yolo() { + return "yolo"; + } + + @GetMapping("/hello2") + public String stringWithDefaultParamValue(@RequestParam(name = "name", defaultValue = "world") String name) { + return "hello " + name; + } + + @GetMapping("/hello3") + public String stringWithNameValue(@RequestParam(name = "name") String name) { + return "hello " + name; + } + + @GetMapping("/wildcard/*/{name}") + public String pathWithWildcard(@PathVariable("name") String name) { + return name; + } + + @RequestMapping(value = "/wildcard2/*/{name}/*", method = RequestMethod.GET) + public String pathWithMultipleWildcards(@PathVariable("name") String name) { + return name; + } + + @GetMapping("/antwildcard/**") + public String pathWithAntStyleWildcard() { + return "ant"; + } + + @GetMapping("/ca?s") + public String pathWithCharacterWildCard() { + return "single"; + } + + @GetMapping("/car?/s?o?/info") + public String pathWithMultipleCharacterWildCards() { + return "multiple"; + } + + @GetMapping("/int/{num}") + public Integer intPathVariable(@PathVariable("num") Integer number) { + return number + 1; + } + + @GetMapping("/{msg}") + public String stringPathVariable(@PathVariable("msg") String message) { + return message; + } + + @GetMapping(path = "/json/{message}") + public SomeClass json(@PathVariable String message) { + return new SomeClass(message); + } + + @RequestMapping(method = RequestMethod.GET, path = "/json2/{message}", produces = MediaType.APPLICATION_JSON) + public SomeClass jsonFromRequestMapping(@PathVariable String message) { + return new SomeClass(message); + } + + @PostMapping(path = "/json", produces = MediaType.TEXT_PLAIN, consumes = MediaType.APPLICATION_JSON) + public String postWithJsonBody(@RequestBody SomeClass someClass) { + return someClass.getMessage(); + } + + @RequestMapping(path = "/json2", method = RequestMethod.POST, produces = MediaType.TEXT_PLAIN, consumes = MediaType.APPLICATION_JSON) + public String postWithJsonBodyFromRequestMapping(@RequestBody SomeClass someClass) { + return someClass.getMessage(); + } + + @PutMapping(path = "/json3") + public Greeting multipleInputAndJsonResponse(@RequestBody SomeClass someClass, + @RequestParam(value = "suffix") String suffix) { + return new Greeting(someClass.getMessage() + suffix); + } + + public void doNothing() { + + } +}