Skip to content

Commit 2dc60b2

Browse files
committed
Add Result serializer
1 parent c7246b3 commit 2dc60b2

File tree

5 files changed

+220
-5
lines changed

5 files changed

+220
-5
lines changed

README.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -665,7 +665,7 @@ public class UserController {
665665

666666
### JSON Serialization
667667

668-
#### Default Structure
668+
#### `ValidationErrors` (Default Structure)
669669

670670
```json
671671
{
@@ -677,7 +677,7 @@ public class UserController {
677677
}
678678
```
679679

680-
#### Flattened Structure
680+
#### `ValidationErrors` (Flattened Structure)
681681

682682
Set `io.github.raniagus.javalidation.flatten-errors: true`:
683683

@@ -689,6 +689,28 @@ Set `io.github.raniagus.javalidation.flatten-errors: true`:
689689
}
690690
```
691691

692+
#### `Result<T>`
693+
694+
**Ok variant:**
695+
```java
696+
Result<String> result = Result.ok("success");
697+
// Serializes to: {"ok": true, "value": "success"}
698+
```
699+
700+
**Err variant:**
701+
```java
702+
Result<String> result = Result.err("email", "Invalid format");
703+
// Serializes to: {"ok": false, "errors": {"rootErrors": [], "fieldErrors": {"email": ["Invalid format"]}}}
704+
```
705+
706+
**Nested in API responses:**
707+
```java
708+
record ApiResponse(String id, Result<User> result) {}
709+
710+
ApiResponse response = new ApiResponse("123", Result.ok(new User("Alice", 30)));
711+
// Serializes to: {"id": "123", "result": {"ok": true, "value": {"name": "Alice", "age": 30}}}
712+
```
713+
692714
### Internationalization
693715

694716
Create message files:

javalidation-jackson/src/main/java/io/github/raniagus/javalidation/jackson/FlattenedErrorsSerializer.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public void serialize(ValidationErrors value, JsonGenerator gen, SerializationCo
1818
}
1919

2020
var entries = context.hasSerializationFeatures(ORDER_MAP_ENTRIES_BY_KEYS.getMask()) ?
21-
value.fieldErrors().entrySet().stream().sorted(Map.Entry.comparingByKey()).toList()
21+
value.fieldErrors().entrySet().stream().sorted(Map.Entry.comparingByKey()).toList()
2222
: value.fieldErrors().entrySet();
2323

2424
for (var entry : entries) {

javalidation-jackson/src/main/java/io/github/raniagus/javalidation/jackson/JavalidationModule.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.github.raniagus.javalidation.jackson;
22

3+
import io.github.raniagus.javalidation.Result;
34
import io.github.raniagus.javalidation.ValidationErrors;
45
import io.github.raniagus.javalidation.format.TemplateString;
56
import io.github.raniagus.javalidation.format.TemplateStringFormatter;
@@ -8,11 +9,12 @@
89
import tools.jackson.databind.module.SimpleModule;
910

1011
/**
11-
* Jackson module for serializing javalidation types ({@link TemplateString} and {@link ValidationErrors}).
12+
* Jackson module for serializing javalidation types ({@link Result}, {@link TemplateString}, and {@link ValidationErrors}).
1213
* <p>
13-
* This module registers custom serializers to control how validation errors are serialized to JSON.
14+
* This module registers custom serializers to control how validation types are serialized to JSON.
1415
* By default, it uses:
1516
* <ul>
17+
* <li>{@link ResultSerializer} for {@link Result} - discriminated union with {@code ok} boolean field</li>
1618
* <li>{@link TemplateStringSerializer} for {@link TemplateString} - formats templates using {@link TemplateStringFormatter}</li>
1719
* <li>{@link ValidationErrorsMixIn} for {@link ValidationErrors} - structures errors as {@code {root: [...], fields: {...}}}</li>
1820
* </ul>
@@ -38,6 +40,7 @@
3840
* .build();
3941
* }</pre>
4042
*
43+
* @see ResultSerializer
4144
* @see TemplateStringSerializer
4245
* @see FlattenedErrorsSerializer
4346
* @see ValidationErrorsMixIn
@@ -51,6 +54,7 @@ private JavalidationModule(
5154
) {
5255
super(JavalidationModule.class.getSimpleName());
5356

57+
addSerializer(new ResultSerializer());
5458
addSerializer(TemplateString.class, templateStringSerializer);
5559

5660
if (validationErrorsSerializer != null) {
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package io.github.raniagus.javalidation.jackson;
2+
3+
import static tools.jackson.databind.SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS;
4+
5+
import io.github.raniagus.javalidation.Result;
6+
import io.github.raniagus.javalidation.ValidationErrors;
7+
import tools.jackson.core.JsonGenerator;
8+
import tools.jackson.databind.SerializationContext;
9+
import tools.jackson.databind.ValueSerializer;
10+
11+
/**
12+
* Jackson serializer for {@link Result} types.
13+
* <p>
14+
* Serializes {@link Result} as a discriminated union with an {@code "ok"} boolean field:
15+
* <ul>
16+
* <li>{@link Result.Ok} is serialized as {@code {"ok": true, "value": ...}}</li>
17+
* <li>{@link Result.Err} is serialized as {@code {"ok": false, "errors": ...}}</li>
18+
* </ul>
19+
* <p>
20+
* The {@code value} field is serialized using the configured Jackson serializer for type {@code T}.
21+
* The {@code errors} field is serialized using the configured {@link ValidationErrors} serializer.
22+
* <p>
23+
* Example output:
24+
* <pre>{@code
25+
* // Result.ok("hello")
26+
* {"ok": true, "value": "hello"}
27+
*
28+
* // Result.err("Invalid input")
29+
* {"ok": false, "errors": {"rootErrors": ["Invalid input"], "fieldErrors": {}}}
30+
* }</pre>
31+
*
32+
* @see Result
33+
* @see JavalidationModule
34+
*/
35+
class ResultSerializer extends ValueSerializer<Result<?>> {
36+
37+
@Override
38+
public Class<?> handledType() {
39+
return Result.class;
40+
}
41+
42+
@Override
43+
public void serialize(Result result, JsonGenerator gen, SerializationContext context) {
44+
gen.writeStartObject();
45+
46+
switch (result) {
47+
case Result.Ok<?>(Object value) -> {
48+
context.defaultSerializeProperty("ok", true, gen);
49+
context.defaultSerializeProperty("value", value, gen);
50+
}
51+
case Result.Err<?>(ValidationErrors errors) -> {
52+
if (context.hasSerializationFeatures(ORDER_MAP_ENTRIES_BY_KEYS.getMask())) {
53+
context.defaultSerializeProperty("errors", errors, gen);
54+
context.defaultSerializeProperty("ok", false, gen);
55+
} else {
56+
context.defaultSerializeProperty("ok", false, gen);
57+
context.defaultSerializeProperty("errors", errors, gen);
58+
}
59+
}
60+
}
61+
62+
gen.writeEndObject();
63+
}
64+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package io.github.raniagus.javalidation.jackson;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import io.github.raniagus.javalidation.Result;
6+
import io.github.raniagus.javalidation.Validation;
7+
import io.github.raniagus.javalidation.ValidationErrors;
8+
import org.junit.jupiter.api.BeforeEach;
9+
import org.junit.jupiter.api.Test;
10+
import tools.jackson.databind.SerializationFeature;
11+
import tools.jackson.databind.json.JsonMapper;
12+
13+
class ResultSerializerTest {
14+
private JsonMapper mapper;
15+
16+
@BeforeEach
17+
void setUp() {
18+
mapper = JsonMapper.builder()
19+
.addModule(JavalidationModule.getDefault())
20+
.enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
21+
.build();
22+
}
23+
24+
// -- serialize Ok --
25+
26+
@Test
27+
void givenOkWithString_whenSerialize_thenWritesTypeAndValue() {
28+
Result<String> result = Result.ok("hello");
29+
30+
String json = mapper.writeValueAsString(result);
31+
32+
assertThat(json).isEqualTo("""
33+
{"ok":true,"value":"hello"}\
34+
""");
35+
}
36+
37+
@Test
38+
void givenOkWithInteger_whenSerialize_thenWritesTypeAndValue() {
39+
Result<Integer> result = Result.ok(42);
40+
41+
String json = mapper.writeValueAsString(result);
42+
43+
assertThat(json).isEqualTo("""
44+
{"ok":true,"value":42}\
45+
""");
46+
}
47+
48+
@Test
49+
void givenOkWithNull_whenSerialize_thenWritesTypeAndNullValue() {
50+
Result<String> result = Result.ok(null);
51+
52+
String json = mapper.writeValueAsString(result);
53+
54+
assertThat(json).isEqualTo("""
55+
{"ok":true,"value":null}\
56+
""");
57+
}
58+
59+
@Test
60+
void givenOkWithComplexObject_whenSerialize_thenWritesTypeAndNestedObject() {
61+
record Person(String name, int age) {}
62+
Result<Person> result = Result.ok(new Person("Alice", 30));
63+
64+
String json = mapper.writeValueAsString(result);
65+
66+
assertThat(json).isEqualTo("""
67+
{"ok":true,"value":{"name":"Alice","age":30}}\
68+
""");
69+
}
70+
71+
// -- serialize Err --
72+
73+
@Test
74+
void givenErrWithRootError_whenSerialize_thenWritesTypeAndErrors() {
75+
Result<String> result = Result.err("Invalid input");
76+
77+
String json = mapper.writeValueAsString(result);
78+
79+
assertThat(json).isEqualTo("""
80+
{"errors":{"rootErrors":["Invalid input"],"fieldErrors":{}},"ok":false}\
81+
""");
82+
}
83+
84+
@Test
85+
void givenErrWithFieldError_whenSerialize_thenWritesTypeAndErrors() {
86+
Result<String> result = Result.err("email", "Invalid format");
87+
88+
String json = mapper.writeValueAsString(result);
89+
90+
assertThat(json).isEqualTo("""
91+
{"errors":{"rootErrors":[],"fieldErrors":{"email":["Invalid format"]}},"ok":false}\
92+
""");
93+
}
94+
95+
@Test
96+
void givenErrWithMultipleErrors_whenSerialize_thenWritesAllErrors() {
97+
ValidationErrors errors = Validation.create()
98+
.addRootError("Global error")
99+
.addFieldError("name", "Required")
100+
.addFieldError("age", "Must be positive")
101+
.finish();
102+
Result<String> result = Result.err(errors);
103+
104+
String json = mapper.writeValueAsString(result);
105+
106+
assertThat(json).isEqualTo("""
107+
{"errors":{"rootErrors":["Global error"],"fieldErrors":{"age":["Must be positive"],"name":["Required"]}},"ok":false}\
108+
""");
109+
}
110+
111+
// -- nested in container --
112+
113+
@Test
114+
void givenResultInContainer_whenSerialize_thenSerializesNested() {
115+
record Response(String id, Result<String> result) {}
116+
Response response = new Response("123", Result.ok("success"));
117+
118+
String json = mapper.writeValueAsString(response);
119+
120+
assertThat(json).isEqualTo("""
121+
{"id":"123","result":{"ok":true,"value":"success"}}\
122+
""");
123+
}
124+
125+
}

0 commit comments

Comments
 (0)