Skip to content

Commit 00e80bf

Browse files
ccudennec-ottotimonbacksam0r040
authored
feat(core): Support for nullable types (#1416)
* feat(core): Support for nullable types Nullable types now correctly have the "null" type in addition to the original type. Example: ```java class Person { @Schema(nullable = true) private String name; } ``` will be rendered as ```yaml Person title: Person type: object properties: name: types: [ "string", "null" ] ``` * Revert change * "type" can be a string or an array removing "types" * feat(core): Support for nullable types Nullable types now correctly have the "null" type in addition to the original type. Example: ```java class Person { @Schema(nullable = true) private String name; } ``` will be rendered as ```yaml Person title: Person type: object properties: name: types: [ "string", "null" ] ``` * Revert change * "type" can be a string or an array removing "types" * define type in schema models as `string | string[]` * feat(core): additions to support nullability in schema * feat(core): enum values may be null * feat: add nullability to kafka example * feat(ui): update schema for validation * chore(core): implement review comment --------- Co-authored-by: Timon Back <timonback@users.noreply.github.com> Co-authored-by: sam0r040 <93372330+sam0r040@users.noreply.github.com>
1 parent 193d53b commit 00e80bf

File tree

24 files changed

+572
-43
lines changed

24 files changed

+572
-43
lines changed

springwolf-add-ons/springwolf-json-schema/src/main/java/io/github/springwolf/addons/json_schema/JsonSchemaGenerator.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ private ObjectNode mapToJsonSchema(
7474
if (schema.getEnumValues() != null) {
7575
ArrayNode arrayNode = objectMapper.createArrayNode();
7676
for (Object property : schema.getEnumValues()) {
77-
arrayNode.add(property.toString());
77+
arrayNode.add(property == null ? null : property.toString());
7878
}
7979
node.set("enum", arrayNode);
8080
}
@@ -141,7 +141,13 @@ private ObjectNode mapToJsonSchema(
141141
node.put("title", schema.getTitle());
142142
}
143143
if (schema.getType() != null) {
144-
node.put("type", schema.getType());
144+
if (schema.getType().size() == 1) {
145+
node.put("type", schema.getType().iterator().next());
146+
} else if (schema.getType().size() > 1) {
147+
ArrayNode arrayNode = objectMapper.createArrayNode();
148+
schema.getType().forEach(arrayNode::add);
149+
node.set("type", arrayNode);
150+
}
145151
}
146152
if (schema.getUniqueItems() != null) {
147153
node.put("uniqueItems", schema.getUniqueItems());

springwolf-add-ons/springwolf-json-schema/src/test/java/io/github/springwolf/addons/json_schema/JsonSchemaGeneratorTest.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,8 @@ public static Stream<Arguments> validateJsonSchemaTest() {
142142
schema.setEnum(List.of("test", "value2"));
143143
return schema;
144144
}),
145-
Arguments.of(
146-
"{\"enum\": [\"test\", \"value2\"],\"type\":\"string\",\"$schema\":\"https://json-schema.org/draft-04/schema#\"}",
145+
Arguments.of( // uses OpenAPI 3.1 for enum + null
146+
"{\"enum\": [\"test\", \"value2\", null],\"type\":[\"string\",\"null\"],\"$schema\":\"https://json-schema.org/draft-04/schema#\"}",
147147
(Supplier<Schema<?>>) () -> {
148148
StringSchema schema = new StringSchema();
149149
schema.setEnum(List.of("test", "value2"));
@@ -165,6 +165,13 @@ public static Stream<Arguments> validateJsonSchemaTest() {
165165
schema.setFormat("test");
166166
return schema;
167167
}),
168+
Arguments.of(
169+
"{\"type\":[\"string\",\"null\"],\"$schema\":\"https://json-schema.org/draft-04/schema#\"}",
170+
(Supplier<Schema<?>>) () -> {
171+
StringSchema schema = new StringSchema();
172+
schema.setNullable(true);
173+
return schema;
174+
}),
168175
Arguments.of(
169176
"{\"items\": {\"type\":\"number\"},\"type\":\"array\",\"$schema\":\"https://json-schema.org/draft-04/schema#\"}",
170177
(Supplier<Schema<?>>) () -> {

springwolf-asyncapi/src/main/java/io/github/springwolf/asyncapi/v3/model/schema/SchemaObject.java

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
package io.github.springwolf.asyncapi.v3.model.schema;
33

44
import com.fasterxml.jackson.annotation.JsonProperty;
5+
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
56
import io.github.springwolf.asyncapi.v3.model.ExtendableObject;
67
import io.github.springwolf.asyncapi.v3.model.ExternalDocumentation;
78
import io.github.springwolf.asyncapi.v3.model.components.ComponentSchema;
@@ -15,6 +16,7 @@
1516
import java.math.BigDecimal;
1617
import java.util.List;
1718
import java.util.Map;
19+
import java.util.Set;
1820

1921
/**
2022
* The Schema Object allows the definition of input and output data types. These types can be objects, but also
@@ -58,8 +60,9 @@ public class SchemaObject extends ExtendableObject implements Schema {
5860
@JsonProperty(value = "title")
5961
private String title;
6062

63+
@JsonSerialize(using = SchemaType.Serializer.class)
6164
@JsonProperty(value = "type")
62-
private String type;
65+
private Set<String> type;
6366

6467
@JsonProperty(value = "properties")
6568
private Map<String, Object> properties;
@@ -139,4 +142,28 @@ public class SchemaObject extends ExtendableObject implements Schema {
139142

140143
@JsonProperty(value = "maxItems")
141144
private Integer maxItems;
145+
146+
public void setType(String type) {
147+
// maintainer note: review with OpenAPI 3.1
148+
this.type = Set.of(type);
149+
}
150+
151+
public void setTypes(Set<String> types) {
152+
// maintainer note: review with OpenAPI 3.1
153+
this.type = types;
154+
}
155+
156+
public static class SchemaObjectBuilder {
157+
// maintainer note: remove custom builder in next major release and use Lomboks provided version
158+
159+
public SchemaObjectBuilder type(Set<String> type) {
160+
this.type = type;
161+
return this;
162+
}
163+
164+
public SchemaObjectBuilder type(String type) {
165+
this.type = Set.of(type);
166+
return this;
167+
}
168+
}
142169
}

springwolf-asyncapi/src/main/java/io/github/springwolf/asyncapi/v3/model/schema/SchemaType.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
// SPDX-License-Identifier: Apache-2.0
22
package io.github.springwolf.asyncapi.v3.model.schema;
33

4+
import com.fasterxml.jackson.core.JsonGenerator;
5+
import com.fasterxml.jackson.databind.JsonSerializer;
6+
import com.fasterxml.jackson.databind.SerializerProvider;
7+
8+
import java.io.IOException;
9+
import java.util.Collection;
10+
411
public class SchemaType {
512
public static final String NULL = "null";
613
public static final String BOOLEAN = "boolean";
@@ -11,4 +18,34 @@ public class SchemaType {
1118
public static final String INTEGER = "integer";
1219

1320
private SchemaType() {}
21+
22+
public static class Serializer extends JsonSerializer<Object> {
23+
24+
public Serializer() {}
25+
26+
@Override
27+
public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
28+
if (value == null) {
29+
gen.writeNull();
30+
return;
31+
}
32+
33+
if (value instanceof String) {
34+
gen.writeString(value.toString());
35+
return;
36+
}
37+
38+
if (value instanceof Collection<?> collection) {
39+
var stringValues = collection.stream()
40+
.filter(v -> v instanceof String)
41+
.map(v -> (String) v)
42+
.toList();
43+
if (stringValues.size() == 1) {
44+
gen.writeString(stringValues.iterator().next());
45+
} else {
46+
gen.writeArray(stringValues.toArray(new String[0]), 0, collection.size());
47+
}
48+
}
49+
}
50+
}
1451
}

springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/scanners/common/utils/TextUtils.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
package io.github.springwolf.core.asyncapi.scanners.common.utils;
33

44
import org.apache.commons.lang3.StringUtils;
5+
import org.apache.commons.lang3.Strings;
56

67
import java.util.Arrays;
78

@@ -36,7 +37,7 @@ public static String trimIndent(String text) {
3637
.reduce((a, b) -> a + newLine + b)
3738
.orElse(StringUtils.EMPTY);
3839

39-
if (StringUtils.endsWith(text, "\n")) {
40+
if (Strings.CS.endsWith(text, "\n")) {
4041
result = result.concat(newLine);
4142
}
4243

springwolf-core/src/main/java/io/github/springwolf/core/asyncapi/schemas/SwaggerSchemaUtil.java

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@
77
import io.github.springwolf.asyncapi.v3.model.schema.SchemaFormat;
88
import io.github.springwolf.asyncapi.v3.model.schema.SchemaObject;
99
import io.github.springwolf.asyncapi.v3.model.schema.SchemaReference;
10+
import io.github.springwolf.asyncapi.v3.model.schema.SchemaType;
1011
import io.swagger.v3.oas.models.media.Schema;
1112
import lombok.RequiredArgsConstructor;
1213
import org.springframework.lang.Nullable;
1314

15+
import java.util.ArrayList;
16+
import java.util.HashSet;
1417
import java.util.List;
1518
import java.util.Map;
19+
import java.util.Set;
1620
import java.util.stream.Collectors;
1721

1822
/**
@@ -67,7 +71,8 @@ public SchemaObject mapSchema(Schema value) {
6771

6872
builder.title(value.getTitle());
6973

70-
builder.type(value.getType());
74+
boolean isNullable = Boolean.TRUE.equals(value.getNullable());
75+
assignType(value, builder, isNullable);
7176

7277
Map<String, Schema> properties = value.getProperties();
7378
if (properties != null) {
@@ -103,9 +108,14 @@ public SchemaObject mapSchema(Schema value) {
103108
builder.minLength(value.getMinLength());
104109
builder.maxLength(value.getMaxLength());
105110

106-
List<Object> anEnum = value.getEnum();
111+
List<?> anEnum = value.getEnum();
107112
if (anEnum != null) {
108-
builder.enumValues(anEnum.stream().map(Object::toString).toList());
113+
List<String> enumStringValues =
114+
anEnum.stream().map(Object::toString).collect(Collectors.toCollection(ArrayList::new));
115+
if (isNullable) {
116+
enumStringValues.add(null);
117+
}
118+
builder.enumValues(enumStringValues);
109119
}
110120

111121
Object example = value.getExample();
@@ -171,6 +181,22 @@ public SchemaObject mapSchema(Schema value) {
171181
return builder.build();
172182
}
173183

184+
private static void assignType(Schema value, SchemaObject.SchemaObjectBuilder builder, boolean isNullable) {
185+
Set<String> types = value.getTypes() == null ? new HashSet<>() : new HashSet<String>(value.getTypes());
186+
if (!types.contains(value.getType())) {
187+
// contradicting types; prefer type for backward compatibility
188+
// maintainer note: remove condition in next major release
189+
builder.type(value.getType());
190+
return;
191+
}
192+
193+
if (isNullable) {
194+
types.add("null");
195+
}
196+
197+
builder.type(types);
198+
}
199+
174200
/**
175201
* expects an object representing an schema and tries to unwrap this schema, if it is a
176202
* ComponentSchema or MultiFormatSchema. The method works recursive on unwrapped Objects.
@@ -241,7 +267,13 @@ public Schema<?> mapToSwagger(Object schema) {
241267
*/
242268
private Schema mapSchemaObjectToSwagger(SchemaObject asyncApiSchema) {
243269
Schema swaggerSchema = new Schema();
244-
swaggerSchema.setType(asyncApiSchema.getType());
270+
if (asyncApiSchema.getType() != null) {
271+
swaggerSchema.setType(asyncApiSchema.getType().stream()
272+
.filter(type -> !type.equals(SchemaType.NULL))
273+
.findFirst()
274+
.orElse(null));
275+
swaggerSchema.setTypes(asyncApiSchema.getType());
276+
}
245277
// swaggerSchema.setFormat(asyncApiSchema.getFormat());
246278
swaggerSchema.setDescription(asyncApiSchema.getDescription());
247279
swaggerSchema.setExamples(asyncApiSchema.getExamples());

springwolf-core/src/main/java/io/github/springwolf/core/controller/PublishingPayloadCreator.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,11 @@ public Result createPayloadObject(MessageDto message) {
6767

6868
private Object resolveActualPayload(MessageDto message, SchemaObject schema, String schemaName)
6969
throws ClassNotFoundException, JsonProcessingException, IllegalArgumentException {
70-
switch (schema.getType()) {
70+
String firstNonNullType = schema.getType().stream()
71+
.filter(type -> !type.equals(SchemaType.NULL))
72+
.findFirst()
73+
.orElseThrow(() -> new IllegalArgumentException("Unsupported schema type: null"));
74+
switch (firstNonNullType) {
7175
case SchemaType.BOOLEAN -> {
7276
return objectMapper.readValue(message.getPayload(), Boolean.class);
7377
}
@@ -84,7 +88,7 @@ private Object resolveActualPayload(MessageDto message, SchemaObject schema, Str
8488
case SchemaType.STRING -> {
8589
return objectMapper.readValue(message.getPayload(), String.class);
8690
}
87-
default -> throw new IllegalArgumentException("Unsupported schema type: " + schema.getType());
91+
default -> throw new IllegalArgumentException("Unsupported schema type: " + firstNonNullType);
8892
}
8993
}
9094

springwolf-core/src/test/java/io/github/springwolf/core/asyncapi/components/DefaultJsonComponentsServiceIntegrationTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ void getListWrapperDefinitions() throws IOException {
117117
String expected = loadDefinition("/schemas/json/generics-wrapper-definitions.json", actualDefinitions);
118118

119119
System.out.println("Got: " + actualDefinitions);
120-
assertEquals(expected, actualDefinitions);
120+
assertThat(actualDefinitions).isEqualTo(expected);
121121
}
122122

123123
private String loadDefinition(String path, String content) throws IOException {

0 commit comments

Comments
 (0)