Skip to content

Commit 0ed32af

Browse files
committed
Support @Schema annotations on Kotlin value classes. Fixes #3168
1 parent 1846049 commit 0ed32af

File tree

5 files changed

+228
-8
lines changed

5 files changed

+228
-8
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
*
3+
* *
4+
* * *
5+
* * * *
6+
* * * * *
7+
* * * * * * Copyright 2019-2025 the original author or authors.
8+
* * * * * *
9+
* * * * * * Licensed under the Apache License, Version 2.0 (the "License");
10+
* * * * * * you may not use this file except in compliance with the License.
11+
* * * * * * You may obtain a copy of the License at
12+
* * * * * *
13+
* * * * * * https://www.apache.org/licenses/LICENSE-2.0
14+
* * * * * *
15+
* * * * * * Unless required by applicable law or agreed to in writing, software
16+
* * * * * * distributed under the License is distributed on an "AS IS" BASIS,
17+
* * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
* * * * * * See the License for the specific language governing permissions and
19+
* * * * * * limitations under the License.
20+
* * * * *
21+
* * * *
22+
* * *
23+
* *
24+
*
25+
*/
26+
27+
package org.springdoc.core.converters;
28+
29+
import java.lang.annotation.Annotation;
30+
import java.lang.reflect.Method;
31+
import java.lang.reflect.Type;
32+
33+
import kotlin.jvm.JvmInline;
34+
import kotlin.reflect.KClass;
35+
import kotlin.reflect.KFunction;
36+
import kotlin.reflect.KParameter;
37+
import kotlin.reflect.jvm.ReflectJvmMapping;
38+
import kotlin.reflect.jvm.internal.KClassImpl;
39+
40+
import org.springframework.core.MethodParameter;
41+
42+
/**
43+
* @author bnasslahsen
44+
*/
45+
// KotlinInlineParameterResolver.java
46+
public final class KotlinInlineParameterResolver {
47+
48+
private KotlinInlineParameterResolver() {}
49+
50+
public static Class<?> resolveInlineType(MethodParameter methodParameter, Type type) {
51+
Method method = methodParameter.getMethod();
52+
if (method == null) return null;
53+
54+
KFunction<?> kFunction = ReflectJvmMapping.getKotlinFunction(method);
55+
if (kFunction == null) return null;
56+
57+
int paramIndex = methodParameter.getParameterIndex();
58+
59+
KParameter kParam = kFunction.getParameters().stream()
60+
.filter(p -> p.getKind() == KParameter.Kind.VALUE)
61+
.skip(paramIndex)
62+
.findFirst()
63+
.orElse(null);
64+
65+
if (kParam == null) return null;
66+
67+
Object classifier = kParam.getType().getClassifier();
68+
if (!(classifier instanceof KClass<?> kClass)) return null;
69+
70+
for (Annotation a : kClass.getAnnotations()) {
71+
if (a.annotationType() == JvmInline.class
72+
&& kClass instanceof KClassImpl<?> impl) {
73+
return impl.getJClass();
74+
}
75+
}
76+
77+
return null;
78+
}
79+
}

springdoc-openapi-starter-common/src/main/java/org/springdoc/core/service/GenericParameterService.java

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
import java.util.Map;
4242
import java.util.Objects;
4343
import java.util.Optional;
44+
import java.util.stream.Stream;
4445

4546
import com.fasterxml.jackson.annotation.JsonView;
4647
import io.swagger.v3.core.util.AnnotationsUtils;
@@ -49,19 +50,23 @@
4950
import io.swagger.v3.oas.annotations.enums.ParameterIn;
5051
import io.swagger.v3.oas.annotations.enums.ParameterStyle;
5152
import io.swagger.v3.oas.annotations.extensions.Extension;
53+
import io.swagger.v3.oas.annotations.media.ArraySchema;
5254
import io.swagger.v3.oas.annotations.media.ExampleObject;
5355
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
56+
import io.swagger.v3.oas.annotations.parameters.RequestBody;
5457
import io.swagger.v3.oas.models.Components;
5558
import io.swagger.v3.oas.models.examples.Example;
5659
import io.swagger.v3.oas.models.media.Content;
5760
import io.swagger.v3.oas.models.media.FileSchema;
5861
import io.swagger.v3.oas.models.media.ObjectSchema;
5962
import io.swagger.v3.oas.models.media.Schema;
6063
import io.swagger.v3.oas.models.parameters.Parameter;
64+
import io.swagger.v3.oas.models.parameters.Parameter.StyleEnum;
6165
import org.apache.commons.lang3.StringUtils;
6266
import org.apache.commons.lang3.reflect.FieldUtils;
6367
import org.slf4j.Logger;
6468
import org.slf4j.LoggerFactory;
69+
import org.springdoc.core.converters.KotlinInlineParameterResolver;
6570
import org.springdoc.core.extractor.DelegatingMethodParameter;
6671
import org.springdoc.core.extractor.MethodParameterPojoExtractor;
6772
import org.springdoc.core.models.ParameterInfo;
@@ -78,6 +83,7 @@
7883
import org.springframework.beans.factory.config.BeanExpressionResolver;
7984
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
8085
import org.springframework.core.GenericTypeResolver;
86+
import org.springframework.core.KotlinDetector;
8187
import org.springframework.core.MethodParameter;
8288
import org.springframework.core.ResolvableType;
8389
import org.springframework.core.annotation.AnnotatedElementUtils;
@@ -152,7 +158,7 @@ public class GenericParameterService {
152158
* @param objectMapperProvider the object mapper provider
153159
* @param javadocProviderOptional the javadoc provider
154160
*/
155-
public GenericParameterService(PropertyResolverUtils propertyResolverUtils,
161+
public GenericParameterService(PropertyResolverUtils propertyResolverUtils,
156162
Optional<WebConversionServiceProvider> optionalWebConversionServiceProvider, ObjectMapperProvider objectMapperProvider, Optional<JavadocProvider> javadocProviderOptional) {
157163
this.propertyResolverUtils = propertyResolverUtils;
158164
this.optionalWebConversionServiceProvider = optionalWebConversionServiceProvider;
@@ -367,15 +373,34 @@ Schema calculateSchema(Components components, ParameterInfo parameterInfo, Reque
367373

368374
if (parameterInfo.getParameterModel() == null || parameterInfo.getParameterModel().getSchema() == null) {
369375
Type type = GenericTypeResolver.resolveType(methodParameter.getGenericParameterType(), methodParameter.getContainingClass());
376+
Annotation[] paramAnnotations = getParameterAnnotations(methodParameter);
377+
Annotation[] typeAnnotations = new Annotation[0];
378+
if (KotlinDetector.isKotlinPresent()
379+
&& KotlinDetector.isKotlinReflectPresent()
380+
&& KotlinDetector.isKotlinType(methodParameter.getContainingClass())
381+
&& type == String.class) {
382+
Class<?> restored = KotlinInlineParameterResolver
383+
.resolveInlineType(methodParameter, type);
384+
if (restored != null) {
385+
type = restored;
386+
typeAnnotations = ((Class<?>) type).getAnnotations();
387+
}
388+
}
389+
Annotation[] mergedAnnotations =
390+
Stream.concat(
391+
Arrays.stream(paramAnnotations),
392+
Arrays.stream(typeAnnotations)
393+
).toArray(Annotation[]::new);
370394
if (type instanceof Class && !((Class<?>) type).isEnum() && optionalWebConversionServiceProvider.isPresent()) {
371395
WebConversionServiceProvider webConversionServiceProvider = optionalWebConversionServiceProvider.get();
372-
if (!MethodParameterPojoExtractor.isSwaggerPrimitiveType((Class) type) && methodParameter.getParameterType().getAnnotation(io.swagger.v3.oas.annotations.media.Schema.class) == null) {
396+
if (!MethodParameterPojoExtractor.isSwaggerPrimitiveType((Class) type) && Arrays.stream(mergedAnnotations)
397+
.noneMatch(a -> a.annotationType() == io.swagger.v3.oas.annotations.media.Schema.class)) {
373398
Class<?> springConvertedType = webConversionServiceProvider.getSpringConvertedType(methodParameter.getParameterType());
374399
if (!(String.class.equals(springConvertedType) && ((Class<?>) type).isEnum()) && requestBodyInfo == null)
375400
type = springConvertedType;
376401
}
377402
}
378-
schemaN = SpringDocAnnotationsUtils.extractSchema(components, type, jsonView, getParameterAnnotations(methodParameter), propertyResolverUtils.getSpecVersion());
403+
schemaN = SpringDocAnnotationsUtils.extractSchema(components, type, jsonView, mergedAnnotations, propertyResolverUtils.getSpecVersion());
379404
}
380405
else
381406
schemaN = parameterInfo.getParameterModel().getSchema();
@@ -505,7 +530,7 @@ else if (Explode.FALSE.equals(p.explode())) {
505530
*/
506531
private void setParameterStyle(Parameter parameter, io.swagger.v3.oas.annotations.Parameter p) {
507532
if (StringUtils.isNotBlank(p.style().toString())) {
508-
parameter.setStyle(Parameter.StyleEnum.valueOf(p.style().toString().toUpperCase()));
533+
parameter.setStyle(StyleEnum.valueOf(p.style().toString().toUpperCase()));
509534
}
510535
}
511536

@@ -517,7 +542,7 @@ private void setParameterStyle(Parameter parameter, io.swagger.v3.oas.annotation
517542
*/
518543
private boolean isExplodable(io.swagger.v3.oas.annotations.Parameter p) {
519544
io.swagger.v3.oas.annotations.media.Schema schema = p.schema();
520-
io.swagger.v3.oas.annotations.media.ArraySchema arraySchema = p.array();
545+
ArraySchema arraySchema = p.array();
521546

522547
boolean explode = true;
523548
Class<?> implementation = schema.implementation();
@@ -665,7 +690,7 @@ public io.swagger.v3.oas.annotations.media.Schema schema() {
665690
}
666691

667692
@Override
668-
public io.swagger.v3.oas.annotations.media.ArraySchema array() {
693+
public ArraySchema array() {
669694
return null;
670695
}
671696

@@ -722,9 +747,9 @@ public JavadocProvider getJavadocProvider() {
722747
* @return the boolean
723748
*/
724749
public boolean isRequestBodyPresent(ParameterInfo parameterInfo) {
725-
return parameterInfo.getMethodParameter().getParameterAnnotation(io.swagger.v3.oas.annotations.parameters.RequestBody.class) != null
750+
return parameterInfo.getMethodParameter().getParameterAnnotation(RequestBody.class) != null
726751
|| parameterInfo.getMethodParameter().getParameterAnnotation(org.springframework.web.bind.annotation.RequestBody.class) != null
727-
|| AnnotatedElementUtils.findMergedAnnotation(Objects.requireNonNull(parameterInfo.getMethodParameter().getMethod()), io.swagger.v3.oas.annotations.parameters.RequestBody.class) != null;
752+
|| AnnotatedElementUtils.findMergedAnnotation(Objects.requireNonNull(parameterInfo.getMethodParameter().getMethod()), RequestBody.class) != null;
728753
}
729754

730755
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
*
3+
* * Copyright 2019-2020 the original author or authors.
4+
* *
5+
* * Licensed under the Apache License, Version 2.0 (the "License");
6+
* * you may not use this file except in compliance with the License.
7+
* * You may obtain a copy of the License at
8+
* *
9+
* * https://www.apache.org/licenses/LICENSE-2.0
10+
* *
11+
* * Unless required by applicable law or agreed to in writing, software
12+
* * distributed under the License is distributed on an "AS IS" BASIS,
13+
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* * See the License for the specific language governing permissions and
15+
* * limitations under the License.
16+
*
17+
*/
18+
19+
package test.org.springdoc.api.v31.app12
20+
21+
import io.swagger.v3.oas.annotations.media.Schema
22+
import org.springframework.web.bind.annotation.GetMapping
23+
import org.springframework.web.bind.annotation.PathVariable
24+
import org.springframework.web.bind.annotation.RestController
25+
26+
27+
@Schema(description = "Office ID", pattern = "^[0-9A-HJKMNP-TV-Z]{26}\$")
28+
@JvmInline
29+
value class OfficeId(private val value: String)
30+
31+
@RestController
32+
class OfficeController {
33+
@GetMapping("/v1/offices/{officeId}")
34+
suspend fun getOffice(@PathVariable officeId: OfficeId): String {
35+
return "ok";
36+
}
37+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
*
3+
* * Copyright 2019-2020 the original author or authors.
4+
* *
5+
* * Licensed under the Apache License, Version 2.0 (the "License");
6+
* * you may not use this file except in compliance with the License.
7+
* * You may obtain a copy of the License at
8+
* *
9+
* * https://www.apache.org/licenses/LICENSE-2.0
10+
* *
11+
* * Unless required by applicable law or agreed to in writing, software
12+
* * distributed under the License is distributed on an "AS IS" BASIS,
13+
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* * See the License for the specific language governing permissions and
15+
* * limitations under the License.
16+
*
17+
*/
18+
19+
package test.org.springdoc.api.v31.app12
20+
21+
import org.springframework.boot.autoconfigure.SpringBootApplication
22+
import org.springframework.context.annotation.ComponentScan
23+
import test.org.springdoc.api.v31.AbstractKotlinSpringDocTest
24+
25+
class SpringDocApp12Test : AbstractKotlinSpringDocTest() {
26+
27+
@SpringBootApplication
28+
@ComponentScan(basePackages = ["org.springdoc", "test.org.springdoc.api.v31.app12"])
29+
class DemoApplication
30+
31+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{
2+
"openapi": "3.1.0",
3+
"info": {
4+
"title": "OpenAPI definition",
5+
"version": "v0"
6+
},
7+
"servers": [
8+
{
9+
"url": "",
10+
"description": "Generated server url"
11+
}
12+
],
13+
"paths": {
14+
"/v1/offices/{officeId}": {
15+
"get": {
16+
"tags": [
17+
"office-controller"
18+
],
19+
"operationId": "getOffice-mB_CmrA",
20+
"parameters": [
21+
{
22+
"name": "officeId",
23+
"in": "path",
24+
"required": true,
25+
"schema": {
26+
"type": "string",
27+
"description": "Office ID",
28+
"pattern": "^[0-9A-HJKMNP-TV-Z]{26}$"
29+
}
30+
}
31+
],
32+
"responses": {
33+
"200": {
34+
"description": "OK",
35+
"content": {
36+
"*/*": {
37+
"schema": {
38+
"type": "string"
39+
}
40+
}
41+
}
42+
}
43+
}
44+
}
45+
}
46+
},
47+
"components": {}
48+
}

0 commit comments

Comments
 (0)