Skip to content

Commit bc977d5

Browse files
authored
Merge pull request #17 from framefork/fp-openapi-schema
add Support for openapi Schema type definitions
2 parents 59f1519 + 7245220 commit bc977d5

File tree

25 files changed

+1159
-2
lines changed

25 files changed

+1159
-2
lines changed

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,17 @@ This also simplifies usage on every other place, where Hibernate might need to r
291291
292292
This library provides `ObjectBigIntIdJacksonModule` and `ObjectUuidJacksonModule`, which can be registered automatically via the standard `java.util.ServiceLoader` mechanism, or explicitly.
293293
294+
Please note that in Spring, the instance of Jackson's `ObjectMapper` used for (de)serializing requests and responses of controllers by default ignores the modules provided via `ServiceLoader`,
295+
so to make it work, you have to either register the modules as beans, or add the following customizer:
296+
297+
```java
298+
@Bean
299+
public Jackson2ObjectMapperBuilderCustomizer enableServiceLoaderModules()
300+
{
301+
return builder -> builder.findModulesViaServiceLoader(true);
302+
}
303+
```
304+
294305
## Usage: (de)serialization with Gson
295306

296307
This library provides `ObjectBigIntIdTypeAdapterFactory` and `ObjectUuidTypeAdapterFactory`, which can be registered automatically via the standard `java.util.ServiceLoader` mechanism, or explicitly.
@@ -336,6 +347,34 @@ and then mark the type as `@Contextual` on every usage
336347
data class UserDto(@Contextual val id: UserId)
337348
```
338349

350+
## Usage: OpenAPI schema
351+
352+
This library provides support for OpenAPI schema generation, so that you don't have to annotate the types with `@Schema` manually (you still can, if you want to).
353+
354+
### OpenAPI - generic Swagger v3 Jakarta
355+
356+
The [org.framefork:typed-ids-openapi-swagger-jakarta](https://central.sonatype.com/artifact/org.framefork/typed-ids-openapi-swagger-jakarta) artifact
357+
provides a `TypedIdsModelConverter`, which should be automatically picked up by the standard Swagger v3 Jakarta implementation,
358+
because it's exposed via the standard `java.util.ServiceLoader` mechanism.
359+
360+
There is a single configurable property - `idsAsRef`, which is the equivalent of `enumsAsRef` in the standard Swagger v3 implementation.
361+
You can set override it via `TypedIdsModelConverter.idsAsRef`, or using a system property `framefork.typed-ids.openapi.as-ref`.
362+
363+
Providing a non-jakarta variant would be straightforward, but given that javax has been deprecated for a long time, I decided to not bother with it.
364+
365+
### OpenAPI - SpringDoc
366+
367+
The [org.framefork:typed-ids-openapi-springdoc](https://central.sonatype.com/artifact/org.framefork/typed-ids-openapi-springdoc) artifact
368+
builds on the Swagger v3 Jakarta integration, and registers it as a standard Spring bean.
369+
370+
With SpringDoc, you shouldn't configure the converter explicitly,
371+
and instead you should use the standard spring configuration to set it via the `framefork.typed-ids.openapi.as-ref` property in config/ENV/etc.
372+
373+
You may notice that the artifact depends on a quite old version of the SpringDoc. That is because I needed compatibility with Spring Boot 3.0.x. However, it works seamlessly with newer Spring Boot versions.
374+
375+
You may want to check the working example in [testing/testing-typed-ids-springdoc-openapi](https://github.com/framefork/typed-ids/tree/master/testing/testing-typed-ids-springdoc-openapi),
376+
with a recent Spring Boot version, and also with a working OpenApi spec generation, and TypeScript client generation.
377+
339378
## More examples
340379

341380
To learn more you can explore the `testing/` directory of this library,

gradle/libs.versions.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,6 @@ hypersistence-utils-hibernate62 = { group = "io.hypersistence", name = "hypersis
3838
hypersistence-utils-hibernate63 = { group = "io.hypersistence", name = "hypersistence-utils-hibernate-63", version = "3.9.9" }
3939
hypersistence-tsid = { module = "io.hypersistence:hypersistence-tsid", version = "2.1.4" }
4040
kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.7.0" }
41+
swagger-v3-core-jakarta = { module = "io.swagger.core.v3:swagger-core-jakarta", version = "2.2.35" }
42+
springdoc-openapi-starter-common = { module = "org.springdoc:springdoc-openapi-starter-common", version = "2.1.0" }
43+
springdoc-openapi-spring-configuration-processor = { module = "org.springframework.boot:spring-boot-configuration-processor", version = "3.0.5" }
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
plugins {
2+
id("framefork.java-public")
3+
}
4+
5+
dependencies {
6+
api(project(":typed-ids"))
7+
api(project(":typed-ids-openapi-swagger-jakarta"))
8+
api(libs.springdoc.openapi.starter.common)
9+
10+
compileOnly(libs.jetbrains.annotations)
11+
12+
compileOnly(libs.autoService.annotations)
13+
annotationProcessor(libs.autoService.processor)
14+
annotationProcessor(libs.springdoc.openapi.spring.configuration.processor)
15+
16+
testImplementation(project(":typed-ids-testing"))
17+
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
18+
}
19+
20+
project.description = "TypeIds seamless integration with SpringDoc OpenAPI"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package org.framefork.typedIds.springdoc.config;
2+
3+
import org.framefork.typedIds.swagger.TypedIdsModelConverter;
4+
import org.springframework.boot.autoconfigure.AutoConfiguration;
5+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
6+
import org.springframework.context.annotation.Bean;
7+
8+
@AutoConfiguration
9+
@EnableConfigurationProperties(TypedIdsOpenApiProperties.class)
10+
public class TypedIdsOpenApiAutoConfiguration
11+
{
12+
13+
private final TypedIdsOpenApiProperties properties;
14+
15+
public TypedIdsOpenApiAutoConfiguration(final TypedIdsOpenApiProperties properties)
16+
{
17+
this.properties = properties;
18+
}
19+
20+
@Bean
21+
public TypedIdsModelConverter typedIdsModelConverter()
22+
{
23+
TypedIdsModelConverter.idsAsRef = this.properties.getAsRef();
24+
return new TypedIdsModelConverter();
25+
}
26+
27+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package org.framefork.typedIds.springdoc.config;
2+
3+
import jakarta.validation.constraints.NotNull;
4+
import org.springframework.boot.context.properties.ConfigurationProperties;
5+
6+
@ConfigurationProperties(prefix = TypedIdsOpenApiProperties.PREFIX)
7+
public class TypedIdsOpenApiProperties
8+
{
9+
10+
public static final String PREFIX = "framefork.typed-ids.openapi";
11+
12+
@NotNull
13+
private Boolean asRef = true;
14+
15+
public Boolean getAsRef()
16+
{
17+
return asRef;
18+
}
19+
20+
public void setAsRef(final Boolean asRef)
21+
{
22+
this.asRef = asRef;
23+
}
24+
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
org.framefork.typedIds.springdoc.config.TypedIdsOpenApiAutoConfiguration
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
plugins {
2+
id("framefork.java-public")
3+
}
4+
5+
dependencies {
6+
api(project(":typed-ids"))
7+
api(libs.swagger.v3.core.jakarta)
8+
9+
compileOnly(libs.jetbrains.annotations)
10+
11+
compileOnly(libs.autoService.annotations)
12+
annotationProcessor(libs.autoService.processor)
13+
14+
testImplementation(project(":typed-ids-testing"))
15+
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
16+
}
17+
18+
project.description = "TypeIds seamless integration with swagger-core-jakarta, and any systems that use it"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package org.framefork.typedIds.swagger;
2+
3+
import com.google.auto.service.AutoService;
4+
import io.swagger.v3.core.converter.AnnotatedType;
5+
import io.swagger.v3.core.converter.ModelConverter;
6+
import io.swagger.v3.core.converter.ModelConverterContext;
7+
import io.swagger.v3.core.jackson.ModelResolver;
8+
import io.swagger.v3.oas.models.Components;
9+
import io.swagger.v3.oas.models.media.Schema;
10+
import org.framefork.typedIds.TypedId;
11+
import org.jspecify.annotations.Nullable;
12+
13+
import java.util.Iterator;
14+
import java.util.Objects;
15+
16+
@AutoService(ModelConverter.class)
17+
public class TypedIdsModelConverter implements ModelConverter
18+
{
19+
20+
public static final String IDS_AS_REFS_PROPERTY_NAME = "framefork.typed-ids.openapi.as-ref";
21+
22+
/**
23+
* Allows all VO-IDs to be resolved as a reference to a scheme added to the components section.
24+
* <p/>
25+
* This is not a very good way to configure something, but it is consistent with {@link ModelResolver#enumsAsRef}
26+
*/
27+
@SuppressWarnings("NonFinalStaticField")
28+
public static boolean idsAsRef = Objects.equals(System.getProperty(IDS_AS_REFS_PROPERTY_NAME, "true"), "true");
29+
30+
@SuppressWarnings("rawtypes")
31+
@Nullable
32+
@Override
33+
public Schema resolve(final AnnotatedType type, final ModelConverterContext context, final Iterator<ModelConverter> chain)
34+
{
35+
final Class<?> rawClass = TypedIdsSchemaUtils.rawClassOf(type.getType());
36+
if (rawClass != null && TypedId.class.isAssignableFrom(rawClass)) {
37+
var schema = TypedIdsSchemaUtils.createSchema(rawClass);
38+
39+
if (idsAsRef) {
40+
var schemaRef = new Schema().$ref(Components.COMPONENTS_SCHEMAS_REF + schema.getName());
41+
context.defineModel(schema.getName(), schema, rawClass, null);
42+
return schemaRef;
43+
44+
} else {
45+
return schema.name(null);
46+
}
47+
}
48+
49+
return chain.hasNext() ? chain.next().resolve(type, context, chain) : null;
50+
}
51+
52+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package org.framefork.typedIds.swagger;
2+
3+
import com.fasterxml.jackson.databind.type.SimpleType;
4+
import io.swagger.v3.core.converter.AnnotatedType;
5+
import io.swagger.v3.core.converter.ModelConverterContext;
6+
import io.swagger.v3.core.converter.ModelConverterContextImpl;
7+
import io.swagger.v3.core.converter.ModelConverters;
8+
import io.swagger.v3.oas.models.media.IntegerSchema;
9+
import io.swagger.v3.oas.models.media.Schema;
10+
import io.swagger.v3.oas.models.media.StringSchema;
11+
import org.framefork.typedIds.TypedId;
12+
import org.framefork.typedIds.bigint.ObjectBigIntId;
13+
import org.framefork.typedIds.uuid.ObjectUuid;
14+
import org.jetbrains.annotations.ApiStatus;
15+
import org.jspecify.annotations.Nullable;
16+
17+
import java.lang.reflect.ParameterizedType;
18+
import java.lang.reflect.Type;
19+
import java.util.Optional;
20+
import java.util.concurrent.ConcurrentHashMap;
21+
import java.util.function.Predicate;
22+
23+
@SuppressWarnings("rawtypes")
24+
@ApiStatus.Internal
25+
public final class TypedIdsSchemaUtils
26+
{
27+
28+
public static final int UUID_LENGTH = 36;
29+
30+
private final static ConcurrentHashMap<Class<?>, Schema> DEFAULT_SCHEMAS = new ConcurrentHashMap<>();
31+
32+
private TypedIdsSchemaUtils()
33+
{
34+
}
35+
36+
public static Schema createSchema(final Class<?> rawClass)
37+
{
38+
if (ObjectUuid.class.isAssignableFrom(rawClass)) {
39+
return createUuidSchema(rawClass);
40+
41+
} else if (ObjectBigIntId.class.isAssignableFrom(rawClass)) {
42+
return createBigIntSchema(rawClass);
43+
}
44+
45+
throw new IllegalArgumentException("Given class is not a subtype of %s, %s was given".formatted(TypedId.class, rawClass));
46+
}
47+
48+
public static Schema createUuidSchema(final Class<?> rawClass)
49+
{
50+
// Map UUID-based typed IDs to primitive string:uuid in OpenAPI
51+
var result = new StringSchema()
52+
.format("uuid")
53+
.minLength(UUID_LENGTH)
54+
.maxLength(UUID_LENGTH);
55+
56+
return applyDefaultSchema(result, getDefaultSchema(rawClass));
57+
}
58+
59+
public static Schema createBigIntSchema(final Class<?> rawClass)
60+
{
61+
// Map bigint-based typed IDs to primitive integer:int64 in OpenAPI
62+
var result = new IntegerSchema()
63+
.format("int64");
64+
65+
return applyDefaultSchema(result, getDefaultSchema(rawClass));
66+
}
67+
68+
private static Schema applyDefaultSchema(final Schema result, final Schema defaultSchema)
69+
{
70+
Optional.ofNullable(defaultSchema.getName())
71+
.filter(Predicate.not(String::isBlank))
72+
.ifPresent(result::setName);
73+
Optional.ofNullable(defaultSchema.getDescription())
74+
.filter(Predicate.not(String::isBlank))
75+
.ifPresent(result::setDescription);
76+
return result;
77+
}
78+
79+
public static Schema getDefaultSchema(final Class<?> rawClass)
80+
{
81+
return DEFAULT_SCHEMAS.computeIfAbsent(rawClass, TypedIdsSchemaUtils::computeDefaultSchema);
82+
}
83+
84+
private static Schema computeDefaultSchema(final Class<?> rawClass)
85+
{
86+
var converterContext = getModelConverterContext();
87+
var schema = converterContext.resolve(
88+
new AnnotatedType()
89+
.type(rawClass)
90+
.resolveAsRef(false)
91+
);
92+
93+
var schemaAnnotation = rawClass.getDeclaredAnnotation(io.swagger.v3.oas.annotations.media.Schema.class);
94+
var declaredName = Optional.ofNullable(schemaAnnotation).map(io.swagger.v3.oas.annotations.media.Schema::name).filter(Predicate.not(String::isBlank)).isPresent();
95+
if (!declaredName) {
96+
schema.setName(getAutomaticSchemaNameForClass(rawClass));
97+
98+
} else {
99+
if (schema.getName() == null || schema.getName().isBlank()) {
100+
schema.setName(getAutomaticSchemaNameForClass(rawClass));
101+
}
102+
}
103+
104+
if (schema.getDescription() == null || schema.getDescription().isBlank()) {
105+
schema.setDescription("ID of type " + schema.getName());
106+
}
107+
108+
return schema;
109+
}
110+
111+
@Nullable
112+
public static Class<?> rawClassOf(final Type type)
113+
{
114+
if (type instanceof SimpleType simpleType) {
115+
return simpleType.getRawClass();
116+
}
117+
if (type instanceof Class<?> c) {
118+
return c;
119+
}
120+
if (type instanceof ParameterizedType p) {
121+
final Type raw = p.getRawType();
122+
if (raw instanceof Class<?> c) {
123+
return c;
124+
}
125+
}
126+
return null;
127+
}
128+
129+
private static ModelConverterContext getModelConverterContext()
130+
{
131+
var converters = ModelConverters.getInstance().getConverters().stream()
132+
.filter(converter -> !(converter instanceof TypedIdsModelConverter))
133+
.toList();
134+
return new ModelConverterContextImpl(converters);
135+
}
136+
137+
/**
138+
* this automatically fixes names of `User.Id` which would otherwise become `Id` but we want it to be `UserId`
139+
*/
140+
private static String getAutomaticSchemaNameForClass(final Class<?> clazz)
141+
{
142+
final Class<?> enclosing = clazz.getEnclosingClass();
143+
if (enclosing == null) {
144+
return clazz.getSimpleName();
145+
}
146+
final String outer = getAutomaticSchemaNameForClass(enclosing);
147+
final String inner = clazz.getSimpleName();
148+
return outer + "_" + inner;
149+
}
150+
151+
}

0 commit comments

Comments
 (0)