Spring REST Docs - Meetupfiles.meetup.com/19071756/spring-rest-docs.pdfSwagger • Describes the...

Preview:

Citation preview

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("test@42.nl")) .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" : "test@42.nl"}----

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("test@42.nl"); userRepository.save(user);

this.webClient.perform(get("/users/" + user.getId())) .andExpect(status().isOk()) .andExpect(jsonPath("email").value("test@42.nl")) .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" : "test@42.nl"}----

[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("test@42.nl"); userRepository.save(user);

this.webClient.perform(get("/users")) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].email").value("test@42.nl")) .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("test@42.nl"); userRepository.save(user);

this.webClient.perform(post("/users") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(user))) .andExpect(status().isOk()) .andExpect(jsonPath("$.email").value("test@42.nl")) .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("test@42.nl"); 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?

Recommended