Provide an API to ease documenting a request field’s constraints

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
This commit is contained in:
Andy Wilkinson
2015-08-25 11:21:28 +01:00
parent bc5a9c3714
commit ff822bd88d
27 changed files with 1291 additions and 104 deletions

View File

@@ -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'

View File

@@ -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'
}

View File

@@ -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
<<documenting-your-api-customizing-including-extra-information, extra information>> 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

View File

@@ -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<String> descriptions = userConstraints.descriptionsForProperty("name"); // <2>
}
static class UserInput {
@NotNull
@Size(min = 1)
String name;
@NotNull
@Size(min = 8)
String password;
}
// end::constraints[]
}

View File

@@ -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<URI> tagUris;
public AbstractNoteInput(String title, String body, List<URI> tagUris) {
this.title = title;
this.body = body;
this.tagUris = tagUris == null ? Collections.<URI> emptyList() : tagUris;
}
public String getTitle() {
return title;
}
public String getBody() {
return body;
}
@JsonProperty("tags")
public List<URI> getTagUris() {
return this.tagUris;
}
}

View File

@@ -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<URI> tagUris;
@JsonCreator
public NoteInput(@NotBlank @JsonProperty("title") String title,
public NoteInput(@JsonProperty("title") String title,
@JsonProperty("body") String body, @JsonProperty("tags") List<URI> tagUris) {
super(title, body, tagUris);
this.title = title;
this.body = body;
this.tagUris = tagUris == null ? Collections.<URI>emptyList() : tagUris;
}
public String getTitle() {
return title;
}
public String getBody() {
return body;
}
@JsonProperty("tags")
public List<URI> getTagUris() {
return this.tagUris;
}
}

View File

@@ -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<URI> tagUris;
@JsonCreator
public NotePatchInput(@NullOrNotBlank @JsonProperty("title") String title,
public NotePatchInput(@JsonProperty("title") String title,
@JsonProperty("body") String body, @JsonProperty("tags") List<URI> tagUris) {
super(title, body, tagUris);
this.title = title;
this.body = body;
this.tagUris = tagUris == null ? Collections.<URI>emptyList() : tagUris;
}
public String getTitle() {
return title;
}
public String getBody() {
return body;
}
@JsonProperty("tags")
public List<URI> getTagUris() {
return this.tagUris;
}
}

View File

@@ -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<? extends Payload>[] payload() default {};
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<String, String> tag = new HashMap<String, String>();
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<String, Object> noteUpdate = new HashMap<String, Object>();
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<String, Object> tagUpdate = new HashMap<String, Object>();
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), ". ")));
}
}
}

View File

@@ -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<ConstraintViolation<Constrained>> violations = validator.validate(new Constrained(null));
assertThat(violations.size(), is(0));
}
@Test
public void zeroLengthValue() {
Set<ConstraintViolation<Constrained>> violations = validator.validate(new Constrained(""));
assertThat(violations.size(), is(2));
}
@Test
public void blankValue() {
Set<ConstraintViolation<Constrained>> violations = validator.validate(new Constrained(" "));
assertThat(violations.size(), is(2));
}
@Test
public void nonBlankValue() {
Set<ConstraintViolation<Constrained>> 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;
}
}
}

View File

@@ -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

View File

@@ -0,0 +1,11 @@
|===
|Path|Type|Description|Constraints
{{#fields}}
|{{path}}
|{{type}}
|{{description}}
|{{constraints}}
{{/fields}}
|===

View File

@@ -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'
}

View File

@@ -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<String, Object> 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<String, Object> 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<String, Object> getConfiguration() {
return this.configuration;
}
}

View File

@@ -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);
}

View File

@@ -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<String> descriptionsForProperty(String property) {
List<Constraint> constraints = this.constraintResolver.resolveForProperty(
property, this.clazz);
List<String> descriptions = new ArrayList<>();
for (Constraint constraint : constraints) {
descriptions.add(this.descriptionResolver.resolveDescription(constraint));
}
Collections.sort(descriptions);
return descriptions;
}
}

View File

@@ -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<Constraint> resolveForProperty(String property, Class<?> clazz);
}

View File

@@ -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}.
* <p>
* Default descriptions are provided for Bean Validation 1.1's constraints:
*
* <ul>
* <li>{@link AssertFalse}
* <li>{@link AssertTrue}
* <li>{@link DecimalMax}
* <li>{@link DecimalMin}
* <li>{@link Digits}
* <li>{@link Future}
* <li>{@link Max}
* <li>{@link Min}
* <li>{@link NotNull}
* <li>{@link Null}
* <li>{@link Past}
* <li>{@link Pattern}
* <li>{@link Size}
* </ul>
*
* @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;
}
}
}

View File

@@ -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<Constraint> resolveForProperty(String property, Class<?> clazz) {
List<Constraint> 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;
}
}

View File

@@ -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

View File

@@ -329,6 +329,7 @@ public class RestDocumentationIntegrationTests {
return new ResponseEntity<Map<String, Object>>(response, headers,
HttpStatus.OK);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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.<String, Object> emptyMap()));
assertThat(description, is(equalTo("Must be false")));
}
@Test
public void defaultMessageAssertTrue() {
String description = this.resolver.resolveDescription(new Constraint(
AssertTrue.class.getName(), Collections.<String, Object> emptyMap()));
assertThat(description, is(equalTo("Must be true")));
}
@Test
public void defaultMessageDecimalMax() {
Map<String, Object> 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<String, Object> 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<String, Object> 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.<String, Object> emptyMap()));
assertThat(description, is(equalTo("Must be in the future")));
}
@Test
public void defaultMessageMax() {
Map<String, Object> 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<String, Object> 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.<String, Object> emptyMap()));
assertThat(description, is(equalTo("Must not be null")));
}
@Test
public void defaultMessageNull() {
String description = this.resolver.resolveDescription(new Constraint(Null.class
.getName(), Collections.<String, Object> emptyMap()));
assertThat(description, is(equalTo("Must be null")));
}
@Test
public void defaultMessagePast() {
String description = this.resolver.resolveDescription(new Constraint(Past.class
.getName(), Collections.<String, Object> emptyMap()));
assertThat(description, is(equalTo("Must be in the past")));
}
@Test
public void defaultMessagePattern() {
Map<String, Object> 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<String, Object> 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.<String, Object> 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
.<String, Object> emptyMap()));
assertThat(description, is(equalTo("Not null")));
}
}

View File

@@ -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<Constraint> 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<Constraint> 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<Constraint> constraints = this.resolver.resolveForProperty("none",
ConstrainedFields.class);
assertThat(constraints, hasSize(0));
}
@Test
public void compositeConstraint() {
List<Constraint> 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<? extends Payload>[] payload() default {};
}
}

View File

@@ -0,0 +1 @@
javax.validation.constraints.NotNull.description=Should not be null