From 39d88631262be7b91823f1752467ed0da1085c58 Mon Sep 17 00:00:00 2001 From: Kevin Wise Date: Fri, 22 Apr 2022 10:59:03 -0700 Subject: [PATCH] Support validation groups with `@Validated` --- .../datavalidation/validationGroups.adoc | 27 +++++ src/main/docs/guide/toc.yml | 10 +- .../docs/datavalidation/groups/Email.groovy | 32 ++++++ .../groups/EmailController.groovy | 46 ++++++++ .../groups/EmailControllerSpec.groovy | 79 ++++++++++++++ .../groups/FinalValidation.groovy | 24 +++++ .../docs/datavalidation/groups/Email.kt | 31 ++++++ .../datavalidation/groups/EmailController.kt | 45 ++++++++ .../groups/EmailControllerSpec.kt | 81 ++++++++++++++ .../datavalidation/groups/FinalValidation.kt | 24 +++++ .../docs/datavalidation/groups/Email.java | 48 +++++++++ .../groups/EmailController.java | 47 ++++++++ .../groups/EmailControllerSpec.java | 100 ++++++++++++++++++ .../groups/FinalValidation.java | 24 +++++ .../io/micronaut/validation/Validated.java | 9 ++ .../validation/ValidatingInterceptor.java | 32 ++++-- .../pojo/PojoBodyParameterSpec.groovy | 89 +++++++++++++++- 17 files changed, 733 insertions(+), 15 deletions(-) create mode 100644 src/main/docs/guide/httpServer/datavalidation/validationGroups.adoc create mode 100644 test-suite-groovy/src/test/groovy/io/micronaut/docs/datavalidation/groups/Email.groovy create mode 100644 test-suite-groovy/src/test/groovy/io/micronaut/docs/datavalidation/groups/EmailController.groovy create mode 100644 test-suite-groovy/src/test/groovy/io/micronaut/docs/datavalidation/groups/EmailControllerSpec.groovy create mode 100644 test-suite-groovy/src/test/groovy/io/micronaut/docs/datavalidation/groups/FinalValidation.groovy create mode 100644 test-suite-kotlin/src/test/kotlin/io/micronaut/docs/datavalidation/groups/Email.kt create mode 100644 test-suite-kotlin/src/test/kotlin/io/micronaut/docs/datavalidation/groups/EmailController.kt create mode 100644 test-suite-kotlin/src/test/kotlin/io/micronaut/docs/datavalidation/groups/EmailControllerSpec.kt create mode 100644 test-suite-kotlin/src/test/kotlin/io/micronaut/docs/datavalidation/groups/FinalValidation.kt create mode 100644 test-suite/src/test/java/io/micronaut/docs/datavalidation/groups/Email.java create mode 100644 test-suite/src/test/java/io/micronaut/docs/datavalidation/groups/EmailController.java create mode 100644 test-suite/src/test/java/io/micronaut/docs/datavalidation/groups/EmailControllerSpec.java create mode 100644 test-suite/src/test/java/io/micronaut/docs/datavalidation/groups/FinalValidation.java diff --git a/src/main/docs/guide/httpServer/datavalidation/validationGroups.adoc b/src/main/docs/guide/httpServer/datavalidation/validationGroups.adoc new file mode 100644 index 00000000000..0675167f1a0 --- /dev/null +++ b/src/main/docs/guide/httpServer/datavalidation/validationGroups.adoc @@ -0,0 +1,27 @@ +You can enforce a subset of constraints using https://beanvalidation.org/2.0/spec/#validationapi-validatorapi-groups[validation groups] using `groups` on api:validation.Validated[]. More information is available in the https://beanvalidation.org/2.0/spec/#constraintdeclarationvalidationprocess-groupsequence[Bean Validation specification] + +snippet::io.micronaut.docs.datavalidation.groups.FinalValidation[tags="clazz", indent=0] + +<1> Define a custom validation group. This one extends `Default` so any validations done with this group will include constraints in the `Default` group. + +snippet::io.micronaut.docs.datavalidation.groups.Email[tags="clazz", indent=0] + +<1> Specify a constraint using the Default validation group. This constraint will only be enforced when `Default` is active. +<2> Specify a constraint using the custom `FinalValidation` validation group. This constraint will only be enforced when `FinalValidation` is active. + +Annotate your controller with api:validation.Validated[], specifying the validation groups that will be active or letting it default to `Default`. Also annotate the binding POJO with `@Valid`. + +snippet::io.micronaut.docs.datavalidation.groups.EmailController[tags="imports,clazz", indent=0,title="Example"] + +<1> Annotating with api:validation.Validated[] without specifying groups means that the `Default` group will be active. Since this is defined on the class, it will apply to all methods. +<2> Constraints in the `Default` validation group will be enforced, inheriting from the class. The effect is that `@NotBlank` on `email.recipient` will not be enforced when this method is called. +<3> Specifying `groups` means that these validation groups will be enforced when this method is called. Note that `FinalValidation` extends `Default` so constraints from both groups will be enforced. +<4> Constraints in the `Default` and `FinalValidation` validation groups will be enforced, since `FinalValidation` extends `Default`. The effect is that both `@NotBlank` constraints in `email` will be enforced when this method is called. + +Validation of POJOs using the default validation group is shown in the following test: + +snippet::io.micronaut.docs.datavalidation.groups.EmailControllerSpec[tags="pojovalidateddefault",indent=0] + +Validation of POJOs using the custom `FinalValidation` validation group is shown in the following test: + +snippet::io.micronaut.docs.datavalidation.groups.EmailControllerSpec[tags="pojovalidatedfinal",indent=0] diff --git a/src/main/docs/guide/toc.yml b/src/main/docs/guide/toc.yml index 182b71536df..aef0b91a0fc 100644 --- a/src/main/docs/guide/toc.yml +++ b/src/main/docs/guide/toc.yml @@ -90,7 +90,9 @@ httpServer: bodyAnnotation: Using the @Body Annotation reactiveResponses: Reactive Responses jsonBinding: JSON Binding with Jackson - datavalidation: Data Validation + datavalidation: + title: Data Validation + validationGroups: Validation Groups staticResources: Serving Static Resources errorHandling: title: Error Handling @@ -288,14 +290,14 @@ i18n: localizedMessageSource: Localized Message Source appendix: title: Appendices - architecture: + architecture: title: Micronaut Architecture - compilerArch: Compiler + compilerArch: Compiler annotationArch: Annotation Metadata introspectionArch: Bean Introspections iocArch: Bean Definitions aopArch: AOP Proxies - containerArch: Application Context + containerArch: Application Context httpServerArch: HTTP Server faq: Frequently Asked Questions (FAQ) usingsnapshots: Using Snapshots diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/docs/datavalidation/groups/Email.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/docs/datavalidation/groups/Email.groovy new file mode 100644 index 00000000000..5452f04d4ce --- /dev/null +++ b/test-suite-groovy/src/test/groovy/io/micronaut/docs/datavalidation/groups/Email.groovy @@ -0,0 +1,32 @@ +/* + * Copyright 2017-2020 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.docs.datavalidation.groups + +//tag::clazz[] +import io.micronaut.core.annotation.Introspected + +import javax.validation.constraints.NotBlank + +@Introspected +class Email { + + @NotBlank // <1> + String subject + + @NotBlank(groups = FinalValidation) // <2> + String recipient +} +//end::clazz[] diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/docs/datavalidation/groups/EmailController.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/docs/datavalidation/groups/EmailController.groovy new file mode 100644 index 00000000000..123a9fa47b8 --- /dev/null +++ b/test-suite-groovy/src/test/groovy/io/micronaut/docs/datavalidation/groups/EmailController.groovy @@ -0,0 +1,46 @@ +/* + * Copyright 2017-2020 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.docs.datavalidation.groups + +import io.micronaut.context.annotation.Requires +//tag::imports[] +import io.micronaut.http.HttpResponse +import io.micronaut.http.annotation.Body +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Post +import io.micronaut.validation.Validated + +import javax.validation.Valid +//end::imports[] + +@Requires(property = "spec.name", value = "datavalidationgroups") +//tag::clazz[] +@Validated // <1> +@Controller("/email") +class EmailController { + + @Post("/createDraft") + HttpResponse createDraft(@Body @Valid Email email) { // <2> + HttpResponse.ok(msg: "OK") + } + + @Post("/send") + @Validated(groups = [FinalValidation]) // <3> + HttpResponse send(@Body @Valid Email email) { // <4> + HttpResponse.ok(msg: "OK") + } +} +//end::clazz[] diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/docs/datavalidation/groups/EmailControllerSpec.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/docs/datavalidation/groups/EmailControllerSpec.groovy new file mode 100644 index 00000000000..f2e4c30b589 --- /dev/null +++ b/test-suite-groovy/src/test/groovy/io/micronaut/docs/datavalidation/groups/EmailControllerSpec.groovy @@ -0,0 +1,79 @@ +/* + * Copyright 2017-2020 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.docs.datavalidation.groups + +import io.micronaut.context.ApplicationContext +import io.micronaut.http.HttpRequest +import io.micronaut.http.HttpStatus +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.exceptions.HttpClientResponseException +import io.micronaut.runtime.server.EmbeddedServer +import spock.lang.AutoCleanup +import spock.lang.Shared +import spock.lang.Specification + +class EmailControllerSpec extends Specification { + + @Shared + @AutoCleanup + EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer, + ['spec.name': 'datavalidationgroups'], + "test") + + @Shared + @AutoCleanup + HttpClient client = embeddedServer.applicationContext.createBean(HttpClient, embeddedServer.URL) + + //tag::pojovalidateddefault[] + def "invoking /email/createDraft parse parameters in a POJO and validates using default validation groups"() { + when: + Email email = new Email(subject: '', recipient: '') + client.toBlocking().exchange(HttpRequest.POST('/email/createDraft', email)) + + then: + def e = thrown(HttpClientResponseException) + def response = e.response + response.status == HttpStatus.BAD_REQUEST + + when: + email = new Email(subject: 'Hi', recipient: '') + response = client.toBlocking().exchange(HttpRequest.POST('/email/createDraft', email)) + + then: + response.status == HttpStatus.OK + } + //end::pojovalidateddefault[] + + //tag::pojovalidatedfinal[] + def "invoking /email/send parse parameters in a POJO and validates using FinalValidation validation group"() { + when: + Email email = new Email(subject: 'Hi', recipient: '') + client.toBlocking().exchange(HttpRequest.POST('/email/send', email)) + + then: + def e = thrown(HttpClientResponseException) + def response = e.response + response.status == HttpStatus.BAD_REQUEST + + when: + email = new Email(subject: 'Hi', recipient: 'me@micronaut.example') + response = client.toBlocking().exchange(HttpRequest.POST('/email/send', email)) + + then: + response.status == HttpStatus.OK + } + //end::pojovalidatedfinal[] +} diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/docs/datavalidation/groups/FinalValidation.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/docs/datavalidation/groups/FinalValidation.groovy new file mode 100644 index 00000000000..eee72ad2adb --- /dev/null +++ b/test-suite-groovy/src/test/groovy/io/micronaut/docs/datavalidation/groups/FinalValidation.groovy @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2020 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.docs.datavalidation.groups; + +//tag::clazz[] + +import javax.validation.groups.Default + +interface FinalValidation extends Default {} // <1> + +//end::clazz[] diff --git a/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/datavalidation/groups/Email.kt b/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/datavalidation/groups/Email.kt new file mode 100644 index 00000000000..db0e6ef981f --- /dev/null +++ b/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/datavalidation/groups/Email.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2017-2020 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.docs.datavalidation.groups + +//tag::clazz[] +import io.micronaut.core.annotation.Introspected +import javax.validation.constraints.NotBlank + +@Introspected +open class Email { + + @NotBlank // <1> + var subject: String? = null + + @NotBlank(groups = [FinalValidation::class]) // <2> + var recipient: String? = null +} +//end::clazz[] diff --git a/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/datavalidation/groups/EmailController.kt b/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/datavalidation/groups/EmailController.kt new file mode 100644 index 00000000000..3bb5439d8ed --- /dev/null +++ b/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/datavalidation/groups/EmailController.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2017-2020 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.docs.datavalidation.groups + +import io.micronaut.context.annotation.Requires +//tag::imports[] +import io.micronaut.http.HttpResponse +import io.micronaut.http.annotation.Body +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Post +import io.micronaut.validation.Validated +import javax.validation.Valid +//end::imports[] + +@Requires(property = "spec.name", value = "datavalidationgroups") +//tag::clazz[] +@Validated // <1> +@Controller("/email") +open class EmailController { + + @Post("/createDraft") + open fun createDraft(@Body @Valid email: Email): HttpResponse<*> { // <2> + return HttpResponse.ok(mapOf("msg" to "OK")) + } + + @Post("/send") + @Validated(groups = [FinalValidation::class]) // <3> + open fun send(@Body @Valid email: Email): HttpResponse<*> { // <4> + return HttpResponse.ok(mapOf("msg" to "OK")) + } +} +//end::clazz[] diff --git a/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/datavalidation/groups/EmailControllerSpec.kt b/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/datavalidation/groups/EmailControllerSpec.kt new file mode 100644 index 00000000000..994781d828c --- /dev/null +++ b/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/datavalidation/groups/EmailControllerSpec.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2017-2020 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.docs.datavalidation.groups + +import io.kotest.matchers.shouldBe +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.micronaut.context.ApplicationContext +import io.micronaut.http.HttpRequest +import io.micronaut.http.HttpStatus +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.exceptions.HttpClientResponseException +import io.micronaut.runtime.server.EmbeddedServer + +class EmailControllerSpec: StringSpec() { + + val embeddedServer = autoClose( + ApplicationContext.run(EmbeddedServer::class.java, mapOf("spec.name" to "datavalidationgroups")) + ) + + val client = autoClose( + embeddedServer.applicationContext.createBean(HttpClient::class.java, embeddedServer.url) + ) + + init { + //tag::pojovalidateddefault[] + "test pojo validation using default validation groups" { + val e = shouldThrow { + val email = Email() + email.subject = "" + email.recipient = "" + client.toBlocking().exchange(HttpRequest.POST("/email/createDraft", email)) + } + var response = e.response + + response.status shouldBe HttpStatus.BAD_REQUEST + + val email = Email() + email.subject = "Hi" + email.recipient = "" + response = client.toBlocking().exchange(HttpRequest.POST("/email/createDraft", email)) + + response.status shouldBe HttpStatus.OK + } + //end::pojovalidateddefault[] + + //tag::pojovalidatedfinal[] + "test pojo validation using FinalValidation validation group" { + val e = shouldThrow { + val email = Email() + email.subject = "Hi" + email.recipient = "" + client.toBlocking().exchange(HttpRequest.POST("/email/send", email)) + } + var response = e.response + + response.status shouldBe HttpStatus.BAD_REQUEST + + val email = Email() + email.subject = "Hi" + email.recipient = "me@micronaut.example" + response = client.toBlocking().exchange(HttpRequest.POST("/email/send", email)) + + response.status shouldBe HttpStatus.OK + } + //end::pojovalidatedfinal[] + } +} diff --git a/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/datavalidation/groups/FinalValidation.kt b/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/datavalidation/groups/FinalValidation.kt new file mode 100644 index 00000000000..a8fe62a3e1d --- /dev/null +++ b/test-suite-kotlin/src/test/kotlin/io/micronaut/docs/datavalidation/groups/FinalValidation.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2020 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.docs.datavalidation.groups + +//tag::clazz[] + +import javax.validation.groups.Default + +interface FinalValidation : Default {} // <1> + +//end::clazz[] diff --git a/test-suite/src/test/java/io/micronaut/docs/datavalidation/groups/Email.java b/test-suite/src/test/java/io/micronaut/docs/datavalidation/groups/Email.java new file mode 100644 index 00000000000..bdd03b53fa9 --- /dev/null +++ b/test-suite/src/test/java/io/micronaut/docs/datavalidation/groups/Email.java @@ -0,0 +1,48 @@ +/* + * Copyright 2017-2020 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.docs.datavalidation.groups; + +//tag::clazz[] +import io.micronaut.core.annotation.Introspected; + +import javax.validation.constraints.NotBlank; + +@Introspected +public class Email { + + @NotBlank // <1> + String subject; + + @NotBlank(groups = FinalValidation.class) // <2> + String recipient; + + public String getSubject() { + return subject; + } + + public void setSubject(String subject) { + this.subject = subject; + } + + public String getRecipient() { + return recipient; + } + + public void setRecipient(String recipient) { + this.recipient = recipient; + } +} +//end::clazz[] diff --git a/test-suite/src/test/java/io/micronaut/docs/datavalidation/groups/EmailController.java b/test-suite/src/test/java/io/micronaut/docs/datavalidation/groups/EmailController.java new file mode 100644 index 00000000000..08de95cbe37 --- /dev/null +++ b/test-suite/src/test/java/io/micronaut/docs/datavalidation/groups/EmailController.java @@ -0,0 +1,47 @@ +/* + * Copyright 2017-2020 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.docs.datavalidation.groups; + +import io.micronaut.context.annotation.Requires; +//tag::imports[] +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Post; +import io.micronaut.validation.Validated; + +import javax.validation.Valid; +import java.util.Collections; +//end::imports[] + +@Requires(property = "spec.name", value = "datavalidationgroups") +//tag::clazz[] +@Validated // <1> +@Controller("/email") +public class EmailController { + + @Post("/createDraft") + public HttpResponse createDraft(@Body @Valid Email email) { // <2> + return HttpResponse.ok(Collections.singletonMap("msg", "OK")); + } + + @Post("/send") + @Validated(groups = FinalValidation.class) // <3> + public HttpResponse send(@Body @Valid Email email) { // <4> + return HttpResponse.ok(Collections.singletonMap("msg", "OK")); + } +} +//end::clazz[] diff --git a/test-suite/src/test/java/io/micronaut/docs/datavalidation/groups/EmailControllerSpec.java b/test-suite/src/test/java/io/micronaut/docs/datavalidation/groups/EmailControllerSpec.java new file mode 100644 index 00000000000..ba260a7672e --- /dev/null +++ b/test-suite/src/test/java/io/micronaut/docs/datavalidation/groups/EmailControllerSpec.java @@ -0,0 +1,100 @@ +/* + * Copyright 2017-2020 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.docs.datavalidation.groups; + +import io.micronaut.context.ApplicationContext; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.runtime.server.EmbeddedServer; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.Collections; + +import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class EmailControllerSpec { + + private static EmbeddedServer server; + private static HttpClient client; + + @BeforeClass + public static void setupServer() { + server = ApplicationContext.run(EmbeddedServer.class, Collections.singletonMap("spec.name", "datavalidationgroups")); + client = server + .getApplicationContext() + .createBean(HttpClient.class, server.getURL()); + } + + @AfterClass + public static void stopServer() { + if (server != null) { + server.stop(); + } + if (client != null) { + client.stop(); + } + } + + //tag::pojovalidateddefault[] + @Test + public void testPojoValidation_defaultGroup() { + HttpClientResponseException e = assertThrows(HttpClientResponseException.class, () -> { + Email email = new Email(); + email.subject = ""; + email.recipient = ""; + client.toBlocking().exchange(HttpRequest.POST("/email/createDraft", email)); + }); + HttpResponse response = e.getResponse(); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatus()); + + Email email = new Email(); + email.subject = "Hi"; + email.recipient = ""; + response = client.toBlocking().exchange(HttpRequest.POST("/email/createDraft", email)); + + assertEquals(HttpStatus.OK, response.getStatus()); + } + //end::pojovalidateddefault[] + + //tag::pojovalidatedfinal[] + @Test + public void testPojoValidation_finalValidationGroup() { + HttpClientResponseException e = assertThrows(HttpClientResponseException.class, () -> { + Email email = new Email(); + email.subject = "Hi"; + email.recipient = ""; + client.toBlocking().exchange(HttpRequest.POST("/email/send", email)); + }); + HttpResponse response = e.getResponse(); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatus()); + + Email email = new Email(); + email.subject = "Hi"; + email.recipient = "me@micronaut.example"; + response = client.toBlocking().exchange(HttpRequest.POST("/email/send", email)); + + assertEquals(HttpStatus.OK, response.getStatus()); + } + //end::pojovalidatedfinal[] +} diff --git a/test-suite/src/test/java/io/micronaut/docs/datavalidation/groups/FinalValidation.java b/test-suite/src/test/java/io/micronaut/docs/datavalidation/groups/FinalValidation.java new file mode 100644 index 00000000000..dae6a2d553c --- /dev/null +++ b/test-suite/src/test/java/io/micronaut/docs/datavalidation/groups/FinalValidation.java @@ -0,0 +1,24 @@ +/* + * Copyright 2017-2020 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.docs.datavalidation.groups; + +//tag::clazz[] + +import javax.validation.groups.Default; + +public interface FinalValidation extends Default {} // <1> + +//end::clazz[] diff --git a/validation/src/main/java/io/micronaut/validation/Validated.java b/validation/src/main/java/io/micronaut/validation/Validated.java index 962df3f7f7b..4be5b158a3d 100644 --- a/validation/src/main/java/io/micronaut/validation/Validated.java +++ b/validation/src/main/java/io/micronaut/validation/Validated.java @@ -39,4 +39,13 @@ @Inherited @Type(ValidatingInterceptor.class) public @interface Validated { + + /** + * The validation groups that will be used for validation. + * + * @return The validation groups + * @since 3.5.0 + */ + Class[] groups() default {}; + } diff --git a/validation/src/main/java/io/micronaut/validation/ValidatingInterceptor.java b/validation/src/main/java/io/micronaut/validation/ValidatingInterceptor.java index 1af618fe423..84e35feffbe 100644 --- a/validation/src/main/java/io/micronaut/validation/ValidatingInterceptor.java +++ b/validation/src/main/java/io/micronaut/validation/ValidatingInterceptor.java @@ -25,13 +25,12 @@ import io.micronaut.validation.validator.ReactiveValidator; import io.micronaut.validation.validator.Validator; import jakarta.inject.Singleton; - +import java.lang.reflect.Method; +import java.util.Set; import javax.validation.ConstraintViolation; import javax.validation.ConstraintViolationException; import javax.validation.ValidatorFactory; import javax.validation.executable.ExecutableValidator; -import java.lang.reflect.Method; -import java.util.Set; /** * A {@link MethodInterceptor} that validates method invocations. @@ -92,7 +91,8 @@ public Object intercept(MethodInvocationContext context) { .validateParameters( context.getTarget(), targetMethod, - context.getParameterValues() + context.getParameterValues(), + getValidationGroups(context) ); if (!constraintViolations.isEmpty()) { throw new ConstraintViolationException(constraintViolations); @@ -105,7 +105,8 @@ public Object intercept(MethodInvocationContext context) { Set> constraintViolations = micronautValidator.validateParameters( context.getTarget(), executableMethod, - context.getParameterValues()); + context.getParameterValues(), + getValidationGroups(context)); if (!constraintViolations.isEmpty()) { throw new ConstraintViolationException(constraintViolations); } @@ -117,11 +118,15 @@ public Object intercept(MethodInvocationContext context) { switch (interceptedMethod.resultType()) { case PUBLISHER: return interceptedMethod.handleResult( - ((ReactiveValidator) micronautValidator).validatePublisher(interceptedMethod.interceptResultAsPublisher()) + ((ReactiveValidator) micronautValidator).validatePublisher( + interceptedMethod.interceptResultAsPublisher(), + getValidationGroups(context)) ); case COMPLETION_STAGE: return interceptedMethod.handleResult( - ((ReactiveValidator) micronautValidator).validateCompletionStage(interceptedMethod.interceptResultAsCompletionStage()) + ((ReactiveValidator) micronautValidator).validateCompletionStage( + interceptedMethod.interceptResultAsCompletionStage(), + getValidationGroups(context)) ); case SYNCHRONOUS: return validateReturnMicronautValidator(context, executableMethod); @@ -142,7 +147,11 @@ public Object intercept(MethodInvocationContext context) { private Object validateReturnMicronautValidator(MethodInvocationContext context, ExecutableMethod executableMethod) { Object result = context.proceed(); - Set> constraintViolations = micronautValidator.validateReturnValue(context.getTarget(), executableMethod, result); + Set> constraintViolations = micronautValidator.validateReturnValue( + context.getTarget(), + executableMethod, + result, + getValidationGroups(context)); if (!constraintViolations.isEmpty()) { throw new ConstraintViolationException(constraintViolations); } @@ -155,7 +164,8 @@ private Object validateReturnExecutableValidator(MethodInvocationContext> constraintViolations = executableValidator.validateReturnValue( context.getTarget(), targetMethod, - result + result, + getValidationGroups(context) ); if (!constraintViolations.isEmpty()) { throw new ConstraintViolationException(constraintViolations); @@ -167,4 +177,8 @@ private Object validateReturnExecutableValidator(MethodInvocationContext context) { return context.hasStereotype(Validator.ANN_VALID) || context.hasStereotype(Validator.ANN_CONSTRAINT); } + + private Class[] getValidationGroups(MethodInvocationContext context) { + return context.classValues(Validated.class, "groups"); + } } diff --git a/validation/src/test/groovy/io/micronaut/validation/validator/pojo/PojoBodyParameterSpec.groovy b/validation/src/test/groovy/io/micronaut/validation/validator/pojo/PojoBodyParameterSpec.groovy index fac97e0327f..406119fd17d 100644 --- a/validation/src/test/groovy/io/micronaut/validation/validator/pojo/PojoBodyParameterSpec.groovy +++ b/validation/src/test/groovy/io/micronaut/validation/validator/pojo/PojoBodyParameterSpec.groovy @@ -30,6 +30,8 @@ import io.micronaut.http.annotation.* import io.micronaut.http.client.HttpClient import io.micronaut.http.client.exceptions.HttpClientResponseException import io.micronaut.runtime.server.EmbeddedServer +import io.micronaut.validation.Validated +import javax.validation.groups.Default import spock.lang.AutoCleanup import spock.lang.Shared import spock.lang.Specification @@ -108,6 +110,75 @@ class PojoBodyParameterSpec extends Specification { def e = thrown(HttpClientResponseException) e.status == HttpStatus.BAD_REQUEST } + + void "should not fail on default validation group with missing nullable value"() { + given: + HttpRequest req = HttpRequest.POST("/search/extended", '{"type":"NULLABLE", "requiredVal":"xxx"}') + + when: + def response = client.toBlocking().exchange(req) + + then: + response.status() == HttpStatus.OK + } + + void "should fail on on default validation group with missing requiredVal"() { + given: + HttpRequest req = HttpRequest.POST("/search/extended", '{"type":"NULLABLE", "nullableValue": "value"}') + + when: + client.toBlocking().exchange(req) + + then: + def e = thrown(HttpClientResponseException) + e.status == HttpStatus.BAD_REQUEST + } + + void "should not fail on on default validation group with nothing missing"() { + given: + HttpRequest req = HttpRequest.POST("/search/extended", '{"type":"NULLABLE", "requiredVal":"xxx", "nullableValue": "value"}') + + when: + def response = client.toBlocking().exchange(req) + + then: + response.status() == HttpStatus.OK + } + + void "should fail on custom validation group with missing nullable value"() { + given: + HttpRequest req = HttpRequest.POST("/search/extended-group", '{"type":"NULLABLE", "requiredVal":"xxx"}') + + when: + client.toBlocking().exchange(req) + + then: + def e = thrown(HttpClientResponseException) + e.status == HttpStatus.BAD_REQUEST + } + + void "should fail on custom validation group with missing requiredVal"() { + given: + HttpRequest req = HttpRequest.POST("/search/extended-group", '{"type":"NULLABLE", "nullableValue": "value"}') + + when: + client.toBlocking().exchange(req) + + then: + def e = thrown(HttpClientResponseException) + e.status == HttpStatus.BAD_REQUEST + } + + void "should not fail on custom validation group with nothing missing"() { + given: + HttpRequest req = HttpRequest.POST("/search/extended-group", '{"type":"NULLABLE", "requiredVal":"xxx", "nullableValue": "value"}') + + when: + def response = client.toBlocking().exchange(req) + + then: + response.status() == HttpStatus.OK + } } @@ -119,7 +190,8 @@ class PojoBodyParameterSpec extends Specification { @Introspected @JsonSubTypes([ @JsonSubTypes.Type(value = ByName.class, name = "NAME"), - @JsonSubTypes.Type(value = ByAge.class, name = "AGE")]) + @JsonSubTypes.Type(value = ByAge.class, name = "AGE"), + @JsonSubTypes.Type(value = ByNullableValue.class, name = "NULLABLE")]) abstract class SearchBy { @NotEmpty String requiredVal; @@ -138,6 +210,14 @@ class ByAge extends SearchBy { Integer age; } +@Introspected +class ByNullableValue extends SearchBy { + @NotNull(groups = TestGroup) + String nullableValue; +} + +interface TestGroup extends Default {} + @Controller("/search") @Requires(property = "spec.name", value = "customValidatorPOJO") class SearchController { @@ -157,9 +237,14 @@ class SearchController { return HttpResponse.ok(search) } + @Post("/extended-group") + @Validated(groups = TestGroup) + HttpResponse extendedSearchWithValidationGroup(@Valid @Body SearchBy search) { + return HttpResponse.ok(search) + } + @Error(exception = ConstraintViolationException.class) HttpResponse validationError(ConstraintViolationException ex) { return HttpResponse.badRequest() } } -