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