edge-contracts: add rest-api contracts

This commit is contained in:
Savosin Denis
2025-06-03 15:27:29 +07:00
parent 76e4af62ae
commit 7e64c57a5a
24 changed files with 276 additions and 11 deletions

View File

@@ -1,4 +1,8 @@
dependencies {
implementation(rootProject.libs.spring.cloud.stream)
implementation(rootProject.libs.json.schema.validator)
implementation(rootProject.libs.springBoot.starter.web)
implementation(rootProject.libs.springData.commons)
implementation(rootProject.libs.springDoc.openapi.starter)
implementation(rootProject.libs.springCloud.stream)
}

View File

@@ -0,0 +1,18 @@
package com.github.dannecron.demo.edgecontracts.api
import com.github.dannecron.demo.edgecontracts.api.exceptions.NotFoundException
import com.github.dannecron.demo.edgecontracts.api.response.GetCustomerResponse
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 java.util.UUID
@RequestMapping(value = ["/api/customer"], produces = [MediaType.APPLICATION_JSON_VALUE])
interface CustomerApi {
@GetMapping("/{guid}")
@Throws(NotFoundException::class)
fun getCustomer(@PathVariable guid: UUID): ResponseEntity<GetCustomerResponse>
}

View File

@@ -0,0 +1,12 @@
package com.github.dannecron.demo.edgecontracts.api
import org.springframework.web.bind.annotation.GetMapping
interface GreetingApi {
@GetMapping("/greeting")
fun greet(): String
@GetMapping(value = ["/example/html"], produces = ["text/html"])
fun exampleHtml(): String
}

View File

@@ -0,0 +1,22 @@
package com.github.dannecron.demo.edgecontracts.api
import com.github.dannecron.demo.edgecontracts.api.response.GetNekoImagesResponse
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.RequestParam
@RequestMapping(value = ["/api/neko"], produces = [MediaType.APPLICATION_JSON_VALUE])
interface NekoApi {
@GetMapping("/categories")
fun categories(): ResponseEntity<Set<String>>
@GetMapping("/images/{category}")
fun images(
@PathVariable category: String,
@RequestParam imagesCount: Int = 1,
): ResponseEntity<GetNekoImagesResponse>
}

View File

@@ -0,0 +1,72 @@
package com.github.dannecron.demo.edgecontracts.api
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.NotFoundResponse
import com.github.dannecron.demo.edgecontracts.api.response.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.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import java.util.UUID
@RequestMapping(value = ["/api/product"], produces = [MediaType.APPLICATION_JSON_VALUE])
interface ProductApi {
@GetMapping("")
@ApiResponses(value = [
ApiResponse(responseCode = "200", content = [
Content(mediaType = "application/json", schema = Schema(implementation = PageResponse::class)),
]),
])
fun getProducts(
@ParameterObject pageable: Pageable,
): ResponseEntity<PageResponse<ProductApiModel>>
@GetMapping("/{guid}")
@ApiResponses(value = [
ApiResponse(responseCode = "200", content = [
Content(mediaType = "application/json", schema = Schema(implementation = ProductApiModel::class)),
]),
ApiResponse(responseCode = "404", content = [
Content(mediaType = "application/json", schema = Schema(implementation = NotFoundResponse::class))
])
])
@Throws(NotFoundException::class)
fun getProduct(
@PathVariable guid: UUID,
): ResponseEntity<ProductApiModel>
@PostMapping(value = [""], consumes = [MediaType.APPLICATION_JSON_VALUE])
fun createProduct(
@Valid @RequestBody product: CreateProductRequest,
): ResponseEntity<ProductApiModel>
@DeleteMapping("/{guid}")
@Throws(NotFoundException::class, UnprocessableException::class)
fun deleteProduct(
@PathVariable guid: UUID,
): ResponseEntity<BaseResponse>
@PostMapping("/{guid}/send")
@Throws(NotFoundException::class)
fun sendProduct(
@PathVariable guid: UUID,
@RequestParam(required = false) topic: String?
): ResponseEntity<BaseResponse>
}

View File

@@ -0,0 +1,6 @@
package com.github.dannecron.demo.edgecontracts.api.exceptions
open class ApiException(
override val message: String,
override val cause: Throwable?,
) : RuntimeException(message, cause)

View File

@@ -0,0 +1,8 @@
package com.github.dannecron.demo.edgecontracts.api.exceptions
class NotFoundException(
override val cause: Throwable?,
): ApiException(
message = "Not found",
cause = cause,
)

View File

@@ -0,0 +1,6 @@
package com.github.dannecron.demo.edgecontracts.api.exceptions
class UnprocessableException(
override val message: String,
override val cause: Throwable? = null,
): RuntimeException(message, cause)

View File

@@ -0,0 +1,21 @@
package com.github.dannecron.demo.edgecontracts.api.model
import com.github.dannecron.demo.db.serialialization.OffsetDateTimeSerialization
import com.github.dannecron.demo.db.serialialization.UuidSerialization
import kotlinx.serialization.Serializable
import java.time.OffsetDateTime
import java.util.UUID
@Serializable
data class CityApiModel(
val id: Long,
@Serializable(with = UuidSerialization::class)
val guid: UUID,
val name: String,
@Serializable(with = OffsetDateTimeSerialization::class)
val createdAt: OffsetDateTime,
@Serializable(with = OffsetDateTimeSerialization::class)
val updatedAt: OffsetDateTime?,
@Serializable(with = OffsetDateTimeSerialization::class)
val deletedAt: OffsetDateTime?,
)

View File

@@ -0,0 +1,20 @@
package com.github.dannecron.demo.edgecontracts.api.model
import com.github.dannecron.demo.db.serialialization.OffsetDateTimeSerialization
import com.github.dannecron.demo.db.serialialization.UuidSerialization
import kotlinx.serialization.Serializable
import java.time.OffsetDateTime
import java.util.UUID
@Serializable
data class CustomerApiModel(
val id: Long,
@Serializable(with = UuidSerialization::class)
val guid: UUID,
val name: String,
val cityId: Long?,
@Serializable(with = OffsetDateTimeSerialization::class)
val createdAt: OffsetDateTime,
@Serializable(with = OffsetDateTimeSerialization::class)
val updatedAt: OffsetDateTime?,
)

View File

@@ -1,14 +1,13 @@
package com.github.dannecron.demo.http.responses.neko
package com.github.dannecron.demo.edgecontracts.api.model
import kotlinx.serialization.Serializable
@Serializable
data class Image(
data class NekoImageApiModel(
val url: String,
val animeName: String?,
val artistHref: String?,
val artistName: String?,
val sourceUrl: String?,
) {
fun isGif() = animeName != null
}
val isGif: Boolean,
)

View File

@@ -0,0 +1,25 @@
package com.github.dannecron.demo.edgecontracts.api.model
import com.github.dannecron.demo.db.serialialization.OffsetDateTimeSerialization
import com.github.dannecron.demo.db.serialialization.UuidSerialization
import kotlinx.serialization.Serializable
import java.time.OffsetDateTime
import java.util.UUID
@Serializable
data class ProductApiModel(
val id: Long,
@Serializable(with = UuidSerialization::class)
val guid: UUID,
val name: String,
val description: String?,
val price: Long,
@Serializable(with = OffsetDateTimeSerialization::class)
val createdAt: OffsetDateTime,
@Serializable(with = OffsetDateTimeSerialization::class)
val updatedAt: OffsetDateTime?,
@Serializable(with = OffsetDateTimeSerialization::class)
val deletedAt: OffsetDateTime?,
val priceDouble: Double,
val isDeleted: Boolean,
)

View File

@@ -1,8 +1,8 @@
package com.github.dannecron.demo.http.responses
package com.github.dannecron.demo.edgecontracts.api.model
import com.fasterxml.jackson.annotation.JsonValue
enum class ResponseStatus(@JsonValue val status: String) {
enum class ResponseStatusModel(@JsonValue val status: String) {
OK("ok"),
NOT_FOUND("not found"),
BAD_REQUEST("bad request"),

View File

@@ -1,8 +1,10 @@
package com.github.dannecron.demo.http.requests
package com.github.dannecron.demo.edgecontracts.api.request
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import kotlinx.serialization.Serializable
@Serializable
data class CreateProductRequest(
@field:NotBlank(message = "name value required")
val name: String,

View File

@@ -0,0 +1,11 @@
package com.github.dannecron.demo.edgecontracts.api.response
import com.github.dannecron.demo.edgecontracts.api.model.CityApiModel
import com.github.dannecron.demo.edgecontracts.api.model.CustomerApiModel
import kotlinx.serialization.Serializable
@Serializable
data class GetCustomerResponse(
val customer: CustomerApiModel,
val city: CityApiModel?,
)

View File

@@ -0,0 +1,9 @@
package com.github.dannecron.demo.edgecontracts.api.response
import com.github.dannecron.demo.edgecontracts.api.model.NekoImageApiModel
import kotlinx.serialization.Serializable
@Serializable
data class GetNekoImagesResponse(
val images: List<NekoImageApiModel>,
)

View File

@@ -0,0 +1,7 @@
package com.github.dannecron.demo.edgecontracts.api.response.common
import com.github.dannecron.demo.edgecontracts.api.model.ResponseStatusModel
data class BadRequestResponse(
val cause: String,
): BaseResponse(status = ResponseStatusModel.BAD_REQUEST)

View File

@@ -0,0 +1,7 @@
package com.github.dannecron.demo.edgecontracts.api.response.common
import com.github.dannecron.demo.edgecontracts.api.model.ResponseStatusModel
open class BaseResponse(val status: ResponseStatusModel)
fun makeOkResponse(): BaseResponse = BaseResponse(status = ResponseStatusModel.OK)

View File

@@ -0,0 +1,5 @@
package com.github.dannecron.demo.edgecontracts.api.response.common
import com.github.dannecron.demo.edgecontracts.api.model.ResponseStatusModel
class NotFoundResponse: BaseResponse(ResponseStatusModel.NOT_FOUND)

View File

@@ -0,0 +1,11 @@
package com.github.dannecron.demo.edgecontracts.api.response.common
import com.github.dannecron.demo.edgecontracts.api.model.ResponseStatusModel
import org.springframework.validation.ObjectError
class UnprocessableResponse(
val cause: String,
val errors: List<ObjectError>?
): BaseResponse(status = ResponseStatusModel.UNPROCESSABLE) {
constructor(cause: String): this(cause, null)
}

View File

@@ -1,4 +1,4 @@
package com.github.dannecron.demo.http.responses.page
package com.github.dannecron.demo.edgecontracts.api.response.page
import kotlinx.serialization.Serializable

View File

@@ -1,4 +1,4 @@
package com.github.dannecron.demo.http.responses.page
package com.github.dannecron.demo.edgecontracts.api.response.page
import kotlinx.serialization.Serializable
import org.springframework.data.domain.Page