move controllers to sub-project, fix dependencies

This commit is contained in:
Savosin Denis
2025-06-03 15:28:03 +07:00
parent 7e64c57a5a
commit d4e36e7354
27 changed files with 401 additions and 362 deletions

View File

@@ -50,11 +50,11 @@ allprojects {
implementation(rootProject.libs.kotlin.reflect) implementation(rootProject.libs.kotlin.reflect)
implementation(rootProject.libs.kotlinx.serialization.json) implementation(rootProject.libs.kotlinx.serialization.json)
implementation(rootProject.libs.logback.encoder) implementation(rootProject.libs.logback.encoder)
implementation(rootProject.libs.spring.aspects) implementation(rootProject.libs.springFramework.aspects)
testImplementation(rootProject.libs.kotlin.test.junit) testImplementation(rootProject.libs.kotlin.test.junit)
testImplementation(rootProject.libs.mockito.kotlin) testImplementation(rootProject.libs.mockito.kotlin)
testImplementation(rootProject.libs.spring.boot.starter.test) testImplementation(rootProject.libs.springBoot.starter.test)
} }
tasks.test { tasks.test {
@@ -81,34 +81,23 @@ dependencies {
implementation(project(":edge-contracts")) implementation(project(":edge-contracts"))
implementation(project(":db")) implementation(project(":db"))
implementation(project(":edge-producing")) implementation(project(":edge-producing"))
implementation(project(":edge-integration"))
implementation(project(":core")) implementation(project(":core"))
implementation(project(":edge-consuming")) implementation(project(":edge-consuming"))
implementation(project(":edge-rest"))
implementation(libs.jackson.datatype.jsr) implementation(libs.springBoot.starter.mustache)
implementation(libs.jackson.module.kotlin) implementation(libs.springBoot.starter.web)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.core)
implementation(libs.postgres)
implementation(libs.spring.boot.starter.jdbc)
implementation(libs.spring.boot.starter.mustache)
implementation(libs.spring.boot.starter.validation)
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.cloud.starter.streamKafka)
implementation(libs.spring.cloud.stream)
implementation(libs.spring.doc.openapi.starter)
testImplementation(libs.ktor.client.mock) developmentOnly(libs.springBoot.devtools)
testImplementation(libs.spring.cloud.streamTestBinder)
testImplementation(libs.testcontainers)
testImplementation(libs.testcontainers.junit.jupiter)
developmentOnly(libs.spring.boot.devtools)
kover(project(":edge-consuming"))
kover(project(":core"))
kover(project(":edge-producing"))
kover(project(":db"))
kover(project(":edge-contracts")) kover(project(":edge-contracts"))
kover(project(":db"))
kover(project(":edge-producing"))
kover(project(":edge-integration"))
kover(project(":core"))
kover(project(":edge-consuming"))
kover(project(":edge-rest"))
} }
tasks.bootJar { tasks.bootJar {

View File

@@ -0,0 +1,7 @@
dependencies {
implementation(project(":edge-contracts"))
implementation(project(":core"))
implementation(rootProject.libs.springBoot.starter.web)
implementation(rootProject.libs.springData.commons)
}

View File

@@ -1,9 +1,13 @@
package com.github.dannecron.demo.http.exceptions package com.github.dannecron.demo.edgerest
import com.github.dannecron.demo.http.responses.BadRequestResponse import com.github.dannecron.demo.core.utils.LoggerDelegate
import com.github.dannecron.demo.http.responses.BaseResponse import com.github.dannecron.demo.edgecontracts.api.exceptions.NotFoundException
import com.github.dannecron.demo.http.responses.NotFoundResponse import com.github.dannecron.demo.edgecontracts.api.exceptions.UnprocessableException
import com.github.dannecron.demo.http.responses.UnprocessableResponse import com.github.dannecron.demo.edgecontracts.api.model.ResponseStatusModel
import com.github.dannecron.demo.edgecontracts.api.response.common.BadRequestResponse
import com.github.dannecron.demo.edgecontracts.api.response.common.BaseResponse
import com.github.dannecron.demo.edgecontracts.api.response.common.NotFoundResponse
import com.github.dannecron.demo.edgecontracts.api.response.common.UnprocessableResponse
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException import org.springframework.http.converter.HttpMessageNotReadableException
@@ -14,12 +18,16 @@ import org.springframework.web.bind.annotation.ResponseStatus
@ControllerAdvice @ControllerAdvice
class ExceptionHandler { class ExceptionHandler {
private val logger by LoggerDelegate()
/* 4xx status codes */ /* 4xx status codes */
// 400 // 400
@ExceptionHandler(HttpMessageNotReadableException::class) @ExceptionHandler(HttpMessageNotReadableException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST) @ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleMessageNotReadable(exception: HttpMessageNotReadableException): ResponseEntity<Any> = ResponseEntity( fun handleMessageNotReadable(
exception: HttpMessageNotReadableException,
): ResponseEntity<BadRequestResponse> = ResponseEntity(
BadRequestResponse(exception.message.toString()), BadRequestResponse(exception.message.toString()),
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
) )
@@ -27,19 +35,21 @@ class ExceptionHandler {
// 404 // 404
@ExceptionHandler(NotFoundException::class) @ExceptionHandler(NotFoundException::class)
@ResponseStatus(HttpStatus.NOT_FOUND) @ResponseStatus(HttpStatus.NOT_FOUND)
fun handleNotFound(): ResponseEntity<Any> = ResponseEntity(NotFoundResponse(), HttpStatus.NOT_FOUND) fun handleNotFound(): ResponseEntity<NotFoundResponse> = ResponseEntity(NotFoundResponse(), HttpStatus.NOT_FOUND)
// 422 // 422
@ExceptionHandler(UnprocessableException::class) @ExceptionHandler(UnprocessableException::class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
fun handleUnprocessable(exception: UnprocessableException): ResponseEntity<Any> = ResponseEntity( fun handleUnprocessable(exception: UnprocessableException): ResponseEntity<UnprocessableResponse> = ResponseEntity(
UnprocessableResponse(exception.message), UnprocessableResponse(exception.message),
HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.UNPROCESSABLE_ENTITY,
) )
@ExceptionHandler(MethodArgumentNotValidException::class) @ExceptionHandler(MethodArgumentNotValidException::class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
fun handleMethodArgumentNotValid(exception: MethodArgumentNotValidException): ResponseEntity<Any> = ResponseEntity( fun handleMethodArgumentNotValid(
exception: MethodArgumentNotValidException,
): ResponseEntity<UnprocessableResponse> = ResponseEntity(
UnprocessableResponse(exception.javaClass.name, exception.allErrors), UnprocessableResponse(exception.javaClass.name, exception.allErrors),
HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.UNPROCESSABLE_ENTITY,
) )
@@ -47,8 +57,10 @@ class ExceptionHandler {
// 500 // 500
@ExceptionHandler(RuntimeException::class) @ExceptionHandler(RuntimeException::class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
fun handleUnexpectedRuntimeException(exception: RuntimeException): ResponseEntity<Any> = ResponseEntity( fun handleUnexpectedRuntimeException(exception: RuntimeException): ResponseEntity<BaseResponse> = ResponseEntity(
BaseResponse(com.github.dannecron.demo.http.responses.ResponseStatus.INTERNAL_ERROR), BaseResponse(ResponseStatusModel.INTERNAL_ERROR),
HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR,
) ).also {
logger.error("internal server error", exception)
}
} }

View File

@@ -0,0 +1,51 @@
package com.github.dannecron.demo.edgerest.controllers
import com.github.dannecron.demo.core.dto.City
import com.github.dannecron.demo.core.dto.Customer
import com.github.dannecron.demo.core.dto.view.CustomerExtended
import com.github.dannecron.demo.core.services.customer.CustomerService
import com.github.dannecron.demo.edgecontracts.api.CustomerApi
import com.github.dannecron.demo.edgecontracts.api.exceptions.NotFoundException
import com.github.dannecron.demo.edgecontracts.api.model.CityApiModel
import com.github.dannecron.demo.edgecontracts.api.model.CustomerApiModel
import com.github.dannecron.demo.edgecontracts.api.response.GetCustomerResponse
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
class CustomerController(
private val customerService: CustomerService,
) : CustomerApi {
@Throws(NotFoundException::class)
override fun getCustomer(guid: UUID): ResponseEntity<GetCustomerResponse> {
val customerExtended = customerService.findByGuid(guid) ?: throw NotFoundException(null)
return ResponseEntity(customerExtended.toResponse(), HttpStatus.OK)
}
private fun CustomerExtended.toResponse() = GetCustomerResponse(
customer = customer.toApiModel(),
city = city?.toApiModel()
)
private fun Customer.toApiModel() = CustomerApiModel(
id = id,
guid = guid,
name = name,
cityId = cityId,
createdAt = createdAt,
updatedAt = updatedAt,
)
private fun City.toApiModel() = CityApiModel(
id = id,
guid = guid,
name = name,
createdAt = createdAt,
updatedAt = updatedAt,
deletedAt = deletedAt,
)
}

View File

@@ -0,0 +1,17 @@
package com.github.dannecron.demo.edgerest.controllers
import com.github.dannecron.demo.edgecontracts.api.GreetingApi
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.bind.annotation.RestController
@RestController
class GreetingController() : GreetingApi {
override fun greet(): String {
return "Hello World!"
}
@ResponseBody
override fun exampleHtml(): String {
TODO("Not yet implemented")
}
}

View File

@@ -0,0 +1,36 @@
package com.github.dannecron.demo.edgerest.controllers
import com.github.dannecron.demo.core.dto.neko.ImageDto
import com.github.dannecron.demo.core.services.neko.NekoService
import com.github.dannecron.demo.edgecontracts.api.NekoApi
import com.github.dannecron.demo.edgecontracts.api.model.NekoImageApiModel
import com.github.dannecron.demo.edgecontracts.api.response.GetNekoImagesResponse
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RestController
@RestController
class NekoController(
private val nekoService: NekoService
) : NekoApi {
override fun categories(): ResponseEntity<Set<String>> =
ResponseEntity(nekoService.getCategories(), HttpStatus.OK)
override fun images(category: String, imagesCount: Int): ResponseEntity<GetNekoImagesResponse> =
ResponseEntity(
GetNekoImagesResponse(
nekoService.getImages(category, imagesCount).map { it.toApiModel() }
),
HttpStatus.OK,
)
private fun ImageDto.toApiModel() = NekoImageApiModel(
url = url,
animeName = animeName,
artistHref = artistHref,
artistName = artistName,
sourceUrl = sourceUrl,
isGif = animeName == null
)
}

View File

@@ -0,0 +1,87 @@
package com.github.dannecron.demo.edgerest.controllers
import com.github.dannecron.demo.core.dto.Product
import com.github.dannecron.demo.core.exceptions.AlreadyDeletedException
import com.github.dannecron.demo.core.exceptions.InvalidDataException
import com.github.dannecron.demo.core.exceptions.ProductNotFoundException
import com.github.dannecron.demo.core.services.product.ProductService
import com.github.dannecron.demo.edgecontracts.api.ProductApi
import com.github.dannecron.demo.edgecontracts.api.exceptions.NotFoundException
import com.github.dannecron.demo.edgecontracts.api.exceptions.UnprocessableException
import com.github.dannecron.demo.edgecontracts.api.model.ProductApiModel
import com.github.dannecron.demo.edgecontracts.api.request.CreateProductRequest
import com.github.dannecron.demo.edgecontracts.api.response.common.BaseResponse
import com.github.dannecron.demo.edgecontracts.api.response.common.makeOkResponse
import com.github.dannecron.demo.edgecontracts.api.response.page.PageResponse
import org.springframework.data.domain.Pageable
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
class ProductController(
private val productService: ProductService,
) : ProductApi {
override fun getProducts(pageable: Pageable): ResponseEntity<PageResponse<ProductApiModel>> {
val products = productService.findAll(pageable)
return ResponseEntity(
PageResponse(products.map { it.toApiModel() }),
HttpStatus.OK,
)
}
@Throws(NotFoundException::class)
override fun getProduct(guid: UUID): ResponseEntity<ProductApiModel> {
val product = productService.findByGuid(guid = guid) ?: throw NotFoundException(null)
return ResponseEntity(product.toApiModel(), HttpStatus.OK)
}
override fun createProduct(product: CreateProductRequest): ResponseEntity<ProductApiModel> {
val saved = productService.create(
product.name,
product.price,
product.description,
)
return ResponseEntity(saved.toApiModel(), HttpStatus.CREATED)
}
@Throws(NotFoundException::class, UnprocessableException::class)
override fun deleteProduct(guid: UUID): ResponseEntity<BaseResponse> {
try {
productService.delete(guid)
return ResponseEntity(makeOkResponse(), HttpStatus.OK)
} catch (ex: ProductNotFoundException) {
throw NotFoundException(ex)
} catch (ex: AlreadyDeletedException) {
throw UnprocessableException("product already deleted", ex)
}
}
@Throws(NotFoundException::class)
override fun sendProduct(guid: UUID, topic: String?): ResponseEntity<BaseResponse> {
try {
productService.send(guid, topic)
return ResponseEntity(makeOkResponse(), HttpStatus.OK)
} catch (_: InvalidDataException) {
throw UnprocessableException("cannot sync product")
}
}
private fun Product.toApiModel() = ProductApiModel(
id = id,
guid = guid,
name = name,
description = description,
price = price,
createdAt = createdAt,
updatedAt = updatedAt,
deletedAt = deletedAt,
priceDouble = getPriceDouble(),
isDeleted = isDeleted(),
)
}

View File

@@ -0,0 +1,17 @@
package com.github.dannecron.demo.edgerest
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
@TestConfiguration
class WebTestConfig {
@Bean
fun objectMapper(): ObjectMapper = ObjectMapper().apply {
registerModules(JavaTimeModule())
configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
}
}

View File

@@ -1,18 +1,21 @@
package com.github.dannecron.demo.http.controllers package com.github.dannecron.demo.edgerest.controllers
import com.github.dannecron.demo.BaseUnitTest
import com.github.dannecron.demo.core.dto.City import com.github.dannecron.demo.core.dto.City
import com.github.dannecron.demo.core.dto.Customer import com.github.dannecron.demo.core.dto.Customer
import com.github.dannecron.demo.core.dto.view.CustomerExtended import com.github.dannecron.demo.core.dto.view.CustomerExtended
import com.github.dannecron.demo.core.services.customer.CustomerService import com.github.dannecron.demo.core.services.customer.CustomerService
import com.github.dannecron.demo.http.responses.ResponseStatus import com.github.dannecron.demo.edgecontracts.api.model.ResponseStatusModel
import com.github.dannecron.demo.edgerest.ExceptionHandler
import com.github.dannecron.demo.edgerest.WebTestConfig
import org.mockito.kotlin.doReturn import org.mockito.kotlin.doReturn
import org.mockito.kotlin.eq import org.mockito.kotlin.eq
import org.mockito.kotlin.whenever import org.mockito.kotlin.whenever
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.get
import java.time.OffsetDateTime import java.time.OffsetDateTime
@@ -20,9 +23,19 @@ import java.util.UUID
import kotlin.test.Test import kotlin.test.Test
@WebMvcTest(CustomerController::class) @WebMvcTest(CustomerController::class)
class CustomerControllerTest( @AutoConfigureMockMvc
@Autowired val mockMvc: MockMvc, @ContextConfiguration(
): BaseUnitTest() { classes = [
WebTestConfig::class,
CustomerController::class,
ExceptionHandler::class,
]
)
class CustomerControllerTest {
@Autowired
private lateinit var mockMvc: MockMvc
@MockBean @MockBean
private lateinit var customerService: CustomerService private lateinit var customerService: CustomerService
@@ -101,6 +114,6 @@ class CustomerControllerTest(
mockMvc.get("/api/customer/$customerGuid") mockMvc.get("/api/customer/$customerGuid")
.andExpect { status { isNotFound() } } .andExpect { status { isNotFound() } }
.andExpect { content { contentType(MediaType.APPLICATION_JSON) } } .andExpect { content { contentType(MediaType.APPLICATION_JSON) } }
.andExpect { jsonPath("\$.status") { value(ResponseStatus.NOT_FOUND.status) } } .andExpect { jsonPath("\$.status") { value(ResponseStatusModel.NOT_FOUND.status) } }
} }
} }

View File

@@ -1,15 +1,31 @@
package com.github.dannecron.demo.http.controllers package com.github.dannecron.demo.edgerest.controllers
import com.github.dannecron.demo.BaseUnitTest import com.github.dannecron.demo.edgerest.ExceptionHandler
import com.github.dannecron.demo.edgerest.WebTestConfig
import org.hamcrest.core.StringContains import org.hamcrest.core.StringContains
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.get
import kotlin.test.Ignore
import kotlin.test.Test import kotlin.test.Test
@WebMvcTest(com.github.dannecron.demo.http.controllers.GreetingController::class) @WebMvcTest(GreetingController::class)
class GreetingControllerTest(@Autowired val mockMvc: MockMvc): BaseUnitTest() { @AutoConfigureMockMvc
@ContextConfiguration(
classes = [
WebTestConfig::class,
GreetingController::class,
ExceptionHandler::class,
]
)
class GreetingControllerTest {
@Autowired
private lateinit var mockMvc: MockMvc
@Test @Test
fun greetings_shouldSeeGreetingMessage() { fun greetings_shouldSeeGreetingMessage() {
mockMvc.get("/greeting") mockMvc.get("/greeting")
@@ -19,6 +35,7 @@ class GreetingControllerTest(@Autowired val mockMvc: MockMvc): BaseUnitTest() {
} }
@Test @Test
@Ignore
fun exampleHtml_shouldSeeRenderedHtml() { fun exampleHtml_shouldSeeRenderedHtml() {
mockMvc.get("/example/html") mockMvc.get("/example/html")
.andExpect { status { isOk() } } .andExpect { status { isOk() } }

View File

@@ -0,0 +1,87 @@
package com.github.dannecron.demo.edgerest.controllers
import com.github.dannecron.demo.core.dto.neko.ImageDto
import com.github.dannecron.demo.core.exceptions.neko.IntegrationException
import com.github.dannecron.demo.core.services.neko.NekoService
import com.github.dannecron.demo.edgerest.ExceptionHandler
import com.github.dannecron.demo.edgerest.WebTestConfig
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.http.MediaType
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import kotlin.test.Test
@WebMvcTest(NekoController::class)
@AutoConfigureMockMvc
@ContextConfiguration(
classes = [
WebTestConfig::class,
NekoController::class,
ExceptionHandler::class,
]
)
class NekoControllerTest {
@Autowired
private lateinit var mockMvc: MockMvc
@MockBean
private lateinit var nekoService: NekoService
@Test
fun `getCategories - success`() {
whenever(nekoService.getCategories()).thenReturn(setOf("cat1", "cat2"))
mockMvc.get("/api/neko/categories")
.andExpect { status { isOk() } }
.andExpect { content { contentType(MediaType.APPLICATION_JSON) } }
.andExpect { content { string("""["cat1","cat2"]""") } }
verify(nekoService, times(1)).getCategories()
}
@Test
fun `getCategories - fail - integration error`() {
whenever(nekoService.getCategories()).thenThrow(IntegrationException("some error"))
mockMvc.get("/api/neko/categories")
.andExpect { status { isInternalServerError() } }
.andExpect { content { contentType(MediaType.APPLICATION_JSON) } }
.andExpect { jsonPath("$.status") { value("internal") } }
verify(nekoService, times(1)).getCategories()
}
@Test
fun `getImages - success`() {
val category = "some"
val animeName = "boku no pico"
whenever(nekoService.getImages(category = category, amount = 1))
.thenReturn(
listOf(
ImageDto(
"http://localhost",
animeName = animeName,
artistHref = null,
artistName = null,
sourceUrl = null,
)
)
)
mockMvc.get("/api/neko/images/$category")
.andExpect { status { isOk() } }
.andExpect { content { contentType(MediaType.APPLICATION_JSON) } }
.andExpect { jsonPath("\$.images[0].animeName") { value(animeName) } }
.andExpect { jsonPath("\$.images[0].artistName") { value(null) } }
verify(nekoService, times(1)).getImages(category = category, amount = 1)
}
}

View File

@@ -1,9 +1,10 @@
package com.github.dannecron.demo.http.controllers package com.github.dannecron.demo.edgerest.controllers
import com.github.dannecron.demo.BaseUnitTest
import com.github.dannecron.demo.core.dto.Product import com.github.dannecron.demo.core.dto.Product
import com.github.dannecron.demo.core.services.product.ProductService import com.github.dannecron.demo.core.services.product.ProductService
import com.github.dannecron.demo.http.responses.ResponseStatus import com.github.dannecron.demo.edgecontracts.api.model.ResponseStatusModel
import com.github.dannecron.demo.edgerest.ExceptionHandler
import com.github.dannecron.demo.edgerest.WebTestConfig
import org.hamcrest.Matchers.contains import org.hamcrest.Matchers.contains
import org.hamcrest.Matchers.nullValue import org.hamcrest.Matchers.nullValue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -14,12 +15,14 @@ import org.mockito.kotlin.verify
import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.verifyNoInteractions
import org.mockito.kotlin.whenever import org.mockito.kotlin.whenever
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.data.domain.PageImpl import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort import org.springframework.data.domain.Sort
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.delete import org.springframework.test.web.servlet.delete
import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.get
@@ -30,7 +33,15 @@ import java.time.format.DateTimeFormatter
import java.util.UUID import java.util.UUID
@WebMvcTest(ProductController::class) @WebMvcTest(ProductController::class)
class ProductControllerTest: BaseUnitTest() { @AutoConfigureMockMvc
@ContextConfiguration(
classes = [
WebTestConfig::class,
ProductController::class,
ExceptionHandler::class,
]
)
class ProductControllerTest {
@Autowired @Autowired
private lateinit var mockMvc: MockMvc private lateinit var mockMvc: MockMvc
@@ -77,7 +88,7 @@ class ProductControllerTest: BaseUnitTest() {
mockMvc.get("/api/product/$guid") mockMvc.get("/api/product/$guid")
.andExpect { status { isNotFound() } } .andExpect { status { isNotFound() } }
.andExpect { content { contentType(MediaType.APPLICATION_JSON) } } .andExpect { content { contentType(MediaType.APPLICATION_JSON) } }
.andExpect { jsonPath("\$.status") { value(ResponseStatus.NOT_FOUND.status) } } .andExpect { jsonPath("\$.status") { value(ResponseStatusModel.NOT_FOUND.status) } }
verify(productService, times(1)).findByGuid(guid) verify(productService, times(1)).findByGuid(guid)
} }
@@ -132,7 +143,7 @@ class ProductControllerTest: BaseUnitTest() {
} }
.andExpect { status { isBadRequest() } } .andExpect { status { isBadRequest() } }
.andExpect { content { contentType(MediaType.APPLICATION_JSON) } } .andExpect { content { contentType(MediaType.APPLICATION_JSON) } }
.andExpect { jsonPath("\$.status") { value(ResponseStatus.BAD_REQUEST.status) } } .andExpect { jsonPath("\$.status") { value(ResponseStatusModel.BAD_REQUEST.status) } }
.andExpect { jsonPath("\$.cause") { contains("name") } } .andExpect { jsonPath("\$.cause") { contains("name") } }
verifyNoInteractions(productService) verifyNoInteractions(productService)
@@ -148,7 +159,7 @@ class ProductControllerTest: BaseUnitTest() {
} }
.andExpect { status { isUnprocessableEntity() } } .andExpect { status { isUnprocessableEntity() } }
.andExpect { content { contentType(MediaType.APPLICATION_JSON) } } .andExpect { content { contentType(MediaType.APPLICATION_JSON) } }
.andExpect { jsonPath("\$.status") { value(ResponseStatus.UNPROCESSABLE.status) } } .andExpect { jsonPath("\$.status") { value(ResponseStatusModel.UNPROCESSABLE.status) } }
.andExpect { jsonPath("\$.cause") { value(MethodArgumentNotValidException::class.qualifiedName) } } .andExpect { jsonPath("\$.cause") { value(MethodArgumentNotValidException::class.qualifiedName) } }
verifyNoInteractions(productService) verifyNoInteractions(productService)
@@ -163,7 +174,7 @@ class ProductControllerTest: BaseUnitTest() {
mockMvc.delete("/api/product/${guid}") mockMvc.delete("/api/product/${guid}")
.andExpect { status { isOk() } } .andExpect { status { isOk() } }
.andExpect { content { contentType(MediaType.APPLICATION_JSON) } } .andExpect { content { contentType(MediaType.APPLICATION_JSON) } }
.andExpect { jsonPath("\$.status") { value(ResponseStatus.OK.status) } } .andExpect { jsonPath("\$.status") { value(ResponseStatusModel.OK.status) } }
verify(productService, times(1)).delete(guid) verify(productService, times(1)).delete(guid)
} }

View File

@@ -7,3 +7,5 @@ include("core")
include("edge-consuming") include("edge-consuming")
include("edge-producing") include("edge-producing")
include("edge-contracts") include("edge-contracts")
include("edge-rest")
include("edge-integration")

View File

@@ -3,16 +3,12 @@ package com.github.dannecron.demo.config
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.cio.CIO
import io.micrometer.observation.ObservationRegistry import io.micrometer.observation.ObservationRegistry
import io.micrometer.observation.aop.ObservedAspect import io.micrometer.observation.aop.ObservedAspect
import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import com.github.dannecron.demo.services.neko.Client as NekoClient
import com.github.dannecron.demo.services.neko.ClientImpl as NekoClientImpl
@Configuration @Configuration
class AppConfig { class AppConfig {
@@ -30,17 +26,5 @@ class AppConfig {
@Bean @Bean
fun observedAspect(observationRegistry: ObservationRegistry) = ObservedAspect(observationRegistry) fun observedAspect(observationRegistry: ObservationRegistry) = ObservedAspect(observationRegistry)
@Bean
fun httpClientEngine(): HttpClientEngine = CIO.create()
@Bean
fun nekoClient(
httpClientEngine: HttpClientEngine,
@Value("\${neko.baseUrl}") baseUrl: String,
): NekoClient = NekoClientImpl(
engine = httpClientEngine,
baseUrl = baseUrl,
)
} }

View File

@@ -1,30 +0,0 @@
package com.github.dannecron.demo.http.controllers
import com.github.dannecron.demo.core.services.customer.CustomerService
import com.github.dannecron.demo.http.exceptions.NotFoundException
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
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.RestController
import java.util.*
@RestController
@RequestMapping(value = ["/api/customer"], produces = [MediaType.APPLICATION_JSON_VALUE])
class CustomerController(
@Autowired
private val customerService: CustomerService,
) {
@GetMapping("/{guid}")
@Throws(NotFoundException::class)
fun getCustomer(
@PathVariable guid: UUID,
): ResponseEntity<Any> {
val customer = customerService.findByGuid(guid) ?: throw NotFoundException()
return ResponseEntity(customer, HttpStatus.OK)
}
}

View File

@@ -1,20 +0,0 @@
package com.github.dannecron.demo.http.controllers
import com.github.dannecron.demo.providers.html.renderProductTable
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.bind.annotation.RestController
@RestController
class GreetingController {
@GetMapping("/greeting")
fun greet(): String {
return "Hello World!"
}
@GetMapping(value = ["/example/html"], produces = ["text/html"])
@ResponseBody
fun exampleHtml(): String {
return renderProductTable()
}
}

View File

@@ -1,26 +0,0 @@
package com.github.dannecron.demo.http.controllers
import com.github.dannecron.demo.http.responses.neko.ImagesResponse
import com.github.dannecron.demo.services.neko.Client
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping(value = ["/api/neko"], produces = [MediaType.APPLICATION_JSON_VALUE])
class NekoController(
private val nekoClient: Client,
) {
@GetMapping("/categories")
fun categories(): ResponseEntity<Any> = ResponseEntity(nekoClient.getCategories(), HttpStatus.OK)
@GetMapping("/images/{category}")
fun images(
@PathVariable category: String,
@RequestParam imagesCount: Int = 1,
): ResponseEntity<Any> = ResponseEntity(
ImagesResponse(baseImages = nekoClient.getImages(category = category, amount = imagesCount)),
HttpStatus.OK,
)
}

View File

@@ -1,110 +0,0 @@
package com.github.dannecron.demo.http.controllers
import com.github.dannecron.demo.core.dto.Product
import com.github.dannecron.demo.core.exceptions.AlreadyDeletedException
import com.github.dannecron.demo.core.exceptions.InvalidDataException
import com.github.dannecron.demo.core.exceptions.ProductNotFoundException
import com.github.dannecron.demo.core.services.product.ProductService
import com.github.dannecron.demo.http.exceptions.NotFoundException
import com.github.dannecron.demo.http.exceptions.UnprocessableException
import com.github.dannecron.demo.http.requests.CreateProductRequest
import com.github.dannecron.demo.http.responses.NotFoundResponse
import com.github.dannecron.demo.http.responses.makeOkResponse
import com.github.dannecron.demo.http.responses.page.PageResponse
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
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import java.util.*
@RestController
@RequestMapping(value = ["/api/product"], produces = [MediaType.APPLICATION_JSON_VALUE])
class ProductController(
private val productService: ProductService,
) {
@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<Any> {
val product = productService.findByGuid(guid = guid) ?: throw NotFoundException()
return ResponseEntity(product, HttpStatus.OK)
}
@GetMapping("")
@ApiResponses(value = [
ApiResponse(responseCode = "200", content = [
Content(mediaType = "application/json", schema = Schema(implementation = PageResponse::class)),
]),
])
fun getProducts(
@ParameterObject pageable: Pageable,
): ResponseEntity<Any> {
val products = productService.findAll(pageable)
return ResponseEntity(
PageResponse(products),
HttpStatus.OK,
)
}
@PostMapping("/{guid}/sync")
@Throws(NotFoundException::class)
fun syncProductToKafka(
@PathVariable guid: UUID,
@RequestParam(required = false) topic: String?
): ResponseEntity<Any> {
try {
productService.send(guid, topic)
} catch (_: InvalidDataException) {
throw UnprocessableException("cannot sync product to kafka")
}
return ResponseEntity(makeOkResponse(), HttpStatus.OK)
}
@PostMapping(value = [""], consumes = [MediaType.APPLICATION_JSON_VALUE])
fun createProduct(
@Valid @RequestBody product: CreateProductRequest,
): ResponseEntity<Any> {
val saved = productService.create(
product.name,
product.price,
product.description,
)
return ResponseEntity(saved, HttpStatus.CREATED)
}
@DeleteMapping("/{guid}")
@Throws(NotFoundException::class, UnprocessableException::class)
fun deleteProduct(
@PathVariable guid: UUID,
): ResponseEntity<Any> {
try {
productService.delete(guid)
} catch (_: ProductNotFoundException) {
throw NotFoundException()
} catch (_: AlreadyDeletedException) {
throw UnprocessableException("product already deleted")
}
return ResponseEntity(makeOkResponse(), HttpStatus.OK)
}
}

View File

@@ -1,3 +0,0 @@
package com.github.dannecron.demo.http.exceptions
class NotFoundException: RuntimeException()

View File

@@ -1,3 +0,0 @@
package com.github.dannecron.demo.http.exceptions
class UnprocessableException(override val message: String): RuntimeException(message)

View File

@@ -1,5 +0,0 @@
package com.github.dannecron.demo.http.responses
data class BadRequestResponse(
val cause: String,
): BaseResponse(status = ResponseStatus.BAD_REQUEST)

View File

@@ -1,5 +0,0 @@
package com.github.dannecron.demo.http.responses
open class BaseResponse(val status: ResponseStatus)
fun makeOkResponse(): BaseResponse = BaseResponse(status = ResponseStatus.OK)

View File

@@ -1,3 +0,0 @@
package com.github.dannecron.demo.http.responses
class NotFoundResponse: BaseResponse(ResponseStatus.NOT_FOUND)

View File

@@ -1,10 +0,0 @@
package com.github.dannecron.demo.http.responses
import org.springframework.validation.ObjectError
class UnprocessableResponse(
val cause: String,
val errors: List<ObjectError>?
): BaseResponse(status = ResponseStatus.UNPROCESSABLE) {
constructor(cause: String): this(cause, null)
}

View File

@@ -1,15 +0,0 @@
package com.github.dannecron.demo.http.responses.neko
import kotlinx.serialization.Serializable
import com.github.dannecron.demo.services.neko.dto.ImagesResponse as BaseResponse
@Serializable
data class ImagesResponse(
val images: List<Image>,
) {
constructor(baseImages: BaseResponse): this(
images = baseImages.results.map {
Image(it.url, it.animeName, it.artistHref, it.artistName, it.sourceUrl)
}
)
}

View File

@@ -1,8 +0,0 @@
package com.github.dannecron.demo
import org.springframework.boot.test.context.TestConfiguration
open class BaseUnitTest {
@TestConfiguration
class TestConfig
}

View File

@@ -1,53 +0,0 @@
package com.github.dannecron.demo.http.controllers
import com.github.dannecron.demo.BaseUnitTest
import com.github.dannecron.demo.services.neko.Client
import com.github.dannecron.demo.services.neko.dto.Image
import com.github.dannecron.demo.services.neko.dto.ImagesResponse
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.whenever
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import kotlin.test.Test
@WebMvcTest(NekoController::class)
class NekoControllerTest(
@Autowired val mockMvc: MockMvc,
): BaseUnitTest() {
@MockBean
private lateinit var nekoClient: Client
@Test
fun categories_success() {
whenever(nekoClient.getCategories()) doReturn setOf("cat1", "cat2")
mockMvc.get("/api/neko/categories")
.andExpect { status { isOk() } }
.andExpect { content { contentType(MediaType.APPLICATION_JSON) } }
.andExpect { content { string("""["cat1","cat2"]""") } }
}
@Test
fun images_success() {
val category = "some"
val animeName = "boku no pico"
whenever(nekoClient.getImages(category = category, amount = 1)) doReturn ImagesResponse(
results = listOf(
Image(
"http://localhost",
animeName = animeName,
)
)
)
mockMvc.get("/api/neko/images/$category")
.andExpect { status { isOk() } }
.andExpect { content { contentType(MediaType.APPLICATION_JSON) } }
.andExpect { jsonPath("\$.images[0].animeName") { value(animeName) } }
.andExpect { jsonPath("\$.images[0].artistName") { value(null) } }
}
}