From b52b7bab222ae461a524110e01339603540a2c81 Mon Sep 17 00:00:00 2001 From: Sebastien Deleuze Date: Sat, 19 Mar 2016 17:01:12 +0100 Subject: [PATCH] Add Spring REST docs + various fixes --- build.gradle | 31 ++- src/main/asciidoc/index.adoc | 180 ++++++++++++++++++ .../kotlin/io/spring/messenger/Application.kt | 9 +- .../messenger/repository/UserRepository.kt | 6 +- .../spring/messenger/web/MessageController.kt | 12 +- .../io/spring/messenger/web/UserController.kt | 13 +- src/main/resources/static/index.html | 3 + .../messenger/MessageControllerTests.kt | 103 +++++++++- .../spring/messenger/UserControllerTests.kt | 98 +++++++++- 9 files changed, 418 insertions(+), 37 deletions(-) create mode 100644 src/main/asciidoc/index.adoc diff --git a/build.gradle b/build.gradle index 8b6a455..a7c2d1e 100644 --- a/build.gradle +++ b/build.gradle @@ -12,22 +12,29 @@ buildscript { } } +plugins { + id "org.asciidoctor.convert" version "1.5.2" +} + +apply plugin: 'kotlin' +apply plugin: 'spring-boot' + repositories { mavenCentral() maven { url 'http://repo.spring.io/snapshot' } maven { url 'https://dl.bintray.com/sdeleuze/maven/' } } -apply plugin: 'kotlin' -apply plugin: 'spring-boot' - jar { baseName = 'geospatial-messenger' version = '1.0.0-SNAPSHOT' + dependsOn asciidoctor + from ("${asciidoctor.outputDir}/html5") { + into 'static/docs' + } } -sourceCompatibility = 1.8 -targetCompatibility = 1.8 +ext['snippetsDir'] = file('build/generated-snippets') ext['spring.version'] = '4.3.0.BUILD-SNAPSHOT' ext['jackson.version'] = '2.7.1' @@ -56,8 +63,22 @@ dependencies { compile('com.github.mayconbordin:postgis-geojson:1.1') testCompile('org.springframework.boot:spring-boot-starter-test') + testCompile('org.springframework.restdocs:spring-restdocs-mockmvc:1.0.1.RELEASE') } task wrapper(type: Wrapper) { gradleVersion = '2.12' } + + +test { + outputs.dir snippetsDir +} + +asciidoctor { + attributes 'snippets': snippetsDir + inputs.dir snippetsDir + outputDir "build/asciidoc" + dependsOn test + sourceDir 'src/main/asciidoc' +} diff --git a/src/main/asciidoc/index.adoc b/src/main/asciidoc/index.adoc new file mode 100644 index 0000000..1a09aaa --- /dev/null +++ b/src/main/asciidoc/index.adoc @@ -0,0 +1,180 @@ += Geospatial Messenger Getting Started Guide +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 4 +:sectlinks: + +[introduction] += Introduction + +Geospatial Messenger provides a RESTful API for users and messages. + +[[overview]] += Overview + +[[overview-http-verbs]] +== HTTP verbs +Person-service tries to adhere as closely as possible to standard HTTP and REST conventions in its +use of HTTP verbs. +|=== +| Verb | Usage + +| `GET` +| Used to retrieve a resource + +| `POST` +| Used to create a new resource + +| `PUT` +| Used to update an existing resource, full updates only + +| `DELETE` +| Used to delete an existing resource +|=== + +[[overview-http-status-codes]] +== HTTP status codes +Person-service tries to adhere as closely as possible to standard HTTP and REST conventions in its +use of HTTP status codes. + +|=== +| Status code | Usage + +| `200 OK` +| Standard response for successful HTTP requests. +| The actual response will depend on the request method used. +| In a GET request, the response will contain an entity corresponding to the requested resource. +| In a POST request, the response will contain an entity describing or containing the result of the action. + +| `201 Created` +| The request has been fulfilled and resulted in a new resource being created. + +| `204 No Content` +| The server successfully processed the request, but is not returning any content. + +| `400 Bad Request` +| The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing). + +| `404 Not Found` +| The requested resource could not be found but may be available again in the future. Subsequent requests by the client are permissible. +|=== + +[[resources]] += Resources + + +[[resources-user]] +== User +The User resource is used to create, modify and list users. + +[[resource-user-list]] + +[[resource-user-create]] +=== Creating user +A `POST` request creates a new user. + +==== Example request + +include::{snippets}/create-user/curl-request.adoc[] + +==== Example response + +include::{snippets}/create-user/http-response.adoc[] + +[[resource-user-update-location]] +=== Updating user's location +This `PUT` request updates the user's location. + +==== Example request + +include::{snippets}/update-user-location/curl-request.adoc[] + +==== Example response + +include::{snippets}/update-user-location/http-response.adoc[] + +[[resource-user-list]] +=== Listing users +A `GET` request lists all of the users. + +include::{snippets}/list-users/response-fields.adoc[] + +==== Example request + +include::{snippets}/list-users/curl-request.adoc[] + +==== Example response + +include::{snippets}/list-users/http-response.adoc[] + +[[resource-user-find-by-bounding-box]] +=== Find users by bounding box +This `GET` request lists all the users within the provided bounding box. + +include::{snippets}/find-users-by-bounding-box/response-fields.adoc[] + +==== Example request + +include::{snippets}/find-users-by-bounding-box/curl-request.adoc[] + +==== Example response + +include::{snippets}/find-users-by-bounding-box/http-response.adoc[] + + + +[[resources-message]] +== Message +The Message resource is used to create, modify and list messages. + +[[resource-message-create]] +=== Creating message +A `POST` request creates a new message. + +==== Example request + +include::{snippets}/create-message/curl-request.adoc[] + +==== Example response + +include::{snippets}/create-message/http-response.adoc[] + + +[[resource-message-list]] +=== Listing messages +A `GET` request lists all of the messages. + +include::{snippets}/list-messages/response-fields.adoc[] + +==== Example request + +include::{snippets}/list-messages/curl-request.adoc[] + +==== Example response + +include::{snippets}/list-messages/http-response.adoc[] + + +[[resource-message-find-by-bounding-box]] +=== Find messages by bounding box +This `GET` request lists all the messages within the provided bounding box. + +include::{snippets}/find-messages-by-bounding-box/response-fields.adoc[] + +==== Example request + +include::{snippets}/find-messages-by-bounding-box/curl-request.adoc[] + +==== Example response + +include::{snippets}/find-messages-by-bounding-box/http-response.adoc[] + +[[resource-message-subscribe]] +=== Receive new messages +A SSE endpoint that received all the new messages. + +include::{snippets}/subscribe-messages/curl-request.adoc[] + + diff --git a/src/main/kotlin/io/spring/messenger/Application.kt b/src/main/kotlin/io/spring/messenger/Application.kt index 550a9f5..5fd4077 100644 --- a/src/main/kotlin/io/spring/messenger/Application.kt +++ b/src/main/kotlin/io/spring/messenger/Application.kt @@ -1,5 +1,7 @@ package io.spring.messenger +import com.fasterxml.jackson.annotation.JsonInclude.Include +import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.KotlinModule import io.spring.messenger.domain.Message import io.spring.messenger.domain.User @@ -17,8 +19,11 @@ import javax.sql.DataSource @SpringBootApplication open class Application { - @Bean open fun objectMapperBuilder(): Jackson2ObjectMapperBuilder - = Jackson2ObjectMapperBuilder().modulesToInstall(PostGISModule(), KotlinModule()) + @Bean open fun objectMapper(): ObjectMapper { + val mapper:ObjectMapper = Jackson2ObjectMapperBuilder().modulesToInstall(PostGISModule(), KotlinModule()).build() + mapper.setSerializationInclusion(Include.NON_NULL) + return mapper + } @Bean open fun db(dataSource: DataSource) = Database.connect(dataSource) diff --git a/src/main/kotlin/io/spring/messenger/repository/UserRepository.kt b/src/main/kotlin/io/spring/messenger/repository/UserRepository.kt index 0b14eb6..65df1dc 100644 --- a/src/main/kotlin/io/spring/messenger/repository/UserRepository.kt +++ b/src/main/kotlin/io/spring/messenger/repository/UserRepository.kt @@ -17,8 +17,10 @@ open class UserRepository @Autowired constructor(val db: Database) { create(Users) } - open fun create(user: User) = db.transaction { - Users.insert( map(user) ) + open fun create(user: User) { + db.transaction { + Users.insert( map(user) ) + } } open fun updateLocation(userName:String, location: Point) = db.transaction { diff --git a/src/main/kotlin/io/spring/messenger/web/MessageController.kt b/src/main/kotlin/io/spring/messenger/web/MessageController.kt index 90c7345..ff32324 100644 --- a/src/main/kotlin/io/spring/messenger/web/MessageController.kt +++ b/src/main/kotlin/io/spring/messenger/web/MessageController.kt @@ -5,6 +5,7 @@ import io.spring.messenger.repository.MessageRepository import org.postgis.PGbox2d import org.postgis.Point import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpStatus.* import org.springframework.web.bind.annotation.* import org.springframework.web.servlet.mvc.method.annotation.SseEmitter @@ -14,14 +15,15 @@ class MessageController @Autowired constructor(val repository: MessageRepository val broadcaster = SseBroadcaster() - @PostMapping - fun create(@RequestBody message: Message) { - repository.create(message) - broadcaster.send(message) + @PostMapping @ResponseStatus(CREATED) + fun create(@RequestBody message: Message): Message { + val m = repository.create(message) + broadcaster.send(m) + return m } @GetMapping - fun findMessages() = repository.findAll() + fun list() = repository.findAll() @GetMapping("/bbox/{xMin},{yMin},{xMax},{yMax}") fun findByBoundingBox(@PathVariable xMin:Double, @PathVariable yMin:Double, diff --git a/src/main/kotlin/io/spring/messenger/web/UserController.kt b/src/main/kotlin/io/spring/messenger/web/UserController.kt index 8bf6855..c2ea2cd 100644 --- a/src/main/kotlin/io/spring/messenger/web/UserController.kt +++ b/src/main/kotlin/io/spring/messenger/web/UserController.kt @@ -11,17 +11,18 @@ import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/user") class UserController @Autowired constructor(val repository: UserRepository) { - @GetMapping fun findAll() = repository.findAll() + @PostMapping @ResponseStatus(CREATED) + fun create(@RequestBody u: User) { repository.create(u) } - @PostMapping fun create(@RequestBody u: User) = repository.create(u) - - @PutMapping("/{userName}/location/{x},{y}") @ResponseStatus(NO_CONTENT) - fun updateLocation(@PathVariable userName:String, @PathVariable x: Double, @PathVariable y: Double) - = repository.updateLocation(userName, Point(x, y)) + @GetMapping + fun list() = repository.findAll() @GetMapping("/bbox/{xMin},{yMin},{xMax},{yMax}") fun findByBoundingBox(@PathVariable xMin:Double, @PathVariable yMin:Double, @PathVariable xMax:Double, @PathVariable yMax:Double) = repository.findByBoundingBox(PGbox2d(Point(xMin, yMin), Point(xMax, yMax))) + @PutMapping("/{userName}/location/{x},{y}") @ResponseStatus(NO_CONTENT) + fun updateLocation(@PathVariable userName:String, @PathVariable x: Double, @PathVariable y: Double) + = repository.updateLocation(userName, Point(x, y)) } \ No newline at end of file diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 60c51d7..8d7f767 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -15,6 +15,9 @@
Current user:
+
+ REST API documentation +
diff --git a/src/test/kotlin/io/spring/messenger/MessageControllerTests.kt b/src/test/kotlin/io/spring/messenger/MessageControllerTests.kt index 9f61cd4..04e415b 100644 --- a/src/test/kotlin/io/spring/messenger/MessageControllerTests.kt +++ b/src/test/kotlin/io/spring/messenger/MessageControllerTests.kt @@ -1,40 +1,125 @@ package io.spring.messenger +import com.fasterxml.jackson.databind.ObjectMapper +import io.spring.messenger.domain.Message +import io.spring.messenger.domain.User import io.spring.messenger.repository.MessageRepository import io.spring.messenger.repository.UserRepository import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.springframework.http.MediaType.APPLICATION_JSON -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.postgis.Point import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.SpringApplicationConfiguration +import org.springframework.http.MediaType.APPLICATION_JSON_UTF8 +import org.springframework.restdocs.RestDocumentation +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler 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.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup import org.springframework.web.context.WebApplicationContext -@RunWith(SpringJUnit4ClassRunner::class) +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration +import org.springframework.restdocs.operation.preprocess.Preprocessors.* +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder +import org.springframework.restdocs.payload.PayloadDocumentation.* +import org.springframework.restdocs.request.RequestDocumentation.* +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.* + +@RunWith(SpringJUnit4ClassRunner::class) @WebAppConfiguration @SpringApplicationConfiguration(classes = arrayOf(Application::class)) -@WebAppConfiguration class MessageControllerTests { + @Rule @JvmField val restDoc = RestDocumentation("build/generated-snippets") @Autowired lateinit var context: WebApplicationContext - @Autowired lateinit var userRepository: UserRepository @Autowired lateinit var messageRepository: MessageRepository + @Autowired lateinit var userRepository: UserRepository + @Autowired lateinit var mapper: ObjectMapper lateinit var mockMvc: MockMvc + lateinit var document: RestDocumentationResultHandler @Before fun setUp() { messageRepository.deleteAll() userRepository.deleteAll() - mockMvc = MockMvcBuilders.webAppContextSetup(this.context).build() + document = document("{method-name}", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint())); + mockMvc = webAppContextSetup(this.context) + .apply(documentationConfiguration(restDoc)) + .alwaysDo(document).build() } - @Test fun findUsers() { - mockMvc.perform(get("/message").accept(APPLICATION_JSON)).andExpect(status().isOk) + @Test fun listMessages() { + userRepository.create(User("swhite", "Skyler", "White")) + messageRepository.create(Message("foo", "swhite")) + messageRepository.create(Message("bar", "swhite", Point(0.0, 0.0))) + document.snippets( + responseFields( + fieldWithPath("[].id").description("The message ID"), + fieldWithPath("[].content").description("The message content"), + fieldWithPath("[].author").description("The message author username"), + fieldWithPath("[].location").optional().description("Optional, the message location (latitude, longitude)") + ) + ) + mockMvc.perform(get("/message").accept(APPLICATION_JSON_UTF8)).andExpect(status().isOk) + } + + @Test fun createMessage() { + userRepository.create(User("swhite", "Skyler", "White")) + document.snippets( + requestFields( + fieldWithPath("content").description("The message content"), + fieldWithPath("author").description("The message author username"), + fieldWithPath("location").optional().description("Optional, the message location (latitude, longitude)") + ), + responseFields( + fieldWithPath("id").description("The message ID"), + fieldWithPath("content").description("The message content"), + fieldWithPath("author").description("The message author username"), + fieldWithPath("location").optional().description("Optional, the message location (latitude, longitude)") + ) + ) + var message = Message("""Lorem ipsum dolor sit amet, consectetur adipiscing elit, + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad + minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea + commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse + cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non + proident, sunt in culpa qui officia deserunt mollit anim id est laborum.""" + , "swhite", Point(0.0, 0.0)) + mockMvc.perform(post("/message") + .content(mapper.writeValueAsString(message)) + .contentType(APPLICATION_JSON_UTF8)) + .andExpect(status().isCreated) + } + + @Test fun findMessagesByBoundingBox() { + userRepository.create(User("swhite", "Skyler", "White")) + messageRepository.create(Message("foo", "swhite", Point(0.0, 0.0))) + messageRepository.create(Message("bar", "swhite", Point(1.0, 1.0))) + document.snippets( + pathParameters( + parameterWithName("xMin").description("The latitude of the lower-left corner"), + parameterWithName("yMin").description("The longitude of the lower-left corner"), + parameterWithName("xMax").description("The latitude of the upper-left corner"), + parameterWithName("yMax").description("The longitude of the upper-left corner") + ), + responseFields( + fieldWithPath("[].id").description("The message ID"), + fieldWithPath("[].content").description("The message content"), + fieldWithPath("[].author").description("The message author username"), + fieldWithPath("[].location").optional().description("Optional, the message location (latitude, longitude)") + ) + ) + mockMvc.perform(get("/message/bbox/{xMin},{yMin},{xMax},{yMax}", -1, -1, 2, 2) + .accept(APPLICATION_JSON_UTF8)) + .andExpect(status().isOk) + } + + @Test fun subscribeMessages() { + mockMvc.perform(get("/message/subscribe")).andExpect(status().isOk) } } diff --git a/src/test/kotlin/io/spring/messenger/UserControllerTests.kt b/src/test/kotlin/io/spring/messenger/UserControllerTests.kt index 57ee963..c362263 100644 --- a/src/test/kotlin/io/spring/messenger/UserControllerTests.kt +++ b/src/test/kotlin/io/spring/messenger/UserControllerTests.kt @@ -1,40 +1,122 @@ package io.spring.messenger +import com.fasterxml.jackson.databind.ObjectMapper +import io.spring.messenger.domain.User import io.spring.messenger.repository.MessageRepository import io.spring.messenger.repository.UserRepository import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.springframework.http.MediaType.APPLICATION_JSON -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.postgis.Point import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.SpringApplicationConfiguration +import org.springframework.http.MediaType.APPLICATION_JSON_UTF8 +import org.springframework.restdocs.RestDocumentation 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.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup import org.springframework.web.context.WebApplicationContext -@RunWith(SpringJUnit4ClassRunner::class) +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler +import org.springframework.restdocs.operation.preprocess.Preprocessors.* +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder +import org.springframework.restdocs.payload.PayloadDocumentation.* +import org.springframework.restdocs.request.RequestDocumentation.* +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.* + + +@RunWith(SpringJUnit4ClassRunner::class) @WebAppConfiguration @SpringApplicationConfiguration(classes = arrayOf(Application::class)) -@WebAppConfiguration class UserControllerTests { + @Rule @JvmField val restDoc = RestDocumentation("build/generated-snippets") @Autowired lateinit var context: WebApplicationContext @Autowired lateinit var userRepository: UserRepository @Autowired lateinit var messageRepository: MessageRepository + @Autowired lateinit var mapper: ObjectMapper lateinit var mockMvc: MockMvc + lateinit var document: RestDocumentationResultHandler @Before fun setUp() { messageRepository.deleteAll() userRepository.deleteAll() - mockMvc = MockMvcBuilders.webAppContextSetup(this.context).build() + document = document("{method-name}", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint())); + mockMvc = webAppContextSetup(this.context) + .apply(documentationConfiguration(restDoc)) + .alwaysDo(document).build() } - @Test fun findUsers() { - mockMvc.perform(get("/user").accept(APPLICATION_JSON)).andExpect(status().isOk) + @Test fun createUser() { + document.snippets( + requestFields( + fieldWithPath("userName").description("The user username"), + fieldWithPath("firstName").description("The user first name"), + fieldWithPath("lastName").description("The user last name"), + fieldWithPath("location").optional().description("Optional, the user location (latitude, longitude)") + ) + ) + var skyler = User("swhite", "Skyler", "White", Point(0.0, 0.0)) + mockMvc.perform(post("/user") + .content(mapper.writeValueAsString(skyler)) + .contentType(APPLICATION_JSON_UTF8) + .accept(APPLICATION_JSON_UTF8)) + .andExpect(status().isCreated) + } + + @Test fun listUsers() { + userRepository.create(User("swhite", "Skyler", "White")) + userRepository.create(User("jpinkman", "Jesse", "Pinkman", Point(0.0, 0.0))) + + document.snippets( + responseFields( + fieldWithPath("[].userName").description("The user username"), + fieldWithPath("[].firstName").description("The user first name"), + fieldWithPath("[].lastName").description("The user last name"), + fieldWithPath("[].location").optional().description("Optional, the user location (latitude, longitude)") + ) + ) + mockMvc.perform(get("/user").accept(APPLICATION_JSON_UTF8)).andExpect(status().isOk) + } + + @Test fun findUsersByBoundingBox() { + userRepository.create(User("swhite", "Skyler", "White", Point(0.0, 0.0))) + userRepository.create(User("jpinkman", "Jesse", "Pinkman", Point(1.0, 1.0))) + document.snippets( + pathParameters( + parameterWithName("xMin").description("The latitude of the lower-left corner"), + parameterWithName("yMin").description("The longitude of the lower-left corner"), + parameterWithName("xMax").description("The latitude of the upper-left corner"), + parameterWithName("yMax").description("The longitude of the upper-left corner") + ), + responseFields( + fieldWithPath("[].userName").description("The user username"), + fieldWithPath("[].firstName").description("The user first name"), + fieldWithPath("[].lastName").description("The user last name"), + fieldWithPath("[].location").optional().description("Optional, the user location (latitude, longitude)") + ) + ) + mockMvc.perform(get("/user/bbox/{xMin},{yMin},{xMax},{yMax}", -1, -1, 2, 2) + .accept(APPLICATION_JSON_UTF8)) + .andExpect(status().isOk) + } + + @Test fun updateUserLocation() { + userRepository.create(User("swhite", "Skyler", "White", Point(0.0, 0.0))) + document.snippets( + pathParameters( + parameterWithName("userName").description("The user username"), + parameterWithName("x").description("The new location latitude"), + parameterWithName("y").description("The new location latitude") + ) + ) + mockMvc.perform(put("/user/{userName}/location/{x},{y}", "swhite", 1.0, 1.0)) + .andExpect(status().isNoContent) } }