Categories
Grails Moving to Grails

Documentation – rest api

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.