Skip to content

Commit 9fdfd95

Browse files
authored
Adds a default validation exception handler (#670)
* Add a default validation exception handler * Update pom.xml * Update pom.xml * Update HttpValidatorHandler.java * follow spec * Update ValidationResponse.java * Update ValidationResponse.java * always json * streaming * fix helidon plugin overridding all
1 parent ab9ada9 commit 9fdfd95

File tree

9 files changed

+289
-7
lines changed

9 files changed

+289
-7
lines changed

http-inject-plugin/pom.xml

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
<dependencies>
2020
<dependency>
2121
<groupId>io.avaje</groupId>
22-
<artifactId>avaje-inject</artifactId>
22+
<artifactId>avaje-inject-generator</artifactId>
2323
<version>12.0</version>
2424
<scope>provided</scope>
2525
<optional>true</optional>
@@ -38,6 +38,28 @@
3838
<scope>provided</scope>
3939
<optional>true</optional>
4040
</dependency>
41+
<dependency>
42+
<groupId>io.javalin</groupId>
43+
<artifactId>javalin</artifactId>
44+
<version>6.7.0</version>
45+
<scope>provided</scope>
46+
<optional>true</optional>
47+
</dependency>
48+
<dependency>
49+
<groupId>io.avaje</groupId>
50+
<artifactId>avaje-jex</artifactId>
51+
<version>3.3</version>
52+
<scope>provided</scope>
53+
<optional>true</optional>
54+
</dependency>
55+
<dependency>
56+
<groupId>io.helidon.webserver</groupId>
57+
<artifactId>helidon-webserver</artifactId>
58+
<version>4.3.2</version>
59+
<scope>provided</scope>
60+
<optional>true</optional>
61+
</dependency>
62+
4163
</dependencies>
4264

4365
<build>

http-inject-plugin/src/main/java/io/avaje/http/inject/DefaultResolverProvider.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
import io.avaje.http.api.context.ThreadLocalRequestContextResolver;
55
import io.avaje.inject.BeanScopeBuilder;
66
import io.avaje.inject.spi.InjectPlugin;
7-
import io.avaje.spi.ServiceProvider;
7+
import io.avaje.inject.spi.PluginProvides;
88

99
/** Plugin for avaje inject that provides a default RequestContextResolver instance. */
10-
@ServiceProvider
10+
@PluginProvides
1111
public final class DefaultResolverProvider implements InjectPlugin {
1212

1313
@Override
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package io.avaje.http.inject;
2+
3+
import java.io.IOException;
4+
import java.io.UncheckedIOException;
5+
6+
import io.avaje.http.api.ValidationException;
7+
import io.helidon.common.Weight;
8+
import io.helidon.webserver.http.HttpFeature;
9+
import io.helidon.webserver.http.HttpRouting.Builder;
10+
import io.helidon.webserver.http.ServerRequest;
11+
import io.helidon.webserver.http.ServerResponse;
12+
13+
@Weight(-67) // execute first so that it can be overridden by a custom error handler.
14+
final class HelidonHandler implements HttpFeature {
15+
16+
@Override
17+
public void setup(Builder routing) {
18+
routing.error(ValidationException.class, this::handle);
19+
}
20+
21+
private void handle(ServerRequest req, ServerResponse res, ValidationException ex) {
22+
try (var os =
23+
res.status(ex.getStatus())
24+
.header("Content-Type", "application/problem+json")
25+
.outputStream()) {
26+
new ValidationResponse(ex.getStatus(), ex.getErrors(), req.path().rawPath()).toJson(os);
27+
} catch (IOException e) {
28+
throw new UncheckedIOException(e);
29+
}
30+
}
31+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package io.avaje.http.inject;
2+
3+
import io.avaje.http.api.AvajeJavalinPlugin;
4+
import io.avaje.inject.BeanScopeBuilder;
5+
import io.avaje.inject.spi.InjectPlugin;
6+
import io.avaje.inject.spi.PluginProvides;
7+
import io.avaje.jex.Routing.HttpService;
8+
import io.helidon.webserver.http.HttpFeature;
9+
10+
/**
11+
* Plugin for avaje inject that provides a default Validator Handler
12+
*/
13+
@PluginProvides(
14+
providesStrings = {
15+
"io.helidon.webserver.http.HttpFeature",
16+
"io.avaje.http.api.AvajeJavalinPlugin",
17+
"io.avaje.jex.Routing.HttpService",
18+
})
19+
public final class HttpValidatorErrorPlugin implements InjectPlugin {
20+
21+
@Override
22+
public void apply(BeanScopeBuilder builder) {
23+
ModuleLayer bootLayer = ModuleLayer.boot();
24+
25+
bootLayer.findModule("io.avaje.http.plugin")
26+
.ifPresentOrElse(m -> {
27+
if (bootLayer.findModule("io.avaje.jex").isPresent()) {
28+
builder.provideDefault(HttpService.class, JexHandler::new);
29+
} else if (bootLayer.findModule("io.helidon.webserver").isPresent()) {
30+
builder.provideDefault(HttpFeature.class, HelidonHandler::new);
31+
} else if (bootLayer.findModule("io.javalin").isPresent()) {
32+
builder.provideDefault(AvajeJavalinPlugin.class, JavalinHandler::new);
33+
}
34+
},
35+
() -> {
36+
try {
37+
builder.provideDefault(HttpService.class, JexHandler::new);
38+
return;
39+
} catch (NoClassDefFoundError e) {
40+
// not present
41+
}
42+
try {
43+
builder.provideDefault(HttpFeature.class, HelidonHandler::new);
44+
return;
45+
} catch (NoClassDefFoundError e) {
46+
// not present
47+
}
48+
try {
49+
builder.provideDefault(AvajeJavalinPlugin.class, JavalinHandler::new);
50+
} catch (NoClassDefFoundError e) {
51+
// not present
52+
}
53+
});
54+
}
55+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package io.avaje.http.inject;
2+
3+
import java.io.IOException;
4+
import java.io.UncheckedIOException;
5+
6+
import io.avaje.http.api.AvajeJavalinPlugin;
7+
import io.avaje.http.api.ValidationException;
8+
import io.javalin.config.JavalinConfig;
9+
import io.javalin.http.Context;
10+
11+
final class JavalinHandler extends AvajeJavalinPlugin {
12+
13+
@Override
14+
public void onStart(JavalinConfig config) {
15+
config.router.mount(r -> r.exception(ValidationException.class, this::handler));
16+
}
17+
18+
private void handler(ValidationException ex, Context ctx) {
19+
try (var os =
20+
ctx.contentType("application/problem+json").status(ex.getStatus()).outputStream()) {
21+
new ValidationResponse(ex.getStatus(), ex.getErrors(), ctx.path()).toJson(os);
22+
} catch (IOException e) {
23+
throw new UncheckedIOException(e);
24+
}
25+
}
26+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package io.avaje.http.inject;
2+
3+
import java.io.IOException;
4+
import java.io.UncheckedIOException;
5+
6+
import io.avaje.http.api.ValidationException;
7+
import io.avaje.jex.Routing;
8+
import io.avaje.jex.Routing.HttpService;
9+
import io.avaje.jex.http.Context;
10+
11+
final class JexHandler implements HttpService {
12+
13+
@Override
14+
public void add(Routing arg0) {
15+
arg0.error(ValidationException.class, this::handler);
16+
}
17+
18+
private void handler(Context ctx, ValidationException ex) {
19+
try (var os =
20+
ctx.contentType("application/problem+json").status(ex.getStatus()).outputStream()) {
21+
new ValidationResponse(ex.getStatus(), ex.getErrors(), ctx.path()).toJson(os);
22+
} catch (IOException e) {
23+
throw new UncheckedIOException(e);
24+
}
25+
}
26+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package io.avaje.http.inject;
2+
3+
import java.io.IOException;
4+
import java.io.OutputStream;
5+
import java.io.OutputStreamWriter;
6+
import java.io.Writer;
7+
import java.nio.charset.StandardCharsets;
8+
import java.util.List;
9+
10+
import io.avaje.http.api.ValidationException.Violation;
11+
12+
final class ValidationResponse {
13+
14+
private static final String title = "Request Failed Validation";
15+
private static final String detail = "You tried to call this endpoint, but a io.avaje.http.api.ValidationException was thrown";
16+
private final int status;
17+
private final List<Violation> errors;
18+
private final String instance;
19+
20+
ValidationResponse(int status, List<Violation> errors, String instance) {
21+
this.status = status;
22+
this.errors = errors;
23+
this.instance = instance;
24+
}
25+
26+
// custom serialize as this is a simple class
27+
void toJson(OutputStream os) throws IOException {
28+
try (Writer writer = new OutputStreamWriter(os, StandardCharsets.UTF_8)) {
29+
writeJsonInternal(writer);
30+
}
31+
}
32+
33+
private void writeJsonInternal(Writer writer) throws IOException {
34+
writer.write('{');
35+
writeKeyValue("title", title, writer);
36+
writer.write(',');
37+
writeKeyValue("detail", detail, writer);
38+
writer.write(',');
39+
writeKeyValue("instance", instance, writer);
40+
writer.write(',');
41+
// status is a number, so no quotes or escaping needed
42+
writer.write("\"status\":");
43+
writer.write(String.valueOf(status));
44+
writer.write(",\"errors\":[");
45+
for (int i = 0; i < errors.size(); i++) {
46+
if (i > 0) {
47+
writer.write(',');
48+
}
49+
var e = errors.get(i);
50+
writer.write('{');
51+
writeKeyValue("path", e.getPath(), writer);
52+
writer.write(',');
53+
writeKeyValue("field", e.getField(), writer);
54+
writer.write(',');
55+
writeKeyValue("message", e.getMessage(), writer);
56+
writer.write('}');
57+
}
58+
59+
writer.write(']');
60+
writer.write('}');
61+
writer.flush();
62+
}
63+
64+
/** Writes a JSON key-value pair where the value is a string, handling quotes and escaping. */
65+
private void writeKeyValue(String key, String value, Writer writer) throws IOException {
66+
writer.write('"');
67+
writer.write(key);
68+
writer.write("\":");
69+
writeEscapedJsonString(value, writer);
70+
}
71+
72+
/**
73+
* Writes the given string to the writer, JSON-escaping it and wrapping it in quotes. Writes
74+
* 'null' (the JSON literal) if the input string is null.
75+
*/
76+
private static void writeEscapedJsonString(String s, Writer writer) throws IOException {
77+
if (s == null) {
78+
writer.write("null");
79+
return;
80+
}
81+
82+
writer.write('"');
83+
for (int i = 0; i < s.length(); i++) {
84+
char ch = s.charAt(i);
85+
switch (ch) {
86+
case '"':
87+
writer.write("\\\"");
88+
break;
89+
case '\\':
90+
writer.write("\\\\");
91+
break;
92+
case '\b':
93+
writer.write("\\b");
94+
break;
95+
case '\f':
96+
writer.write("\\f");
97+
break;
98+
case '\n':
99+
writer.write("\\n");
100+
break;
101+
case '\r':
102+
writer.write("\\r");
103+
break;
104+
case '\t':
105+
writer.write("\\t");
106+
break;
107+
default:
108+
// Check for control characters that must be escaped
109+
if (ch < ' ' || ch >= 0x7F && ch <= 0x9F) {
110+
writer.write(String.format("\\u%04x", (int) ch));
111+
} else {
112+
writer.write(ch);
113+
}
114+
}
115+
}
116+
writer.write('"');
117+
}
118+
}
Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
import io.avaje.http.inject.DefaultResolverProvider;
2+
import io.avaje.http.inject.HttpValidatorErrorPlugin;
3+
14
module io.avaje.http.plugin {
25

36
requires io.avaje.http.api;
47
requires io.avaje.inject;
5-
requires static io.avaje.spi;
6-
7-
provides io.avaje.inject.spi.InjectExtension with io.avaje.http.inject.DefaultResolverProvider;
8+
requires static io.avaje.jex;
9+
requires static io.javalin;
10+
requires static io.helidon.webserver;
11+
provides io.avaje.inject.spi.InjectExtension with DefaultResolverProvider, HttpValidatorErrorPlugin;
812
}

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@
4444
<module>http-client</module>
4545
<module>http-client-gson-adapter</module>
4646
<module>http-client-moshi-adapter</module>
47-
<module>http-inject-plugin</module>
4847
<module>http-generator-core</module>
4948
<module>http-generator-javalin</module>
5049
<module>http-generator-sigma</module>
@@ -68,6 +67,7 @@
6867
<jdk>[21,)</jdk>
6968
</activation>
7069
<modules>
70+
<module>http-inject-plugin</module>
7171
<module>htmx-nima</module>
7272
<module>htmx-nima-jstache</module>
7373
<module>http-generator-helidon</module>

0 commit comments

Comments
 (0)