When resarching documentation for the previous blog posts I stumbled upon this project: https://github.com/grails/groovy-rest-doc where Sergio del Amo have writen a tool to help document a rest api.
First I downloaded the whole code, and run integration test, then asciidoctor task.
Now lets see how this works with a new project.
grails create-app docsrest1 --profile=rest-api
Then create a domain Book with controller and service.
grails create-domain-class no.prpr.Book
grails generate-all no.prpr.Book
I just added a couple of properties to the Book domain:
package no.prpr
class Book {
static constraints = {
title nullable: false, unique: true
author nullable: false
}
String title
String author
}
Create some books during bootstrap:
package docsrest1
import no.prpr.Book
import no.prpr.BookService
class BootStrap {
BookService bookService
def init = { servletContext ->
bookService.save(new Book(title: "The Godfather", author: "Mario Puzo"))
bookService.save(new Book(title: "Hotel New Hampshire", author: "John Irving"))
}
def destroy = {
}
}
Run the application and try:
curl "http://localhost:8080/book/"
The result should look like:
[{"id":1,"title":"The Godfather","author":"Mario Puzo"},{"id":2,"title":"Hotel New Hampshire","author":"John Irving"}]
Now its time configure build.gradle. All of the settings below have some reason for being there:
buildscript {
dependencies {
classpath 'org.asciidoctor:asciidoctor-gradle-plugin:1.6.1'
}
}
apply plugin: 'org.asciidoctor.convert'
repositories {
maven { url 'http://dl.bintray.com/sdelamo/libs' }
}
dependencies {
testCompile 'org.grails:rest-doc:0.2'
}
asciidoctor.dependsOn 'integrationTest'
task cleanGeneratedRestDocs(type: Delete) {
group 'build'
description 'Removes src/docs/asciidoc/generated adoc files'
delete fileTree("${project.projectDir}/src/docs/asciidoc/generated") {
include '**/*.adoc'
}
}
integrationTest.dependsOn 'cleanGeneratedRestDocs'
clean.dependsOn 'cleanGeneratedRestDocs'
asciidoctor {
backends 'html5'
attributes \
'build-gradle': file('build.gradle'),
'sourcedir': project.sourceSets.main.java.srcDirs[0],
'source-highlighter': 'coderay',
'imagesdir': './images',
'toc': 'left',
'icons': 'font',
'setanchors': '',
'idprefix': '',
'idseparator': '-',
'docinfo1': ''
}
First I want to make sure the integration-test is good. The generate-all created “BookFunctionalSpec” for me.
Then I create BookIndexEndpoint in src/integration-test/groovy/no.prpr directory:
package no.prpr
import groovy.transform.CompileStatic
import org.grails.restdoc.HeaderDoc
import org.grails.restdoc.HttpVerb
import org.grails.restdoc.ParamDoc
import org.grails.restdoc.RestEndpoint
@CompileStatic
class BookIndexEndpoint extends RestEndpoint {
@Override
List<String> getAuthorizationRoles() {
return []
}
@Override
HttpVerb getHttpVerb() {
return HttpVerb.GET
}
@Override
String getPath() {
return '/book'
}
@Override
List<HeaderDoc> getHeaders() {
return [HeaderDoc.builder()
.name('Accept')
.value('application/json')
.build(),
HeaderDoc.builder()
.name('Content-Type')
.value('application/json')
.build()
]
}
@Override
List<ParamDoc> getParams() {
return null
}
}
The BookIndexEndpoint is then used when “Test the index action”:
void "Test the index action"() {
when:"The index action is requested"
BookIndexEndpoint bookIndexEndpoint = new BookIndexEndpoint()
HttpResponse<List<Map>> response1 = client.toBlocking().exchange(HttpRequest.GET(resourcePath), Argument.of(List, Map))
then:"The response is correct"
response1.status == HttpStatus.OK
response1.body() != []
when:
new RestDoc(bookIndexEndpoint).doc {
sample {
description 'Get list / index'
request {
headers bookIndexEndpoint.headersMap
}
response {
statusCode response1.status.getCode()
json JsonOutput.toJson(response1.body())
}
}
}
then:
noExceptionThrown()
}
I then played around with get one book, by creating BookGetEndpoint. Not sure about. If I kept the path as /book the documentation would mix with index, so I set the path to /book/id.
package no.prpr
import org.grails.restdoc.HeaderDoc
import org.grails.restdoc.HttpVerb
import org.grails.restdoc.ParamDoc
import org.grails.restdoc.ParamType
import org.grails.restdoc.RestEndpoint
class BookGetEndpoint extends RestEndpoint {
@Override
List<String> getAuthorizationRoles() {
return []
}
@Override
HttpVerb getHttpVerb() {
return HttpVerb.GET
}
@Override
String getPath() {
return '/book/id'
}
@Override
List<HeaderDoc> getHeaders() {
return [HeaderDoc.builder()
.name('Accept')
.value('application/json')
.build(),
HeaderDoc.builder()
.name('Content-Type')
.value('application/json')
.build()
]
}
@Override
List<ParamDoc> getParams() {
return [ParamDoc.builder()
.name('id')
.schema(Long.simpleName)
.required(false)
.description('id of book you want to retrieve')
.type(ParamType.URL)
.build()
]
}
}
And then using it with “Test the show action correctly renders an instance”:
void "Test the show action correctly renders an instance"() {
when:"The save action is executed with valid data"
HttpResponse<Map> response1 = client.toBlocking().exchange(HttpRequest.POST(resourcePath, validJson), Map)
then:"The response is correct"
response1.status == HttpStatus.CREATED
response1.body().id
when:"When the show action is called to retrieve a resource"
def id = response1.body().id
BookGetEndpoint bookGetEndpoint = new BookGetEndpoint()
String path = bookGetEndpoint.path.replace('id', "${id}")
log.debug("Show action.path = ${path}")
response1 = client.toBlocking().exchange(HttpRequest.GET(path), Map)
then:"The response is correct"
response1.status == HttpStatus.OK
response1.body().id == id
when: "document endpoint"
new RestDoc(bookGetEndpoint).doc {
sample {
description "Get book with id"
request {
headers bookGetEndpoint.headersMap
}
response {
statusCode = response1.status.getCode()
payload = JsonOutput.toJson(response1.body())
}
}
}
Then I created BookPostEndpoint:
package no.prpr
import org.grails.restdoc.*
class BookPostEndpoint extends RestEndpoint {
@Override
List<String> getAuthorizationRoles() {
return []
}
@Override
HttpVerb getHttpVerb() {
return HttpVerb.POST
}
@Override
String getPath() {
return '/book'
}
@Override
List<HeaderDoc> getHeaders() {
return [HeaderDoc.builder()
.name('Accept')
.value('application/json')
.build(),
HeaderDoc.builder()
.name('Content-Type')
.value('application/json')
.build()
]
}
@Override
List<ParamDoc> getParams() {
return [ParamDoc.builder()
.name('id')
.schema(Long.simpleName)
.required(false)
.description('id of book you want to retrieve')
.type(ParamType.URL)
.build()
]
}
}
And added the documentation code to “Test the save action correctly persists an instance”
@Rollback
void "Test the save action correctly persists an instance"() {
when:"The save action is executed with valid data"
log.debug("save valid data")
long holdBookCount = Book.count()
BookPostEndpoint bookPostEndpoint = new BookPostEndpoint()
HttpResponse<Map> response1 = client.toBlocking().exchange(HttpRequest.POST(resourcePath, validJson), Map)
then:"The response is correct"
response1.status == HttpStatus.CREATED
response1.body().id
(Book.count() - holdBookCount) == 1
when:
new RestDoc(bookPostEndpoint).doc {
sample {
description 'Create a book'
request {
headers bookPostEndpoint.headersMap
jsonBody JsonOutput.toJson(validJson)
}
response {
statusCode response1.status.getCode()
payload = JsonOutput.toJson(response1.body())
}
}
}
then:
noExceptionThrown()
cleanup:
log.debug("cleanup")
def id = response1.body().id
def path = "${resourcePath}/${id}"
response1 = client.toBlocking().exchange(HttpRequest.DELETE(path))
assert response1.status() == HttpStatus.NO_CONTENT // || response.status() == HttpStatus.BAD_REQUEST
}
Conclusion
This little plugin can make it possible to keep api documentation up to date.
- Declare each endpoint extending RestEndpoint inside the integration-test directory.
- Add documentation as part of integration test.