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.
What you’ll build
Section titled “What you’ll build”A small REST API for managing tasks, fully documented:
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/tasks | List tasks (paginated, filterable) |
| GET | /api/v1/tasks/{id} | Get task by ID |
| POST | /api/v1/tasks | Create 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.
The worked solution
Section titled “The worked solution”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
Adding the dependency
Section titled “Adding the dependency”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.
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")}Configuring the doc paths
Section titled “Configuring the doc paths”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.
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: 8080Top-level API metadata: OpenApiConfig
Section titled “Top-level API metadata: OpenApiConfig”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(...).
package com.example.openapi.config
import io.swagger.v3.oas.models.Componentsimport io.swagger.v3.oas.models.OpenAPIimport io.swagger.v3.oas.models.info.Contactimport io.swagger.v3.oas.models.info.Infoimport io.swagger.v3.oas.models.security.SecurityRequirementimport io.swagger.v3.oas.models.security.SecuritySchemeimport org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configuration
@Configurationclass 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.
Documenting endpoints: TaskController
Section titled “Documenting endpoints: TaskController”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:
@Taggroups the endpoints under a named, described section in the UI.@Operationgives each method asummaryanddescription.@ApiResponse/@ApiResponsesdocument the status codes you return — including error bodies, here typed as Spring’sProblemDetail.@Parameterdocuments query/path params with descriptions andexamplevalues 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.
package com.example.openapi.controller
import com.example.openapi.model.*import com.example.openapi.service.TaskServiceimport io.swagger.v3.oas.annotations.Operationimport io.swagger.v3.oas.annotations.Parameterimport io.swagger.v3.oas.annotations.media.Contentimport io.swagger.v3.oas.annotations.media.Schemaimport io.swagger.v3.oas.annotations.responses.ApiResponseimport io.swagger.v3.oas.annotations.responses.ApiResponsesimport io.swagger.v3.oas.annotations.tags.Tagimport org.springframework.http.HttpStatusimport org.springframework.http.ProblemDetailimport org.springframework.http.ResponseEntityimport 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() } }}Documenting the data shapes: Dtos.kt
Section titled “Documenting the data shapes: Dtos.kt”@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.
package com.example.openapi.model
import io.swagger.v3.oas.annotations.media.Schemaimport 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,)The backing service
Section titled “The backing service”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.
@Serviceclass 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}Run it
Section titled “Run it”-
Start the app:
Terminal window ./gradlew bootRun -
Open the interactive docs in your browser:
- Swagger UI:
http://localhost:8080/swagger-ui.html - OpenAPI JSON spec:
http://localhost:8080/api-docs
- Swagger UI:
-
Try the API from the command line:
Terminal window # List tasks with paginationcurl "http://localhost:8080/api/v1/tasks?page=0&size=10"# Filter by status and prioritycurl "http://localhost:8080/api/v1/tasks?status=OPEN&priority=HIGH"# Create a taskcurl -X POST http://localhost:8080/api/v1/tasks \-H "Content-Type: application/json" \-d '{"title":"New task","priority":"HIGH"}' -
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.
./gradlew test@Testfun `openapi spec is available`() { mockMvc.get("/api-docs") .andExpect { status { isOk() } jsonPath("$.openapi") { isNotEmpty() } jsonPath("$.info.title") { value("Task API") } }}