Skip to content

Web & REST Utilities

Zygarde provides utilities for building robust REST APIs with consistent error handling, validation, and response standardization.

REST API Generation

Three-Layer Architecture

Zygarde generates three layers for REST APIs:

  1. API Interface: Contract definition with Spring annotations
  2. Controller Implementation: HTTP endpoint routing
  3. Service Interface: Business logic contract

This separation provides: - Clear contracts - Easy testing (mock services) - Consistent API structure

API Interface

Define your REST API contract:

import org.springframework.web.bind.annotation.*

@RequestMapping("/api/books")
interface BookApi {

  @GetMapping
  fun getAllBooks(): List<BookDto>

  @GetMapping("/{id}")
  fun getBookById(@PathVariable id: Long): BookDto

  @PostMapping
  fun createBook(@Valid @RequestBody request: CreateBookReq): BookDto

  @PutMapping("/{id}")
  fun updateBook(
    @PathVariable id: Long,
    @Valid @RequestBody request: UpdateBookReq
  ): BookDto

  @DeleteMapping("/{id}")
  fun deleteBook(@PathVariable id: Long)
}

Controller Implementation

Implement the API interface:

@RestController
class BookController(
  private val bookService: BookService
) : BookApi {

  override fun getAllBooks() = bookService.getAllBooks()

  override fun getBookById(id: Long) = bookService.getBookById(id)

  override fun createBook(request: CreateBookReq) = bookService.createBook(request)

  override fun updateBook(id: Long, request: UpdateBookReq) =
    bookService.updateBook(id, request)

  override fun deleteBook(id: Long) = bookService.deleteBook(id)
}

Service Interface

Define business logic contract:

interface BookService {
  fun getAllBooks(): List<BookDto>
  fun getBookById(id: Long): BookDto
  fun createBook(request: CreateBookReq): BookDto
  fun updateBook(id: Long, request: UpdateBookReq): BookDto
  fun deleteBook(id: Long)
}

Service Implementation

@Service
class BookServiceImpl(private val dao: Dao) : BookService {

  @Transactional(readOnly = true)
  override fun getAllBooks() = dao.book.findAll().map { it.toBookDto() }

  @Transactional(readOnly = true)
  override fun getBookById(id: Long): BookDto {
    return dao.book.findById(id)
      .orElseThrow { NoSuchElementException("Book not found: $id") }
      .toBookDto()
  }

  @Transactional
  override fun createBook(request: CreateBookReq): BookDto {
    val book = Book().applyFrom(request)
    return dao.book.save(book).toBookDto()
  }

  @Transactional
  override fun updateBook(id: Long, request: UpdateBookReq): BookDto {
    val book = dao.book.findById(id)
      .orElseThrow { NoSuchElementException("Book not found: $id") }

    val updated = book.applyFrom(request)
    return dao.book.save(updated).toBookDto()
  }

  @Transactional
  override fun deleteBook(id: Long) {
    dao.book.deleteById(id)
  }
}

Exception Handling

Business Exception

Define domain-specific exceptions:

import zygarde.core.exception.BusinessException

class BookNotFoundException(id: Long) :
  BusinessException("Book not found: $id", code = "BOOK_NOT_FOUND")

class InvalidISBNException(isbn: String) :
  BusinessException("Invalid ISBN: $isbn", code = "INVALID_ISBN")

Global Exception Handler

Zygarde provides ApiExceptionHandler for consistent error responses:

import zygarde.webmvc.exception.ApiExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice

@RestControllerAdvice
class GlobalExceptionHandler : ApiExceptionHandler() {

  // BusinessException handling is automatic

  // Add custom exception mappings
  override fun getExceptionMappers(): List<ExceptionToBusinessExceptionMapper<*>> {
    return listOf(
      IllegalArgumentExceptionMapper(),
      NoSuchElementExceptionMapper()
    )
  }
}

Custom Exception Mappers

Map application exceptions to BusinessException:

import zygarde.core.exception.ExceptionToBusinessExceptionMapper
import zygarde.core.exception.BusinessException

class NoSuchElementExceptionMapper :
  ExceptionToBusinessExceptionMapper<NoSuchElementException> {

  override val exceptionClass = NoSuchElementException::class.java

  override fun map(exception: NoSuchElementException) = BusinessException(
    message = exception.message ?: "Resource not found",
    code = "NOT_FOUND"
  )
}

class IllegalArgumentExceptionMapper :
  ExceptionToBusinessExceptionMapper<IllegalArgumentException> {

  override val exceptionClass = IllegalArgumentException::class.java

  override fun map(exception: IllegalArgumentException) = BusinessException(
    message = exception.message ?: "Invalid argument",
    code = "INVALID_ARGUMENT"
  )
}

Error Response Format

Standard error response structure:

data class ApiErrorResponse(
  val code: String,
  val message: String,
  val timestamp: Long = System.currentTimeMillis(),
  val path: String? = null,
  val details: Map<String, Any>? = null
)

Example response:

{
  "code": "BOOK_NOT_FOUND",
  "message": "Book not found: 123",
  "timestamp": 1704067200000,
  "path": "/api/books/123"
}

Validation

Bean Validation

Use standard Java validation annotations:

import javax.validation.constraints.*

data class CreateBookReq(
  @field:NotBlank(message = "Title is required")
  @field:Size(min = 1, max = 200)
  val title: String,

  @field:Size(max = 1000)
  val description: String?,

  @field:NotNull
  @field:DecimalMin("0.01")
  @field:DecimalMax("9999.99")
  val price: Double,

  @field:Min(1000)
  @field:Max(9999)
  val publishedYear: Int
)

Validation Error Handling

Validation errors are automatically handled:

{
  "code": "VALIDATION_ERROR",
  "message": "Validation failed",
  "timestamp": 1704067200000,
  "path": "/api/books",
  "details": {
    "title": "Title is required",
    "price": "must be greater than or equal to 0.01"
  }
}

Custom Validators

Create custom validation logic:

import javax.validation.Constraint
import javax.validation.ConstraintValidator
import javax.validation.ConstraintValidatorContext
import kotlin.reflect.KClass

@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [ISBNValidator::class])
annotation class ISBN(
  val message: String = "Invalid ISBN format",
  val groups: Array<KClass<*>> = [],
  val payload: Array<KClass<out Any>> = []
)

class ISBNValidator : ConstraintValidator<ISBN, String> {
  override fun isValid(value: String?, context: ConstraintValidatorContext): Boolean {
    if (value == null) return true
    return value.matches(Regex("^(978|979)-\\d{1,5}-\\d{1,7}-\\d{1,7}-\\d$"))
  }
}

// Usage
data class CreateBookReq(
  @field:ISBN
  val isbn: String,
  // ... other fields
)

Response Standardization

Pagination Response

Use PageDto for paginated responses:

import zygarde.core.dto.PageDto

@GetMapping
fun getAllBooks(
  @RequestParam(defaultValue = "0") page: Int,
  @RequestParam(defaultValue = "20") size: Int
): PageDto<BookDto> {
  val pageable = PageRequest.of(page, size)
  val bookPage = dao.book.findAll(pageable)

  return PageDto(
    atPage = bookPage.number,
    totalPages = bookPage.totalPages,
    totalCount = bookPage.totalElements,
    items = bookPage.content.map { it.toBookDto() }
  )
}

Response format:

{
  "atPage": 0,
  "totalPages": 5,
  "totalCount": 100,
  "items": [
    {"id": 1, "title": "Book 1", ...},
    {"id": 2, "title": "Book 2", ...}
  ]
}

Success Response Wrapper

Optional success response wrapper:

data class ApiResponse<T>(
  val success: Boolean = true,
  val data: T,
  val timestamp: Long = System.currentTimeMillis()
)

@GetMapping("/{id}")
fun getBookById(@PathVariable id: Long): ApiResponse<BookDto> {
  val book = bookService.getBookById(id)
  return ApiResponse(data = book)
}

Search and Filtering

Query Parameters

@GetMapping("/search")
fun searchBooks(
  @RequestParam(required = false) title: String?,
  @RequestParam(required = false) authorId: Long?,
  @RequestParam(required = false) minPrice: Double?,
  @RequestParam(required = false) maxPrice: Double?,
  @RequestParam(required = false) status: Status?,
  @RequestParam(defaultValue = "0") page: Int,
  @RequestParam(defaultValue = "20") size: Int
): PageDto<BookDto> {
  return bookService.searchBooks(
    title = title,
    authorId = authorId,
    minPrice = minPrice,
    maxPrice = maxPrice,
    status = status,
    pageable = PageRequest.of(page, size)
  )
}

Search Request DTO

Encapsulate search parameters:

import zygarde.core.dto.KeywordPagingAndSortingRequest

data class BookSearchReq(
  val title: String? = null,
  val authorId: Long? = null,
  val minPrice: Double? = null,
  val maxPrice: Double? = null,
  val status: Status? = null
) : KeywordPagingAndSortingRequest()

@PostMapping("/search")
fun searchBooks(@RequestBody request: BookSearchReq): PageDto<BookDto> {
  return bookService.searchBooks(request)
}

CORS Configuration

Global CORS

import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer

@Configuration
class CorsConfig : WebMvcConfigurer {

  override fun addCorsMappings(registry: CorsRegistry) {
    registry.addMapping("/api/**")
      .allowedOrigins("http://localhost:3000", "https://myapp.com")
      .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
      .allowedHeaders("*")
      .allowCredentials(true)
      .maxAge(3600)
  }
}

Controller-Level CORS

@RestController
@RequestMapping("/api/books")
@CrossOrigin(origins = ["http://localhost:3000"])
class BookController(private val bookService: BookService) : BookApi {
  // ...
}

Request Tracing

API Tracing Context

Track requests with ApiTracingContext:

import zygarde.webmvc.tracing.ApiTracingContext
import org.slf4j.LoggerFactory

@Service
class BookService(private val dao: Dao) {

  private val logger = LoggerFactory.getLogger(javaClass)

  fun getBookById(id: Long): BookDto {
    val traceId = ApiTracingContext.getTraceId()
    logger.info("[$traceId] Fetching book: $id")

    return dao.book.findById(id)
      .orElseThrow { NoSuchElementException("Book not found: $id") }
      .toBookDto()
  }
}

Request Interceptor

Add trace ID to responses:

import org.springframework.web.servlet.HandlerInterceptor
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

class TracingInterceptor : HandlerInterceptor {

  override fun preHandle(
    request: HttpServletRequest,
    response: HttpServletResponse,
    handler: Any
  ): Boolean {
    val traceId = UUID.randomUUID().toString()
    ApiTracingContext.setTraceId(traceId)
    response.addHeader("X-Trace-Id", traceId)
    return true
  }

  override fun afterCompletion(
    request: HttpServletRequest,
    response: HttpServletResponse,
    handler: Any,
    ex: Exception?
  ) {
    ApiTracingContext.clear()
  }
}

Testing REST APIs

Controller Tests

import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.test.web.servlet.*
import com.ninjasquad.springmockk.MockkBean
import io.mockk.every

@WebMvcTest(BookController::class)
class BookControllerTest {

  @Autowired
  lateinit var mockMvc: MockMvc

  @MockkBean
  lateinit var bookService: BookService

  @Test
  fun `should get book by id`() {
    // given
    val bookDto = BookDto(id = 1L, title = "Test Book", price = 29.99)
    every { bookService.getBookById(1L) } returns bookDto

    // when & then
    mockMvc.get("/api/books/1")
      .andExpect {
        status { isOk() }
        content { contentType(MediaType.APPLICATION_JSON) }
        jsonPath("$.id") { value(1) }
        jsonPath("$.title") { value("Test Book") }
      }
  }

  @Test
  fun `should create book`() {
    // given
    val request = CreateBookReq(title = "New Book", price = 39.99, publishedYear = 2024)
    val bookDto = BookDto(id = 1L, title = "New Book", price = 39.99)
    every { bookService.createBook(request) } returns bookDto

    // when & then
    mockMvc.post("/api/books") {
      contentType = MediaType.APPLICATION_JSON
      content = objectMapper.writeValueAsString(request)
    }.andExpect {
      status { isOk() }
      jsonPath("$.id") { value(1) }
      jsonPath("$.title") { value("New Book") }
    }
  }
}

Integration Tests

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class BookApiIntegrationTest {

  @Autowired
  lateinit var restTemplate: TestRestTemplate

  @Test
  fun `should create and retrieve book`() {
    // given
    val request = CreateBookReq(
      title = "Integration Test Book",
      price = 49.99,
      publishedYear = 2024
    )

    // when - create
    val createResponse = restTemplate.postForEntity(
      "/api/books",
      request,
      BookDto::class.java
    )

    // then - verify creation
    createResponse.statusCode shouldBe HttpStatus.OK
    val created = createResponse.body!!
    created.title shouldBe "Integration Test Book"

    // when - retrieve
    val getResponse = restTemplate.getForEntity(
      "/api/books/${created.id}",
      BookDto::class.java
    )

    // then - verify retrieval
    getResponse.statusCode shouldBe HttpStatus.OK
    getResponse.body!! shouldBe created
  }
}

Using Zygarde with REST APIs

1. Use Generated DTOs from Zygarde

Always use Zygarde-generated DTOs at API boundaries:

// ✅ Use Zygarde-generated DTOs
@GetMapping("/{id}")
fun getBook(@PathVariable id: Long): BookDto {
  return dao.book.findById(id)
    .map { it.toBookDto() }  // Generated by Zygarde
    .orElseThrow { NotFoundException() }
}

// ❌ Avoid exposing entities directly
@GetMapping("/{id}")
fun getBook(@PathVariable id: Long): Book

2. Combine Zygarde Search with DTOs

Use search DSL with generated mapping:

@GetMapping("/search")
fun searchBooks(@RequestParam keyword: String): List<BookDto> {
  return dao.book.search {
    title() containsIgnoreCase keyword
  }.map { it.toBookDto() }
}

3. Use Zygarde-Generated API Layer (Optional)

If using Zygarde's Web DSL code generation:

// Define API in DSL
api("BookApi") {
  basePath = "/api/books"
  endpoints = listOf(
    endpoint {
      name = "searchBooks"
      method = GET
      path = "/search"
      params = listOf("keyword" to "String")
      returns = "List<BookDto>"
    }
  )
}

// Zygarde generates controller, service interface, and wiring

Next Steps