diff --git a/build.gradle.kts b/build.gradle.kts index 3bcc479..77cef8f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,11 +24,13 @@ repositories { dependencies { api("org.springframework.boot:spring-boot-starter-data-jdbc:3.2.4") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.4") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.4") implementation("org.flywaydb:flyway-core:9.22.3") implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.20") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") implementation("org.postgresql:postgresql:42.6.2") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0") implementation("org.springframework.boot:spring-boot-starter-mustache:3.2.4") implementation("org.springframework.boot:spring-boot-starter-validation:3.2.4") implementation("org.springframework.boot:spring-boot-starter-web:3.2.4") diff --git a/src/main/kotlin/com/example/demo/http/controllers/ProductController.kt b/src/main/kotlin/com/example/demo/http/controllers/ProductController.kt index 4796aa2..a950282 100644 --- a/src/main/kotlin/com/example/demo/http/controllers/ProductController.kt +++ b/src/main/kotlin/com/example/demo/http/controllers/ProductController.kt @@ -3,11 +3,18 @@ package com.example.demo.http.controllers import com.example.demo.http.exceptions.NotFoundException import com.example.demo.http.exceptions.UnprocessableException import com.example.demo.http.requests.CreateProductRequest +import com.example.demo.http.responses.NotFoundResponse import com.example.demo.http.responses.makeOkResponse +import com.example.demo.http.responses.page.PageResponse +import com.example.demo.models.Product import com.example.demo.services.database.exceptions.AlreadyDeletedException import com.example.demo.services.database.product.ProductService import com.example.demo.services.database.product.exceptions.ProductNotFoundException import com.example.demo.services.kafka.exceptions.InvalidArgumentException +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses import jakarta.validation.Valid import org.springdoc.core.annotations.ParameterObject import org.springframework.data.domain.Pageable @@ -24,6 +31,14 @@ class ProductController( ) { @GetMapping("/{guid}") @Throws(NotFoundException::class) + @ApiResponses(value = [ + ApiResponse(responseCode = "200", content = [ + Content(mediaType = "application/json", schema = Schema(implementation = Product::class)), + ]), + ApiResponse(responseCode = "404", content = [ + Content(mediaType = "application/json", schema = Schema(implementation = NotFoundResponse::class)) + ]) + ]) fun getProduct( @PathVariable guid: UUID, ): ResponseEntity { @@ -33,19 +48,18 @@ class ProductController( } @GetMapping("") + @ApiResponses(value = [ + ApiResponse(responseCode = "200", content = [ + Content(mediaType = "application/json", schema = Schema(implementation = PageResponse::class)), + ]), + ]) fun getProducts( @ParameterObject pageable: Pageable, ): ResponseEntity { val products = productService.findAll(pageable) return ResponseEntity( - mapOf( - "data" to products.content, - "meta" to mapOf( - "total" to products.totalElements, - "pages" to products.totalPages, - ), - ), + PageResponse(products), HttpStatus.OK, ) } diff --git a/src/main/kotlin/com/example/demo/http/exceptions/ExceptionHandler.kt b/src/main/kotlin/com/example/demo/http/exceptions/ExceptionHandler.kt index 8514ce5..ff3b060 100644 --- a/src/main/kotlin/com/example/demo/http/exceptions/ExceptionHandler.kt +++ b/src/main/kotlin/com/example/demo/http/exceptions/ExceptionHandler.kt @@ -1,15 +1,15 @@ package com.example.demo.http.exceptions -import com.example.demo.http.responses.makeBadRequestResponse -import com.example.demo.http.responses.makeNotFoundResponse -import com.example.demo.http.responses.makeUnprocessableResponse -import com.example.demo.http.responses.makeUnprocessableResponseWithErrors +import com.example.demo.http.responses.BadRequestResponse +import com.example.demo.http.responses.NotFoundResponse +import com.example.demo.http.responses.UnprocessableResponse import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.http.converter.HttpMessageNotReadableException import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ControllerAdvice import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.ResponseStatus @ControllerAdvice class ExceptionHandler { @@ -17,25 +17,29 @@ class ExceptionHandler { // 400 @ExceptionHandler(HttpMessageNotReadableException::class) + @ResponseStatus(HttpStatus.BAD_REQUEST) fun handleMessageNotReadable(exception: HttpMessageNotReadableException): ResponseEntity = ResponseEntity( - makeBadRequestResponse(exception.message.toString()), + BadRequestResponse(exception.message.toString()), HttpStatus.BAD_REQUEST, ) // 404 @ExceptionHandler(NotFoundException::class) - fun handleNotFound(): ResponseEntity = ResponseEntity(makeNotFoundResponse(), HttpStatus.NOT_FOUND) + @ResponseStatus(HttpStatus.NOT_FOUND) + fun handleNotFound(): ResponseEntity = ResponseEntity(NotFoundResponse(), HttpStatus.NOT_FOUND) // 422 @ExceptionHandler(UnprocessableException::class) + @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) fun handleUnprocessable(exception: UnprocessableException): ResponseEntity = ResponseEntity( - makeUnprocessableResponse(exception.message), + UnprocessableResponse(exception.message), HttpStatus.UNPROCESSABLE_ENTITY, ) @ExceptionHandler(MethodArgumentNotValidException::class) + @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) fun handleMethodArgumentNotValid(exception: MethodArgumentNotValidException): ResponseEntity = ResponseEntity( - makeUnprocessableResponseWithErrors(exception.javaClass.name, exception.allErrors), + UnprocessableResponse(exception.javaClass.name, exception.allErrors), HttpStatus.UNPROCESSABLE_ENTITY, ) } \ No newline at end of file diff --git a/src/main/kotlin/com/example/demo/http/responses/BadRequestResponse.kt b/src/main/kotlin/com/example/demo/http/responses/BadRequestResponse.kt index ce29dd9..07d2f3e 100644 --- a/src/main/kotlin/com/example/demo/http/responses/BadRequestResponse.kt +++ b/src/main/kotlin/com/example/demo/http/responses/BadRequestResponse.kt @@ -3,5 +3,3 @@ package com.example.demo.http.responses data class BadRequestResponse( val cause: String, ): BaseResponse(status = ResponseStatus.BAD_REQUEST) - -fun makeBadRequestResponse(cause: String): BadRequestResponse = BadRequestResponse(cause) diff --git a/src/main/kotlin/com/example/demo/http/responses/BaseResponse.kt b/src/main/kotlin/com/example/demo/http/responses/BaseResponse.kt index da03cb6..5692e63 100644 --- a/src/main/kotlin/com/example/demo/http/responses/BaseResponse.kt +++ b/src/main/kotlin/com/example/demo/http/responses/BaseResponse.kt @@ -3,5 +3,3 @@ package com.example.demo.http.responses open class BaseResponse(val status: ResponseStatus) fun makeOkResponse(): BaseResponse = BaseResponse(status = ResponseStatus.OK) - -fun makeNotFoundResponse(): BaseResponse = BaseResponse(status = ResponseStatus.NOT_FOUND) diff --git a/src/main/kotlin/com/example/demo/http/responses/NotFoundResponse.kt b/src/main/kotlin/com/example/demo/http/responses/NotFoundResponse.kt new file mode 100644 index 0000000..f8c5c50 --- /dev/null +++ b/src/main/kotlin/com/example/demo/http/responses/NotFoundResponse.kt @@ -0,0 +1,3 @@ +package com.example.demo.http.responses + +class NotFoundResponse: BaseResponse(ResponseStatus.NOT_FOUND) \ No newline at end of file diff --git a/src/main/kotlin/com/example/demo/http/responses/UnprocessableResponse.kt b/src/main/kotlin/com/example/demo/http/responses/UnprocessableResponse.kt index 070f3d4..8a96e50 100644 --- a/src/main/kotlin/com/example/demo/http/responses/UnprocessableResponse.kt +++ b/src/main/kotlin/com/example/demo/http/responses/UnprocessableResponse.kt @@ -4,10 +4,7 @@ import org.springframework.validation.ObjectError class UnprocessableResponse( val cause: String, - val errors: List? = null -): BaseResponse(status = ResponseStatus.UNPROCESSABLE) - -fun makeUnprocessableResponse(cause: String): UnprocessableResponse = UnprocessableResponse(cause) -fun makeUnprocessableResponseWithErrors( - cause: String, errors: List, -): UnprocessableResponse = UnprocessableResponse(cause, errors) \ No newline at end of file + val errors: List? +): BaseResponse(status = ResponseStatus.UNPROCESSABLE) { + constructor(cause: String): this(cause, null) +} \ No newline at end of file diff --git a/src/main/kotlin/com/example/demo/http/responses/page/PageMetaDto.kt b/src/main/kotlin/com/example/demo/http/responses/page/PageMetaDto.kt new file mode 100644 index 0000000..d8a6356 --- /dev/null +++ b/src/main/kotlin/com/example/demo/http/responses/page/PageMetaDto.kt @@ -0,0 +1,9 @@ +package com.example.demo.http.responses.page + +import kotlinx.serialization.Serializable + +@Serializable +data class PageMetaDto( + val total: Long, + val pages: Int, +) \ No newline at end of file diff --git a/src/main/kotlin/com/example/demo/http/responses/page/PageResponse.kt b/src/main/kotlin/com/example/demo/http/responses/page/PageResponse.kt new file mode 100644 index 0000000..3bb7040 --- /dev/null +++ b/src/main/kotlin/com/example/demo/http/responses/page/PageResponse.kt @@ -0,0 +1,18 @@ +package com.example.demo.http.responses.page + +import kotlinx.serialization.Serializable +import org.springframework.data.domain.Page + +@Serializable +data class PageResponse( + val data: List, + val meta: PageMetaDto, +) { + constructor(page: Page) : this( + data = page.content, + meta = PageMetaDto( + total = page.totalElements, + pages = page.totalPages + ) + ) +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1a583e2..fec240b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -24,3 +24,9 @@ kafka: group-id: demo-consumer topics: demo-city-sync auto-startup: true + +springdoc: + api-docs: + path: /doc/openapi + swagger-ui: + path: /doc/swagger-ui.html \ No newline at end of file diff --git a/src/test/kotlin/com/example/demo/http/controllers/ProductControllerTest.kt b/src/test/kotlin/com/example/demo/http/controllers/ProductControllerTest.kt index f195975..d29030f 100644 --- a/src/test/kotlin/com/example/demo/http/controllers/ProductControllerTest.kt +++ b/src/test/kotlin/com/example/demo/http/controllers/ProductControllerTest.kt @@ -83,8 +83,10 @@ class ProductControllerTest(@Autowired val mockMvc: MockMvc): BaseUnitTest() { @Test fun getProducts_success() { val now = OffsetDateTime.now() + val pageRequest = PageRequest.of(1, 2, Sort.by(Sort.Direction.DESC, "createdAt")) + whenever(productService.findAll( - PageRequest.of(1, 2, Sort.by(Sort.Direction.DESC, "createdAt")), + pageRequest, )) doReturn PageImpl(listOf(Product( id = 12, guid = UUID.randomUUID(), @@ -96,7 +98,7 @@ class ProductControllerTest(@Autowired val mockMvc: MockMvc): BaseUnitTest() { deletedAt = null, ))) - mockMvc.get("/api/product?page=1&size=2&sort=created_at,desc") + mockMvc.get("/api/product?page=1&size=2&sort=createdAt,desc") .andExpect { status { status { isOk() } } } .andExpect { content { contentType(MediaType.APPLICATION_JSON) } } .andExpect { jsonPath("\$.meta.total") { value(1) } }