From ff822bd88da29d7ba2157752c8fd8a241837b807 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Tue, 25 Aug 2015 11:21:28 +0100 Subject: [PATCH] =?UTF-8?q?Provide=20an=20API=20to=20ease=20documenting=20?= =?UTF-8?q?a=20request=20field=E2=80=99s=20constraints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, users that wanted to document a request field’s constraints had to roll their own solution. This commit introduces a new API that makes it easier to document constraints. Support is provided for discovering Bean Validation constraints and resolving descriptions for them. The constraint descriptions can then be used as required. For example, they can included in a field’s description or in an additional constraints attribute that’s included in an additional table column via the use of a custom snippet template. Closes gh-42 --- build.gradle | 16 +- docs/build.gradle | 1 + .../docs/asciidoc/documenting-your-api.adoc | 86 ++++++++ .../test/java/com/example/Constraints.java | 31 +++ .../com/example/notes/AbstractNoteInput.java | 51 ----- .../java/com/example/notes/NoteInput.java | 30 ++- .../com/example/notes/NotePatchInput.java | 29 ++- .../com/example/notes/NullOrNotBlank.java | 16 +- .../main/java/com/example/notes/TagInput.java | 12 +- .../java/com/example/notes/TagPatchInput.java | 14 +- .../com/example/notes/ApiDocumentation.java | 58 +++++- .../example/notes/NullOrNotBlankTests.java | 70 +++++++ .../ConstraintDescriptions.properties | 2 + .../restdocs/templates/request-fields.snippet | 11 + spring-restdocs/build.gradle | 18 +- .../restdocs/constraints/Constraint.java | 62 ++++++ .../ConstraintDescriptionResolver.java | 29 +-- .../constraints/ConstraintDescriptions.java | 108 ++++++++++ .../constraints/ConstraintResolver.java | 38 ++++ ...ceBundleConstraintDescriptionResolver.java | 145 +++++++++++++ .../ValidatorConstraintResolver.java | 82 ++++++++ .../DefaultConstraintDescriptions.properties | 13 ++ .../RestDocumentationIntegrationTests.java | 1 + .../ConstraintDescriptionsTests.java | 182 +++++++++++++++++ ...dleConstraintDescriptionResolverTests.java | 192 ++++++++++++++++++ .../ValidatorConstraintResolverTests.java | 97 +++++++++ .../TestConstraintDescriptions.properties | 1 + 27 files changed, 1291 insertions(+), 104 deletions(-) create mode 100644 docs/src/test/java/com/example/Constraints.java delete mode 100644 samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/AbstractNoteInput.java create mode 100644 samples/rest-notes-spring-hateoas/src/test/java/com/example/notes/NullOrNotBlankTests.java create mode 100644 samples/rest-notes-spring-hateoas/src/test/resources/org/springframework/restdocs/constraints/ConstraintDescriptions.properties create mode 100644 samples/rest-notes-spring-hateoas/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet create mode 100644 spring-restdocs/src/main/java/org/springframework/restdocs/constraints/Constraint.java rename samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/AbstractTagInput.java => spring-restdocs/src/main/java/org/springframework/restdocs/constraints/ConstraintDescriptionResolver.java (56%) create mode 100644 spring-restdocs/src/main/java/org/springframework/restdocs/constraints/ConstraintDescriptions.java create mode 100644 spring-restdocs/src/main/java/org/springframework/restdocs/constraints/ConstraintResolver.java create mode 100644 spring-restdocs/src/main/java/org/springframework/restdocs/constraints/ResourceBundleConstraintDescriptionResolver.java create mode 100644 spring-restdocs/src/main/java/org/springframework/restdocs/constraints/ValidatorConstraintResolver.java create mode 100644 spring-restdocs/src/main/resources/org/springframework/restdocs/constraints/DefaultConstraintDescriptions.properties create mode 100644 spring-restdocs/src/test/java/org/springframework/restdocs/constraints/ConstraintDescriptionsTests.java create mode 100644 spring-restdocs/src/test/java/org/springframework/restdocs/constraints/ResourceBundleConstraintDescriptionResolverTests.java create mode 100644 spring-restdocs/src/test/java/org/springframework/restdocs/constraints/ValidatorConstraintResolverTests.java create mode 100644 spring-restdocs/src/test/resources/org/springframework/restdocs/constraints/TestConstraintDescriptions.properties diff --git a/build.gradle b/build.gradle index f44023b2..ee1baef9 100644 --- a/build.gradle +++ b/build.gradle @@ -1,9 +1,11 @@ buildscript { repositories { jcenter() + maven { url 'https://repo.spring.io/plugins-release' } } dependencies { classpath 'io.spring.gradle:dependency-management-plugin:0.5.3.RELEASE' + classpath 'org.springframework.build.gradle:propdeps-plugin:0.0.7' } } @@ -13,27 +15,27 @@ allprojects { apply plugin: 'samples' -ext { - springVersion = '4.1.7.RELEASE' - jmustacheVersion = '1.10' -} - subprojects { apply plugin: 'io.spring.dependency-management' apply plugin: 'java' apply plugin: 'eclipse' + apply plugin: 'propdeps' + apply plugin: 'propdeps-eclipse' + apply plugin: 'propdeps-maven' dependencyManagement { imports { - mavenBom "org.springframework:spring-framework-bom:$springVersion" + mavenBom 'org.springframework:spring-framework-bom:4.1.7.RELEASE' } dependencies { dependency 'com.fasterxml.jackson.core:jackson-databind:2.4.6' - dependency "com.samskivert:jmustache:$jmustacheVersion" + dependency 'com.samskivert:jmustache:1.10' dependency 'javax.servlet:javax.servlet-api:3.1.0' + dependency 'javax.validation:validation-api:1.1.0.Final' dependency 'junit:junit:4.12' dependency 'org.hamcrest:hamcrest-core:1.3' dependency 'org.hamcrest:hamcrest-library:1.3' + dependency 'org.hibernate:hibernate-validator:5.2.1.Final' dependency 'org.mockito:mockito-core:1.10.19' dependency 'org.springframework.hateoas:spring-hateoas:0.17.0.RELEASE' dependency 'org.jacoco:org.jacoco.agent:0.7.2.201409121644' diff --git a/docs/build.gradle b/docs/build.gradle index 92d3a897..b3184452 100644 --- a/docs/build.gradle +++ b/docs/build.gradle @@ -6,6 +6,7 @@ dependencies { compile 'org.springframework:spring-webmvc' testCompile project(':spring-restdocs') + testCompile 'javax.validation:validation-api' testCompile 'junit:junit' testCompile 'org.springframework:spring-test' } diff --git a/docs/src/docs/asciidoc/documenting-your-api.adoc b/docs/src/docs/asciidoc/documenting-your-api.adoc index d7fa3f75..c4352df4 100644 --- a/docs/src/docs/asciidoc/documenting-your-api.adoc +++ b/docs/src/docs/asciidoc/documenting-your-api.adoc @@ -261,6 +261,92 @@ TIP: To make the path parameters available for documentation, the request must b built using one of the methods on `RestDocumentationRequestBuilders` rather than `MockMvcRequestBuilders`. + + +[[documenting-your-api-constraints]] +=== Documenting constraints + +Spring REST Docs provides a number of classes that can help you to document constraints. +An instance of `ConstraintDescriptions` can be used to access descriptions of a class's +constraints: + +[source,java,indent=0] +---- +include::{examples-dir}/com/example/Constraints.java[tags=constraints] +---- +<1> Create an instance of `ConstraintDescriptions` for the `UserInput` class +<2> Get the descriptions of the name property's constraints. This list will contain two + descriptions; one for the `NotNull` constraint and one for the `Size` constraint. + +The `ApiDocumentation` class in the Spring HATEOAS sample shows this functionality in +action. + + + +[[documenting-your-api-constraints-finding]] +==== Finding constraints + +By default, constraints are found using a Bean Validation `Validator`. Currently, only +property constraints are supported. You can customize the `Validator` that's used by +creating `ConstraintDescriptions` with a custom `ValidatorConstraintResolver` instance. +To take complete control of constraint resolution, your own implementation of +`ConstraintResolver` can be used. + + + +[[documenting-your-api-constraints-describing]] +==== Describing constraints + +Default descriptions are provided for all of the Bean Validation 1.1's constraints: + +* AssertFalse +* AssertTrue +* DecimalMax +* DecimalMin +* Digits +* Future +* Max +* Min +* NotNull +* Null +* Past +* Pattern +* Size + +To override the default descriptions, or to provide a new description, create a resource +bundle with the base name +`org.springframework.restdocs.constraints.ConstraintDescriptions`. The Spring +HATEOAS-based sample contains +{samples}/rest-notes-spring-hateoas/src/test/resources/org/springframework/restdocs/constraints/ConstraintDescriptions.properties[an +example of such a resource bundle]. + +Each key in the resource bundle is the fully-qualified name of a constraint plus +`.description`. For example, the key for the standard `@NotNull` constraint is +`javax.validation.constraints.NotNull.description`. + +Property placeholder's referring to a constraint's attributes can be used in its +description. For example, the default description of the `@Min` constraint, +`Must be at least ${value}`, refers to the constraint's `value` attribute. + +To take more control of constraint description resolution, create `ConstraintDescriptions` +with a custom `ResourceBundleConstraintDescriptionResolver`. To take complete control, +create `ConstraintDescriptions` with a custom `ConstraintDescriptionResolver` +implementation. + + + +==== Using constraint descriptions in generated snippets + +Once you have a constraint's descriptions, you're free to use them however you like in +the generated snippets. For example, you may want to include the constraint descriptions +as part of a field's description. Alternatively, you could include the constraints as +<> in +the request fields snippet. The +{samples}/rest-notes-spring-hateoas/src/test/java/com/example/notes/ApiDocumentation.java[`ApiDocumentation`] +class in the Spring HATEOAS-based sample illustrates the latter approach. + + + [[documenting-your-api-default-snippets]] === Default snippets diff --git a/docs/src/test/java/com/example/Constraints.java b/docs/src/test/java/com/example/Constraints.java new file mode 100644 index 00000000..6370b30c --- /dev/null +++ b/docs/src/test/java/com/example/Constraints.java @@ -0,0 +1,31 @@ +package com.example; + +import java.util.List; + +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; + +import org.springframework.restdocs.constraints.ConstraintDescriptions; + +public class Constraints { + + @SuppressWarnings("unused") + // tag::constraints[] + public void example() { + ConstraintDescriptions userConstraints = new ConstraintDescriptions(UserInput.class); // <1> + List descriptions = userConstraints.descriptionsForProperty("name"); // <2> + } + + static class UserInput { + + @NotNull + @Size(min = 1) + String name; + + @NotNull + @Size(min = 8) + String password; + } + // end::constraints[] + +} diff --git a/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/AbstractNoteInput.java b/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/AbstractNoteInput.java deleted file mode 100644 index f79aecdb..00000000 --- a/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/AbstractNoteInput.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2014 the original author or 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 - * - * http://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 com.example.notes; - -import java.net.URI; -import java.util.Collections; -import java.util.List; - -import com.fasterxml.jackson.annotation.JsonProperty; - -abstract class AbstractNoteInput { - - private final String title; - - private final String body; - - private final List tagUris; - - public AbstractNoteInput(String title, String body, List tagUris) { - this.title = title; - this.body = body; - this.tagUris = tagUris == null ? Collections. emptyList() : tagUris; - } - - public String getTitle() { - return title; - } - - public String getBody() { - return body; - } - - @JsonProperty("tags") - public List getTagUris() { - return this.tagUris; - } -} diff --git a/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/NoteInput.java b/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/NoteInput.java index 95e0dbaf..27b04f7b 100644 --- a/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/NoteInput.java +++ b/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/NoteInput.java @@ -17,6 +17,7 @@ package com.example.notes; import java.net.URI; +import java.util.Collections; import java.util.List; import org.hibernate.validator.constraints.NotBlank; @@ -24,11 +25,34 @@ import org.hibernate.validator.constraints.NotBlank; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -public class NoteInput extends AbstractNoteInput { +public class NoteInput { + + @NotBlank + private final String title; + + private final String body; + + private final List tagUris; @JsonCreator - public NoteInput(@NotBlank @JsonProperty("title") String title, + public NoteInput(@JsonProperty("title") String title, @JsonProperty("body") String body, @JsonProperty("tags") List tagUris) { - super(title, body, tagUris); + this.title = title; + this.body = body; + this.tagUris = tagUris == null ? Collections.emptyList() : tagUris; } + + public String getTitle() { + return title; + } + + public String getBody() { + return body; + } + + @JsonProperty("tags") + public List getTagUris() { + return this.tagUris; + } + } diff --git a/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/NotePatchInput.java b/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/NotePatchInput.java index 1921e724..62e07d01 100644 --- a/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/NotePatchInput.java +++ b/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/NotePatchInput.java @@ -17,16 +17,39 @@ package com.example.notes; import java.net.URI; +import java.util.Collections; import java.util.List; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -public class NotePatchInput extends AbstractNoteInput { +public class NotePatchInput { + + @NullOrNotBlank + private final String title; + + private final String body; + + private final List tagUris; @JsonCreator - public NotePatchInput(@NullOrNotBlank @JsonProperty("title") String title, + public NotePatchInput(@JsonProperty("title") String title, @JsonProperty("body") String body, @JsonProperty("tags") List tagUris) { - super(title, body, tagUris); + this.title = title; + this.body = body; + this.tagUris = tagUris == null ? Collections.emptyList() : tagUris; + } + + public String getTitle() { + return title; + } + + public String getBody() { + return body; + } + + @JsonProperty("tags") + public List getTagUris() { + return this.tagUris; } } diff --git a/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/NullOrNotBlank.java b/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/NullOrNotBlank.java index a60e035d..7421a0ea 100644 --- a/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/NullOrNotBlank.java +++ b/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/NullOrNotBlank.java @@ -17,18 +17,30 @@ package com.example.notes; import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import javax.validation.constraints.NotNull; +import javax.validation.Constraint; +import javax.validation.Payload; +import javax.validation.constraints.Null; import org.hibernate.validator.constraints.CompositionType; import org.hibernate.validator.constraints.ConstraintComposition; import org.hibernate.validator.constraints.NotBlank; @ConstraintComposition(CompositionType.OR) -@NotNull +@Constraint(validatedBy = {}) +@Null @NotBlank @Target({ ElementType.FIELD, ElementType.PARAMETER }) +@Retention(RetentionPolicy.RUNTIME) public @interface NullOrNotBlank { + String message() default "Must be null or not blank"; + + Class[] groups() default {}; + + Class[] payload() default {}; + } diff --git a/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/TagInput.java b/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/TagInput.java index 8c3c3d39..2df23e5f 100644 --- a/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/TagInput.java +++ b/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/TagInput.java @@ -21,10 +21,18 @@ import org.hibernate.validator.constraints.NotBlank; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -public class TagInput extends AbstractTagInput { +public class TagInput { + + @NotBlank + private final String name; @JsonCreator public TagInput(@NotBlank @JsonProperty("name") String name) { - super(name); + this.name = name; } + + public String getName() { + return name; + } + } diff --git a/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/TagPatchInput.java b/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/TagPatchInput.java index 254a7886..ba5b2fad 100644 --- a/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/TagPatchInput.java +++ b/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/TagPatchInput.java @@ -19,10 +19,18 @@ package com.example.notes; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -public class TagPatchInput extends AbstractTagInput { +public class TagPatchInput { + + @NullOrNotBlank + private final String name; @JsonCreator public TagPatchInput(@NullOrNotBlank @JsonProperty("name") String name) { - super(name); + this.name = name; } -} + + public String getName() { + return name; + } + +} \ No newline at end of file diff --git a/samples/rest-notes-spring-hateoas/src/test/java/com/example/notes/ApiDocumentation.java b/samples/rest-notes-spring-hateoas/src/test/java/com/example/notes/ApiDocumentation.java index 1bf10810..06e22615 100644 --- a/samples/rest-notes-spring-hateoas/src/test/java/com/example/notes/ApiDocumentation.java +++ b/samples/rest-notes-spring-hateoas/src/test/java/com/example/notes/ApiDocumentation.java @@ -20,14 +20,15 @@ import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.notNullValue; import static org.springframework.restdocs.RestDocumentation.document; import static org.springframework.restdocs.RestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.RestDocumentationRequestBuilders.post; import static org.springframework.restdocs.hypermedia.HypermediaDocumentation.linkWithRel; import static org.springframework.restdocs.hypermedia.HypermediaDocumentation.links; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; -import static org.springframework.restdocs.RestDocumentationRequestBuilders.get; -import static org.springframework.restdocs.RestDocumentationRequestBuilders.patch; -import static org.springframework.restdocs.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.snippet.Attributes.key; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -44,11 +45,14 @@ import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.SpringApplicationConfiguration; import org.springframework.hateoas.MediaTypes; +import org.springframework.restdocs.constraints.ConstraintDescriptions; +import org.springframework.restdocs.payload.FieldDescriptor; import org.springframework.restdocs.payload.JsonFieldType; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.context.web.WebAppConfiguration; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.util.StringUtils; import org.springframework.web.context.WebApplicationContext; import com.fasterxml.jackson.databind.ObjectMapper; @@ -147,15 +151,17 @@ public class ApiDocumentation { note.put("body", "http://martinfowler.com/articles/richardsonMaturityModel.html"); note.put("tags", Arrays.asList(tagLocation)); + ConstrainedFields fields = new ConstrainedFields(NoteInput.class); + this.mockMvc.perform( post("/notes").contentType(MediaTypes.HAL_JSON).content( this.objectMapper.writeValueAsString(note))) .andExpect(status().isCreated()) .andDo(document("notes-create-example", requestFields( - fieldWithPath("title").description("The title of the note"), - fieldWithPath("body").description("The body of the note"), - fieldWithPath("tags").description("An array of tag resource URIs")))); + fields.withPath("title").description("The title of the note"), + fields.withPath("body").description("The body of the note"), + fields.withPath("tags").description("An array of tag resource URIs")))); } @Test @@ -220,13 +226,15 @@ public class ApiDocumentation { Map tag = new HashMap(); tag.put("name", "REST"); + ConstrainedFields fields = new ConstrainedFields(TagInput.class); + this.mockMvc.perform( post("/tags").contentType(MediaTypes.HAL_JSON).content( this.objectMapper.writeValueAsString(tag))) .andExpect(status().isCreated()) .andDo(document("tags-create-example", requestFields( - fieldWithPath("name").description("The name of the tag")))); + fields.withPath("name").description("The name of the tag")))); } @Test @@ -261,15 +269,24 @@ public class ApiDocumentation { Map noteUpdate = new HashMap(); noteUpdate.put("tags", Arrays.asList(tagLocation)); + ConstrainedFields fields = new ConstrainedFields(NotePatchInput.class); + this.mockMvc.perform( patch(noteLocation).contentType(MediaTypes.HAL_JSON).content( this.objectMapper.writeValueAsString(noteUpdate))) .andExpect(status().isNoContent()) .andDo(document("note-update-example", requestFields( - fieldWithPath("title").description("The title of the note").type(JsonFieldType.STRING).optional(), - fieldWithPath("body").description("The body of the note").type(JsonFieldType.STRING).optional(), - fieldWithPath("tags").description("An array of tag resource URIs").optional()))); + fields.withPath("title") + .description("The title of the note") + .type(JsonFieldType.STRING) + .optional(), + fields.withPath("body") + .description("The body of the note") + .type(JsonFieldType.STRING) + .optional(), + fields.withPath("tags") + .description("An array of tag resource URIs")))); } @Test @@ -311,13 +328,16 @@ public class ApiDocumentation { Map tagUpdate = new HashMap(); tagUpdate.put("name", "RESTful"); + ConstrainedFields fields = new ConstrainedFields(TagPatchInput.class); + this.mockMvc.perform( patch(tagLocation).contentType(MediaTypes.HAL_JSON).content( this.objectMapper.writeValueAsString(tagUpdate))) .andExpect(status().isNoContent()) .andDo(document("tag-update-example", requestFields( - fieldWithPath("name").description("The name of the tag")))); + fields.withPath("name") + .description("The name of the tag")))); } private void createNote(String title, String body) { @@ -333,4 +353,20 @@ public class ApiDocumentation { tag.setName(name); this.tagRepository.save(tag); } + + private static class ConstrainedFields { + + private final ConstraintDescriptions constraintDescriptions; + + ConstrainedFields(Class input) { + this.constraintDescriptions = new ConstraintDescriptions(input); + } + + private FieldDescriptor withPath(String path) { + return fieldWithPath(path).attributes(key("constraints").value(StringUtils + .collectionToDelimitedString(this.constraintDescriptions + .descriptionsForProperty(path), ". "))); + } + } + } diff --git a/samples/rest-notes-spring-hateoas/src/test/java/com/example/notes/NullOrNotBlankTests.java b/samples/rest-notes-spring-hateoas/src/test/java/com/example/notes/NullOrNotBlankTests.java new file mode 100644 index 00000000..24d9e85a --- /dev/null +++ b/samples/rest-notes-spring-hateoas/src/test/java/com/example/notes/NullOrNotBlankTests.java @@ -0,0 +1,70 @@ +/* + * Copyright 2014-2015 the original author or 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 + * + * http://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 com.example.notes; + +import static org.hamcrest.CoreMatchers.is; +import static org.junit.Assert.assertThat; + +import java.util.Set; + +import javax.validation.ConstraintViolation; +import javax.validation.Validation; +import javax.validation.Validator; + +import org.junit.Test; + + +public class NullOrNotBlankTests { + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + public void nullValue() { + Set> violations = validator.validate(new Constrained(null)); + assertThat(violations.size(), is(0)); + } + + @Test + public void zeroLengthValue() { + Set> violations = validator.validate(new Constrained("")); + assertThat(violations.size(), is(2)); + } + + @Test + public void blankValue() { + Set> violations = validator.validate(new Constrained(" ")); + assertThat(violations.size(), is(2)); + } + + @Test + public void nonBlankValue() { + Set> violations = validator.validate(new Constrained("test")); + assertThat(violations.size(), is(0)); + } + + static class Constrained { + + @NullOrNotBlank + private final String value; + + public Constrained(String value) { + this.value = value; + } + + } + +} diff --git a/samples/rest-notes-spring-hateoas/src/test/resources/org/springframework/restdocs/constraints/ConstraintDescriptions.properties b/samples/rest-notes-spring-hateoas/src/test/resources/org/springframework/restdocs/constraints/ConstraintDescriptions.properties new file mode 100644 index 00000000..433d946d --- /dev/null +++ b/samples/rest-notes-spring-hateoas/src/test/resources/org/springframework/restdocs/constraints/ConstraintDescriptions.properties @@ -0,0 +1,2 @@ +com.example.notes.NullOrNotBlank.description=Must be null or not blank +org.hibernate.validator.constraints.NotBlank.description=Must not be blank \ No newline at end of file diff --git a/samples/rest-notes-spring-hateoas/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet b/samples/rest-notes-spring-hateoas/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet new file mode 100644 index 00000000..cd1e825c --- /dev/null +++ b/samples/rest-notes-spring-hateoas/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet @@ -0,0 +1,11 @@ +|=== +|Path|Type|Description|Constraints + +{{#fields}} +|{{path}} +|{{type}} +|{{description}} +|{{constraints}} + +{{/fields}} +|=== \ No newline at end of file diff --git a/spring-restdocs/build.gradle b/spring-restdocs/build.gradle index e4c2fd50..bf1fc834 100644 --- a/spring-restdocs/build.gradle +++ b/spring-restdocs/build.gradle @@ -33,16 +33,9 @@ configurations { jmustache } -ext { - javadocLinks = [ - 'http://docs.oracle.com/javase/8/docs/api/', - "http://docs.spring.io/spring-framework/docs/$springVersion/javadoc-api/" - ] as String[] -} - task jmustacheRepackJar(type: Jar) { repackJar -> repackJar.baseName = "restdocs-jmustache-repack" - repackJar.version = jmustacheVersion + repackJar.version = dependencyManagement.managedVersions['com.samskivert:jmustache'] doLast() { project.ant { @@ -70,10 +63,13 @@ dependencies { jacoco 'org.jacoco:org.jacoco.agent::runtime' jarjar 'com.googlecode.jarjar:jarjar:1.3' jmustache 'com.samskivert:jmustache@jar' + optional 'javax.validation:validation-api' testCompile 'org.springframework.hateoas:spring-hateoas' testCompile 'org.mockito:mockito-core' testCompile 'org.hamcrest:hamcrest-core' testCompile 'org.hamcrest:hamcrest-library' + testCompile 'org.hibernate:hibernate-validator' + testRuntime 'org.glassfish:javax.el:3.0.0' } jar { @@ -94,7 +90,11 @@ javadoc { options.author = true options.header = "Spring REST Docs $version" options.docTitle = "${options.header} API" - options.links(project.ext.javadocLinks) + options.links = [ + 'http://docs.oracle.com/javase/8/docs/api/', + "http://docs.spring.io/spring-framework/docs/${dependencyManagement.managedVersions['org.springframework:spring-core']}/javadoc-api/", + 'https://docs.jboss.org/hibernate/stable/beanvalidation/api/' + ] as String[] options.addStringOption '-quiet' } diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/constraints/Constraint.java b/spring-restdocs/src/main/java/org/springframework/restdocs/constraints/Constraint.java new file mode 100644 index 00000000..1adf117e --- /dev/null +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/constraints/Constraint.java @@ -0,0 +1,62 @@ +/* + * Copyright 2014-2015 the original author or 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 + * + * http://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 org.springframework.restdocs.constraints; + +import java.util.Map; + +/** + * A constraint + * + * @author Andy Wilkinson + */ +public class Constraint { + + private final String name; + + private final Map configuration; + + /** + * Creates a new {@code Constraint} with the given {@code name} and + * {@code configuration}. + * + * @param name the name + * @param configuration the configuration + */ + public Constraint(String name, Map configuration) { + this.name = name; + this.configuration = configuration; + } + + /** + * Returns the name of the constraint + * + * @return the name + */ + public String getName() { + return this.name; + } + + /** + * Returns the configuration of the constraint + * + * @return the configuration + */ + public Map getConfiguration() { + return this.configuration; + } + +} diff --git a/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/AbstractTagInput.java b/spring-restdocs/src/main/java/org/springframework/restdocs/constraints/ConstraintDescriptionResolver.java similarity index 56% rename from samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/AbstractTagInput.java rename to spring-restdocs/src/main/java/org/springframework/restdocs/constraints/ConstraintDescriptionResolver.java index 824ce43b..7a428e3f 100644 --- a/samples/rest-notes-spring-hateoas/src/main/java/com/example/notes/AbstractTagInput.java +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/constraints/ConstraintDescriptionResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2014 the original author or authors. + * Copyright 2014-2015 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,18 +14,21 @@ * limitations under the License. */ -package com.example.notes; +package org.springframework.restdocs.constraints; +/** + * Resolves a description for a {@link Constraint}. + * + * @author Andy Wilkinson + * + */ +public interface ConstraintDescriptionResolver { -abstract class AbstractTagInput { - - private final String name; - - public AbstractTagInput(String name) { - this.name = name; - } - - public String getName() { - return name; - } + /** + * Resolves the description for the given {@code constraint}. + * + * @param constraint the constraint + * @return the description + */ + public String resolveDescription(Constraint constraint); } diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/constraints/ConstraintDescriptions.java b/spring-restdocs/src/main/java/org/springframework/restdocs/constraints/ConstraintDescriptions.java new file mode 100644 index 00000000..0161b6d5 --- /dev/null +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/constraints/ConstraintDescriptions.java @@ -0,0 +1,108 @@ +/* + * Copyright 2014-2015 the original author or 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 + * + * http://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 org.springframework.restdocs.constraints; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Provides access to descriptions of a class's constraints + * + * @author Andy Wilkinson + */ +public class ConstraintDescriptions { + + private final Class clazz; + + private final ConstraintResolver constraintResolver; + + private final ConstraintDescriptionResolver descriptionResolver; + + /** + * Create a new {@code ConstraintDescriptions} for the given {@code clazz}. + * Constraints will be resolved using a {@link ValidatorConstraintResolver} and + * descriptions will be resolved using a + * {@link ResourceBundleConstraintDescriptionResolver}. + * + * @param clazz the class + */ + public ConstraintDescriptions(Class clazz) { + this(clazz, new ValidatorConstraintResolver(), + new ResourceBundleConstraintDescriptionResolver()); + } + + /** + * Create a new {@code ConstraintDescriptions} for the given {@code clazz}. + * Constraints will be resolved using the given {@link constraintResolver} and + * descriptions will be resolved using a + * {@link ResourceBundleConstraintDescriptionResolver}. + * + * @param clazz the class + * @param constraintResolver the constraint resolver + */ + public ConstraintDescriptions(Class clazz, ConstraintResolver constraintResolver) { + this(clazz, constraintResolver, new ResourceBundleConstraintDescriptionResolver()); + } + + /** + * Create a new {@code ConstraintDescriptions} for the given {@code clazz}. + * Constraints will be resolved using a {@link ValidatorConstraintResolver} and + * descriptions will be resolved using the given {@code descriptionResolver}. + * + * @param clazz the class + * @param descriptionResolver the description resolver + */ + public ConstraintDescriptions(Class clazz, + ConstraintDescriptionResolver descriptionResolver) { + this(clazz, new ValidatorConstraintResolver(), descriptionResolver); + } + + /** + * Create a new {@code ConstraintDescriptions} for the given {@code clazz}. + * Constraints will be resolved using the given {@code constraintResolver} and + * descriptions will be resolved using the given {@code descriptionResolver}. + * + * @param clazz the class + * @param constraintResolver the constraint resolver + * @param descriptionResolver the description resolver + */ + public ConstraintDescriptions(Class clazz, ConstraintResolver constraintResolver, + ConstraintDescriptionResolver descriptionResolver) { + this.clazz = clazz; + this.constraintResolver = constraintResolver; + this.descriptionResolver = descriptionResolver; + } + + /** + * Returns a list of the descriptions for the constraints on the given property + * + * @param property the property + * @return the list of constraint descriptions + */ + public List descriptionsForProperty(String property) { + List constraints = this.constraintResolver.resolveForProperty( + property, this.clazz); + List descriptions = new ArrayList<>(); + for (Constraint constraint : constraints) { + descriptions.add(this.descriptionResolver.resolveDescription(constraint)); + } + Collections.sort(descriptions); + return descriptions; + } + +} diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/constraints/ConstraintResolver.java b/spring-restdocs/src/main/java/org/springframework/restdocs/constraints/ConstraintResolver.java new file mode 100644 index 00000000..8f5c6580 --- /dev/null +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/constraints/ConstraintResolver.java @@ -0,0 +1,38 @@ +/* + * Copyright 2014-2015 the original author or 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 + * + * http://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 org.springframework.restdocs.constraints; + +import java.util.List; + +/** + * An abstraction for resolving a class's constraints. + * + * @author Andy Wilkinson + */ +public interface ConstraintResolver { + + /** + * Resolves and returns the constraints for the given {@code property} on the given + * {@code clazz}. If there are no constraints, an empty list is returned. + * + * @param property the property + * @param clazz the class + * @return the list of constraints, never {@code null} + */ + List resolveForProperty(String property, Class clazz); + +} diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/constraints/ResourceBundleConstraintDescriptionResolver.java b/spring-restdocs/src/main/java/org/springframework/restdocs/constraints/ResourceBundleConstraintDescriptionResolver.java new file mode 100644 index 00000000..7ea94a8d --- /dev/null +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/constraints/ResourceBundleConstraintDescriptionResolver.java @@ -0,0 +1,145 @@ +/* + * Copyright 2014-2015 the original author or 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 + * + * http://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 org.springframework.restdocs.constraints; + +import java.util.Locale; +import java.util.MissingResourceException; +import java.util.ResourceBundle; + +import javax.validation.constraints.AssertFalse; +import javax.validation.constraints.AssertTrue; +import javax.validation.constraints.DecimalMax; +import javax.validation.constraints.DecimalMin; +import javax.validation.constraints.Digits; +import javax.validation.constraints.Future; +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Null; +import javax.validation.constraints.Past; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; + +import org.springframework.util.PropertyPlaceholderHelper; +import org.springframework.util.PropertyPlaceholderHelper.PlaceholderResolver; + +/** + * A {@link ConstraintDescriptionResolver} that resolves constraint descriptions from a + * {@link ResourceBundle}. The resource bundle's keys are the name of the constraint with + * {@code .description} appended. For example, the key for the constraint named + * {@code javax.validation.constraints.NotNull} is + * {@code javax.validation.constraints.NotNull.description}. + *

+ * Default descriptions are provided for Bean Validation 1.1's constraints: + * + *

    + *
  • {@link AssertFalse} + *
  • {@link AssertTrue} + *
  • {@link DecimalMax} + *
  • {@link DecimalMin} + *
  • {@link Digits} + *
  • {@link Future} + *
  • {@link Max} + *
  • {@link Min} + *
  • {@link NotNull} + *
  • {@link Null} + *
  • {@link Past} + *
  • {@link Pattern} + *
  • {@link Size} + *
+ * + * @author Andy Wilkinson + */ +public class ResourceBundleConstraintDescriptionResolver implements + ConstraintDescriptionResolver { + + private final PropertyPlaceholderHelper propertyPlaceholderHelper = new PropertyPlaceholderHelper( + "${", "}"); + + private final ResourceBundle defaultDescriptions; + + private final ResourceBundle userDescriptions; + + /** + * Creates a new {@code ResourceBundleConstraintDescriptionResolver} that will resolve + * descriptions by looking them up in a resource bundle with the base name + * {@code org.springframework.restdocs.constraints.ConstraintDescriptions} in the + * default locale loaded using the thread context class loader + */ + public ResourceBundleConstraintDescriptionResolver() { + this(getBundle("ConstraintDescriptions")); + } + + /** + * Creates a new {@code ResourceBundleConstraintDescriptionResolver} that will resolve + * descriptions by looking them up in the given {@code resourceBundle}. + * + * @param resourceBundle the resource bundle + */ + public ResourceBundleConstraintDescriptionResolver(ResourceBundle resourceBundle) { + this.defaultDescriptions = getBundle("DefaultConstraintDescriptions"); + this.userDescriptions = resourceBundle; + } + + private static ResourceBundle getBundle(String name) { + try { + return ResourceBundle.getBundle( + ResourceBundleConstraintDescriptionResolver.class.getPackage() + .getName() + "." + name, Locale.getDefault(), Thread + .currentThread().getContextClassLoader()); + } + catch (MissingResourceException ex) { + return null; + } + } + + @Override + public String resolveDescription(Constraint constraint) { + String key = constraint.getName() + ".description"; + return this.propertyPlaceholderHelper.replacePlaceholders(getDescription(key), + new ConstraintPlaceholderResolver(constraint)); + } + + private String getDescription(String key) { + try { + if (this.userDescriptions != null) { + return this.userDescriptions.getString(key); + } + } + catch (MissingResourceException ex) { + // Continue and return default description, if available + } + return this.defaultDescriptions.getString(key); + } + + private static class ConstraintPlaceholderResolver implements PlaceholderResolver { + + private final Constraint constraint; + + private ConstraintPlaceholderResolver(Constraint constraint) { + this.constraint = constraint; + } + + @Override + public String resolvePlaceholder(String placeholderName) { + Object replacement = this.constraint.getConfiguration().get(placeholderName); + return replacement != null ? replacement.toString() : null; + } + + } + +} diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/constraints/ValidatorConstraintResolver.java b/spring-restdocs/src/main/java/org/springframework/restdocs/constraints/ValidatorConstraintResolver.java new file mode 100644 index 00000000..2a638ed6 --- /dev/null +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/constraints/ValidatorConstraintResolver.java @@ -0,0 +1,82 @@ +/* + * Copyright 2014-2015 the original author or 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 + * + * http://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 org.springframework.restdocs.constraints; + +import java.util.ArrayList; +import java.util.List; + +import javax.validation.Validation; +import javax.validation.Validator; +import javax.validation.ValidatorFactory; +import javax.validation.constraints.NotNull; +import javax.validation.metadata.BeanDescriptor; +import javax.validation.metadata.ConstraintDescriptor; +import javax.validation.metadata.PropertyDescriptor; + +/** + * A {@link ConstraintResolver} that uses a Bean Validation {@link Validator} to resolve + * constraints. The name of the constraint is the fully-qualified class name of the + * constraint annotation. For example, a {@link NotNull} constraint will be named + * {@code javax.validation.constraints.NotNull}. + * + * @author Andy Wilkinson + * + */ +public class ValidatorConstraintResolver implements ConstraintResolver { + + private final Validator validator; + + /** + * Creates a new {@code ValidatorConstraintResolver} that will use a {@link Validator} + * in its default configurationto resolve constraints. + * + * @see Validation#buildDefaultValidatorFactory() + * @see ValidatorFactory#getValidator() + */ + public ValidatorConstraintResolver() { + this(Validation.buildDefaultValidatorFactory().getValidator()); + } + + /** + * Creates a new {@code ValidatorConstraintResolver} that will use the given + * {@code Validator} to resolve constraints. + * + * @param validator the validator + */ + public ValidatorConstraintResolver(Validator validator) { + this.validator = validator; + } + + @Override + public List resolveForProperty(String property, Class clazz) { + List constraints = new ArrayList<>(); + BeanDescriptor beanDescriptor = this.validator.getConstraintsForClass(clazz); + PropertyDescriptor propertyDescriptor = beanDescriptor + .getConstraintsForProperty(property); + if (propertyDescriptor != null) { + for (ConstraintDescriptor constraintDescriptor : propertyDescriptor + .getConstraintDescriptors()) { + constraints + .add(new Constraint(constraintDescriptor.getAnnotation() + .annotationType().getName(), constraintDescriptor + .getAttributes())); + } + } + return constraints; + } + +} diff --git a/spring-restdocs/src/main/resources/org/springframework/restdocs/constraints/DefaultConstraintDescriptions.properties b/spring-restdocs/src/main/resources/org/springframework/restdocs/constraints/DefaultConstraintDescriptions.properties new file mode 100644 index 00000000..c945d486 --- /dev/null +++ b/spring-restdocs/src/main/resources/org/springframework/restdocs/constraints/DefaultConstraintDescriptions.properties @@ -0,0 +1,13 @@ +javax.validation.constraints.AssertFalse.description=Must be false +javax.validation.constraints.AssertTrue.description=Must be true +javax.validation.constraints.DecimalMax.description=Must be at most ${value} +javax.validation.constraints.DecimalMin.description=Must be at least ${value} +javax.validation.constraints.Digits.description=Must have at most ${integer} integral digits and ${fraction} fractional digits +javax.validation.constraints.Future.description=Must be in the future +javax.validation.constraints.Max.description=Must be at most ${value} +javax.validation.constraints.Min.description=Must be at least ${value} +javax.validation.constraints.NotNull.description=Must not be null +javax.validation.constraints.Null.description=Must be null +javax.validation.constraints.Past.description=Must be in the past +javax.validation.constraints.Pattern.description=Must match the regular expression '${regexp}' +javax.validation.constraints.Size.description=Size must be between ${min} and ${max} inclusive \ No newline at end of file diff --git a/spring-restdocs/src/test/java/org/springframework/restdocs/RestDocumentationIntegrationTests.java b/spring-restdocs/src/test/java/org/springframework/restdocs/RestDocumentationIntegrationTests.java index 7f8cb279..194d0bd7 100644 --- a/spring-restdocs/src/test/java/org/springframework/restdocs/RestDocumentationIntegrationTests.java +++ b/spring-restdocs/src/test/java/org/springframework/restdocs/RestDocumentationIntegrationTests.java @@ -329,6 +329,7 @@ public class RestDocumentationIntegrationTests { return new ResponseEntity>(response, headers, HttpStatus.OK); } + } } diff --git a/spring-restdocs/src/test/java/org/springframework/restdocs/constraints/ConstraintDescriptionsTests.java b/spring-restdocs/src/test/java/org/springframework/restdocs/constraints/ConstraintDescriptionsTests.java new file mode 100644 index 00000000..8aa88989 --- /dev/null +++ b/spring-restdocs/src/test/java/org/springframework/restdocs/constraints/ConstraintDescriptionsTests.java @@ -0,0 +1,182 @@ +package org.springframework.restdocs.constraints; + +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.Assert.assertThat; + +import java.math.BigDecimal; +import java.util.Date; + +import javax.validation.constraints.AssertFalse; +import javax.validation.constraints.AssertTrue; +import javax.validation.constraints.DecimalMax; +import javax.validation.constraints.DecimalMin; +import javax.validation.constraints.Digits; +import javax.validation.constraints.Future; +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Null; +import javax.validation.constraints.Past; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; + +import org.junit.Test; + +/** + * Tests for {@link ConstraintDescriptions} + * + * @author Andy Wilkinson + */ +public class ConstraintDescriptionsTests { + + private final ConstraintDescriptions constraintDescriptions = new ConstraintDescriptions( + Constrained.class); + + @Test + public void assertFalse() { + assertThat(this.constraintDescriptions.descriptionsForProperty("assertFalse"), + contains("Must be false")); + } + + @Test + public void assertTrue() { + assertThat(this.constraintDescriptions.descriptionsForProperty("assertTrue"), + contains("Must be true")); + } + + @Test + public void decimalMax() { + assertThat(this.constraintDescriptions.descriptionsForProperty("decimalMax"), + contains("Must be at most 9.875")); + } + + @Test + public void decimalMin() { + assertThat(this.constraintDescriptions.descriptionsForProperty("decimalMin"), + contains("Must be at least 1.5")); + } + + @Test + public void digits() { + assertThat(this.constraintDescriptions.descriptionsForProperty("digits"), + contains("Must have at most 2 integral digits and 5 fractional digits")); + } + + @Test + public void future() { + assertThat(this.constraintDescriptions.descriptionsForProperty("future"), + contains("Must be in the future")); + } + + @Test + public void max() { + assertThat(this.constraintDescriptions.descriptionsForProperty("max"), + contains("Must be at most 10")); + } + + @Test + public void min() { + assertThat(this.constraintDescriptions.descriptionsForProperty("min"), + contains("Must be at least 5")); + } + + @Test + public void notNull() { + assertThat(this.constraintDescriptions.descriptionsForProperty("notNull"), + contains("Must not be null")); + } + + @Test + public void nul() { + assertThat(this.constraintDescriptions.descriptionsForProperty("nul"), + contains("Must be null")); + } + + @Test + public void past() { + assertThat(this.constraintDescriptions.descriptionsForProperty("past"), + contains("Must be in the past")); + } + + @Test + public void pattern() { + assertThat(this.constraintDescriptions.descriptionsForProperty("pattern"), + contains("Must match the regular expression '[A-Z][a-z]+'")); + } + + @Test + public void size() { + assertThat(this.constraintDescriptions.descriptionsForProperty("size"), + contains("Size must be between 0 and 10 inclusive")); + } + + @Test + public void sizeList() { + assertThat( + this.constraintDescriptions.descriptionsForProperty("sizeList"), + contains("Size must be between 1 and 4 inclusive", + "Size must be between 8 and 10 inclusive")); + } + + @Test + public void unconstrained() { + assertThat(this.constraintDescriptions.descriptionsForProperty("unconstrained"), + hasSize(0)); + } + + @Test + public void nonExistentProperty() { + assertThat(this.constraintDescriptions.descriptionsForProperty("doesNotExist"), + hasSize(0)); + } + + private static class Constrained { + + @AssertFalse + private boolean assertFalse; + + @AssertTrue + private boolean assertTrue; + + @DecimalMax("9.875") + private BigDecimal decimalMax; + + @DecimalMin("1.5") + private BigDecimal decimalMin; + + @Digits(fraction = 5, integer = 2) + private BigDecimal digits; + + @Future + private Date future; + + @NotNull + private String notNull; + + @Max(10) + private int max; + + @Min(5) + private int min; + + @Null + private String nul; + + @Past + private Date past; + + @Pattern(regexp = "[A-Z][a-z]+") + private String pattern; + + @Size(min = 0, max = 10) + private String size; + + @Size.List({ @Size(min = 1, max = 4), @Size(min = 8, max = 10) }) + private String sizeList; + + @SuppressWarnings("unused") + private String unconstrained; + } + +} diff --git a/spring-restdocs/src/test/java/org/springframework/restdocs/constraints/ResourceBundleConstraintDescriptionResolverTests.java b/spring-restdocs/src/test/java/org/springframework/restdocs/constraints/ResourceBundleConstraintDescriptionResolverTests.java new file mode 100644 index 00000000..03202540 --- /dev/null +++ b/spring-restdocs/src/test/java/org/springframework/restdocs/constraints/ResourceBundleConstraintDescriptionResolverTests.java @@ -0,0 +1,192 @@ +package org.springframework.restdocs.constraints; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; +import static org.junit.Assert.assertThat; + +import java.net.URL; +import java.util.Collections; +import java.util.HashMap; +import java.util.ListResourceBundle; +import java.util.Map; +import java.util.ResourceBundle; + +import javax.validation.constraints.AssertFalse; +import javax.validation.constraints.AssertTrue; +import javax.validation.constraints.DecimalMax; +import javax.validation.constraints.DecimalMin; +import javax.validation.constraints.Digits; +import javax.validation.constraints.Future; +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Null; +import javax.validation.constraints.Past; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; + +import org.junit.Test; + +/** + * Tests for {@link ResourceBundleConstraintDescriptionResolver} + * + * @author Andy Wilkinson + */ +public class ResourceBundleConstraintDescriptionResolverTests { + + private final ResourceBundleConstraintDescriptionResolver resolver = new ResourceBundleConstraintDescriptionResolver(); + + @Test + public void defaultMessageAssertFalse() { + String description = this.resolver.resolveDescription(new Constraint( + AssertFalse.class.getName(), Collections. emptyMap())); + assertThat(description, is(equalTo("Must be false"))); + } + + @Test + public void defaultMessageAssertTrue() { + String description = this.resolver.resolveDescription(new Constraint( + AssertTrue.class.getName(), Collections. emptyMap())); + assertThat(description, is(equalTo("Must be true"))); + } + + @Test + public void defaultMessageDecimalMax() { + Map configuration = new HashMap<>(); + configuration.put("value", "9.875"); + String description = this.resolver.resolveDescription(new Constraint( + DecimalMax.class.getName(), configuration)); + assertThat(description, is(equalTo("Must be at most 9.875"))); + } + + @Test + public void defaultMessageDecimalMin() { + Map configuration = new HashMap<>(); + configuration.put("value", "1.5"); + String description = this.resolver.resolveDescription(new Constraint( + DecimalMin.class.getName(), configuration)); + assertThat(description, is(equalTo("Must be at least 1.5"))); + } + + @Test + public void defaultMessageDigits() { + Map configuration = new HashMap<>(); + configuration.put("integer", "2"); + configuration.put("fraction", "5"); + String description = this.resolver.resolveDescription(new Constraint(Digits.class + .getName(), configuration)); + assertThat(description, is(equalTo("Must have at most 2 integral digits and 5 " + + "fractional digits"))); + } + + @Test + public void defaultMessageFuture() { + String description = this.resolver.resolveDescription(new Constraint(Future.class + .getName(), Collections. emptyMap())); + assertThat(description, is(equalTo("Must be in the future"))); + } + + @Test + public void defaultMessageMax() { + Map configuration = new HashMap<>(); + configuration.put("value", 10); + String description = this.resolver.resolveDescription(new Constraint(Max.class + .getName(), configuration)); + assertThat(description, is(equalTo("Must be at most 10"))); + } + + @Test + public void defaultMessageMin() { + Map configuration = new HashMap<>(); + configuration.put("value", 10); + String description = this.resolver.resolveDescription(new Constraint(Min.class + .getName(), configuration)); + assertThat(description, is(equalTo("Must be at least 10"))); + } + + @Test + public void defaultMessageNotNull() { + String description = this.resolver.resolveDescription(new Constraint( + NotNull.class.getName(), Collections. emptyMap())); + assertThat(description, is(equalTo("Must not be null"))); + } + + @Test + public void defaultMessageNull() { + String description = this.resolver.resolveDescription(new Constraint(Null.class + .getName(), Collections. emptyMap())); + assertThat(description, is(equalTo("Must be null"))); + } + + @Test + public void defaultMessagePast() { + String description = this.resolver.resolveDescription(new Constraint(Past.class + .getName(), Collections. emptyMap())); + assertThat(description, is(equalTo("Must be in the past"))); + } + + @Test + public void defaultMessagePattern() { + Map configuration = new HashMap<>(); + configuration.put("regexp", "[A-Z][a-z]+"); + String description = this.resolver.resolveDescription(new Constraint( + Pattern.class.getName(), configuration)); + assertThat(description, + is(equalTo("Must match the regular expression '[A-Z][a-z]+'"))); + } + + @Test + public void defaultMessageSize() { + Map configuration = new HashMap<>(); + configuration.put("min", 2); + configuration.put("max", 10); + String description = this.resolver.resolveDescription(new Constraint(Size.class + .getName(), configuration)); + assertThat(description, is(equalTo("Size must be between 2 and 10 inclusive"))); + } + + @Test + public void customMessage() { + Thread.currentThread().setContextClassLoader(new ClassLoader() { + + @Override + public URL getResource(String name) { + if (name.startsWith("org/springframework/restdocs/constraints/ConstraintDescriptions")) { + return super + .getResource("org/springframework/restdocs/constraints/TestConstraintDescriptions.properties"); + } + return super.getResource(name); + } + + }); + + try { + String description = new ResourceBundleConstraintDescriptionResolver() + .resolveDescription(new Constraint(NotNull.class.getName(), + Collections. emptyMap())); + assertThat(description, is(equalTo("Should not be null"))); + + } + finally { + Thread.currentThread().setContextClassLoader(getClass().getClassLoader()); + } + } + + @Test + public void customResourceBundle() { + ResourceBundle bundle = new ListResourceBundle() { + + @Override + protected Object[][] getContents() { + return new String[][] { { NotNull.class.getName() + ".description", + "Not null" } }; + } + + }; + String description = new ResourceBundleConstraintDescriptionResolver(bundle) + .resolveDescription(new Constraint(NotNull.class.getName(), Collections + . emptyMap())); + assertThat(description, is(equalTo("Not null"))); + } + +} diff --git a/spring-restdocs/src/test/java/org/springframework/restdocs/constraints/ValidatorConstraintResolverTests.java b/spring-restdocs/src/test/java/org/springframework/restdocs/constraints/ValidatorConstraintResolverTests.java new file mode 100644 index 00000000..12accc24 --- /dev/null +++ b/spring-restdocs/src/test/java/org/springframework/restdocs/constraints/ValidatorConstraintResolverTests.java @@ -0,0 +1,97 @@ +package org.springframework.restdocs.constraints; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.Assert.assertThat; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.List; + +import javax.validation.Payload; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Null; +import javax.validation.constraints.Size; + +import org.hibernate.validator.constraints.CompositionType; +import org.hibernate.validator.constraints.ConstraintComposition; +import org.hibernate.validator.constraints.NotBlank; +import org.junit.Test; + +/** + * Tests for {@link ValidatorConstraintResolver} + * + * @author Andy Wilkinson + */ +public class ValidatorConstraintResolverTests { + + private final ValidatorConstraintResolver resolver = new ValidatorConstraintResolver(); + + @Test + public void singleFieldConstraint() { + List constraints = this.resolver.resolveForProperty("single", + ConstrainedFields.class); + assertThat(constraints, hasSize(1)); + assertThat(constraints.get(0).getName(), is(NotNull.class.getName())); + } + + @Test + public void multipleFieldConstraints() { + List constraints = this.resolver.resolveForProperty("multiple", + ConstrainedFields.class); + assertThat(constraints, hasSize(2)); + assertThat(constraints.get(0).getName(), is(NotNull.class.getName())); + assertThat(constraints.get(1).getName(), is(Size.class.getName())); + assertThat(constraints.get(1).getConfiguration().get("min"), is((Object) 8)); + assertThat(constraints.get(1).getConfiguration().get("max"), is((Object) 16)); + } + + @Test + public void noFieldConstraints() { + List constraints = this.resolver.resolveForProperty("none", + ConstrainedFields.class); + assertThat(constraints, hasSize(0)); + } + + @Test + public void compositeConstraint() { + List constraints = this.resolver.resolveForProperty("composite", + ConstrainedFields.class); + assertThat(constraints, hasSize(1)); + } + + private static class ConstrainedFields { + + @NotNull + private String single; + + @NotNull + @Size(min = 8, max = 16) + private String multiple; + + @SuppressWarnings("unused") + private String none; + + @CompositeConstraint + private String composite; + } + + @ConstraintComposition(CompositionType.OR) + @Null + @NotBlank + @Target(ElementType.FIELD) + @Retention(RetentionPolicy.RUNTIME) + @javax.validation.Constraint(validatedBy = {}) + public @interface CompositeConstraint { + + String message() default "Must be null or not blank"; + + Class[] groups() default {}; + + Class[] payload() default {}; + + } + +} diff --git a/spring-restdocs/src/test/resources/org/springframework/restdocs/constraints/TestConstraintDescriptions.properties b/spring-restdocs/src/test/resources/org/springframework/restdocs/constraints/TestConstraintDescriptions.properties new file mode 100644 index 00000000..ea0f3a91 --- /dev/null +++ b/spring-restdocs/src/test/resources/org/springframework/restdocs/constraints/TestConstraintDescriptions.properties @@ -0,0 +1 @@ +javax.validation.constraints.NotNull.description=Should not be null \ No newline at end of file