Spring REST Docs Documenting RESTful services
Spring REST DocsDocumenting RESTful services
About me
@jeroenvschagen
• Senior developer at 42.nl • Open source • Lead product developer • Academic domain: VU, WUR
API docs
Needs to be done..
Accuracy
• Otherwise nobody will read it
Efficiency
• Use a tool designed for writing
• Generate when possible
• No duplication
• Cross cutting concerns
Swagger
Swagger
• Describes the RESTful API: /apidocs
• Analysing the implementation
• Spring MVC
• Jersey
Swagger@RestController @RequestMapping(value = "/books") public class BooksController { @RequestMapping(value = "/{id}", method = RequestMethod.GET) public Book findById(@PathVariable Long id) { return bookService.findById(id); } @RequestMapping(method = RequestMethod.POST) public Book save(@RequestBody Book book) { return bookService.save(book); } @RequestMapping(value = "/{id}", method = RequestMethod.DELETE) public Book delete(@PathVariable Long id) { return bookService.delete(id); } ... }
Swagger UI
Not quite there yet...
Hypermedia
http://martinfowler.com/articles/richardsonMaturityModel.html
Spring HATEAOS{ "_embedded" : { "product" : [ { "name" : "My product", "_links" : { "self" : { "href" : "http://localhost:8080/products/1" },
"vendors": { "href" : "http://localhost:8080/products/1/vendors" }
} } ] }, "_links" : { "self" : { "href" : "http://localhost:8080/products" } }}
http://localhost:8080/products
Hypermedia
Data
URI vs Resource based
URI vs Resource based
Buggy
Frameworks are complicated
Interceptor, Filter, ControllerAdvice, ArgumentResolver,
ResultHandler, Converters
Customisation @ApiOperation(value = "find-all", notes = "Retrieves all system users.") @ApiResponses({ @ApiResponse(code = 200, message = "Everything goes ok."), @ApiResponse(code = 403, message = "Not allowed to retrieve data."), @ApiResponse(code = 500, message = "Unexpected server error.") }) @ResponseBody @RequestMapping(method = GET) public Iterable<User> findAll(Principal principal) { return userService.findAll(Users.getUserName(principal)); }
Annotation hell
Duplication (code, annotation)
@ApiModel(value = "CreateUserForm", description = "Form for creating a user") public class CreateUserForm { @NotNull @ApiModelProperty(value = "email", required = true) private String email; }
Cross cutting concerns
• HTTP verbs, status codes
• Authentication, versioning
• Error handling
• Duplication...
Production
• Security risk
• Only for authorised users
• Disable /api-docs
• Framework size
Instant try
Alternatives?
Swagger2Markup
Spring REST Docs
Spring project
• Documenting RESTful services
• Andy Wilkinson (Pivotal)
• 1.0 release in October 20151.1 released May 31, 2016
• Webinar: Documenting RESTful APIshttps://www.youtube.com/watch?v=knH5ihPNiUs
Write tests
Generate snippets
Add handwritten content
User guide
(Frequently changing)
(Rarely changing)
Test driven documentation
• Encourages testing
• No annotations / duplication in code
• Realistic examples
• Documentation is accurate, or the test fails
Generate snippetsSpring test
ResultHandler (org.springframework.restdocs.mockmvc.MockMvcRestDocumentation)
this.webClient.perform(get("/users/1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.email").value("[email protected]")) .andDo(document("user-find-by-id"));
Generate snippets
Generate snippetscurl-request.adoc
http-request.adoc
request-fields.adoc
http-response.adoc
response-fields.adoc
links.adoc
Generate snippets
[source,http]----HTTP/1.1 200 OKContent-Type: application/json;charset=UTF-8Content-Length: 40
{ "id" : 2, "email" : "[email protected]"}----
http-response.adoc
Start writingAsciidoctor
Include contentAsciidoctor
Document
Alternatives (1.1.0+)
• JUnit / TestNG
• MockMVC / REST Assured
• Asciidoctor / Markdown
Let's code
Spring boot<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-rest</artifactId></dependency><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId></dependency><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId></dependency><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId></dependency>
Application
@SpringBootApplicationpublic class SampleApplication { public static void main(String[] args) throws Exception { SpringApplication.run(SampleApplication.class, args); }
}
JPA Entity
@Entitypublic class User extends BaseEntity { private String email;
public String getEmail() { return email; } public void setEmail(String email) { this.email = email; }
}
Controller
@RestController@RequestMapping("/users")public class UserController { private final UserRepository userRepository; @Autowired public UserController(UserRepository userRepository) { this.userRepository = userRepository; }
@RequestMapping(method = RequestMethod.GET) public Iterable<User> findAll() { return userRepository.findAll(); } ... }
+ Spring REST docs
Add dependency
<dependency> <groupId>org.springframework.restdocs</groupId> <artifactId>spring-restdocs-mockmvc</artifactId> <scope>test</scope></dependency>
Spring test@WebAppConfigurationpublic abstract class AbstractWebIntegrationTest extends AbstractIntegrationTest { @Rule public final JunitRestDocumentation restDocumentation = new JUnitRestDocumentation("target/generated-snippets"); @Autowired private WebApplicationContext webApplicationContext; protected MockMvc webClient; @Before public void initWebClient() { this.webClient = MockMvcBuilders.webAppContextSetup(webApplicationContext) .apply(MockMvcRestDocumentation.documentationConfiguration(this.restDocumentation)) .alwaysDo(print()) .build(); }
}
MockMvcConfigurer (org.springframework.restdocs.mockmvc.RestDocumentationMockMvcConfigurer)
Spring test
public class UserControllerTest extends AbstractWebIntegrationTest {
@Autowired private UserRepository userRepository; @Test public void testFindById() throws Exception { final User user = new User(); user.setEmail("[email protected]"); userRepository.save(user);
this.webClient.perform(get("/users/" + user.getId())) .andExpect(status().isOk()) .andExpect(jsonPath("email").value("[email protected]")) .andDo(document("user-find-by-id", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), responseFields( fieldWithPath("id").description("The user's identifier."), fieldWithPath("email").description("The user's email address.")))); } }
Spring test
[source,http]----HTTP/1.1 200 OKContent-Type: application/json;charset=UTF-8Content-Length: 40
{ "id" : 2, "email" : "[email protected]"}----
[source,bash]----$ curl 'http://localhost:8080/users/2' -i----
|===|Path|Type|Description
|id|Number|The user's identifier.
|email|String|The user's email address.
|===
curl-request.adoc
http-response.adoc
response-fields.adoc
target/generated-snippets
Asciidoctor plugin<plugin> <groupId>org.asciidoctor</groupId> <artifactId>asciidoctor-maven-plugin</artifactId> <version>1.5.2</version> <executions> <execution> <id>generate-docs</id> <phase>package</phase> <goals> <goal>process-asciidoc</goal> </goals> <configuration> <backend>html</backend> <doctype>book</doctype> <sourceDocumentName>index.adoc</sourceDocumentName> <attributes> <generated>${project.build.directory}/generated-doc</generated> </attributes> </configuration> </execution> </executions></plugin>
Manual docs
:toc: left
= Sample REST APIJeroen van Schagen
[[abstract]]
Generated documentation using Spring REST Docs.
include::chapters/general.adoc[]include::chapters/resources.adoc[]
:snippets: ../../../../../target/generated-snippets
== Users
Users are used to authenticate with the system.
=== Get one user
Retrieves a user based on identifier.
Request
include::{snippets}/user-find-by-id/curl-request.adoc[]
Response
include::{snippets}/user-find-by-id/http-response.adoc[]include::{snippets}/user-find-by-id/response-fields.adoc[]
Publish
Github pages
https://github.com/github/maven-plugins
Nexus assembly
Github
https://developer.github.com/v3/
More requests..
GET List
@Test public void testFindAll() throws Exception { final User user = new User(); user.setEmail("[email protected]"); userRepository.save(user);
this.webClient.perform(get("/users")) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].email").value("[email protected]")) .andDo(document("user-find-all", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), responseFields( fieldWithPath("[].id").description("The user's identifier."), fieldWithPath("[].email").description("The user's email address.")))); }
Arrays
POST
@Test public void testSave() throws Exception { final User user = new User(); user.setEmail("[email protected]"); userRepository.save(user);
this.webClient.perform(post("/users") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(user))) .andExpect(status().isOk()) .andExpect(jsonPath("$.email").value("[email protected]")) .andDo(document("user-save", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestFields( fieldWithPath("id").description("The user's identifier."), fieldWithPath("email").description("The user's email address.")), responseFields( fieldWithPath("id").description("The user's identifier."), fieldWithPath("email").description("The user's email address.")))); }
DELETE
@Test public void testDelete() throws Exception { final User user = new User(); user.setEmail("[email protected]"); userRepository.save(user);
this.webClient.perform(delete("/users/" + user.getId())) .andExpect(status().isOk()) .andDo(document("user-delete", preprocessResponse(prettyPrint()))); }
Error handling
@ControllerAdvicepublic class ControllerExceptionAdvice {
private static final Logger LOGGER = LoggerFactory.getLogger(ControllerExceptionAdvice.class);
@ExceptionHandler({ Exception.class }) public ModelAndView handleOther(HttpServletResponse response, Object handler, Exception ex) { LOGGER.error("Handling request, for [" + handler + "], resulted in the following exception.", ex); response.setStatus(INTERNAL_SERVER_ERROR.value()); return new ModelAndView(new MappingJackson2JsonView(), "error", new ExceptionJsonBody(ex)); }
public static class ExceptionJsonBody { private final Class<?> type; private final String message;
public ExceptionJsonBody(Exception ex) { this.type = ex.getClass(); this.message = ex.getMessage(); }
public Class<?> getType() { return type; } public String getMessage() { return message; }
}
}
Error handling
@Test public void testDeleteUnknown() throws Exception { this.webClient.perform(delete("/users/42")) .andExpect(status().isInternalServerError()) .andExpect(jsonPath("$.error.message").value("User with id 42 does not exist.")) .andDo(document("user-delete-unknown", preprocessResponse(prettyPrint()), responseFields( fieldWithPath("error.type").description("The type of error."), fieldWithPath("error.message").description("The error message.")))); }
Spring Data REST@RepositoryRestResource(collectionResourceRel = "product", path = "products")public interface ProductRepository extends CrudRepository<Product, Long> { }
{ "_embedded" : { "product" : [ { "name" : "My product", "_links" : { "self" : { "href" : "http://localhost:8080/products/1" }, "product" : { "href" : "http://localhost:8080/products/1" } } } ] }, "_links" : { "self" : { "href" : "http://localhost:8080/products" }, "profile" : { "href" : "http://localhost:8080/profile/products" } }}
/products
Hypermedia
Spring Data REST @Test public void testFindAll() throws Exception { final Product product = new Product(); product.setName("My product"); productRepository.save(product);
this.webClient.perform(get("/products")) .andExpect(status().isOk()) .andExpect(jsonPath("_embedded.product[0].name").value("My product")) .andExpect(jsonPath("_links.self", is(notNullValue()))) .andExpect(jsonPath("_links.profile", is(notNullValue()))) .andDo(document("product-find-all", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), responseFields( fieldWithPath("_embedded.product[].name").description("The product name."), fieldWithPath("_embedded.product[]._links").description("Links related to this product."), fieldWithPath("_links").description("Links related to products in general.")), links( linkWithRel("self").description("Reference to self."), linkWithRel("profile").description("Shows the product profile.")))); }
Hypermedia
Reusing snippets (1.1.0+) @Test public void testFindAll() throws Exception { final Product product = new Product(); product.setName("My product"); productRepository.save(product);
this.webClient.perform(get("/products")) .andExpect(status().isOk()) .andExpect(jsonPath("_embedded.product[0].name").value("My product")) .andExpect(jsonPath("_links.self", is(notNullValue()))) .andExpect(jsonPath("_links.profile", is(notNullValue()))) .andDo(document("product-find-all", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), responseFields( fieldWithPath("_embedded.product[].name").description("The product name."), fieldWithPath("_embedded.product[]._links").description("Links related to this product."), fieldWithPath("_links").description("Links related to products in general.")), links( this.pageLinks.and( linkWithRel("profile").description("Shows the product profile.") )))); }
Reused
Demo
Questions?