English | 한국어
A modified version of the ePages-de/restdocs-api-spec with class field type and constraint inference. And only support OpenAPI 3.0.1 specs.
| Artifact line | Spring Boot | Spring REST Docs | Java bytecode | Tested JDKs | Status |
|---|---|---|---|---|---|
2.x (main) |
4.1.x | 4.0.x | 17 | 17, 21, 25 | Active (latest release: 2.1.4) |
1.x (v1.x) |
3.5.x | 3.0.x | 17 | 17, 21, 25 (historical) | Frozen, unsupported |
0.x (v0.x) |
2.7.x | 2.0.x | — | — | Frozen, unsupported |
The public packages remain under com.keecon.restdocs.*, and the Gradle plugin ID remains
com.keecon.restdocs-openapi3 across release lines.
The 2.x line requires Java 17 or newer. CI verifies the LTS JDK releases 17, 21, and 25. Use 1.1.2 for Spring Boot 3.5 applications. Version 2.1.4 is available from JitPack; no Plugin Portal publication is assumed.
See MAINTENANCE.md for the branch lifecycle, backport, and release policy.
Local builds require JDK 25; Java and Kotlin artifacts retain Java 17 compatibility.
CI and release verification continue on JDK 17, 21, and 25. Use ./gradlew clean build.
The v1.x line is frozen: no further fixes, dependency updates, or automated releases.
-
Add the plugin
buildscript { repositories { // ... maven { url = uri('https://jitpack.io') } } dependencies { // ... classpath 'com.github.keecon.restdocs-openapi3:restdocs-api-spec-gradle-plugin:2.1.4' } } apply plugin: 'com.keecon.restdocs-openapi3' -
Add required dependencies to your tests
repositories { // ... maven { url 'https://jitpack.io' } } dependencies { //.. testImplementation 'com.github.keecon.restdocs-openapi3:restdocs-api-spec:2.1.4' testImplementation 'com.github.keecon.restdocs-openapi3:restdocs-api-spec-mockmvc:2.1.4' } openapi3 { server = 'http://localhost:8080' title = 'My API' description = 'My API description' tagDescriptionsPropertiesFile = 'src/test/resources/openapi-tags.yml' version = '0.1.0' format = 'yaml' }
On the current main branch, the openapi3 task resolves its default input and output directories
from the project's Gradle layout.buildDirectory. With the standard build directory, it reads
snippets from build/generated-snippets and writes build/api-spec/openapi3.json or
build/api-spec/openapi3.yaml, depending on format.
This build-directory-aware behavior and the additive method and Gradle Action syntax below are
available from 2.1.1. With 2.1.0, use the assignment syntax shown above.
Groovy builds can use method syntax:
openapi3 {
server 'https://api.example.com'
contact {
name = 'API Support'
email = 'support@example.com'
}
}After adding the same JitPack buildscript classpath and applying the plugin, Kotlin builds can configure the named extension explicitly:
import com.keecon.restdocs.apispec.gradle.OpenApi3Extension
extensions.configure<OpenApi3Extension>("openapi3") {
server("https://api.example.com")
contact {
name = "API Support"
email = "support@example.com"
}
oauth2SecuritySchemeDefinition {
flows = arrayOf("authorizationCode")
tokenUrl = "https://example.com/token"
authorizationUrl = "https://example.com/authorize"
}
}When documented JWT scopes create an OAuth2 security requirement, configure at least one OAuth2 flow. Specification generation fails if the matching OAuth2 security scheme definition is missing.
The 2.x line moves to Spring Boot 4, Spring REST Docs 4, and Jackson 3. Extension-level Groovy
configuration remains compatible. Code that configures the openapi3 task directly must adapt to
its managed Gradle properties: scalar task getters now return Property<T> and directory getters
return DirectoryProperty, so set values with .set(...) instead of assigning the 1.x
String/Boolean task properties directly.
OpenAPI contact metadata can be configured in the Gradle DSL.
openapi3 {
contact = {
name = 'API Support'
email = 'support@example.com'
url = 'https://example.com/support'
}
}Java time fields are inferred as follows:
| Java type | OpenAPI type | OpenAPI format | Typical Jackson text output |
|---|---|---|---|
LocalDate |
string |
date |
YYYY-MM-DD |
OffsetDateTime |
string |
date-time |
RFC 3339 with a numeric offset or Z |
Instant |
string |
date-time |
RFC 3339 in UTC with Z |
ZonedDateTime |
string |
date-time |
RFC 3339 with a numeric offset; the region ID must be omitted |
JSON examples for these inferred Java time fields are validated when the OpenAPI document is
generated. The accepted range is an RFC 3339 profile without positive leap seconds: seconds and an
offset are required; seconds must range from 00 through 59; T and Z may be uppercase or
lowercase; fractions may contain any positive number of digits; and numeric offsets range through
+23:59/-23:59, including the unknown-local-offset form -00:00. Uppercase T and Z are
recommended for interoperability. All three types accept either Z or a numeric offset; the table
shows the typical textual output produced by Jackson.
To keep examples consistent with typical Jackson serialization, leap seconds (:60) are rejected
regardless of their historical occurrence. This library does not maintain external leap-second
announcements or history.
accepted: 2026-08-30T15:30:00+09:00
accepted: 2026-08-30t06:30:00.123456789012z
rejected: 2026-08-30T15:30:00
rejected: 2026-08-30T15:30:00+09:00[Asia/Seoul]
rejected: 1788071400
rejected: 1990-12-31T23:59:60Z
This library does not configure the application's JSON serializer. Configure Jackson to emit
textual date values rather than timestamps, and do not enable zone-ID output for ZonedDateTime.
Generation fails if captured JSON request or response body examples violate this contract.
Fields declared manually as DATETIME retain their existing validation behavior and are not
subject to this inferred-type RFC 3339 validation.
LocalDateTime is intentionally not inferred as date-time: even its textual Jackson
representation has no offset, while RFC 3339 date-time requires one. Use Instant,
OffsetDateTime, or ZonedDateTime when the value represents an instant.
The WebTestClient integration is available from the restdocs-api-spec-webtestclient module.
dependencies {
testImplementation 'com.github.keecon.restdocs-openapi3:restdocs-api-spec-webtestclient:2.1.4'
}Replace Spring REST Docs' WebTestClient document consumer with the wrapper. It keeps the normal
REST Docs snippets and adds resource.json from their descriptors.
webTestClient.get()
.uri('/v1/products/{id}', 1)
.exchange()
.expectStatus().isOk()
.expectBody()
.consumeWith(WebTestClientRestDocumentationWrapper.document(
'products-id-get',
responseFields(fieldWithPath('id').description('product id'))
))when:
def resultActions = mockMvc.perform(
post('/v1/products/{productId}/result', 1)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsBytes(new ProductResultCreateRequestBody(result)))
.accept(MediaType.APPLICATION_JSON)
)
then:
def reqModel = Constraints.model(ProductResultCreateRequest.class)
def reqBodyModel = Constraints.model(ProductResultCreateRequestBody.class)
def respModel = Constraints.model(ProductResultCreateResponse.class)
resultActions
.andExpect(status().isOk())
.andDo(document('products-id-result-post',
resource(ResourceSnippetParameters.builder()
.tag('product')
.summary('Create a product result')
.description('''
|Create a product result
|
|### Error details
|
|`400` BAD_REQUEST
|- bad request description
|
|`401` UNAUTHORIZED
|- unauthorized description
|
|'''.stripMargin())
.requestSchema(schema('ProductResultCreateRequest'))
.pathParameters(
reqModel.withName('productId').description('product id'),
)
.requestFields(
reqBodyModel.withPath('result').description('product result'),
reqBodyModel.withPath('result.code').description('product result code'),
reqBodyModel.withPath('result.seq').description('product result seq'),
reqBodyModel.withPath('result.score').description('product result score'),
reqBodyModel.withPath('result.assigns[]').description('result assign object list'),
reqBodyModel.withPath('result.assigns[].code').description('result assign code'),
reqBodyModel.withPath('result.assigns[].seq').description('result assign seq'),
reqBodyModel.withPath('result.assigns[].objectId').description('result assign object id'),
reqBodyModel.withPath('result.assigns[].fileType').description('result assign file type')
.optional(),
reqBodyModel.withPath('result.assigns[].fileUrl').description('result assign file url')
.optional(),
reqBodyModel.withPath('result.assigns[].comments[]').description('result assign comment list')
.type(DataType.ARRAY)
.attributes(Attributes.items(DataType.STRING, null, null))
.optional(),
)
.responseSchema(schema('ProductResultCreateResponse'))
.responseFields(
respModel.withPath('status').description('operation status'),
respModel.withPath('code').description('product result code')
.optional(),
)
.build())))when:
def resultActions = mockMvc.perform(
get('/v1/products/{productId}/result?code={code}', 1, 1)
.accept(MediaType.APPLICATION_JSON)
)
then:
def reqModel = Constraints.model(ProductResultRequest.class)
def respModel = Constraints.model(ProductResultResponse.class)
resultActions
.andExpect(status().isOk())
.andDo(document('products-id-result-code-get',
resource(ResourceSnippetParameters.builder()
.tag('product')
.summary('Get a product result info')
.description('''
|Get a product result info
|
|### Error details
|
|`400` BAD_REQUEST
|- bad request description
|
|`401` UNAUTHORIZED
|- unauthorized description
|
|`404` NOT_FOUND
|- not found description
|
|'''.stripMargin())
.requestSchema(schema('ProductResultRequest'))
.pathParameters(
reqModel.withName('productId').description('product id'),
)
.queryParameters(
reqModel.withName('code').description('product result code'),
reqModel.withName('seq').description('product result seq')
.defaultValue(ProductResultRequest.DEFAULT_RESULT_SEQ)
.optional(),
)
.responseSchema(schema('ProductResultResponse'))
.responseFields(
respModel.withPath('result').description('product result'),
respModel.withPath('result.code').description('product result code'),
respModel.withPath('result.seq').description('product result seq'),
respModel.withPath('result.score').description('product result score'),
respModel.withPath('result.assigns[]').description('result assign object list'),
respModel.withPath('result.assigns[].code').description('result assign code'),
respModel.withPath('result.assigns[].seq').description('result assign seq'),
respModel.withPath('result.assigns[].objectId').description('result assign object id'),
respModel.withPath('result.assigns[].fileType').description('result assign file type')
.optional(),
respModel.withPath('result.assigns[].fileUrl').description('result assign file url')
.optional(),
respModel.withPath('result.assigns[].comments[]').description('result assign comment list')
.type(DataType.ARRAY)
.attributes(Attributes.items(DataType.STRING, null, null))
.optional(),
)
.build())))