diff --git a/build.gradle b/build.gradle index 701488b..9daa167 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,19 @@ buildscript { } } +plugins { + id "org.asciidoctor.convert" version "1.5.2" +} + +ext { + snippetsDir = file('build/generated-snippets') +} + +repositories { + mavenLocal() + mavenCentral() +} + apply plugin: "idea" apply plugin: 'kotlin' apply plugin: 'kotlin-spring' @@ -33,13 +46,13 @@ apply plugin: 'org.springframework.boot' apply plugin: "io.spring.dependency-management" apply plugin: "org.flywaydb.flyway" -repositories { - mavenLocal() - mavenCentral() -} jar { baseName = "startapp" + dependsOn asciidoctor + from("${asciidoctor.outputDir}/html5") { + into "static/docs" + } } sourceCompatibility = 1.8 @@ -56,6 +69,9 @@ dependencies { compile "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" compile "org.springframework.boot:spring-boot-starter-hateoas" runtime "org.springframework.boot:spring-boot-devtools" + //utils + compile "com.fasterxml.jackson.module:jackson-module-kotlin:2.8.4" + compile "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" //data compile "org.springframework.boot:spring-boot-starter-jooq" compile "org.springframework.boot:spring-boot-starter-actuator" @@ -63,6 +79,14 @@ dependencies { runtime "com.h2database:h2" //rest compile "org.springframework.data:spring-data-rest-hal-browser" + + // Initial test dependencies. + testCompile("org.springframework.boot:spring-boot-starter-test") + testCompile("junit:junit:4.12") + + // Extras + testCompile("com.jayway.jsonpath:json-path:2.0.0") + testCompile("org.springframework.restdocs:spring-restdocs-mockmvc:1.1.2.RELEASE") } task generateDbShemaSource << { @@ -107,6 +131,18 @@ flyway { locations = ["filesystem:$project.projectDir/src/main/resources/db/migration"] } + +test { + outputs.dir snippetsDir +} + +asciidoctor { + attributes 'snippets': snippetsDir + inputs.dir snippetsDir + dependsOn test + dependsOn test +} + task wrapper(type: Wrapper) { gradleVersion = "2.14.1" } diff --git a/src/docs/asciidoc/api-guide.adoc b/src/docs/asciidoc/api-guide.adoc new file mode 100644 index 0000000..f5e5ca1 --- /dev/null +++ b/src/docs/asciidoc/api-guide.adoc @@ -0,0 +1,107 @@ += Maxur Tutor - API Guide +Maxim Yunusov; +:doctype: book +:toc: +:sectanchors: +:sectlinks: +:toclevels: 4 +:source-highlighter: highlightjs + +[[overview]] += Overview + +[[overview-http-verbs]] +== HTTP verbs + +|=== +| Verb | Usage + +| `GET` +| Used to retrieve a resource + +| `POST` +| Used to create a new resource + +| `PUT` +| Used to create a new resource as well as replace existing resource + +| `PATCH` +| Used to update an existing resource, including partial updates + +| `DELETE` +| Used to delete an existing resource +|=== + +[[overview-http-status-codes]] +== HTTP status codes + +|=== +| Status code | Usage + +| `200 OK` +| The request completed successfully + +| `201 Created` +| A new resource has been created successfully. The resource's URI is available from the response's +`Location` header + +| `204 No Content` +| An update to an existing resource has been applied successfully + +| `400 Bad Request` +| The request was malformed. The response body will include an error providing further information + +| `404 Not Found` +| The requested resource did not exist + +| `405 Method Not Allowed` +| The requested resource does not support method + +| `409 Conflict` +| The request tries to put the resource into a conflicting state +|=== + +[[overview-hypermedia]] +== Hypermedia + +This API uses hypermedia and resources include links to other resources in their +responses. Responses are in http://stateless.co/hal_specification.html[Hypertext Application +Language (HAL)] format. Links can be found beneath the `_links` key. Users of the API should +not create URIs themselves, instead they should use the above-described links to navigate +from resource to resource. + +[[resources]] += Resources + +[[issues]] +== Issues + +The Ads resource / 'issues' relation is used to create and list issues. + +[[resources-issues-create]] + +=== Creating an issue + +A `POST` request is used to create an issue + +==== Example curl request + +include::{snippets}/create-issue/curl-request.adoc[] + +==== Example HTTP request + +include::{snippets}/create-issue/http-request.adoc[] + +==== Example response + +include::{snippets}/create-issue/http-response.adoc[] + +==== Links + +include::{snippets}/create-issue/links.adoc[] + +==== Response Fields + +include::{snippets}/create-issue/response-fields.adoc[] + + diff --git a/src/main/kotlin/org/maxur/tutor/startapp/api/rest/IssueController.kt b/src/main/kotlin/org/maxur/tutor/startapp/api/rest/IssueController.kt index 745eb5c..a91e1f2 100644 --- a/src/main/kotlin/org/maxur/tutor/startapp/api/rest/IssueController.kt +++ b/src/main/kotlin/org/maxur/tutor/startapp/api/rest/IssueController.kt @@ -5,13 +5,13 @@ import org.maxur.tutor.startapp.domain.IssueRepository import org.springframework.hateoas.ExposesResourceFor import org.springframework.hateoas.ResourceSupport import org.springframework.hateoas.mvc.ControllerLinkBuilder +import org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.stereotype.Controller -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.* /** * Project Resource Controller @@ -31,6 +31,18 @@ class IssueController(val repository: IssueRepository) { return ResponseEntity(full(issue), HttpStatus.OK) } + @ResponseBody + @PostMapping("", produces = arrayOf("application/hal+json"), consumes = arrayOf("application/json")) + fun add(@RequestBody issue: Issue): ResponseEntity{ + repository.add(issue) + val headers: HttpHeaders = HttpHeaders() + headers.add( + HttpHeaders.LOCATION, + linkTo(IssueController::class.java).slash(issue.id).withSelfRel().expand(issue.id).href + ) + return ResponseEntity(full(issue), headers, HttpStatus.CREATED) + } + private fun full(issue: Issue): FullIssueResource { val link = ControllerLinkBuilder.linkTo(IssueController::class.java) .slash(issue.id) diff --git a/src/main/kotlin/org/maxur/tutor/startapp/domain/Issue.kt b/src/main/kotlin/org/maxur/tutor/startapp/domain/Issue.kt index 36361bd..931d37c 100644 --- a/src/main/kotlin/org/maxur/tutor/startapp/domain/Issue.kt +++ b/src/main/kotlin/org/maxur/tutor/startapp/domain/Issue.kt @@ -7,4 +7,4 @@ package org.maxur.tutor.startapp.domain * @version 1.0 * @since
18.01.2017
*/ -data class Issue(val id: String, val name: String, val description: String?) +data class Issue(val id: String, val name: String, val description: String?, val projectId: String) diff --git a/src/main/kotlin/org/maxur/tutor/startapp/domain/IssueRepository.kt b/src/main/kotlin/org/maxur/tutor/startapp/domain/IssueRepository.kt index 307000a..e18d9ca 100644 --- a/src/main/kotlin/org/maxur/tutor/startapp/domain/IssueRepository.kt +++ b/src/main/kotlin/org/maxur/tutor/startapp/domain/IssueRepository.kt @@ -27,7 +27,14 @@ open class IssueRepository(val dsl: DSLContext) { fun findOne(id: String): Issue? = select.where(ISSUES.ISSUE_ID.eq(id)) .fetchOneInto(Issues::class.java) - ?.let { record -> Issue(record.issueId, record.name, record.description)} + ?.let { record -> Issue(record.issueId, record.name, record.description, record.projectId)} + + fun add(issue: Issue) { + dsl.insertInto(ISSUES, ISSUES.ISSUE_ID, ISSUES.NAME, ISSUES.DESCRIPTION, ISSUES.PROJECT_ID) + .values(issue.id, issue.name, issue.description, issue.projectId) + .execute() + + } } \ No newline at end of file diff --git a/src/main/kotlin/org/maxur/tutor/startapp/domain/ProjectRepository.kt b/src/main/kotlin/org/maxur/tutor/startapp/domain/ProjectRepository.kt index 18c6e11..e7ceeb3 100644 --- a/src/main/kotlin/org/maxur/tutor/startapp/domain/ProjectRepository.kt +++ b/src/main/kotlin/org/maxur/tutor/startapp/domain/ProjectRepository.kt @@ -58,7 +58,7 @@ open class ProjectRepository(val dsl: DSLContext) { .sortAsc(PROJECTS.PROJECT_ID) .into(ISSUES.ISSUE_ID, ISSUES.NAME, ISSUES.DESCRIPTION) .filter { iss -> iss.value1() != null } - .map { iss -> Issue(iss.value1(), iss.value2(), iss.value3()) } + .map { iss -> Issue(iss.value1(), iss.value2(), iss.value3(), project.value1()) } .toList() .orEmpty() return Project(project.value1(), project.value2(), issues) diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 0000000..ce99bbf --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,27 @@ +logging: + level: + org.jooq.tools: debug + +flyway: + enabled: true + schemas: PUBLIC + + +spring: + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:alm;DB_CLOSE_ON_EXIT=FALSE + username: sa + password: + schema: PUBLIC + + h2.console: + enabled: true + path: /admin/h2 + + jooq: + sql-dialect: h2 + +management: + context-path: /admin + diff --git a/src/main/resources/documentatiomproperties b/src/main/resources/documentatiomproperties new file mode 100644 index 0000000..5ca480c --- /dev/null +++ b/src/main/resources/documentatiomproperties @@ -0,0 +1 @@ +org.springframework.restdocs.outputDir: build/generated-snippets diff --git a/src/test/kotlin/org/maxur/tutor/startapp/IssueHttpApiWithDocsTests.kt b/src/test/kotlin/org/maxur/tutor/startapp/IssueHttpApiWithDocsTests.kt new file mode 100644 index 0000000..c008a48 --- /dev/null +++ b/src/test/kotlin/org/maxur/tutor/startapp/IssueHttpApiWithDocsTests.kt @@ -0,0 +1,87 @@ +package org.maxur.tutor.startapp + +import org.hamcrest.Matchers +import org.junit.Test +import org.junit.runner.RunWith +import org.maxur.tutor.startapp.domain.Issue +import org.maxur.tutor.startapp.domain.IssueRepository +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.hateoas.MediaTypes +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.restdocs.hypermedia.HypermediaDocumentation.* +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document +import org.springframework.restdocs.payload.JsonFieldType +import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath +import org.springframework.restdocs.payload.PayloadDocumentation.responseFields +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.junit4.SpringRunner +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* + +@RunWith(SpringRunner::class) +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc +@AutoConfigureRestDocs("build/generated-snippets") +class IssueHttpApiWithDocsTests { + + @Autowired + lateinit var mockMvc: MockMvc + + @Autowired + lateinit var repository: IssueRepository + + @Test + @Throws(Exception::class) + fun createIssue() { + val issue = issue() + val requestBody = saveRequestJsonString(issue) + + val resultActions = mockMvc.perform(MockMvcRequestBuilders + .post("/issues") + .accept(MediaTypes.HAL_JSON) + .content(requestBody) + .contentType(MediaType.APPLICATION_JSON) + ) + + resultActions.andExpect(status().isCreated) + + val createdIssue = findCreatedIssue() ?: throw AssertionError() + + resultActions + .andExpect(header().string(HttpHeaders.LOCATION, "http://localhost:8080/issues/" + createdIssue.id)) + .andExpect(jsonPath("$.name", Matchers.`is`(createdIssue.name))) + .andExpect(jsonPath("$.description", Matchers.`is`(createdIssue.description))) + + resultActions.andDo(document("create-issue", + links(halLinks(), + linkWithRel("self").description("This issue") + ), + responseFields( + fieldWithPath("_links").type(JsonFieldType.OBJECT).description("Links"), + fieldWithPath("name").type(JsonFieldType.STRING).description("Issue name"), + fieldWithPath("description").type(JsonFieldType.STRING).description("Issue description") + ))) + } + + private fun findCreatedIssue(): Issue? { + return repository.findOne("id") + } + + private fun issue(): Issue { + return Issue("id", "Issue", "Description", "pr1") + } + + private fun saveRequestJsonString(issue: Issue): String = """{ + "id": "${issue.id}", + "name": "${issue.name}", + "description": "${issue.description}", + "projectId": "${issue.projectId}" +}""" + + }