Skip to content

OpenAPI Documentation with springdoc-openapi

Add comprehensive OpenAPI/Swagger documentation to a Spring Boot Task API using springdoc-openapi. You annotate the controller and DTOs once, and springdoc generates a live, interactive Swagger UI plus a machine-readable OpenAPI JSON spec from your method signatures — no separate doc file to keep in sync.

If you’ve used swagger-jsdoc in Express or swaggo/swag in Go, this is the same idea, but the annotations live right on the Kotlin types and Spring MVC already knows your routes, status codes, and request/response shapes.

A small REST API for managing tasks, fully documented:

MethodPathDescription
GET/api/v1/tasksList tasks (paginated, filterable)
GET/api/v1/tasks/{id}Get task by ID
POST/api/v1/tasksCreate a task
PATCH/api/v1/tasks/{id}Update a task
DELETE/api/v1/tasks/{id}Delete a task

By the end you’ll have:

  • A Swagger UI page that lists every endpoint with descriptions and examples.
  • An OpenAPI 3 JSON spec served at /api-docs (usable for client generation).
  • A documented JWT “Bearer Auth” security scheme with an Authorize button.

A standard Spring Boot layout. The interesting work is concentrated in three files: the springdoc config bean, the annotated controller, and the annotated DTOs.

  • Directoryopenapi-docs/
    • build.gradle.kts deps incl. springdoc starter
    • settings.gradle.kts project name
    • Directorysrc/main/
      • Directorykotlin/com/example/openapi/
        • OpenApiApplication.kt Spring Boot entry point
        • config/OpenApiConfig.kt API metadata + JWT scheme
        • controller/TaskController.kt annotated endpoints
        • model/Dtos.kt annotated request/response DTOs
        • service/TaskService.kt in-memory store
      • resources/application.yml springdoc paths
    • Directorysrc/test/kotlin/com/example/openapi/
      • TaskApiTest.kt MockMvc tests incl. the spec

springdoc ships as one starter. For a Spring MVC (servlet) app, that’s springdoc-openapi-starter-webmvc-ui — it pulls in both the spec generator and the bundled Swagger UI. The other dependencies are the usual Spring Web, validation, and the Jackson Kotlin module so data classes serialize cleanly.

build.gradle.kts
plugins {
kotlin("jvm") version "2.1.0"
kotlin("plugin.spring") version "2.1.0"
id("org.springframework.boot") version "3.4.1"
id("io.spring.dependency-management") version "1.1.7"
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
// OpenAPI / Swagger
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.0")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
}

springdoc works with zero config, but application.yml lets you choose the served paths and sort order. Here the spec lives at /api-docs and the UI at /swagger-ui.html.

src/main/resources/application.yml
spring:
application:
name: openapi-docs-demo
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: method
default-produces-media-type: application/json
server:
port: 8080

springdoc fills in defaults, but to set the title, version, description, contact, and a security scheme you expose a single OpenAPI bean. This is where the “Bearer Auth” JWT scheme is declared — that’s what powers the Authorize button in Swagger UI. The DSL is fluent: each method returns the builder so you chain .info(...).addSecurityItem(...).components(...).

src/main/kotlin/com/example/openapi/config/OpenApiConfig.kt
package com.example.openapi.config
import io.swagger.v3.oas.models.Components
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.info.Contact
import io.swagger.v3.oas.models.info.Info
import io.swagger.v3.oas.models.security.SecurityRequirement
import io.swagger.v3.oas.models.security.SecurityScheme
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class OpenApiConfig {
@Bean
fun customOpenAPI(): OpenAPI {
return OpenAPI()
.info(
Info()
.title("Task API")
.version("1.0.0")
.description(
"""
Task management REST API with comprehensive OpenAPI documentation.
This API demonstrates springdoc-openapi integration with Spring Boot,
including JWT authentication, pagination, filtering, and error responses.
""".trimIndent()
)
.contact(Contact().name("Dev Team").email("dev@example.com"))
)
.addSecurityItem(SecurityRequirement().addList("Bearer Auth"))
.components(
Components().addSecuritySchemes(
"Bearer Auth",
SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.description("Enter your JWT token")
)
)
}
}

The addSecurityItem (line 32) marks the whole API as requiring the scheme, and the addSecuritySchemes("Bearer Auth", ...) block (lines 34–41) defines it as an HTTP bearer token formatted as a JWT. The name "Bearer Auth" is just a label — it must match between the requirement and the scheme.

This is where most teaching happens. springdoc already infers the route, HTTP method, parameters, and return type from Spring’s own annotations (@GetMapping, @RequestParam, @PathVariable, the return type). The OpenAPI annotations only add the human-readable layer on top:

  • @Tag groups the endpoints under a named, described section in the UI.
  • @Operation gives each method a summary and description.
  • @ApiResponse / @ApiResponses document the status codes you return — including error bodies, here typed as Spring’s ProblemDetail.
  • @Parameter documents query/path params with descriptions and example values that pre-fill the “Try it out” form.

Note how nullable query params (status: TaskStatus?) become optional filters, and the enum types (TaskStatus, Priority) render as dropdowns in the UI automatically — springdoc reads the Kotlin types directly.

src/main/kotlin/com/example/openapi/controller/TaskController.kt
package com.example.openapi.controller
import com.example.openapi.model.*
import com.example.openapi.service.TaskService
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
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 io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/v1/tasks")
@Tag(name = "Tasks", description = "Task management endpoints")
class TaskController(
private val taskService: TaskService,
) {
@Operation(
summary = "List all tasks",
description = "Returns a paginated list of tasks with optional filtering by status and priority",
)
@ApiResponse(responseCode = "200", description = "Tasks retrieved successfully")
@GetMapping
fun getTasks(
@Parameter(description = "Filter by task status")
@RequestParam(required = false) status: TaskStatus?,
@Parameter(description = "Filter by priority")
@RequestParam(required = false) priority: Priority?,
@Parameter(description = "Page number (0-indexed)", example = "0")
@RequestParam(defaultValue = "0") page: Int,
@Parameter(description = "Page size", example = "20")
@RequestParam(defaultValue = "20") size: Int,
): PagedResponse<TaskResponse> {
return taskService.findAll(status, priority, page, size)
}
@Operation(summary = "Get task by ID")
@ApiResponses(
ApiResponse(responseCode = "200", description = "Task found"),
ApiResponse(
responseCode = "404",
description = "Task not found",
content = [Content(schema = Schema(implementation = ProblemDetail::class))],
),
)
@GetMapping("/{id}")
fun getTask(
@Parameter(description = "Task ID", example = "1") @PathVariable id: Long,
): ResponseEntity<TaskResponse> {
val task = taskService.findById(id)
?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(task)
}
@Operation(summary = "Create a new task")
@ApiResponses(
ApiResponse(responseCode = "201", description = "Task created successfully"),
ApiResponse(
responseCode = "400",
description = "Invalid request body",
content = [Content(schema = Schema(implementation = ProblemDetail::class))],
),
)
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun createTask(@RequestBody request: CreateTaskRequest): TaskResponse {
return taskService.create(request)
}
@Operation(summary = "Update an existing task")
@ApiResponses(
ApiResponse(responseCode = "200", description = "Task updated"),
ApiResponse(responseCode = "404", description = "Task not found"),
)
@PatchMapping("/{id}")
fun updateTask(
@Parameter(description = "Task ID") @PathVariable id: Long,
@RequestBody request: UpdateTaskRequest,
): ResponseEntity<TaskResponse> {
val updated = taskService.update(id, request)
?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(updated)
}
@Operation(summary = "Delete a task")
@ApiResponses(
ApiResponse(responseCode = "204", description = "Task deleted"),
ApiResponse(responseCode = "404", description = "Task not found"),
)
@DeleteMapping("/{id}")
fun deleteTask(
@Parameter(description = "Task ID") @PathVariable id: Long,
): ResponseEntity<Void> {
return if (taskService.delete(id)) {
ResponseEntity.noContent().build()
} else {
ResponseEntity.notFound().build()
}
}
}

@Schema on a data class describes the model; @Schema on each property adds a description, an example, and constraints like minLength / maxLength. These examples are what populate the sample request/response bodies in Swagger UI. Kotlin nullables map to optional fields, and PagedResponse<T> shows that a generic wrapper documents fine too.

src/main/kotlin/com/example/openapi/model/Dtos.kt
package com.example.openapi.model
import io.swagger.v3.oas.annotations.media.Schema
import java.time.Instant
enum class TaskStatus { OPEN, IN_PROGRESS, DONE }
enum class Priority { LOW, MEDIUM, HIGH }
@Schema(description = "Request to create a new task")
data class CreateTaskRequest(
@Schema(description = "Task title", example = "Buy groceries", minLength = 1, maxLength = 200)
val title: String,
@Schema(description = "Task description", example = "Milk, eggs, bread", required = false)
val description: String? = null,
@Schema(description = "Task priority", example = "MEDIUM")
val priority: Priority = Priority.MEDIUM,
)
@Schema(description = "Request to update a task")
data class UpdateTaskRequest(
@Schema(description = "Updated title", example = "Buy groceries and snacks")
val title: String? = null,
@Schema(description = "Updated description")
val description: String? = null,
@Schema(description = "Updated status")
val status: TaskStatus? = null,
@Schema(description = "Updated priority")
val priority: Priority? = null,
)
@Schema(description = "Task response object")
data class TaskResponse(
@Schema(description = "Task ID", example = "1")
val id: Long,
@Schema(description = "Task title", example = "Buy groceries")
val title: String,
@Schema(description = "Task description", example = "Milk, eggs, bread")
val description: String?,
@Schema(description = "Current status", example = "OPEN")
val status: TaskStatus,
@Schema(description = "Task priority", example = "MEDIUM")
val priority: Priority,
@Schema(description = "Creation timestamp", example = "2025-01-15T10:30:00Z")
val createdAt: Instant,
)
@Schema(description = "Paginated response wrapper")
data class PagedResponse<T>(
@Schema(description = "List of items")
val content: List<T>,
@Schema(description = "Current page number (0-indexed)", example = "0")
val page: Int,
@Schema(description = "Page size", example = "20")
val size: Int,
@Schema(description = "Total number of items", example = "42")
val totalElements: Long,
@Schema(description = "Total number of pages", example = "3")
val totalPages: Int,
)

There’s no database here — a ConcurrentHashMap and an AtomicLong ID counter keep the example self-contained, with a few seeded tasks so the UI has data to show. Nothing OpenAPI-specific, but it’s what makes the endpoints actually respond.

src/main/kotlin/com/example/openapi/service/TaskService.kt
@Service
class TaskService {
private val tasks = ConcurrentHashMap<Long, TaskResponse>()
private val idCounter = AtomicLong(0)
init {
// Seed some sample data
create(CreateTaskRequest("Learn Spring Boot", "Complete module 08", Priority.HIGH))
create(CreateTaskRequest("Learn Spring Security", "Complete module 14", Priority.HIGH))
create(CreateTaskRequest("Build REST API", "Add OpenAPI docs", Priority.MEDIUM))
}
fun findAll(
status: TaskStatus?,
priority: Priority?,
page: Int,
size: Int,
): PagedResponse<TaskResponse> {
val filtered = tasks.values
.filter { status == null || it.status == status }
.filter { priority == null || it.priority == priority }
.sortedByDescending { it.createdAt }
val total = filtered.size.toLong()
val totalPages = if (size > 0) ((total + size - 1) / size).toInt() else 0
val content = filtered.drop(page * size).take(size)
return PagedResponse(content, page, size, total, totalPages)
}
fun findById(id: Long): TaskResponse? = tasks[id]
// create / update / delete elided — ordinary map operations
}
  1. Start the app:

    Terminal window
    ./gradlew bootRun
  2. Open the interactive docs in your browser:

    • Swagger UI: http://localhost:8080/swagger-ui.html
    • OpenAPI JSON spec: http://localhost:8080/api-docs
  3. Try the API from the command line:

    Terminal window
    # List tasks with pagination
    curl "http://localhost:8080/api/v1/tasks?page=0&size=10"
    # Filter by status and priority
    curl "http://localhost:8080/api/v1/tasks?status=OPEN&priority=HIGH"
    # Create a task
    curl -X POST http://localhost:8080/api/v1/tasks \
    -H "Content-Type: application/json" \
    -d '{"title":"New task","priority":"HIGH"}'
  4. Export the spec (e.g. for client generation):

    Terminal window
    curl http://localhost:8080/api-docs | jq .

The test suite uses MockMvc and asserts not just the endpoints but that the generated spec is actually served and titled correctly.

Terminal window
./gradlew test
src/test/kotlin/com/example/openapi/TaskApiTest.kt
@Test
fun `openapi spec is available`() {
mockMvc.get("/api-docs")
.andExpect {
status { isOk() }
jsonPath("$.openapi") { isNotEmpty() }
jsonPath("$.info.title") { value("Task API") }
}
}