A Spring Boot library that fixes generic type flattening in springdoc-openapi.
When springdoc generates OpenAPI specs for endpoints returning CommonResponse<T>, it flattens
generic types into concatenated names like CommonResponseTestResponse. This makes frontend type
generation tools (e.g. swagger-typescript-api) produce non-generic, non-reusable types.
This library rewrites those flattened schemas into allOf + x-extension structures so that
frontends can reconstruct proper generic types like CommonResponse<TestResponse>.
# springdoc default — type explosion, no generics
CommonResponseTestResponse:
properties: { status, code, message, data: $ref TestResponse }
CommonResponseCursorSliceResponseProjectSummaryResponse:
properties: { status, code, message, data: { content: [...], hasNext, ... } }// swagger-typescript-api output — no generics, not reusable
export interface CommonResponseTestResponse {
status?: number;
code?: string;
data?: TestResponse;
}# After this library
CommonResponse:
properties: { status, code, message }
CommonResponseTestResponse:
allOf:
- $ref: CommonResponse
- properties: { data: { $ref: TestResponse } }
x-generic-base: CommonResponse
x-generic-type-arg: TestResponse
CommonResponseCursorSliceResponseProjectSummaryResponse:
allOf:
- $ref: CommonResponse
- properties: { data: { $ref: CursorSliceResponseProjectSummaryResponse } }
x-generic-base: CommonResponse
x-generic-type-arg: "CursorSliceResponse<ProjectSummaryResponse>"// swagger-typescript-api output — proper generics
export type CommonResponseTestResponse = CommonResponse<TestResponse>;
export type CommonResponseCursorSliceResponseProjectSummaryResponse =
CommonResponse<CursorSliceResponse<ProjectSummaryResponse>>;- Spring Boot 3.x
- springdoc-openapi 2.x (
springdoc-openapi-starter-webmvc-ui) - Kotlin / Java
// build.gradle.kts
implementation("io.github.tnals0924:springdoc-generic-response:1.0.0")@GenericWrapper(dataField = "data")
data class CommonResponse<T>(
val status: Int,
val code: String,
val message: String,
val data: T? = null
)
@GenericWrapper(dataField = "content")
data class CursorSliceResponse<T>(
val content: List<T>,
val size: Int,
val hasNext: Boolean,
val nextCursorId: Long?,
val nextCursorValue: String?
)That's it. Spring Boot AutoConfiguration detects @GenericWrapper classes automatically — no
additional configuration required.
Add this 5-line hook to your swagger-typescript-api config to reconstruct generic types:
hooks: {
onParseSchema: (originalSchema, parsedSchema) => {
if (originalSchema?.["x-generic-base"] && originalSchema?.["x-generic-type-arg"]) {
parsedSchema.content =
`${originalSchema["x-generic-base"]}<${originalSchema["x-generic-type-arg"]}>`;
}
return parsedSchema;
}
}All settings are optional. Zero-config works out of the box.
# application.yml
generic-response:
enabled: true # disable the library entirely (default: true)
auto-detect: true # detect wrapper classes without @GenericWrapper (default: true)
base-packages: # packages to scan (default: auto-detected from @SpringBootApplication)
- com.example.responseWhen true, the library also scans for generic wrapper classes that are not annotated
with @GenericWrapper. It identifies them by looking for classes with a single type parameter
whose field directly uses that type parameter (T or List<T>).
This carries a name-collision risk if multiple classes share the same simple name. Prefer explicit
@GenericWrapperannotation when possible.
-
@GenericWrapper— marks a class as a generic wrapper and declares which field holdsT. -
FlattenedTypeNameParser— parses a flat name likeCommonResponseCursorSliceResponseProjectSummaryResponseinto a tree:Generic("CommonResponse", Generic("CursorSliceResponse", Leaf("ProjectSummaryResponse")))Uses longest-match on known generic names to avoid ambiguous splits.
-
GenericSchemaRewriter— rewrites each flat schema into:allOf [$ref: BaseType, {dataField: originalDataProperty}]x-generic-base: base type namex-generic-type-arg: reconstructed type argument string (e.g.CursorSliceResponse<ProjectSummaryResponse>)- Automatically creates the base schema (e.g.
CommonResponse) if it does not yet exist.
-
GenericResponseCustomizer— implements springdoc'sOpenApiCustomizer. Runs after spec generation and rewrites all matching schemas in one pass. -
GenericResponseAutoConfiguration— Spring Boot AutoConfiguration entry point. Scans for@GenericWrapperannotated classes and registers the customizer as a bean.
| Case | Example flat name | x-generic-type-arg |
|---|---|---|
| 1-level | CommonResponseTestResponse |
TestResponse |
| 2-level nested | CommonResponseCursorSliceResponseProjectSummaryResponse |
CursorSliceResponse<ProjectSummaryResponse> |
| 3-level nested | CommonResponseCursorSliceResponsePageWrapperProjectSummaryResponse |
CursorSliceResponse<PageWrapper<ProjectSummaryResponse>> |
| No data field | CommonResponseObject |
Object |
springdoc-generic-response/
├── lib/ # Published library
│ └── src/main/kotlin/io/github/tnals0924/genericresponse/
│ ├── annotation/ # @GenericWrapper
│ ├── model/ # GenericWrapperInfo
│ ├── parser/ # FlattenedTypeNameParser
│ ├── rewriter/ # GenericSchemaRewriter
│ ├── customizer/ # GenericResponseCustomizer (OpenApiCustomizer)
│ ├── detector/ # GenericWrapperDetector (classpath scan + auto-detect)
│ ├── properties/ # GenericResponseProperties (@ConfigurationProperties)
│ └── autoconfigure/ # GenericResponseAutoConfiguration
└── sample/ # Sample Spring Boot app
./gradlew :sample:bootRun
# OpenAPI spec: http://localhost:8080/v3/api-docs
# Swagger UI: http://localhost:8080/swagger-ui.html./gradlew :lib:test