diff --git a/buildSrc/src/main/groovy/io.micronaut.build.internal.servlet-module.gradle b/buildSrc/src/main/groovy/io.micronaut.build.internal.servlet-module.gradle deleted file mode 100644 index 23b7cae50..000000000 --- a/buildSrc/src/main/groovy/io.micronaut.build.internal.servlet-module.gradle +++ /dev/null @@ -1,4 +0,0 @@ -plugins { - id "io.micronaut.build.internal.servlet-base" - id "io.micronaut.build.internal.module" -} diff --git a/buildSrc/src/main/groovy/io.micronaut.build.internal.servlet-tests.gradle b/buildSrc/src/main/groovy/io.micronaut.build.internal.servlet-tests.gradle deleted file mode 100644 index 8e6b181a0..000000000 --- a/buildSrc/src/main/groovy/io.micronaut.build.internal.servlet-tests.gradle +++ /dev/null @@ -1,3 +0,0 @@ -plugins { - id "io.micronaut.build.internal.servlet-base" -} diff --git a/buildSrc/src/main/groovy/io.micronaut.build.internal.servlet-base.gradle b/buildSrc/src/main/groovy/io.micronaut.build.internal.servlet.base.gradle similarity index 86% rename from buildSrc/src/main/groovy/io.micronaut.build.internal.servlet-base.gradle rename to buildSrc/src/main/groovy/io.micronaut.build.internal.servlet.base.gradle index c899fd236..760c0174e 100644 --- a/buildSrc/src/main/groovy/io.micronaut.build.internal.servlet-base.gradle +++ b/buildSrc/src/main/groovy/io.micronaut.build.internal.servlet.base.gradle @@ -13,8 +13,3 @@ configurations.all { .using(module("org.apache.groovy:groovy:4.0.6")) } } - -dependencies { - testRuntimeOnly mn.snakeyaml -} - diff --git a/buildSrc/src/main/groovy/io.micronaut.build.internal.servlet.implementation.gradle b/buildSrc/src/main/groovy/io.micronaut.build.internal.servlet.implementation.gradle new file mode 100644 index 000000000..9a819dfce --- /dev/null +++ b/buildSrc/src/main/groovy/io.micronaut.build.internal.servlet.implementation.gradle @@ -0,0 +1,26 @@ +plugins { + id 'io.micronaut.build.internal.servlet.module' +} + +dependencies { + annotationProcessor mn.micronaut.graal + + api project(":servlet-engine") + api mn.micronaut.http.server + + compileOnly mn.graal + + testAnnotationProcessor mn.micronaut.inject.java + + testImplementation mn.snakeyaml + testImplementation mn.micronaut.http.client + testImplementation mn.micronaut.session + testImplementation mn.micronaut.reactor + testImplementation (mn.micronaut.security) { + version { + strictly '4.0.0-SNAPSHOT' + } + } + testImplementation mn.micronaut.management + testImplementation mn.groovy.json +} diff --git a/buildSrc/src/main/groovy/io.micronaut.build.internal.servlet.module.gradle b/buildSrc/src/main/groovy/io.micronaut.build.internal.servlet.module.gradle new file mode 100644 index 000000000..0d6ccd22d --- /dev/null +++ b/buildSrc/src/main/groovy/io.micronaut.build.internal.servlet.module.gradle @@ -0,0 +1,4 @@ +plugins { + id 'io.micronaut.build.internal.servlet.base' + id 'io.micronaut.build.internal.module' +} diff --git a/gradle.properties b/gradle.properties index 5bdebebfe..f636ca8c5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -projectVersion=4.0.0-SNAPSHOT +projectVersion=3.3.2-SNAPSHOT projectGroup=io.micronaut.servlet micronautDocsVersion=2.0.0 diff --git a/http-server-jetty/build.gradle b/http-server-jetty/build.gradle index e9137200a..ee723b339 100644 --- a/http-server-jetty/build.gradle +++ b/http-server-jetty/build.gradle @@ -1,22 +1,7 @@ plugins { - id "io.micronaut.build.internal.servlet-module" + id 'io.micronaut.build.internal.servlet.implementation' } dependencies { - annotationProcessor mn.micronaut.graal - - api projects.servletEngine - api mn.micronaut.http.server - - implementation(libs.jetty) - - testAnnotationProcessor mn.micronaut.inject.java - testImplementation mn.micronaut.management - testImplementation mn.micronaut.http.client - testImplementation mn.micronaut.session - testImplementation(mn.groovy.json) - testImplementation mn.micronaut.reactor - testImplementation mn.micronaut.reactor.http.client - - testImplementation(mn.micronaut.security) + implementation libs.jetty } diff --git a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyBlockingCrudSpec.groovy b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyBlockingCrudSpec.groovy index 187027cbc..463706d10 100644 --- a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyBlockingCrudSpec.groovy +++ b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyBlockingCrudSpec.groovy @@ -4,6 +4,7 @@ package io.micronaut.servlet.jetty import io.micronaut.context.ApplicationContext import io.micronaut.context.annotation.Property import io.micronaut.context.annotation.Requires +import io.micronaut.core.annotation.Introspected import io.micronaut.http.HttpResponse import io.micronaut.http.HttpStatus import io.micronaut.http.annotation.* @@ -290,6 +291,7 @@ class JettyBlockingCrudSpec extends Specification { } + @Introspected static class Book { Long id String title diff --git a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyCompletableFutureCrudSpec.groovy b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyCompletableFutureCrudSpec.groovy index bca193078..82cfd5392 100644 --- a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyCompletableFutureCrudSpec.groovy +++ b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyCompletableFutureCrudSpec.groovy @@ -3,6 +3,7 @@ package io.micronaut.servlet.jetty import io.micronaut.context.ApplicationContext import io.micronaut.context.annotation.Requires +import io.micronaut.core.annotation.Introspected import io.micronaut.http.annotation.Controller import io.micronaut.http.annotation.Delete import io.micronaut.http.annotation.Get @@ -146,6 +147,7 @@ class JettyCompletableFutureCrudSpec extends Specification { } + @Introspected static class Book { Long id String title diff --git a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyCorsSpec.groovy b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyCorsSpec.groovy index 16be4a4b4..2a7b26838 100644 --- a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyCorsSpec.groovy +++ b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyCorsSpec.groovy @@ -38,8 +38,11 @@ class JettyCorsSpec extends Specification implements TestPropertyProvider { then: response.status == HttpStatus.NO_CONTENT response.contentLength == -1 - headerNames.size() == 3 - headerNames.contains(CONNECTION) + headerNames.size() == 2 + // Client is now keep-alive so we don't get the connection header + !headerNames.contains(CONNECTION) + headerNames.contains(DATE) + headerNames.contains(SERVER) } void "test cors request without configuration"() { @@ -54,8 +57,11 @@ class JettyCorsSpec extends Specification implements TestPropertyProvider { then: response.status == HttpStatus.NO_CONTENT - headerNames.size() == 3 - headerNames.contains(CONNECTION) + headerNames.size() == 2 + // Client is now keep-alive so we don't get the connection header + !headerNames.contains(CONNECTION) + headerNames.contains(DATE) + headerNames.contains(SERVER) } void "test cors request with a controller that returns map"() { @@ -140,7 +146,10 @@ class JettyCorsSpec extends Specification implements TestPropertyProvider { then: response.code() == HttpStatus.FORBIDDEN.code - headerNames == [CONNECTION, DATE, SERVER] as Set + // Client is now keep-alive so we don't get the connection header + !headerNames.contains(CONNECTION) + headerNames.contains(DATE) + headerNames.contains(SERVER) } void "test cors request with invalid header"() { diff --git a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyHealthSpec.groovy b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyHealthSpec.groovy index a3308f427..f7ceec089 100644 --- a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyHealthSpec.groovy +++ b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyHealthSpec.groovy @@ -21,19 +21,19 @@ class JettyHealthSpec extends Specification { void 'test healthy'() { given: - def liveness = client.toBlocking().exchange("/health/liveness", HealthResult) - def readiness = client.toBlocking().exchange("/health/readiness", HealthResult) - def overall = client.toBlocking().exchange("/health", HealthResult) + def liveness = client.toBlocking().exchange("/health/liveness", Map) + def readiness = client.toBlocking().exchange("/health/readiness", Map) + def overall = client.toBlocking().exchange("/health", Map) expect: - liveness.status() == HttpStatus.OK - readiness.status() == HttpStatus.OK - overall.status() == HttpStatus.OK + liveness.status == HttpStatus.OK + readiness.status == HttpStatus.OK + overall.status == HttpStatus.OK and:"there are no liveness indicators so unknown" - liveness.body().status == HealthStatus.UNKNOWN + liveness.body.get().status == HealthStatus.UNKNOWN as String and:"readiness indicates up" - readiness.body().status == HealthStatus.UP + readiness.body.get().status == HealthStatus.UP as String and:'so does overall status' - overall.body().status == HealthStatus.UP + overall.body.get().status == HealthStatus.UP as String } } diff --git a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyHttpPostSpec.groovy b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyHttpPostSpec.groovy index f7fa4ff8d..16fd97245 100644 --- a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyHttpPostSpec.groovy +++ b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyHttpPostSpec.groovy @@ -441,6 +441,7 @@ class JettyHttpPostSpec extends Specification { Integer pages } + @Introspected static class Params { List param } diff --git a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyJsonBodyBindingSpec.groovy b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyJsonBodyBindingSpec.groovy index c657c26f7..46eaf24fa 100644 --- a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyJsonBodyBindingSpec.groovy +++ b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyJsonBodyBindingSpec.groovy @@ -1,10 +1,10 @@ package io.micronaut.servlet.jetty -import com.fasterxml.jackson.core.JsonParseException import groovy.json.JsonSlurper import io.micronaut.context.annotation.Property import io.micronaut.context.annotation.Requires +import io.micronaut.core.annotation.Introspected import io.micronaut.http.HttpHeaders import io.micronaut.http.HttpRequest import io.micronaut.http.HttpResponse @@ -16,6 +16,7 @@ import io.micronaut.http.annotation.Post import io.micronaut.http.client.HttpClient import io.micronaut.http.client.annotation.Client import io.micronaut.http.client.exceptions.HttpClientResponseException +import io.micronaut.http.codec.CodecException import io.micronaut.http.hateoas.JsonError import io.micronaut.http.hateoas.Link import io.micronaut.test.extensions.spock.annotation.MicronautTest @@ -93,8 +94,8 @@ class JettyJsonBodyBindingSpec extends Specification { then: response.code() == HttpStatus.BAD_REQUEST.code response.headers.get(HttpHeaders.CONTENT_TYPE) == io.micronaut.http.MediaType.APPLICATION_JSON - result['_links'].self.href == '/json/map' -// result.message.startsWith('Invalid JSON') + result['_links'].self.href == ['/json/map'] + result['_embedded'].errors[0].message.contains "Unrecognized token 'The'" } void "test simple map body parsing"() { @@ -397,17 +398,9 @@ class JettyJsonBodyBindingSpec extends Specification { String requestGeneric(HttpRequest request) { return request.getBody().map({ foo -> foo.toString()}).orElse("not found") } - - @Error(JsonParseException) - HttpResponse jsonError(HttpRequest request, JsonParseException jsonParseException) { - def response = HttpResponse.status(HttpStatus.BAD_REQUEST, "No!! Invalid JSON") - def error = new JsonError("Invalid JSON: ${jsonParseException.message}") - error.link(Link.SELF, Link.of(request.getUri())) - response.body(error) - return response - } } + @Introspected static class Foo { String name Integer age diff --git a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyJsonBodyBindingSpec2.groovy b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyJsonBodyBindingSpec2.groovy new file mode 100644 index 000000000..63a3b62d4 --- /dev/null +++ b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyJsonBodyBindingSpec2.groovy @@ -0,0 +1,86 @@ + +package io.micronaut.servlet.jetty + +import groovy.json.JsonSlurper +import io.micronaut.context.annotation.Property +import io.micronaut.context.annotation.Requires +import io.micronaut.core.annotation.Introspected +import io.micronaut.http.HttpHeaders +import io.micronaut.http.HttpRequest +import io.micronaut.http.HttpResponse +import io.micronaut.http.HttpStatus +import io.micronaut.http.annotation.Body +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Error +import io.micronaut.http.annotation.Post +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.http.client.exceptions.HttpClientResponseException +import io.micronaut.http.codec.CodecException +import io.micronaut.http.hateoas.JsonError +import io.micronaut.http.hateoas.Link +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import spock.lang.Specification + +@MicronautTest +@Property(name = 'spec.name', value = 'JettyJsonBodyBindingSpec2') +class JettyJsonBodyBindingSpec2 extends Specification { + + @Inject + @Client("/") + HttpClient rxClient + + void "test map-based body parsing with invalid JSON"() { + + when: + rxClient.toBlocking().exchange(HttpRequest.POST('/json/map', '{"title":The Stand}'), String) + + then: + def e = thrown(HttpClientResponseException) + e.response.getBody(Map).get().message.contains """Unable to decode request body: Error decoding JSON stream for type [json]: Unrecognized token 'The'""" + e.response.status == HttpStatus.BAD_REQUEST + + when: + def response = e.response + def body = e.response.getBody(String).orElse(null) + def result = new JsonSlurper().parseText(body) + + then: + response.code() == HttpStatus.BAD_REQUEST.code + response.headers.get(HttpHeaders.CONTENT_TYPE) == io.micronaut.http.MediaType.APPLICATION_JSON + result['_links'].self.href == ['/json/map'] + result.message.startsWith "Invalid JSON" + result.message.contains "Unrecognized token 'The'" + } + + @Requires(property = 'spec.name', value = 'JettyJsonBodyBindingSpec2') + @Controller(value = "/json", produces = io.micronaut.http.MediaType.APPLICATION_JSON) + static class JsonController { + + @Post("/map") + String map(@Body Map json) { + "Body: ${json}" + } + + @Error(CodecException) + HttpResponse jsonError(HttpRequest request, CodecException jsonParseException) { + def response = HttpResponse.status(HttpStatus.BAD_REQUEST, "No!! Invalid JSON") + def error = new JsonError("Invalid JSON: ${jsonParseException.message}") + error.link(Link.SELF, Link.of(request.getUri())) + response.body(error) + return response + } + } + + @Introspected + static class Foo { + String name + Integer age + + @Override + String toString() { + "Foo($name, $age)" + } + } +} diff --git a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyJsonBodyBindingSpec3.groovy b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyJsonBodyBindingSpec3.groovy new file mode 100644 index 000000000..b57bc4cd5 --- /dev/null +++ b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyJsonBodyBindingSpec3.groovy @@ -0,0 +1,68 @@ + +package io.micronaut.servlet.jetty + + +import io.micronaut.context.annotation.Property +import io.micronaut.context.annotation.Requires +import io.micronaut.core.annotation.Introspected +import io.micronaut.http.HttpRequest +import io.micronaut.http.HttpResponse +import io.micronaut.http.HttpStatus +import io.micronaut.http.annotation.Body +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Error +import io.micronaut.http.annotation.Post +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.http.client.exceptions.HttpClientResponseException +import io.micronaut.http.codec.CodecException +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import spock.lang.Specification + +import static io.micronaut.http.MediaType.APPLICATION_JSON + +@MicronautTest +@Property(name = 'spec.name', value = 'JettyJsonBodyBindingSpec3') +class JettyJsonBodyBindingSpec3 extends Specification { + + @Inject + @Client("/") + HttpClient rxClient + + void "test map-based body parsing with invalid JSON"() { + when: + rxClient.toBlocking().exchange(HttpRequest.POST('/json/map', '{"title":The Stand}'), String) + + then: + def e = thrown(HttpClientResponseException) + e.response.status == HttpStatus.BAD_REQUEST + e.response.getBody().isEmpty() + } + + @Requires(property = 'spec.name', value = 'JettyJsonBodyBindingSpec3') + @Controller(value = "/json", produces = APPLICATION_JSON) + static class JsonController { + + @Post("/map") + String map(@Body Map json) { + "Body: ${json}" + } + + @Error(CodecException) + HttpResponse jsonError(HttpRequest request, CodecException jsonParseException) { + return HttpResponse.status(HttpStatus.BAD_REQUEST, "No!! Invalid JSON") + } + } + + @Introspected + static class Foo { + String name + Integer age + + @Override + String toString() { + "Foo($name, $age)" + } + } +} diff --git a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyJsonStreamSpec.groovy b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyJsonStreamSpec.groovy index eca00b64e..27d06926f 100644 --- a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyJsonStreamSpec.groovy +++ b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyJsonStreamSpec.groovy @@ -3,6 +3,7 @@ package io.micronaut.servlet.jetty import io.micronaut.context.ApplicationContext import io.micronaut.context.annotation.Requires +import io.micronaut.core.annotation.Introspected import io.micronaut.core.async.annotation.SingleResult import io.micronaut.http.HttpRequest import io.micronaut.http.MediaType @@ -249,14 +250,17 @@ class JettyJsonStreamSpec extends Specification { } } + @Introspected static class Book { String title } + @Introspected static class LibraryStats { Integer bookCount } + @Introspected static class Chunk { String type } diff --git a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyNotFoundSpec.groovy b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyNotFoundSpec.groovy index aeac83bc1..89da09329 100644 --- a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyNotFoundSpec.groovy +++ b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyNotFoundSpec.groovy @@ -25,34 +25,53 @@ class JettyNotFoundSpec extends Specification { @Inject InventoryClient client - void "test 404 handling with Flux"() { + void "test 404 handling with streaming publisher"() { expect: - Flux.from(client.stream('1234')).blockFirst() - Flux.from(client.stream('notthere')).collectList().block() == [] + Flux.from(client.streaming('1234')).blockFirst() + Flux.from(client.streaming('notthere')).collectList().block() == [] } - void "test 404 handling with Mono"() { - expect: - Mono.from(client.single('1234')).block() + void "test 404 handling with not streaming publisher"() { + when: + def exists = Mono.from(client.mono('1234')).block() + + then: + exists + + when: + Mono.from(client.mono('notthere')).block() + + then: + def e = thrown(HttpClientResponseException) + e.status == HttpStatus.NOT_FOUND + + when:exists = Mono.from(client.flux('1234')).block() + + then: + exists when: - Mono.from(client.single('notthere')).block() + Mono.from(client.flux('notthere')).block() then: - noExceptionThrown() + e = thrown(HttpClientResponseException) + e.status == HttpStatus.NOT_FOUND } @Requires(property = 'spec.name', value = 'JettyNotFoundSpec') @Client('/not-found') static interface InventoryClient { - @Consumes(MediaType.TEXT_PLAIN) - @Get('/maybe/{isbn}') + @Get(value = '/mono/{isbn}', processes = MediaType.TEXT_PLAIN) @SingleResult - Publisher single(String isbn) + Publisher mono(String isbn) - @Get(value = '/flux/{isbn}', processes = MediaType.TEXT_EVENT_STREAM) - Publisher stream(String isbn) + @Get(value = '/flux/{isbn}', processes = MediaType.TEXT_PLAIN) + @SingleResult + Publisher flux(String isbn) + + @Get(value = '/streaming/{isbn}', processes = MediaType.TEXT_EVENT_STREAM) + Publisher streaming(String isbn) } @Requires(property = 'spec.name', value = 'JettyNotFoundSpec') @@ -62,7 +81,7 @@ class JettyNotFoundSpec extends Specification { '1234': true ] - @Get('/maybe/{isbn}') + @Get('/mono/{isbn}') @SingleResult Publisher maybe(String isbn) { Boolean value = stock[isbn] @@ -72,7 +91,8 @@ class JettyNotFoundSpec extends Specification { return Mono.empty() } - @Get(value = '/flux/{isbn}', processes = MediaType.TEXT_EVENT_STREAM) + @Get(value = '/flux/{isbn}') + @SingleResult Publisher flux(String isbn) { Boolean value = stock[isbn] if (value != null) { @@ -80,5 +100,15 @@ class JettyNotFoundSpec extends Specification { } return Flux.empty() } + + @Get(value = '/streaming/{isbn}', processes = MediaType.TEXT_EVENT_STREAM) + Publisher streaming(String isbn) { + Boolean value = stock[isbn] + if (value != null) { + return Flux.just(value) + } + return Flux.empty() + } + } } diff --git a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyNullableCrudSpec.groovy b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyNullableCrudSpec.groovy index 5cc230344..f1b019fee 100644 --- a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyNullableCrudSpec.groovy +++ b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyNullableCrudSpec.groovy @@ -3,6 +3,7 @@ package io.micronaut.servlet.jetty import io.micronaut.context.ApplicationContext import io.micronaut.context.annotation.Requires +import io.micronaut.core.annotation.Introspected import io.micronaut.http.annotation.Controller import io.micronaut.http.annotation.Delete import io.micronaut.http.annotation.Get @@ -176,7 +177,7 @@ class JettyNullableCrudSpec extends Specification { NullableBook update(Long id, @Nullable String title) } - + @Introspected static class NullableBook { Long id String title diff --git a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyParameterBindingSpec.groovy b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyParameterBindingSpec.groovy index 7730fb50b..80ee127e2 100644 --- a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyParameterBindingSpec.groovy +++ b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyParameterBindingSpec.groovy @@ -95,7 +95,7 @@ class JettyParameterBindingSpec extends Specification { expect: response.status() == HttpStatus.BAD_REQUEST response.body().contains('Failed to convert argument') - response.body().contains('Book[\\"age\\"])') + response.body().contains('Expected one integer, but got array of multiple values') } @Requires(property = 'spec.name', value = 'JettyParameterBindingSpec') diff --git a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyReactorCrudSpec.groovy b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyReactorCrudSpec.groovy index 299f87a73..7e4d18d11 100644 --- a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyReactorCrudSpec.groovy +++ b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyReactorCrudSpec.groovy @@ -3,6 +3,7 @@ package io.micronaut.servlet.jetty import io.micronaut.context.ApplicationContext import io.micronaut.context.annotation.Requires +import io.micronaut.core.annotation.Introspected import io.micronaut.http.HttpResponse import io.micronaut.http.HttpStatus import io.micronaut.http.annotation.Controller @@ -11,6 +12,7 @@ import io.micronaut.http.annotation.Get import io.micronaut.http.annotation.Patch import io.micronaut.http.annotation.Post import io.micronaut.http.client.annotation.Client +import io.micronaut.http.client.exceptions.HttpClientResponseException import io.micronaut.runtime.server.EmbeddedServer import reactor.core.publisher.Mono import spock.lang.AutoCleanup @@ -34,15 +36,20 @@ class JettyReactorCrudSpec extends Specification { BookClient client = embeddedServer.applicationContext.getBean(BookClient) when: - Book book = client.get(99).block() + client.get(99).block() + + then: + def e = thrown(HttpClientResponseException) + e.response.status == HttpStatus.NOT_FOUND + + when: List books = client.list().block() then: - book == null books.size() == 0 when: - book = client.save("The Stand").block() + Book book = client.save("The Stand").block() then: book != null @@ -80,10 +87,11 @@ class JettyReactorCrudSpec extends Specification { book != null when: - book = client.get(book.id).block() + client.get(book.id).block() then: - book == null + def e2 = thrown(HttpClientResponseException) + e2.response.status == HttpStatus.NOT_FOUND } @Requires(property = 'spec.name', value = 'JettyReactorCrudSpec') @@ -169,6 +177,7 @@ class JettyReactorCrudSpec extends Specification { Mono update(Long id, String title) } + @Introspected static class Book { Long id String title diff --git a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyValidationStatusSpec.groovy b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyValidationStatusSpec.groovy index 02007cd27..6ccc9a1a9 100644 --- a/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyValidationStatusSpec.groovy +++ b/http-server-jetty/src/test/groovy/io/micronaut/servlet/jetty/JettyValidationStatusSpec.groovy @@ -16,7 +16,6 @@ import io.micronaut.http.client.HttpClient import io.micronaut.http.client.annotation.Client import io.micronaut.http.client.exceptions.HttpClientResponseException import io.micronaut.test.extensions.spock.annotation.MicronautTest -import io.micronaut.validation.Validated import jakarta.inject.Inject import spock.lang.Issue import spock.lang.Specification @@ -68,7 +67,6 @@ class JettyValidationStatusSpec extends Specification { } @Requires(property = 'spec.name', value = 'JettyValidationStatusSpec') - @Validated @Controller('/validation-status-test') static class StatusController { Map books = [:] diff --git a/http-server-jetty/src/test/java/io/micronaut/servlet/jetty/Person.java b/http-server-jetty/src/test/java/io/micronaut/servlet/jetty/Person.java index 3b79e746d..7dd78e0d1 100644 --- a/http-server-jetty/src/test/java/io/micronaut/servlet/jetty/Person.java +++ b/http-server-jetty/src/test/java/io/micronaut/servlet/jetty/Person.java @@ -1,6 +1,7 @@ package io.micronaut.servlet.jetty; +import io.micronaut.core.annotation.Creator; import io.micronaut.core.annotation.Introspected; @Introspected @@ -12,6 +13,7 @@ public Person(String name) { this.name = name; } + @Creator public Person(String name, int age) { this.name = name; this.age = age; diff --git a/http-server-tomcat/build.gradle b/http-server-tomcat/build.gradle index 707387bb5..af619e660 100644 --- a/http-server-tomcat/build.gradle +++ b/http-server-tomcat/build.gradle @@ -1,21 +1,8 @@ plugins { - id "io.micronaut.build.internal.servlet-module" + id 'io.micronaut.build.internal.servlet.implementation' } dependencies { - annotationProcessor mn.micronaut.graal - - api projects.servletEngine - api mn.micronaut.http.server - - compileOnly mn.graal - implementation libs.tomcat.embed.core - - testAnnotationProcessor mn.micronaut.inject.java - testImplementation mn.micronaut.session - testImplementation mn.micronaut.http.client - testImplementation mn.reactor - testImplementation mn.micronaut.security } diff --git a/http-server-tomcat/src/test/groovy/io/micronaut/servlet/tomcat/TomcatParameterBindingSpec.groovy b/http-server-tomcat/src/test/groovy/io/micronaut/servlet/tomcat/TomcatParameterBindingSpec.groovy index 0364bf18b..24da2eb1f 100644 --- a/http-server-tomcat/src/test/groovy/io/micronaut/servlet/tomcat/TomcatParameterBindingSpec.groovy +++ b/http-server-tomcat/src/test/groovy/io/micronaut/servlet/tomcat/TomcatParameterBindingSpec.groovy @@ -88,7 +88,6 @@ class TomcatParameterBindingSpec extends Specification { expect: response.status() == HttpStatus.BAD_REQUEST response.body().contains('Failed to convert argument') - response.body().contains('Book[\\"age\\"])') } @Controller(value = "/parameter", produces = MediaType.TEXT_PLAIN) diff --git a/http-server-tomcat/src/test/java/io/micronaut/servlet/tomcat/Person.java b/http-server-tomcat/src/test/java/io/micronaut/servlet/tomcat/Person.java index 70a815a28..af446c674 100644 --- a/http-server-tomcat/src/test/java/io/micronaut/servlet/tomcat/Person.java +++ b/http-server-tomcat/src/test/java/io/micronaut/servlet/tomcat/Person.java @@ -1,6 +1,7 @@ package io.micronaut.servlet.tomcat; +import io.micronaut.core.annotation.Creator; import io.micronaut.core.annotation.Introspected; @Introspected @@ -12,6 +13,7 @@ public Person(String name) { this.name = name; } + @Creator public Person(String name, int age) { this.name = name; this.age = age; diff --git a/http-server-undertow/build.gradle b/http-server-undertow/build.gradle index 5104fade7..925f8cbfc 100644 --- a/http-server-undertow/build.gradle +++ b/http-server-undertow/build.gradle @@ -1,18 +1,7 @@ plugins { - id "io.micronaut.build.internal.servlet-module" + id 'io.micronaut.build.internal.servlet.implementation' } dependencies { - annotationProcessor mn.micronaut.graal - - api projects.servletEngine - api mn.micronaut.http.server - implementation libs.undertow.servlet - - testAnnotationProcessor mn.micronaut.inject.java - testImplementation mn.micronaut.http.client - testImplementation mn.micronaut.session - testImplementation mn.reactor - testImplementation mn.micronaut.security } diff --git a/http-server-undertow/src/test/groovy/io/micronaut/servlet/undertow/UndertowParameterBindingSpec.groovy b/http-server-undertow/src/test/groovy/io/micronaut/servlet/undertow/UndertowParameterBindingSpec.groovy index 0a6aa2ed5..0d24a8de9 100644 --- a/http-server-undertow/src/test/groovy/io/micronaut/servlet/undertow/UndertowParameterBindingSpec.groovy +++ b/http-server-undertow/src/test/groovy/io/micronaut/servlet/undertow/UndertowParameterBindingSpec.groovy @@ -91,7 +91,7 @@ class UndertowParameterBindingSpec extends Specification { expect: response.status() == HttpStatus.BAD_REQUEST response.body().contains('Failed to convert argument') - response.body().contains('Book[\\"age\\"])') + response.body().contains('Expected one integer, but got array of multiple value') } @Requires(property = 'spec.name', value = 'UndertowParameterBindingSpec') diff --git a/http-server-undertow/src/test/java/io/micronaut/servlet/undertow/Person.java b/http-server-undertow/src/test/java/io/micronaut/servlet/undertow/Person.java index b4fdec263..f681e19b2 100644 --- a/http-server-undertow/src/test/java/io/micronaut/servlet/undertow/Person.java +++ b/http-server-undertow/src/test/java/io/micronaut/servlet/undertow/Person.java @@ -1,6 +1,7 @@ package io.micronaut.servlet.undertow; +import io.micronaut.core.annotation.Creator; import io.micronaut.core.annotation.Introspected; @Introspected @@ -12,6 +13,7 @@ public Person(String name) { this.name = name; } + @Creator public Person(String name, int age) { this.name = name; this.age = age; diff --git a/servlet-core/build.gradle b/servlet-core/build.gradle index 1e7d0d8c6..43d2a0fa8 100644 --- a/servlet-core/build.gradle +++ b/servlet-core/build.gradle @@ -1,5 +1,5 @@ plugins { - id "io.micronaut.build.internal.servlet-module" + id 'io.micronaut.build.internal.servlet.module' } dependencies { diff --git a/servlet-core/src/main/java/io/micronaut/servlet/http/CodecErrorHandler.java b/servlet-core/src/main/java/io/micronaut/servlet/http/CodecErrorHandler.java new file mode 100644 index 000000000..262cdfdb3 --- /dev/null +++ b/servlet-core/src/main/java/io/micronaut/servlet/http/CodecErrorHandler.java @@ -0,0 +1,51 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.servlet.http; + +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.codec.CodecException; +import io.micronaut.http.server.exceptions.ExceptionHandler; +import io.micronaut.http.server.exceptions.response.Error; +import io.micronaut.http.server.exceptions.response.ErrorContext; +import io.micronaut.http.server.exceptions.response.ErrorResponseProcessor; +import jakarta.inject.Singleton; + +/** + * Error mapper for {@link CodecException}. + * + * @author Denis Stepanov + * @since 4.0.0 + */ +@Singleton +@Produces +final class CodecErrorHandler implements ExceptionHandler> { + private final ErrorResponseProcessor responseProcessor; + + public CodecErrorHandler(ErrorResponseProcessor responseProcessor) { + this.responseProcessor = responseProcessor; + } + + public HttpResponse handle(HttpRequest request, CodecException exception) { + return this.responseProcessor.processResponse(ErrorContext.builder(request).cause(exception).error(new Error() { + @Override + public String getMessage() { + return exception.getMessage(); + } + }).build(), HttpResponse.badRequest()); + } +} diff --git a/servlet-core/src/main/java/io/micronaut/servlet/http/DefaultServletExchange.java b/servlet-core/src/main/java/io/micronaut/servlet/http/DefaultServletExchange.java index 21ae9a5f4..232934e30 100644 --- a/servlet-core/src/main/java/io/micronaut/servlet/http/DefaultServletExchange.java +++ b/servlet-core/src/main/java/io/micronaut/servlet/http/DefaultServletExchange.java @@ -55,7 +55,7 @@ public DefaultServletExchange( /** * @return The response object */ - public ServletHttpResponse getResponse() { + public ServletHttpResponse getResponse() { return response; } } diff --git a/servlet-core/src/main/java/io/micronaut/servlet/http/ServletBodyBinder.java b/servlet-core/src/main/java/io/micronaut/servlet/http/ServletBodyBinder.java index c9247e638..78a249785 100644 --- a/servlet-core/src/main/java/io/micronaut/servlet/http/ServletBodyBinder.java +++ b/servlet-core/src/main/java/io/micronaut/servlet/http/ServletBodyBinder.java @@ -56,12 +56,12 @@ public class ServletBodyBinder extends DefaultBodyAnnotationBinder impleme /** * Default constructor. - * @param conversionService The conversion service + * + * @param conversionService The conversion service * @param mediaTypeCodecRegistry The codec registry */ - protected ServletBodyBinder( - ConversionService conversionService, - MediaTypeCodecRegistry mediaTypeCodecRegistry) { + protected ServletBodyBinder(ConversionService conversionService, + MediaTypeCodecRegistry mediaTypeCodecRegistry) { super(conversionService); this.mediaTypeCodeRegistry = mediaTypeCodecRegistry; } @@ -76,8 +76,7 @@ public BindingResult bind(ArgumentConversionContext context, HttpRequest argument = context.getArgument(); final Class type = argument.getType(); String name = argument.getAnnotationMetadata().stringValue(Body.class).orElse(null); - if (source instanceof ServletHttpRequest) { - ServletHttpRequest servletHttpRequest = (ServletHttpRequest) source; + if (source instanceof ServletHttpRequest servletHttpRequest) { if (Readable.class.isAssignableFrom(type)) { Readable readable = new Readable() { @Override @@ -103,7 +102,8 @@ public String getName() { } }; return () -> (Optional) Optional.of(readable); - } else if (CharSequence.class.isAssignableFrom(type) && name == null) { + } + if (CharSequence.class.isAssignableFrom(type) && name == null) { try (InputStream inputStream = servletHttpRequest.getInputStream()) { final String content = IOUtils.readText(new BufferedReader(new InputStreamReader(inputStream, source.getCharacterEncoding()))); return () -> (Optional) Optional.of(content); @@ -122,55 +122,49 @@ public List getConversionErrors() { } }; } - } else { - final MediaType mediaType = source.getContentType().orElse(MediaType.APPLICATION_JSON_TYPE); - if (isFormSubmission(mediaType)) { - if (name != null) { - return () -> servletHttpRequest.getParameters().get(name, context); - } else { - Optional result = conversionService.convert(servletHttpRequest.getParameters().asMap(), context); - return () -> result; - } + } + final MediaType mediaType = source.getContentType().orElse(MediaType.APPLICATION_JSON_TYPE); + if (isFormSubmission(mediaType)) { + if (name != null) { + return () -> servletHttpRequest.getParameters().get(name, context); } else { - final MediaTypeCodec codec = mediaTypeCodeRegistry - .findCodec(mediaType, type) - .orElse(null); - - if (codec != null) { + Optional result = conversionService.convert(servletHttpRequest.getParameters().asMap(), context); + return () -> result; + } + } + final MediaTypeCodec codec = mediaTypeCodeRegistry + .findCodec(mediaType, type) + .orElse(null); - try (InputStream inputStream = servletHttpRequest.getInputStream()) { - if (Publishers.isConvertibleToPublisher(type)) { - final Argument typeArg = argument.getFirstTypeVariable().orElse(Argument.OBJECT_ARGUMENT); - if (Publishers.isSingle(type)) { - T content = (T) codec.decode(typeArg, inputStream); - final Publisher publisher = Publishers.just(content); - final T converted = conversionService.convertRequired(publisher, type); - return () -> Optional.of(converted); - } else { - final Argument> containerType = Argument.listOf(typeArg.getType()); - T content = (T) codec.decode(containerType, inputStream); - final Publisher publisher = Flux.fromIterable((Iterable) content); - final T converted = conversionService.convertRequired(publisher, type); - return () -> Optional.of(converted); - } - } else { - if (type.isArray()) { - Class componentType = type.getComponentType(); - List content = (List) codec.decode(Argument.listOf(componentType), inputStream); - Object[] array = content.toArray((Object[]) Array.newInstance(componentType, 0)); - return () -> Optional.of((T) array); - } else { - T content = codec.decode(argument, inputStream); - return () -> Optional.of(content); - } - } - } catch (CodecException | IOException e) { - throw new CodecException("Unable to decode request body: " + e.getMessage(), e); + if (codec != null) { + try (InputStream inputStream = servletHttpRequest.getInputStream()) { + if (Publishers.isConvertibleToPublisher(type)) { + final Argument typeArg = argument.getFirstTypeVariable().orElse(Argument.OBJECT_ARGUMENT); + if (Publishers.isSingle(type)) { + T content = (T) codec.decode(typeArg, inputStream); + final Publisher publisher = Publishers.just(content); + final T converted = conversionService.convertRequired(publisher, type); + return () -> Optional.of(converted); } + final Argument> containerType = Argument.listOf(typeArg.getType()); + T content = (T) codec.decode(containerType, inputStream); + final Publisher publisher = Flux.fromIterable((Iterable) content); + final T converted = conversionService.convertRequired(publisher, type); + return () -> Optional.of(converted); + } + if (type.isArray()) { + Class componentType = type.getComponentType(); + List content = (List) codec.decode(Argument.listOf(componentType), inputStream); + Object[] array = content.toArray((Object[]) Array.newInstance(componentType, 0)); + return () -> Optional.of((T) array); } + T content = codec.decode(argument, inputStream); + return () -> Optional.of(content); + } catch (CodecException | IOException e) { + throw new CodecException("Unable to decode request body: " + e.getMessage(), e); } - } + } return super.bind(context, source); } diff --git a/servlet-core/src/main/java/io/micronaut/servlet/http/ServletExchange.java b/servlet-core/src/main/java/io/micronaut/servlet/http/ServletExchange.java index b8c6095f9..9774bf23d 100644 --- a/servlet-core/src/main/java/io/micronaut/servlet/http/ServletExchange.java +++ b/servlet-core/src/main/java/io/micronaut/servlet/http/ServletExchange.java @@ -33,5 +33,5 @@ public interface ServletExchange { /** * @return The response object */ - ServletHttpResponse getResponse(); + ServletHttpResponse getResponse(); } diff --git a/servlet-core/src/main/java/io/micronaut/servlet/http/ServletHttpHandler.java b/servlet-core/src/main/java/io/micronaut/servlet/http/ServletHttpHandler.java index 02868e8ee..ad267fe2a 100644 --- a/servlet-core/src/main/java/io/micronaut/servlet/http/ServletHttpHandler.java +++ b/servlet-core/src/main/java/io/micronaut/servlet/http/ServletHttpHandler.java @@ -18,19 +18,15 @@ import io.micronaut.context.ApplicationContext; import io.micronaut.context.LifeCycle; import io.micronaut.core.annotation.AnnotationMetadata; +import io.micronaut.core.annotation.AnnotationMetadataProvider; import io.micronaut.core.annotation.AnnotationValue; import io.micronaut.core.annotation.NonNull; import io.micronaut.core.async.publisher.Publishers; -import io.micronaut.core.async.subscriber.CompletionAwareSubscriber; -import io.micronaut.core.convert.exceptions.ConversionErrorException; -import io.micronaut.core.convert.value.ConvertibleValues; +import io.micronaut.core.execution.ExecutionFlow; import io.micronaut.core.io.Writable; -import io.micronaut.core.type.Argument; import io.micronaut.core.util.ArrayUtils; -import io.micronaut.core.util.CollectionUtils; import io.micronaut.http.HttpAttributes; import io.micronaut.http.HttpHeaders; -import io.micronaut.http.HttpMethod; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; import io.micronaut.http.HttpStatus; @@ -38,41 +34,25 @@ import io.micronaut.http.MutableHttpResponse; import io.micronaut.http.annotation.Header; import io.micronaut.http.annotation.Produces; -import io.micronaut.http.annotation.Status; -import io.micronaut.http.bind.binders.ContinuationArgumentBinder; import io.micronaut.http.codec.CodecException; import io.micronaut.http.codec.MediaTypeCodec; import io.micronaut.http.codec.MediaTypeCodecRegistry; -import io.micronaut.http.context.ServerRequestContext; import io.micronaut.http.context.event.HttpRequestReceivedEvent; import io.micronaut.http.context.event.HttpRequestTerminatedEvent; import io.micronaut.http.exceptions.HttpStatusException; -import io.micronaut.http.filter.HttpFilter; -import io.micronaut.http.filter.ServerFilterChain; +import io.micronaut.http.server.RouteExecutor; import io.micronaut.http.server.binding.RequestArgumentSatisfier; -import io.micronaut.http.server.exceptions.ExceptionHandler; -import io.micronaut.http.server.exceptions.response.ErrorContext; -import io.micronaut.http.server.exceptions.response.ErrorResponseProcessor; import io.micronaut.http.server.types.files.FileCustomizableResponseType; import io.micronaut.http.server.types.files.StreamedFile; import io.micronaut.http.server.types.files.SystemFile; -import io.micronaut.inject.qualifiers.Qualifiers; -import io.micronaut.web.router.MethodBasedRouteMatch; +import io.micronaut.web.router.RouteInfo; import io.micronaut.web.router.RouteMatch; -import io.micronaut.web.router.Router; -import io.micronaut.web.router.UriRoute; -import io.micronaut.web.router.UriRouteMatch; -import io.micronaut.web.router.exceptions.DuplicateRouteException; -import io.micronaut.web.router.exceptions.UnsatisfiedRouteException; import io.micronaut.web.router.resource.StaticResourceResolver; -import io.netty.handler.codec.http.HttpHeaderValues; import org.reactivestreams.Publisher; -import org.reactivestreams.Subscription; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; import java.io.BufferedWriter; import java.io.File; @@ -81,28 +61,13 @@ import java.net.URISyntaxException; import java.net.URL; import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.Set; -import java.util.concurrent.Callable; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Function; -import java.util.function.Supplier; -import java.util.regex.Pattern; +import java.util.function.Consumer; import java.util.stream.Collectors; -import static io.micronaut.core.util.KotlinUtils.isKotlinCoroutineSuspended; -import static io.micronaut.http.HttpAttributes.AVAILABLE_HTTP_METHODS; -import static io.micronaut.http.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD; -import static io.micronaut.inject.beans.KotlinExecutableMethodUtils.isKotlinFunctionReturnTypeUnit; - /** * An HTTP handler that can deal with Serverless requests. * @@ -111,21 +76,18 @@ * @author graemerocher * @since 1.2.0 */ -public abstract class ServletHttpHandler implements AutoCloseable, LifeCycle> { +public abstract class ServletHttpHandler implements AutoCloseable, + LifeCycle>, RouteExecutor.StaticResourceResponseFinder { /** * Logger to be used by subclasses for logging. */ protected static final Logger LOG = LoggerFactory.getLogger(ServletHttpHandler.class); - private static final Pattern IGNORABLE_ERROR_MESSAGE = Pattern.compile( - "^.*(?:connection.*(?:reset|closed|abort|broken)|broken.*pipe).*$", Pattern.CASE_INSENSITIVE); - - private final Router router; + protected final ApplicationContext applicationContext; private final RequestArgumentSatisfier requestArgumentSatisfier; + private final RouteExecutor routeExecutor; private final MediaTypeCodecRegistry mediaTypeCodecRegistry; - private final ApplicationContext applicationContext; private final Map, ServletResponseEncoder> responseEncoders; - private final ErrorResponseProcessor errorResponseProcessor; private final StaticResourceResolver staticResourceResolver; /** @@ -135,21 +97,20 @@ public abstract class ServletHttpHandler implements AutoCloseable, Lif */ public ServletHttpHandler(ApplicationContext applicationContext) { this.applicationContext = Objects.requireNonNull(applicationContext, "The application context cannot be null"); - this.router = applicationContext.getBean(Router.class); this.requestArgumentSatisfier = applicationContext.getBean(RequestArgumentSatisfier.class); this.mediaTypeCodecRegistry = applicationContext.getBean(MediaTypeCodecRegistry.class); //noinspection unchecked this.responseEncoders = applicationContext.streamOfType(ServletResponseEncoder.class) - .collect(Collectors.toMap( - ServletResponseEncoder::getResponseType, - (o) -> o - )); - this.errorResponseProcessor = applicationContext.getBean(ErrorResponseProcessor.class); + .collect(Collectors.toMap( + ServletResponseEncoder::getResponseType, + (o) -> o + )); this.staticResourceResolver = applicationContext.getBean(StaticResourceResolver.class); + this.routeExecutor = applicationContext.getBean(RouteExecutor.class); // hack for bug fixed in Micronaut 1.3.3 applicationContext.getEnvironment() - .addConverter(HttpRequest.class, HttpRequest.class, httpRequest -> httpRequest); + .addConverter(HttpRequest.class, HttpRequest.class, httpRequest -> httpRequest); } /** @@ -205,10 +166,9 @@ public boolean isRunning() { return getApplicationContext().isRunning(); } - static boolean isPreflightRequest(HttpRequest request) { - HttpHeaders headers = request.getHeaders(); - Optional origin = headers.getOrigin(); - return origin.isPresent() && headers.contains(ACCESS_CONTROL_REQUEST_METHOD) && HttpMethod.OPTIONS == request.getMethod(); + @Override + public FileCustomizableResponseType find(HttpRequest httpRequest) { + return matchFile(httpRequest.getPath()).orElse(null); } /** @@ -218,135 +178,71 @@ static boolean isPreflightRequest(HttpRequest request) { */ public void service(ServletExchange exchange) { final long time = System.currentTimeMillis(); - try { - final MutableHttpResponse res = exchange.getResponse(); - final HttpRequest req = exchange.getRequest(); - applicationContext.publishEvent(new HttpRequestReceivedEvent(req)); - - final List> matchingRoutes = router.findAllClosest(req); - - boolean preflightRequest = isPreflightRequest(req); - - if (CollectionUtils.isEmpty(matchingRoutes) && preflightRequest) { - - List> anyUriRoutes = router.findAny(req.getUri().getPath(), req) - .collect(Collectors.toList()); - req.setAttribute(AVAILABLE_HTTP_METHODS, anyUriRoutes.stream().map(UriRouteMatch::getHttpMethod).collect(Collectors.toList())); - if (anyUriRoutes.isEmpty()) { - handlePageNotFound(exchange, res, req); - } else { - UriRouteMatch establishedRoute = anyUriRoutes.get(0); - req.setAttribute(HttpAttributes.ROUTE, establishedRoute.getRoute()); - req.setAttribute(HttpAttributes.ROUTE_MATCH, establishedRoute); - req.setAttribute(HttpAttributes.URI_TEMPLATE, establishedRoute.getRoute().getUriMatchTemplate().toString()); - invokeRouteMatch(req, res, establishedRoute, false, true, exchange); - } - - } else if (CollectionUtils.isNotEmpty(matchingRoutes)) { - - RouteMatch route; - if (matchingRoutes.size() > 1) { - throw new DuplicateRouteException(req.getPath(), matchingRoutes); - } else { - UriRouteMatch establishedRoute = matchingRoutes.get(0); - req.setAttribute(HttpAttributes.ROUTE, establishedRoute.getRoute()); - req.setAttribute(HttpAttributes.ROUTE_MATCH, establishedRoute); - req.setAttribute(HttpAttributes.URI_TEMPLATE, establishedRoute.getRoute().getUriMatchTemplate().toString()); - route = establishedRoute; - } - + Consumer> requestTerminated = ignore -> { + applicationContext.publishEvent(new HttpRequestTerminatedEvent(exchange.getRequest())); + if (LOG.isTraceEnabled()) { + final HttpRequest r = exchange.getRequest(); + LOG.trace("Executed HTTP Request [{} {}] in: {}ms", + r.getMethod(), + r.getPath(), + (System.currentTimeMillis() - time) + ); + } + }; - if (LOG.isDebugEnabled()) { - LOG.debug("{} - {} - routed to controller {}", req.getMethodName(), req.getPath(), route.getDeclaringType().getSimpleName()); - traceHeaders(req.getHeaders()); - } + final HttpRequest req = exchange.getRequest(); + applicationContext.publishEvent(new HttpRequestReceivedEvent(req)); - invokeRouteMatch(req, res, route, false, true, exchange); + RouteExecutor.RequestBodyReader requestBodyReader = (routeMatch, httpRequest) -> { + RouteMatch route = requestArgumentSatisfier.fulfillArgumentRequirements(routeMatch, httpRequest, false); + return ExecutionFlow.just(route); + }; - } else { + if (exchange.getRequest().isAsyncSupported()) { + exchange.getRequest().executeAsync(asyncExecution -> { + routeExecutor.executeRoute(requestBodyReader, req, true, this) + .onComplete((response, throwable) -> onComplete(exchange, req, response, throwable, httpResponse -> { + asyncExecution.complete(); + requestTerminated.accept(httpResponse); + })); + }); + } else { + routeExecutor.executeRoute(requestBodyReader, req, true, this) + .onComplete((response, throwable) -> onComplete(exchange, req, response, throwable, requestTerminated)); + } + } + private void onComplete(ServletExchange exchange, + HttpRequest req, + MutableHttpResponse response, + Throwable throwable, + Consumer> responsePublisherCallback) { + if (throwable != null) { + response = routeExecutor.createDefaultErrorResponse(req, throwable); + } + if (response != null) { + try { if (LOG.isDebugEnabled()) { - LOG.debug("{} - {} - No matching routes found", req.getMethodName(), req.getPath()); - traceHeaders(req.getHeaders()); + LOG.debug("Request [{} - {}] completed successfully", req.getMethodName(), req.getUri()); } - - Set existingRouteMethods = router - .findAny(req.getUri().toString(), req) - .map(UriRouteMatch::getRoute) - .map(UriRoute::getHttpMethodName) - .collect(Collectors.toSet()); - - if (CollectionUtils.isNotEmpty(existingRouteMethods)) { - if (existingRouteMethods.contains(req.getMethodName())) { - MediaType contentType = req.getContentType().orElse(null); - if (contentType != null) { - // must be invalid mime type - boolean invalidMediaType = router.findAny(req.getUri().toString(), req) - .anyMatch(rm -> rm.doesConsume(contentType)); - if (!invalidMediaType) { - handleStatusRoute(exchange, res, req, HttpStatus.UNSUPPORTED_MEDIA_TYPE); - } else { - handlePageNotFound(exchange, res, req); - } - - } else { - handlePageNotFound(exchange, res, req); - } - } else { - final RouteMatch notAllowedRoute = - router.route(HttpStatus.METHOD_NOT_ALLOWED).orElse(null); - - if (notAllowedRoute != null) { - invokeRouteMatch(req, res, notAllowedRoute, true, true, exchange); - } else { - handleStatusRoute(exchange, res, req, HttpStatus.METHOD_NOT_ALLOWED, () -> { - res.getHeaders().allowGeneric(existingRouteMethods); - res.status(HttpStatus.METHOD_NOT_ALLOWED); - return errorResponseProcessor.processResponse(ErrorContext.builder(req) - .errorMessage("Method [" + req.getMethod() + "] not allowed for URI [" + req - .getPath() + "]. Allowed methods: " + existingRouteMethods) - .build(), res); - }); - } - } - } else { - final Optional fileMatch = matchFile(req.getPath()); - - if (fileMatch.isPresent()) { - res.body(fileMatch.get()); - if (exchange.getRequest().isAsyncSupported()) { - Flux.from(exchange.getRequest().subscribeOnExecutor(Mono.just(res))) - .subscribe(response -> { - encodeResponse(exchange, AnnotationMetadata.EMPTY_METADATA, response); - if (LOG.isDebugEnabled()) { - LOG.debug("Request [{} - {}] completed successfully", req.getMethodName(), req.getUri()); - } - }, throwable -> LOG.error("Request [{} - {}] completed with error: {}", req.getMethodName(), req.getUri(), throwable.getMessage(), throwable)); - } else { - try { - encodeResponse(exchange, AnnotationMetadata.EMPTY_METADATA, res); - if (LOG.isDebugEnabled()) { - LOG.debug("Request [{} - {}] completed successfully", req.getMethodName(), req.getUri()); - } - } catch (Exception e) { - LOG.error("Request [{} - {}] completed with error: {}", req.getMethodName(), req.getUri(), e.getMessage(), e); - } - } - } else { - handlePageNotFound(exchange, res, req); - } + encodeResponse(exchange, req, response, responsePublisherCallback); + } catch (Throwable e) { + response = routeExecutor.createDefaultErrorResponse(req, e); + try { + encodeResponse(exchange, req, response, responsePublisherCallback); + } catch (Throwable e2) { + LOG.error("Request [{} - {}] completed with error: {}", req.getMethodName(), req.getUri(), e2.getMessage(), e2); + responsePublisherCallback.accept(null); + return; } } - } finally { - applicationContext.publishEvent(new HttpRequestTerminatedEvent(exchange.getRequest())); - if (LOG.isTraceEnabled()) { - final HttpRequest r = exchange.getRequest(); - LOG.trace("Executed HTTP Request [{} {}] in: {}ms", - r.getMethod(), - r.getPath(), - (System.currentTimeMillis() - time) - ); + if (throwable != null) { + LOG.error("Request [{} - {}] completed with error: {}", req.getMethodName(), req.getUri(), throwable.getMessage(), throwable); + } else { + LOG.debug("Request [{} - {}] completed successfully", req.getMethodName(), req.getUri()); } + } else { + responsePublisherCallback.accept(null); } } @@ -380,53 +276,6 @@ private void traceHeaders(HttpHeaders httpHeaders) { } } - private void handlePageNotFound(ServletExchange exchange, MutableHttpResponse res, HttpRequest req) { - handleStatusRoute(exchange, res, req, HttpStatus.NOT_FOUND); - } - - private void handleStatusRoute(ServletExchange exchange, MutableHttpResponse res, HttpRequest req, HttpStatus httpStatus) { - handleStatusRoute(exchange, res, req, httpStatus, () -> { - res.status(httpStatus); - return errorResponseProcessor.processResponse(ErrorContext.builder(req).build(), res); - }); - } - - private void handleStatusRoute(ServletExchange exchange, MutableHttpResponse res, HttpRequest req, HttpStatus httpStatus, - Callable> defaultProcessor) { - - final RouteMatch statusRoute = router.route(httpStatus).orElse(null); - if (statusRoute != null) { - invokeRouteMatch(req, res, statusRoute, true, true, exchange); - } else { - Publisher> responsePublisher = filterPublisher(exchange, new AtomicReference<>(req), Mono.fromCallable(defaultProcessor), null); - subscribeToResponsePublisher(req, res, null, false, exchange, responsePublisher, AnnotationMetadata.EMPTY_METADATA); - } - } - - private Publisher> handleStatusException(ServletExchange exchange, - MutableHttpResponse response, - AtomicReference> requestReference) { - HttpStatus status = response.status(); - Optional route = response.getAttribute(HttpAttributes.ROUTE_MATCH, RouteMatch.class); - if (route.isPresent()) { - boolean isErrorRoute = route.filter(RouteMatch::isErrorRoute).isPresent(); - if (!isErrorRoute && status.getCode() >= 400) { - //overwrite any previously set status so the route `@Status` can apply - - final RouteMatch errorRoute = lookupStatusRoute(route.get(), status); - if (errorRoute != null) { - exchange.getResponse().status(HttpStatus.OK); - return buildResponsePublisher( - exchange, - requestReference.get(), - errorRoute - ); - } - } - } - return Flux.just(response); - } - @Override public void close() { if (applicationContext.isRunning()) { @@ -459,439 +308,144 @@ public ServletHttpHandler stop() { */ protected abstract ServletExchange createExchange(Req request, Res response); - private void invokeRouteMatch( - HttpRequest req, - MutableHttpResponse res, - final RouteMatch route, - boolean isErrorRoute, - boolean executeFilters, - ServletExchange exchange) { - - AtomicReference> requestReference = new AtomicReference<>(req); - Publisher> responsePublisher = buildResponsePublisher(exchange, req, route) - .flatMap(response -> { - return handleStatusException(exchange, response, requestReference); - }) - .onErrorResume(t -> { - final HttpRequest httpRequest = requestReference.get(); - return handleException(httpRequest, exchange.getResponse(), route, false, t, exchange); - }); - - if (executeFilters) { - responsePublisher = filterPublisher(exchange, new AtomicReference<>(req), responsePublisher, route); - } - final AnnotationMetadata annotationMetadata = route.getAnnotationMetadata(); - subscribeToResponsePublisher(req, res, route, isErrorRoute, exchange, responsePublisher, annotationMetadata); - } - - private void subscribeToResponsePublisher(HttpRequest req, - MutableHttpResponse res, - RouteMatch route, - boolean isErrorRoute, - ServletExchange exchange, - Publisher> responsePublisher, - AnnotationMetadata annotationMetadata) { - final ServletHttpRequest exchangeRequest = exchange.getRequest(); - boolean isAsyncSupported = exchangeRequest.isAsyncSupported(); - final Flux> responseFlux = Flux.from(responsePublisher) - .flatMap(response -> { - Object body = response.body(); - - if (body != null) { - if (Publishers.isConvertibleToPublisher(body)) { - boolean isSingle = Publishers.isSingle(body.getClass()); - if (isSingle) { - Flux flux = Flux.from(Publishers.convertPublisher(body, Publisher.class)); - return flux.map((Function>) o -> { - if (o instanceof HttpResponse) { - return res; - } else { - ServletHttpResponse res1 = exchange.getResponse(); - res1.body(o); - return res1; - } - }); - } else { - // stream case - Publisher bodyPublisher = Publishers.convertPublisher(body, Publisher.class); - final ServletHttpResponse servletResponse = exchange.getResponse(); - if (isAsyncSupported) { - servletResponse.body(servletResponse.stream(bodyPublisher)); - } else { - // fallback to blocking - servletResponse.body(Flux.from(bodyPublisher).collectList().block()); - } - return Flux.just(servletResponse); - } - } - } - - return Mono.just(response); - }).onErrorResume(throwable -> - handleException(req, res, route, isErrorRoute, throwable, exchange)); - - if (isAsyncSupported) { - //noinspection ResultOfMethodCallIgnored - Flux.from(exchangeRequest.subscribeOnExecutor(responseFlux)) - .subscribe(response -> { - encodeResponse(exchange, annotationMetadata, response); - if (LOG.isDebugEnabled()) { - LOG.debug("Request [{} - {}] completed successfully", req.getMethodName(), req.getUri()); - } - }, throwable -> LOG.error("Request [{} - {}] completed with error: {}", req.getMethodName(), req.getUri(), throwable.getMessage(), throwable)); - } else { - responseFlux - .subscribeOn(Schedulers.immediate()) - .subscribe(response -> { - encodeResponse(exchange, annotationMetadata, response); - if (LOG.isDebugEnabled()) { - LOG.debug("Request [{} - {}] completed successfully", req.getMethodName(), req.getUri()); - } - }, throwable -> LOG.error("Request [{} - {}] completed with error: {}", req.getMethodName(), req.getUri(), throwable.getMessage(), throwable)); - } - } - - private MutableHttpResponse toMutableResponse(ServletExchange exchange, HttpResponse message) { - MutableHttpResponse mutableHttpResponse; - if (message instanceof MutableHttpResponse) { - mutableHttpResponse = (MutableHttpResponse) message; - } else { - HttpStatus httpStatus = message.status(); - mutableHttpResponse = exchange.getResponse().status(httpStatus, httpStatus.getReason()); - mutableHttpResponse.body(message.body()); - message.getHeaders().forEach((name, value) -> { - for (String val: value) { - mutableHttpResponse.header(name, val); - } - }); - mutableHttpResponse.getAttributes().putAll(message.getAttributes()); - } - return mutableHttpResponse; - } - - private boolean isSingle(RouteMatch finalRoute, Class bodyClass) { - return finalRoute.isSpecifiedSingle() || (finalRoute.isSingleResult() && - (finalRoute.isAsync() || finalRoute.isSuspended() || Publishers.isSingle(bodyClass))); - } - - private MutableHttpResponse forStatus(ServletExchange exchange, RouteMatch routeMatch) { - return forStatus(exchange, routeMatch, HttpStatus.OK); - } + private void encodeResponse(ServletExchange exchange, + HttpRequest request, + MutableHttpResponse response, + Consumer> responsePublisherCallback) { + final Object body = response.getBody().orElse(null); - private MutableHttpResponse forStatus(ServletExchange exchange, RouteMatch routeMatch, HttpStatus defaultStatus) { - HttpStatus status = routeMatch.findStatus(defaultStatus); - final ServletHttpResponse response = exchange.getResponse(); - // Unfortunately it's impossible to tell if the status is OK because its the - // default or because it was explicitly set - if (response.status() == null || response.status() == HttpStatus.OK) { - return response.status(status); - } else { - return response; + if (LOG.isDebugEnabled()) { + LOG.debug("Sending response {}", response.status()); + traceHeaders(response.getHeaders()); } - } - private MutableHttpResponse newNotFoundError(ServletExchange exchange, HttpRequest request) { - return errorResponseProcessor.processResponse( - ErrorContext.builder(request) - .errorMessage("Page Not Found") - .build(), exchange.getResponse().status(HttpStatus.NOT_FOUND)); - } + AnnotationMetadata routeAnnotationMetadata = response.getAttribute(HttpAttributes.ROUTE_INFO, RouteInfo.class) + .map(AnnotationMetadataProvider::getAnnotationMetadata) + .orElse(AnnotationMetadata.EMPTY_METADATA); - private Flux> buildResponsePublisher( - ServletExchange exchange, - HttpRequest req, - RouteMatch route - ) { - return Flux.deferContextual(contextView -> { - return Flux.create((subscriber) -> { - RouteMatch computedRoute = route; - if (!computedRoute.isExecutable()) { - computedRoute = requestArgumentSatisfier.fulfillArgumentRequirements( - computedRoute, - req, - false - ); - } - if (!computedRoute.isExecutable() && HttpMethod.permitsRequestBody(req.getMethod()) && !computedRoute.getBodyArgument().isPresent()) { - final ConvertibleValues convertibleValues = req.getBody(ConvertibleValues.class).orElse(null); - if (convertibleValues != null) { - final Collection requiredArguments = route.getRequiredArguments(); - Map newValues = new HashMap<>(requiredArguments.size()); - for (Argument requiredArgument : requiredArguments) { - final String name = requiredArgument.getName(); - convertibleValues.get(name, requiredArgument).ifPresent(v -> newValues.put(name, v)); - } - if (CollectionUtils.isNotEmpty(newValues)) { - computedRoute = computedRoute.fulfill( - newValues - ); - } - } - } + ServletHttpResponse servletResponse = exchange.getResponse(); + servletResponse.status(response.status(), response.reason()); - RouteMatch finalRoute = computedRoute; - if (finalRoute.isSuspended()) { - ContinuationArgumentBinder.setupCoroutineContext(req, contextView); - } - Object result = null; - try { - result = ServerRequestContext.with(req, (Callable) finalRoute::execute); - } catch (Throwable t) { - subscriber.error(t); + if (body != null) { + Class bodyType = body.getClass(); + ServletResponseEncoder responseEncoder = (ServletResponseEncoder) responseEncoders.get(bodyType); + if (responseEncoder != null) { + if (exchange.getRequest().isAsyncSupported()) { + Flux.from(responseEncoder.encode(exchange, routeAnnotationMetadata, body)) + .subscribe(responsePublisherCallback); return; + } else { + // NOTE[moss]: blockLast() here *was* subscribe(), but that returns immediately, which was + // sometimes allowing the main response publisher to complete before this responseEncoder + // could fill out the response! Blocking here will ensure that the response is filled out + // before the main response publisher completes. This will be improved later to avoid the block. + Flux.from(responseEncoder.encode(exchange, routeAnnotationMetadata, body)).blockLast(); + // Continue blocking execution } - if (result instanceof Optional) { - result = ((Optional) result).orElse(null); - } - MutableHttpResponse outgoingResponse; + } - if (result == null) { - if (finalRoute.isVoid()) { - outgoingResponse = forStatus(exchange, finalRoute); - if (HttpMethod.permitsRequestBody(req.getMethod())) { - outgoingResponse.header(HttpHeaders.CONTENT_LENGTH, HttpHeaderValues.ZERO); + MediaType mediaType = response.getContentType().orElse(null); + if (mediaType == null) { + mediaType = response.getAttribute(HttpAttributes.ROUTE_INFO, RouteInfo.class) + .map(routeInfo -> { + final Produces ann = bodyType.getAnnotation(Produces.class); + if (ann != null) { + final String[] v = ann.value(); + if (ArrayUtils.isNotEmpty(v)) { + return new MediaType(v[0]); + } } - } else { - outgoingResponse = newNotFoundError(exchange, req); - } - } else { - HttpStatus defaultHttpStatus = finalRoute.isErrorRoute() ? HttpStatus.INTERNAL_SERVER_ERROR : HttpStatus.OK; - boolean isReactive = finalRoute.isAsyncOrReactive() || Publishers.isConvertibleToPublisher(result); - if (isReactive) { - Class bodyClass = result.getClass(); - boolean isSingle = isSingle(finalRoute, bodyClass); - boolean isCompletable = !isSingle && finalRoute.isVoid() && Publishers.isCompletable(bodyClass); - if (isSingle || isCompletable) { - // full response case - Publisher publisher = Publishers.convertPublisher(result, Publisher.class); - Publishers.mapOrSupplyEmpty(publisher, new Publishers.MapOrSupplyEmpty>() { - @Override - public MutableHttpResponse map(Object o) { - MutableHttpResponse singleResponse; - if (o instanceof Optional) { - Optional optional = (Optional) o; - if (optional.isPresent()) { - o = ((Optional) o).get(); - } else { - return supplyEmpty(); - } - } - if (o instanceof HttpResponse) { - singleResponse = toMutableResponse(exchange, (HttpResponse) o); - } else if (o instanceof HttpStatus) { - singleResponse = forStatus(exchange, finalRoute, (HttpStatus) o); - } else { - singleResponse = forStatus(exchange, finalRoute, defaultHttpStatus) - .body(o); - } - singleResponse.setAttribute(HttpAttributes.ROUTE_MATCH, finalRoute); - return singleResponse; - } - - @Override - public MutableHttpResponse supplyEmpty() { - MutableHttpResponse singleResponse; - if (isCompletable || finalRoute.isVoid()) { - singleResponse = forStatus(exchange, finalRoute, HttpStatus.OK) - .header(HttpHeaders.CONTENT_LENGTH, HttpHeaderValues.ZERO); - } else { - singleResponse = newNotFoundError(exchange, req); - } - singleResponse.setAttribute(HttpAttributes.ROUTE_MATCH, finalRoute); - return singleResponse; - } - - }).subscribe(new CompletionAwareSubscriber>() { - - @Override - public void doOnSubscribe(Subscription s) { - s.request(1); - } - - @Override - public void doOnNext(MutableHttpResponse mutableHttpResponse) { - subscriber.next(mutableHttpResponse); - } - - @Override - public void doOnError(Throwable t) { - subscriber.error(t); - } + return routeExecutor.resolveDefaultResponseContentType(request, routeInfo); + }) + // RouteExecutor will pick json by default, so we do too + .orElse(MediaType.APPLICATION_JSON_TYPE); + response.contentType(mediaType); + } - @Override - public void doOnComplete() { - subscriber.complete(); + setHeadersFromMetadata(servletResponse, routeAnnotationMetadata, body); + if (Publishers.isConvertibleToPublisher(body)) { + boolean isSingle = Publishers.isSingle(body.getClass()); + Publisher publisher = Publishers.convertPublisher(body, Publisher.class); + if (isSingle) { + if (exchange.getRequest().isAsyncSupported()) { + Flux flux = Flux.from(publisher); + flux.next().switchIfEmpty(Mono.just(response)).subscribe(bodyValue -> { + MutableHttpResponse nextResponse; + if (bodyValue instanceof MutableHttpResponse) { + nextResponse = ((MutableHttpResponse) bodyValue); + if (response == nextResponse) { + nextResponse.body(null); } - }); - return; - } - } - // now we have the raw result, transform it as necessary - if (result instanceof HttpStatus) { - outgoingResponse = exchange.getResponse().status((HttpStatus) result); - } else { - boolean isSuspended = finalRoute.isSuspended(); - if (isSuspended) { - boolean isKotlinFunctionReturnTypeUnit = - finalRoute instanceof MethodBasedRouteMatch && - isKotlinFunctionReturnTypeUnit(((MethodBasedRouteMatch) finalRoute).getExecutableMethod()); - final Supplier> supplier = ContinuationArgumentBinder.extractContinuationCompletableFutureSupplier(req); - if (isKotlinCoroutineSuspended(result)) { - CompletableFuture f = supplier.get(); - f.whenComplete((o, throwable) -> { - if (throwable != null) { - subscriber.error(throwable); - } else { - if (o == null) { - subscriber.next(newNotFoundError(exchange, req)); - } else { - MutableHttpResponse response; - if (o instanceof HttpResponse) { - response = toMutableResponse(exchange, (HttpResponse) o); - } else { - response = forStatus(exchange, finalRoute, defaultHttpStatus); - if (!isKotlinFunctionReturnTypeUnit) { - response = response.body(o); - } - } - response.setAttribute(HttpAttributes.ROUTE_MATCH, finalRoute); - subscriber.next(response); - } - subscriber.complete(); - } - }); - return; } else { - Object suspendedBody; - if (isKotlinFunctionReturnTypeUnit) { - suspendedBody = Mono.empty(); - } else { - suspendedBody = result; - } - if (suspendedBody instanceof HttpResponse) { - outgoingResponse = toMutableResponse(exchange, (HttpResponse) suspendedBody); - } else { - outgoingResponse = forStatus(exchange, finalRoute, defaultHttpStatus) - .body(suspendedBody); - } - } - - } else { - if (result instanceof HttpResponse) { - outgoingResponse = toMutableResponse(exchange, (HttpResponse) result); - } else { - outgoingResponse = forStatus(exchange, finalRoute, defaultHttpStatus) - .body(result); + nextResponse = response.body(bodyValue); } - } + // Call encoding again, the body might need to be encoded + encodeResponse(exchange, request, nextResponse, responsePublisherCallback); + }); + return; + } else { + // fallback to blocking + response.body(Mono.from(publisher).block()); } - - // for head request we never emit the body - if (req != null && req.getMethod().equals(HttpMethod.HEAD)) { - outgoingResponse.body(null); + } else { + // stream case + if (exchange.getRequest().isAsyncSupported()) { + Mono.from(servletResponse.stream(publisher)).subscribe(responsePublisherCallback); + return; + } else { + // fallback to blocking + servletResponse.body(Flux.from(publisher).collectList().block()); } } - outgoingResponse.setAttribute(HttpAttributes.ROUTE_MATCH, finalRoute); - subscriber.next(outgoingResponse); - subscriber.complete(); - }); - }); - } - - private void encodeResponse(ServletExchange exchange, - AnnotationMetadata annotationMetadata, - HttpResponse response) { - final Object body = response.getBody().orElse(null); - - if (LOG.isDebugEnabled()) { - LOG.debug("Sending response {}", response.status()); - traceHeaders(response.getHeaders()); - } - - if (body != null) { - Class bodyType = body.getClass(); - ServletResponseEncoder responseEncoder = (ServletResponseEncoder) responseEncoders.get(bodyType); - if (responseEncoder != null) { - // NOTE[moss]: blockLast() here *was* subscribe(), but that returns immediately, which was - // sometimes allowing the main response publisher to complete before this responseEncoder - // could fill out the response! Blocking here will ensure that the response is filled out - // before the main response publisher completes. This will be improved later to avoid the block. - - Flux.from(responseEncoder.encode(exchange, annotationMetadata, body)).blockLast(); - return; } - - setHeadersFromMetadata(exchange.getResponse(), annotationMetadata, body); - - if (body instanceof Publisher) { - Flux.from((Publisher) body).blockLast(); - } else if (body instanceof HttpStatus) { - exchange.getResponse().status((HttpStatus) body); + if (body instanceof HttpStatus) { + servletResponse.status((HttpStatus) body); } else if (body instanceof CharSequence) { - if (response instanceof MutableHttpResponse) { - if (!response.getContentType().isPresent()) { - ((MutableHttpResponse) response).contentType(MediaType.APPLICATION_JSON); - } + if (response.getContentType().isEmpty()) { + response.contentType(MediaType.APPLICATION_JSON); } - try (BufferedWriter writer = exchange.getResponse().getWriter()) { + try (BufferedWriter writer = servletResponse.getWriter()) { writer.write(body.toString()); writer.flush(); } catch (IOException e) { throw new HttpStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); } - } else if (body instanceof byte[]) { - try (OutputStream outputStream = exchange.getResponse().getOutputStream()) { - outputStream.write((byte[]) body); + } else if (body instanceof byte[] byteArray) { + try (OutputStream outputStream = servletResponse.getOutputStream()) { + outputStream.write(byteArray); outputStream.flush(); } catch (IOException e) { throw new HttpStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); } - } else if (body instanceof Writable) { - Writable writable = (Writable) body; - try (OutputStream outputStream = exchange.getResponse().getOutputStream()) { + } else if (body instanceof Writable writable) { + try (OutputStream outputStream = servletResponse.getOutputStream()) { writable.writeTo(outputStream, response.getCharacterEncoding()); outputStream.flush(); } catch (IOException e) { throw new HttpStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); } } else { - final MediaType ct = response.getContentType().orElseGet(() -> { - final Produces ann = bodyType.getAnnotation(Produces.class); - if (ann != null) { - final String[] v = ann.value(); - if (ArrayUtils.isNotEmpty(v)) { - final MediaType mediaType = new MediaType(v[0]); - if (response instanceof MutableHttpResponse) { - ((MutableHttpResponse) response).contentType(mediaType); - } - return mediaType; - } - } - if (response instanceof MutableHttpResponse) { - ((MutableHttpResponse) response).contentType(MediaType.APPLICATION_JSON_TYPE); - } - return MediaType.APPLICATION_JSON_TYPE; - }); - final MediaTypeCodec codec = mediaTypeCodecRegistry.findCodec(ct, bodyType).orElse(null); + final MediaTypeCodec codec = mediaTypeCodecRegistry.findCodec(mediaType, bodyType).orElse(null); if (codec != null) { - try (OutputStream outputStream = exchange.getResponse().getOutputStream()) { + try (OutputStream outputStream = servletResponse.getOutputStream()) { codec.encode(body, outputStream); outputStream.flush(); } catch (Throwable e) { - throw new CodecException("Failed to encode object [" + body + "] to content type [" + ct + "]: " + e.getMessage(), e); + throw new CodecException("Failed to encode object [" + body + "] to content type [" + mediaType + "]: " + e.getMessage(), e); } } else { - throw new CodecException("No codec present capable of encoding object [" + body + "] to content type [" + ct + "]"); + throw new CodecException("No codec present capable of encoding object [" + body + "] to content type [" + mediaType + "]"); } } } + responsePublisherCallback.accept(response); } - private void setHeadersFromMetadata(MutableHttpResponse res, AnnotationMetadata annotationMetadata, Object result) { - if (!res.getContentType().isPresent()) { + private void setHeadersFromMetadata(MutableHttpResponse res, AnnotationMetadata annotationMetadata, Object result) { + if (res.getContentType().isEmpty()) { final String contentType = annotationMetadata.stringValue(Produces.class) - .orElse(getDefaultMediaType(result)); + .orElse(getDefaultMediaType(result)); if (contentType != null) { res.contentType(contentType); } @@ -914,242 +468,4 @@ private String getDefaultMediaType(Object result) { return null; } - private void logException(Throwable cause) { - //handling connection reset by peer exceptions - if (isIgnorable(cause)) { - logIgnoredException(cause); - } else { - if (LOG.isErrorEnabled()) { - LOG.error("Unexpected error occurred: " + cause.getMessage(), cause); - } - } - } - - private boolean isIgnorable(Throwable cause) { - String message = cause.getMessage(); - return cause instanceof IOException && message != null && IGNORABLE_ERROR_MESSAGE.matcher(message).matches(); - } - - private void logIgnoredException(Throwable cause) { - if (LOG.isDebugEnabled()) { - LOG.debug("Swallowed an IOException caused by client connectivity: " + cause.getMessage(), cause); - } - } - - private Publisher> handleException( - HttpRequest req, - MutableHttpResponse res, - RouteMatch route, - boolean isErrorRoute, - Throwable e, - ServletExchange exchange) { - req.setAttribute(HttpAttributes.ERROR, e); - //overwrite any previously set status so the route `@Status` can apply - exchange.getResponse().status(HttpStatus.OK); - if (isErrorRoute) { - // handle error default - if (LOG.isErrorEnabled()) { - LOG.error("Error occurred executing Error route [" + route + "]: " + e.getMessage(), e); - } - return Flux.just(res.status(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage())); - } else { - if (e instanceof UnsatisfiedRouteException || e instanceof ConversionErrorException) { - final RouteMatch badRequestRoute = lookupStatusRoute(route, HttpStatus.BAD_REQUEST); - if (badRequestRoute != null) { - return buildResponsePublisher(exchange, req, badRequestRoute); - } else { - return invokeExceptionHandlerIfPossible(req, e, exchange); - } - } else if (e instanceof HttpStatusException) { - HttpStatusException statusException = (HttpStatusException) e; - final HttpStatus status = statusException.getStatus(); - final int code = status.getCode(); - final boolean isErrorStatus = code >= 400; - final RouteMatch statusRoute = isErrorStatus ? lookupStatusRoute(route, status) : null; - if (statusRoute != null) { - return buildResponsePublisher(exchange, req, statusRoute); - } else { - MutableHttpResponse response = res.status(code, statusException.getMessage()); - final Object body = statusException.getBody().orElse(null); - if (body != null) { - response.body(body); - } else if (isErrorStatus) { - response = errorResponseProcessor.processResponse(ErrorContext.builder(req) - .errorMessage(statusException.getMessage()) - .build(), response); - } - return Flux.just(response); - } - } else { - RouteMatch errorRoute = lookupErrorRoute(route, e); - if (errorRoute == null) { - if (e instanceof CodecException) { - Throwable cause = e.getCause(); - if (cause != null) { - errorRoute = lookupErrorRoute(route, cause); - } - if (errorRoute == null) { - final RouteMatch badRequestRoute = lookupStatusRoute(route, HttpStatus.BAD_REQUEST); - if (badRequestRoute != null) { - return buildResponsePublisher(exchange, req, badRequestRoute); - } else { - return invokeExceptionHandlerIfPossible(req, e, exchange, HttpStatus.BAD_REQUEST); - } - } - } - } - if (errorRoute != null) { - return buildResponsePublisher(exchange, req, errorRoute); - } else { - return invokeExceptionHandlerIfPossible(req, e, exchange); - } - } - } - } - - private MutableHttpResponse errorResultToResponse(ServletExchange exchange, Object result, HttpStatus defaultStatus) { - MutableHttpResponse response; - if (result instanceof HttpResponse) { - return toMutableResponse(exchange, (HttpResponse) result); - } else { - if (result instanceof HttpStatus) { - response = exchange.getResponse().status((HttpStatus) result); - } else { - response = exchange.getResponse().status(defaultStatus).body(result); - } - } - return response; - } - - private Publisher> createDefaultErrorResponsePublisher(ServletExchange exchange, - HttpRequest request, - Throwable cause, - HttpStatus defaultStatus) { - return Publishers.just(createDefaultErrorResponse(exchange, request, cause, defaultStatus)); - } - - private MutableHttpResponse createDefaultErrorResponse(ServletExchange exchange, - HttpRequest request, - Throwable cause, - HttpStatus defaultStatus) { - logException(cause); - HttpStatus status = defaultStatus != null ? defaultStatus : HttpStatus.INTERNAL_SERVER_ERROR; - final MutableHttpResponse response = exchange.getResponse().status(status); - response.setAttribute(HttpAttributes.EXCEPTION, cause); - return errorResponseProcessor.processResponse( - ErrorContext.builder(request) - .cause(cause) - .errorMessage("Internal Server Error: " + cause.getMessage()) - .build(), response); - } - - private Publisher> invokeExceptionHandlerIfPossible( - HttpRequest req, - Throwable e, - ServletExchange exchange) { - return invokeExceptionHandlerIfPossible(req, e, exchange, HttpStatus.INTERNAL_SERVER_ERROR); - } - - private Publisher> invokeExceptionHandlerIfPossible( - HttpRequest req, - Throwable e, - ServletExchange exchange, - HttpStatus defaultStatus) { - - final Class type = e.getClass(); - final ExceptionHandler exceptionHandler = applicationContext.findBean(ExceptionHandler.class, Qualifiers.byTypeArgumentsClosest(type, Object.class)) - .orElse(null); - - if (exceptionHandler != null) { - try { - return ServerRequestContext.with(req, (Supplier>>) () -> { - final Object result = exceptionHandler.handle(req, e); - MutableHttpResponse resp = errorResultToResponse(exchange, result, defaultStatus); - resp.setAttribute(HttpAttributes.ROUTE_MATCH, null); - return Flux.just(resp); - }); - } catch (Throwable ex) { - if (LOG.isErrorEnabled()) { - LOG.error("Error occurred executing exception handler [" + exceptionHandler.getClass() + "]: " + e.getMessage(), ex); - } - return createDefaultErrorResponsePublisher(exchange, req, ex, defaultStatus); - } - } else { - return createDefaultErrorResponsePublisher(exchange, req, e, defaultStatus); - } - } - - private RouteMatch lookupErrorRoute(RouteMatch route, Throwable e) { - if (route == null) { - return router.route(e).orElse(null); - } else { - return router.route(route.getDeclaringType(), e) - .orElseGet(() -> router.route(e).orElse(null)); - } - } - - private RouteMatch lookupStatusRoute(RouteMatch route, HttpStatus status) { - if (route == null) { - return router.route(status).orElse(null); - } else { - return router.route(route.getDeclaringType(), status) - .orElseGet(() -> - router.route(status).orElse(null) - ); - } - } - - private Publisher> filterPublisher( - ServletExchange exchange, - AtomicReference> requestReference, - Publisher> routePublisher, - RouteMatch routeMatch - ) { - List filters = new ArrayList<>(router.findFilters(requestReference.get())); - if (filters.isEmpty()) { - return routePublisher; - } - - final Function, Publisher>> checkForStatus = (response) -> { - return handleStatusException(exchange, response, requestReference); - }; - - final Function>> onError = (t) -> { - final HttpRequest httpRequest = requestReference.get(); - return handleException(httpRequest, exchange.getResponse(), routeMatch, false, t, exchange); - }; - - AtomicInteger integer = new AtomicInteger(); - int len = filters.size(); - ServerFilterChain filterChain = new ServerFilterChain() { - @SuppressWarnings("unchecked") - @Override - public Publisher> proceed(io.micronaut.http.HttpRequest request) { - int pos = integer.incrementAndGet(); - if (pos > len) { - throw new IllegalStateException("The FilterChain.proceed(..) method should be invoked exactly once per filter execution. The method has instead been invoked multiple times by an erroneous filter definition."); - } - if (pos == len) { - return routePublisher; - } - HttpFilter httpFilter = filters.get(pos); - return Flux.from((Publisher>) httpFilter.doFilter(requestReference.getAndSet(request), this)) - .flatMap(checkForStatus) - .onErrorResume(onError); - } - }; - HttpFilter httpFilter = filters.get(0); - final HttpRequest req = requestReference.get(); - Publisher> resultingPublisher = - ServerRequestContext.with( - req, - (Supplier>>) () -> - Flux.from((Publisher>) httpFilter.doFilter(req, filterChain)) - .flatMap(checkForStatus) - .onErrorResume(onError) - ); - - //noinspection unchecked - return resultingPublisher; - } } diff --git a/servlet-core/src/main/java/io/micronaut/servlet/http/ServletHttpRequest.java b/servlet-core/src/main/java/io/micronaut/servlet/http/ServletHttpRequest.java index 250db12b7..965d11ae1 100644 --- a/servlet-core/src/main/java/io/micronaut/servlet/http/ServletHttpRequest.java +++ b/servlet-core/src/main/java/io/micronaut/servlet/http/ServletHttpRequest.java @@ -16,8 +16,6 @@ package io.micronaut.servlet.http; import io.micronaut.http.HttpRequest; -import io.micronaut.http.MutableHttpResponse; -import org.reactivestreams.Publisher; import java.io.BufferedReader; import java.io.IOException; @@ -76,7 +74,6 @@ default String getContextPath() { * * @return true if this request supports asynchronous operation, false * otherwise - * * @since Servlet 3.0 */ default boolean isAsyncSupported() { @@ -85,14 +82,46 @@ default boolean isAsyncSupported() { /** * Causes the container to dispatch a thread, possibly from a managed - * thread pool, to run the specified {@link Runnable}. The container may - * propagate appropriate contextual information to the {@link Runnable}. + * thread pool, to run the specified {@link AsyncExecutionCallback}. + * After the execution is complete {@link AsyncExecution#complete()} should be called. * - * @param responsePublisher The response publisher - * @return A publisher that emits the response + * @param asyncExecutionCallback The response publisher */ - default Publisher> subscribeOnExecutor(Publisher> responsePublisher) { + default void executeAsync(AsyncExecutionCallback asyncExecutionCallback) { throw new UnsupportedOperationException("Asynchronous processing is not supported"); } + /** + * Async execution callback. + * + * @author Denis Stepanov + * @since 4.0.0 + */ + interface AsyncExecutionCallback { + + /** + * Do job in the asynchronous way. + * After the completion {@link AsyncExecution#complete()} should be called. + * + * @param asyncExecution The async execution + */ + void run(AsyncExecution asyncExecution); + + } + + /** + * Async execution. + * + * @author Denis Stepanov + * @since 4.0.0 + */ + interface AsyncExecution { + + /** + * Method should be called after the async processing is completed. + */ + void complete(); + + } + } diff --git a/servlet-core/src/main/java/io/micronaut/servlet/http/ServletResponseFactory.java b/servlet-core/src/main/java/io/micronaut/servlet/http/ServletResponseFactory.java index 1989513f2..c99e9211b 100644 --- a/servlet-core/src/main/java/io/micronaut/servlet/http/ServletResponseFactory.java +++ b/servlet-core/src/main/java/io/micronaut/servlet/http/ServletResponseFactory.java @@ -49,19 +49,16 @@ public class ServletResponseFactory implements HttpResponseFactory { ALTERNATE = Objects.requireNonNullElseGet(alternate, SimpleHttpResponseFactory::new); } - @SuppressWarnings("unchecked") @Override public MutableHttpResponse ok(T body) { final HttpRequest req = ServerRequestContext.currentRequest().orElse(null); - if (req instanceof ServletExchange) { - final MutableHttpResponse response = ((ServletExchange) req).getResponse(); - return response.status(HttpStatus.OK).body(body); + if (req instanceof ServletExchange servletExchange) { + return servletExchange.getResponse().status(HttpStatus.OK).body(body); } else { return ALTERNATE.ok(body); } } - @SuppressWarnings("unchecked") @Override public MutableHttpResponse status(HttpStatus status, String reason) { return status(status.getCode(), reason); @@ -70,21 +67,18 @@ public MutableHttpResponse status(HttpStatus status, String reason) { @Override public MutableHttpResponse status(int status, String reason) { final HttpRequest req = ServerRequestContext.currentRequest().orElse(null); - if (req instanceof ServletExchange) { - final MutableHttpResponse response = ((ServletExchange) req).getResponse(); - return response.status(status, reason); + if (req instanceof ServletExchange servletExchange) { + return (MutableHttpResponse) servletExchange.getResponse().status(status, reason); } else { return ALTERNATE.status(status, reason); } } - @SuppressWarnings("unchecked") @Override public MutableHttpResponse status(HttpStatus status, T body) { final HttpRequest req = ServerRequestContext.currentRequest().orElse(null); - if (req instanceof ServletExchange) { - final MutableHttpResponse response = ((ServletExchange) req).getResponse(); - return response.status(status).body(body); + if (req instanceof ServletExchange servletExchange) { + return servletExchange.getResponse().status(status).body(body); } else { return ALTERNATE.status(status, body); } diff --git a/servlet-core/src/main/java/io/micronaut/servlet/http/encoders/AbstractFileEncoder.java b/servlet-core/src/main/java/io/micronaut/servlet/http/encoders/AbstractFileEncoder.java index c61948064..7b48ea0d0 100644 --- a/servlet-core/src/main/java/io/micronaut/servlet/http/encoders/AbstractFileEncoder.java +++ b/servlet-core/src/main/java/io/micronaut/servlet/http/encoders/AbstractFileEncoder.java @@ -86,7 +86,7 @@ protected void setDateAndCacheHeaders(MutableHttpResponse response, long lastMod */ protected boolean ifNotModified(@NonNull T value, ServletHttpRequest request, - ServletHttpResponse response) { + ServletHttpResponse response) { long lastModified = value.getLastModified(); // Cache Validation diff --git a/servlet-core/src/main/java/io/micronaut/servlet/http/encoders/StreamFileEncoder.java b/servlet-core/src/main/java/io/micronaut/servlet/http/encoders/StreamFileEncoder.java index a9372f028..fb0d2a86d 100644 --- a/servlet-core/src/main/java/io/micronaut/servlet/http/encoders/StreamFileEncoder.java +++ b/servlet-core/src/main/java/io/micronaut/servlet/http/encoders/StreamFileEncoder.java @@ -55,7 +55,7 @@ public Publisher> encode( AnnotationMetadata annotationMetadata, @NonNull StreamedFile value) { final ServletHttpRequest request = exchange.getRequest(); - ServletHttpResponse response = exchange.getResponse(); + ServletHttpResponse response = exchange.getResponse(); if (ifNotModified(value, request, response)) { return Publishers.just( setDateHeader( diff --git a/servlet-core/src/main/java/io/micronaut/servlet/http/encoders/SystemFileEncoder.java b/servlet-core/src/main/java/io/micronaut/servlet/http/encoders/SystemFileEncoder.java index e1c9ec896..b83815d08 100644 --- a/servlet-core/src/main/java/io/micronaut/servlet/http/encoders/SystemFileEncoder.java +++ b/servlet-core/src/main/java/io/micronaut/servlet/http/encoders/SystemFileEncoder.java @@ -56,7 +56,7 @@ public Publisher> encode( AnnotationMetadata annotationMetadata, @NonNull SystemFile value) { final ServletHttpRequest request = exchange.getRequest(); - ServletHttpResponse response = exchange.getResponse(); + ServletHttpResponse response = exchange.getResponse(); if (ifNotModified(value, request, response)) { return Publishers.just( setDateHeader( diff --git a/servlet-engine/build.gradle b/servlet-engine/build.gradle index ec2bf8608..5d925ec53 100644 --- a/servlet-engine/build.gradle +++ b/servlet-engine/build.gradle @@ -1,14 +1,15 @@ plugins { - id "io.micronaut.build.internal.servlet-module" + id 'io.micronaut.build.internal.servlet.module' } dependencies { annotationProcessor mn.micronaut.graal - api projects.servletCore + api project(":servlet-core") api libs.managed.servlet.api implementation mn.reactor + implementation mn.micronaut.discovery testAnnotationProcessor mn.micronaut.inject.java testImplementation mn.google.function.framework diff --git a/servlet-engine/src/main/java/io/micronaut/servlet/engine/DefaultMicronautServlet.java b/servlet-engine/src/main/java/io/micronaut/servlet/engine/DefaultMicronautServlet.java index ad334b59b..8d3460130 100644 --- a/servlet-engine/src/main/java/io/micronaut/servlet/engine/DefaultMicronautServlet.java +++ b/servlet-engine/src/main/java/io/micronaut/servlet/engine/DefaultMicronautServlet.java @@ -44,6 +44,7 @@ public class DefaultMicronautServlet extends HttpServlet { public static final String CONTEXT_ATTRIBUTE = "io.micronaut.servlet.APPLICATION_CONTEXT"; private ApplicationContext applicationContext; + private boolean isContextOwner; private DefaultServletHttpHandler handler; /** @@ -63,16 +64,13 @@ public DefaultMicronautServlet() { @Override protected void service(HttpServletRequest req, HttpServletResponse resp) { if (handler != null) { - handler.service( - req, - resp - ); + handler.service(req, resp); } } @Override public void destroy() { - if (applicationContext != null && applicationContext.isRunning()) { + if (isContextOwner && applicationContext != null && applicationContext.isRunning()) { applicationContext.stop(); applicationContext = null; } @@ -88,14 +86,13 @@ public void init() { } } if (this.applicationContext == null) { - final ApplicationContextBuilder builder = - Objects.requireNonNull(newApplicationContextBuilder(), "builder cannot be null"); + Objects.requireNonNull(newApplicationContextBuilder(), "builder cannot be null"); this.applicationContext = Objects.requireNonNull(buildApplicationContext(builder), "Context cannot be null"); - } - - if (!this.applicationContext.isRunning()) { - this.applicationContext.start(); + if (!this.applicationContext.isRunning()) { + this.applicationContext.start(); + } + isContextOwner = true; } if (servletContext != null) { servletContext.setAttribute(CONTEXT_ATTRIBUTE, applicationContext); diff --git a/servlet-engine/src/main/java/io/micronaut/servlet/engine/DefaultServletHttpHandler.java b/servlet-engine/src/main/java/io/micronaut/servlet/engine/DefaultServletHttpHandler.java index 0a92c8deb..439941302 100644 --- a/servlet-engine/src/main/java/io/micronaut/servlet/engine/DefaultServletHttpHandler.java +++ b/servlet-engine/src/main/java/io/micronaut/servlet/engine/DefaultServletHttpHandler.java @@ -44,7 +44,7 @@ public DefaultServletHttpHandler(ApplicationContext applicationContext) { protected ServletExchange createExchange( HttpServletRequest request, HttpServletResponse response) { - return new DefaultServletHttpRequest<>(request, response, getMediaTypeCodecRegistry()); + return new DefaultServletHttpRequest<>(applicationContext.getConversionService(), request, response, getMediaTypeCodecRegistry()); } @Override diff --git a/servlet-engine/src/main/java/io/micronaut/servlet/engine/DefaultServletHttpRequest.java b/servlet-engine/src/main/java/io/micronaut/servlet/engine/DefaultServletHttpRequest.java index cc08897d3..7c89b14bc 100644 --- a/servlet-engine/src/main/java/io/micronaut/servlet/engine/DefaultServletHttpRequest.java +++ b/servlet-engine/src/main/java/io/micronaut/servlet/engine/DefaultServletHttpRequest.java @@ -32,7 +32,6 @@ import io.micronaut.http.HttpMethod; import io.micronaut.http.HttpParameters; import io.micronaut.http.MediaType; -import io.micronaut.http.MutableHttpResponse; import io.micronaut.http.codec.CodecException; import io.micronaut.http.codec.MediaTypeCodec; import io.micronaut.http.codec.MediaTypeCodecRegistry; @@ -41,12 +40,9 @@ import io.micronaut.servlet.http.ServletHttpRequest; import io.micronaut.servlet.http.ServletHttpResponse; import io.micronaut.servlet.http.StreamedServletMessage; -import org.reactivestreams.Publisher; import org.reactivestreams.Subscriber; import reactor.core.publisher.Flux; -import reactor.core.publisher.FluxSink; -import reactor.core.scheduler.Scheduler; -import reactor.core.scheduler.Schedulers; +import reactor.core.publisher.Sinks; import javax.servlet.AsyncContext; import javax.servlet.ReadListener; @@ -61,7 +57,17 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.security.Principal; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; /** @@ -73,33 +79,36 @@ */ @Internal public class DefaultServletHttpRequest implements - ServletHttpRequest, - MutableConvertibleValues, - ServletExchange, - StreamedServletMessage { + ServletHttpRequest, + MutableConvertibleValues, + ServletExchange, + StreamedServletMessage { + private final ConversionService conversionService; private final HttpServletRequest delegate; private final URI uri; private final HttpMethod method; private final ServletRequestHeaders headers; private final ServletParameters parameters; - private final DefaultServletHttpResponse response; + private final DefaultServletHttpResponse response; private final MediaTypeCodecRegistry codecRegistry; private DefaultServletCookies cookies; private Object body; - private Scheduler scheduler; + private boolean bodyIsReadAsync; /** * Default constructor. * - * @param delegate The servlet request - * @param response The servlet response - * @param codecRegistry The codec registry + * @param conversionService The servlet request + * @param delegate The servlet request + * @param response The servlet response + * @param codecRegistry The codec registry */ - protected DefaultServletHttpRequest( - HttpServletRequest delegate, - HttpServletResponse response, - MediaTypeCodecRegistry codecRegistry) { + protected DefaultServletHttpRequest(ConversionService conversionService, + HttpServletRequest delegate, + HttpServletResponse response, + MediaTypeCodecRegistry codecRegistry) { + this.conversionService = conversionService; this.delegate = delegate; this.codecRegistry = codecRegistry; final String contextPath = delegate.getContextPath(); @@ -123,10 +132,7 @@ protected DefaultServletHttpRequest( this.method = method; this.headers = new ServletRequestHeaders(); this.parameters = new ServletParameters(); - this.response = new DefaultServletHttpResponse<>( - this, - response - ); + this.response = new DefaultServletHttpResponse<>(conversionService, this, response); } /** @@ -142,75 +148,64 @@ public boolean isAsyncSupported() { } @Override - public Publisher> subscribeOnExecutor(Publisher> responsePublisher) { - if (this.scheduler == null) { - - final AsyncContext asyncContext = delegate.startAsync(); - this.scheduler = Schedulers.fromExecutor(asyncContext::start); - return Flux.from(responsePublisher) - .subscribeOn(scheduler) - .doAfterTerminate(asyncContext::complete); - } else { - return responsePublisher; - } + public void executeAsync(AsyncExecutionCallback asyncExecutionCallback) { + AsyncContext asyncContext = delegate.startAsync(); + asyncContext.start(() -> asyncExecutionCallback.run(asyncContext::complete)); } @NonNull @Override public Optional getBody(@NonNull Argument arg) { - if (arg != null) { - final Class type = arg.getType(); - final MediaType contentType = getContentType().orElse(MediaType.APPLICATION_JSON_TYPE); - long contentLength = getContentLength(); - if (body == null && contentLength != 0) { - - boolean isConvertibleValues = ConvertibleValues.class == type; - if (isFormSubmission(contentType)) { - body = getParameters(); + Objects.requireNonNull(arg); + if (bodyIsReadAsync) { + throw new IllegalStateException("Body is being read asynchronously!"); + } + final Class type = arg.getType(); + final MediaType contentType = getContentType().orElse(MediaType.APPLICATION_JSON_TYPE); + long contentLength = getContentLength(); + if (body == null && contentLength != 0) { + + boolean isConvertibleValues = ConvertibleValues.class == type; + if (isFormSubmission(contentType)) { + body = getParameters(); + if (isConvertibleValues) { + return (Optional) Optional.of(body); + } + return Optional.empty(); + } + if (CharSequence.class.isAssignableFrom(type)) { + try (BufferedReader reader = delegate.getReader()) { + final T value = (T) IOUtils.readText(reader); + body = value; + return Optional.ofNullable(value); + } catch (IOException e) { + throw new CodecException("Error decoding request body: " + e.getMessage(), e); + } + } + final MediaTypeCodec codec = codecRegistry.findCodec(contentType, type).orElse(null); + if (codec != null) { + try (InputStream inputStream = delegate.getInputStream()) { if (isConvertibleValues) { + final Map map = codec.decode(Map.class, inputStream); + body = ConvertibleValues.of(map); return (Optional) Optional.of(body); - } else { - return Optional.empty(); - } - } else if (CharSequence.class.isAssignableFrom(type)) { - try (BufferedReader reader = delegate.getReader()) { - final T value = (T) IOUtils.readText(reader); - body = value; - return Optional.ofNullable(value); - } catch (IOException e) { - throw new CodecException("Error decoding request body: " + e.getMessage(), e); - } - } else { - - final MediaTypeCodec codec = codecRegistry.findCodec(contentType, type).orElse(null); - if (codec != null) { - try (InputStream inputStream = delegate.getInputStream()) { - if (isConvertibleValues) { - final Map map = codec.decode(Map.class, inputStream); - body = ConvertibleValues.of(map); - return (Optional) Optional.of(body); - } else { - final T value = codec.decode(arg, inputStream); - body = value; - return Optional.ofNullable(value); - } - } catch (CodecException | IOException e) { - throw new CodecException("Error decoding request body: " + e.getMessage(), e); - } - } + final T value = codec.decode(arg, inputStream); + body = value; + return Optional.ofNullable(value); + } catch (CodecException | IOException e) { + throw new CodecException("Error decoding request body: " + e.getMessage(), e); } - } else { - if (type.isInstance(body)) { - return (Optional) Optional.of(body); - } else { - if (body != null && body != parameters) { - final T result = ConversionService.SHARED.convertRequired(body, arg); - return Optional.ofNullable(result); - } - } - } + } else { + if (type.isInstance(body)) { + return (Optional) Optional.of(body); + } + if (body != null && body != parameters) { + final T result = (T) conversionService.convertRequired(body, arg); + return Optional.ofNullable(result); + } + } return Optional.empty(); } @@ -219,8 +214,8 @@ public Optional getBody(@NonNull Argument arg) { @Override public Optional getUserPrincipal() { return Optional.ofNullable( - ServletHttpRequest.super.getUserPrincipal() - .orElse(delegate.getUserPrincipal()) + ServletHttpRequest.super.getUserPrincipal() + .orElse(delegate.getUserPrincipal()) ); } @@ -233,7 +228,7 @@ public boolean isSecure() { @Override public Optional getContentType() { return Optional.ofNullable(delegate.getContentType()) - .map(MediaType::new); + .map(MediaType::new); } @Override @@ -245,8 +240,8 @@ public long getContentLength() { @Override public InetSocketAddress getRemoteAddress() { return new InetSocketAddress( - delegate.getRemoteHost(), - delegate.getRemotePort() + delegate.getRemoteHost(), + delegate.getRemotePort() ); } @@ -254,7 +249,7 @@ public InetSocketAddress getRemoteAddress() { @Override public InetSocketAddress getServerAddress() { return new InetSocketAddress( - delegate.getServerPort() + delegate.getServerPort() ); } @@ -274,8 +269,8 @@ public Optional getLocale() { @Override public Charset getCharacterEncoding() { return Optional.ofNullable(delegate.getCharacterEncoding()) - .map(Charset::forName) - .orElse(StandardCharsets.UTF_8); + .map(Charset::forName) + .orElse(StandardCharsets.UTF_8); } @Override @@ -329,7 +324,7 @@ public HttpMethod getMethod() { @NonNull @Override public String getMethodName() { - return delegate.getMethod(); + return Objects.requireNonNullElseGet(delegate.getMethod(), getMethod()::name); } @NonNull @@ -391,9 +386,9 @@ public Set names() { @Override public Collection values() { return names() - .stream() - .map(delegate::getAttribute) - .collect(Collectors.toList()); + .stream() + .map(delegate::getAttribute) + .collect(Collectors.toList()); } @Override @@ -405,7 +400,7 @@ public Optional get(CharSequence key, ArgumentConversionContext conver //noinspection unchecked return (Optional) Optional.of(v); } else { - return ConversionService.SHARED.convert(v, conversionContext); + return conversionService.convert(v, conversionContext); } } return Optional.empty(); @@ -418,7 +413,7 @@ public Optional get(CharSequence key, ArgumentConversionContext conver } @Override - public ServletHttpResponse getResponse() { + public ServletHttpResponse getResponse() { return response; } @@ -436,15 +431,11 @@ private List enumerationToList(Enumeration enumeration) { @Override public void subscribe(Subscriber s) { - Flux.create(emitter -> { - ServletInputStream inputStream; - try { - inputStream = delegate.getInputStream(); - } catch (IOException e) { - emitter.error(e); - return; - } - byte[] buffer = new byte[1024]; + bodyIsReadAsync = true; + Sinks.Many emitter = Sinks.many().replay().all(); + byte[] buffer = new byte[1024]; + try { + ServletInputStream inputStream = delegate.getInputStream(); inputStream.setReadListener(new ReadListener() { boolean complete = false; @@ -456,19 +447,19 @@ public void onDataAvailable() { int length = inputStream.read(buffer); if (length == -1) { complete = true; - emitter.complete(); + emitter.emitComplete(Sinks.EmitFailureHandler.FAIL_FAST); break; } else { if (buffer.length == length) { - emitter.next(buffer); + emitter.emitNext(buffer, Sinks.EmitFailureHandler.FAIL_FAST); } else { - emitter.next(Arrays.copyOf(buffer, length)); + emitter.emitNext(Arrays.copyOf(buffer, length), Sinks.EmitFailureHandler.FAIL_FAST); } } } while (inputStream.isReady()); } catch (IOException e) { complete = true; - emitter.error(e); + emitter.emitError(e, Sinks.EmitFailureHandler.FAIL_FAST); } } } @@ -477,7 +468,7 @@ public void onDataAvailable() { public void onAllDataRead() { if (!complete) { complete = true; - emitter.complete(); + emitter.emitComplete(Sinks.EmitFailureHandler.FAIL_FAST); } } @@ -485,14 +476,17 @@ public void onAllDataRead() { public void onError(Throwable t) { if (!complete) { complete = true; - emitter.error(t); + emitter.emitError(t, Sinks.EmitFailureHandler.FAIL_FAST); } } }); - }, FluxSink.OverflowStrategy.BUFFER).subscribe(s); + } catch (Exception e) { + emitter.emitError(e, Sinks.EmitFailureHandler.FAIL_FAST); + } + Flux bodyContent = emitter.asFlux(); + bodyContent.subscribe(s); } - /** * The servlet request headers. */ @@ -501,7 +495,7 @@ private class ServletRequestHeaders implements HttpHeaders { @Override public List getAll(CharSequence name) { final Enumeration e = - delegate.getHeaders(Objects.requireNonNull(name, "Header name should not be null").toString()); + delegate.getHeaders(Objects.requireNonNull(name, "Header name should not be null").toString()); return enumerationToList(e); } @@ -520,16 +514,16 @@ public Set names() { @Override public Collection> values() { return names() - .stream() - .map(this::getAll) - .collect(Collectors.toList()); + .stream() + .map(this::getAll) + .collect(Collectors.toList()); } @Override public Optional get(CharSequence name, ArgumentConversionContext conversionContext) { final String v = get(name); if (v != null) { - return ConversionService.SHARED.convert(v, conversionContext); + return conversionService.convert(v, conversionContext); } return Optional.empty(); } @@ -543,7 +537,7 @@ private class ServletParameters implements HttpParameters { @Override public List getAll(CharSequence name) { final String[] values = delegate.getParameterValues( - Objects.requireNonNull(name, "Parameter name cannot be null").toString() + Objects.requireNonNull(name, "Parameter name cannot be null").toString() ); return Arrays.asList(values); } @@ -552,7 +546,7 @@ public List getAll(CharSequence name) { @Override public String get(CharSequence name) { return delegate.getParameter( - Objects.requireNonNull(name, "Parameter name cannot be null").toString() + Objects.requireNonNull(name, "Parameter name cannot be null").toString() ); } @@ -564,9 +558,9 @@ public Set names() { @Override public Collection> values() { return names() - .stream() - .map(this::getAll) - .collect(Collectors.toList()); + .stream() + .map(this::getAll) + .collect(Collectors.toList()); } @Override @@ -583,18 +577,18 @@ public Optional get(CharSequence name, ArgumentConversionContext conve final String[] parameterValues = delegate.getParameterValues(paramName); if (ArrayUtils.isNotEmpty(parameterValues)) { if (parameterValues.length == 1) { - return ConversionService.SHARED.convert(parameterValues[0], conversionContext); + return conversionService.convert(parameterValues[0], conversionContext); } else { if (isOptional) { - return (Optional) ConversionService.SHARED.convert(parameterValues, ConversionContext.of( - argument.getFirstTypeVariable().orElse(argument) + return (Optional) conversionService.convert(parameterValues, ConversionContext.of( + argument.getFirstTypeVariable().orElse(argument) )); } else { - return ConversionService.SHARED.convert(parameterValues, conversionContext); + return conversionService.convert(parameterValues, conversionContext); } } } else { - return ConversionService.SHARED.convert(Collections.emptyList(), conversionContext); + return conversionService.convert(Collections.emptyList(), conversionContext); } } else { final String v = get(name); @@ -603,7 +597,7 @@ public Optional get(CharSequence name, ArgumentConversionContext conve //noinspection unchecked return (Optional) Optional.of(v); } else { - return ConversionService.SHARED.convert(v, conversionContext); + return conversionService.convert(v, conversionContext); } } } diff --git a/servlet-engine/src/main/java/io/micronaut/servlet/engine/DefaultServletHttpResponse.java b/servlet-engine/src/main/java/io/micronaut/servlet/engine/DefaultServletHttpResponse.java index 035ae76df..9dc89f62b 100644 --- a/servlet-engine/src/main/java/io/micronaut/servlet/engine/DefaultServletHttpResponse.java +++ b/servlet-engine/src/main/java/io/micronaut/servlet/engine/DefaultServletHttpResponse.java @@ -33,7 +33,6 @@ import io.micronaut.http.annotation.Produces; import io.micronaut.http.codec.MediaTypeCodec; import io.micronaut.http.cookie.Cookie; -import io.micronaut.http.server.exceptions.InternalServerException; import io.micronaut.servlet.http.ServletHttpResponse; import org.reactivestreams.Publisher; import org.reactivestreams.Subscriber; @@ -69,6 +68,7 @@ public class DefaultServletHttpResponse implements ServletHttpResponse request; private final ServletResponseHeaders headers; @@ -78,12 +78,15 @@ public class DefaultServletHttpResponse implements ServletHttpResponse request, + HttpServletResponse delegate) { + this.conversionService = conversionService; this.delegate = delegate; this.request = request; this.headers = new ServletResponseHeaders(); @@ -217,7 +220,7 @@ public void onComplete() { @Override @NonNull public Optional getContentType() { - return ConversionService.SHARED.convert(delegate.getContentType(), Argument.of(MediaType.class)); + return conversionService.convert(delegate.getContentType(), Argument.of(MediaType.class)); } @Override @@ -360,27 +363,25 @@ public MutableHttpResponse status(int status, CharSequence message) { } else { this.reason = message.toString(); } - if (message != null) { - try { - delegate.sendError(status, reason); - } catch (IOException e) { - throw new InternalServerException("Error sending error code: " + e.getMessage(), e); - } - } else { - delegate.setStatus(status); - } + delegate.setStatus(status); return this; } - @Override public int code() { - return status; + return delegate.getStatus(); } @Override public String reason() { - return reason; + if (reason != null) { + return reason; + } + try { + return HttpStatus.valueOf(delegate.getStatus()).getReason(); + } catch (Exception e) { + return ""; + } } /** @@ -453,9 +454,13 @@ public Collection> values() { public Optional get(CharSequence name, ArgumentConversionContext conversionContext) { final String v = get(name); if (v != null) { - return ConversionService.SHARED.convert(v, conversionContext); + return conversionService.convert(v, conversionContext); } return Optional.empty(); } + + @Override + public void setConversionService(ConversionService conversionService) { + } } } diff --git a/settings.gradle b/settings.gradle index 6ca82bade..8f87630a5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,19 +9,6 @@ plugins { id 'io.micronaut.build.shared.settings' version '6.0.1' } -enableFeaturePreview 'TYPESAFE_PROJECT_ACCESSORS' - -rootProject.name = 'servlet-parent' - -include 'servlet-bom' -include 'servlet-core' -include 'servlet-engine' -include 'http-server-jetty' -include 'http-server-undertow' -include 'http-server-tomcat' - -include 'test-suite-kotlin-jetty' - dependencyResolutionManagement { repositories { mavenCentral() @@ -29,8 +16,18 @@ dependencyResolutionManagement { } } +rootProject.name = 'servlet-parent' + micronautBuild { importMicronautCatalog() } +include 'servlet-bom' +include 'servlet-core' +include 'servlet-engine' +include 'http-server-jetty' +include 'http-server-undertow' +include 'http-server-tomcat' + +include 'test-suite-kotlin-jetty' diff --git a/test-suite-kotlin-jetty/build.gradle b/test-suite-kotlin-jetty/build.gradle index 9da6113da..2f948b70b 100644 --- a/test-suite-kotlin-jetty/build.gradle +++ b/test-suite-kotlin-jetty/build.gradle @@ -1,12 +1,10 @@ plugins { + id 'io.micronaut.build.internal.servlet.base' id "org.jetbrains.kotlin.jvm" version "$kotlinVersion" - id("org.jetbrains.kotlin.kapt") version "$kotlinVersion" - id "io.micronaut.build.internal.servlet-tests" + id "org.jetbrains.kotlin.kapt" version "$kotlinVersion" } dependencies { - kaptTest platform("io.micronaut:micronaut-bom:$micronautVersion") - testImplementation platform("io.micronaut:micronaut-bom:$micronautVersion") kaptTest mn.micronaut.inject.java testRuntimeOnly libs.junit.jupiter.engine @@ -17,13 +15,14 @@ dependencies { testImplementation libs.kotlinx.coroutines.jdk8 testImplementation libs.kotlinx.coroutines.rx2 + testImplementation mn.micronaut.jackson.databind + testImplementation mn.snakeyaml testImplementation mn.micronaut.http.client testImplementation mn.reactor - testImplementation mn.micronaut.test.kotest5 + testImplementation mn.micronaut.test.kotest testImplementation libs.kotest.runner - testImplementation projects.httpServerJetty - + testImplementation project(":http-server-jetty") } tasks.named('test') { @@ -36,3 +35,8 @@ compileTestKotlin { javaParameters = true } } + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} diff --git a/test-suite-kotlin-jetty/src/test/resources/application-test.yml b/test-suite-kotlin-jetty/src/test/resources/application-test.yml new file mode 100644 index 000000000..6df99eebe --- /dev/null +++ b/test-suite-kotlin-jetty/src/test/resources/application-test.yml @@ -0,0 +1,6 @@ +micronaut: + http: + client: + read-timeout: 60s + +