diff --git a/http-inject-plugin/pom.xml b/http-inject-plugin/pom.xml index 93c2b6966..329d0281e 100644 --- a/http-inject-plugin/pom.xml +++ b/http-inject-plugin/pom.xml @@ -19,7 +19,7 @@ io.avaje - avaje-inject + avaje-inject-generator 12.0 provided true @@ -38,6 +38,28 @@ provided true + + io.javalin + javalin + 6.7.0 + provided + true + + + io.avaje + avaje-jex + 3.3 + provided + true + + + io.helidon.webserver + helidon-webserver + 4.3.2 + provided + true + + diff --git a/http-inject-plugin/src/main/java/io/avaje/http/inject/DefaultResolverProvider.java b/http-inject-plugin/src/main/java/io/avaje/http/inject/DefaultResolverProvider.java index b22eb7714..e44ab9d84 100644 --- a/http-inject-plugin/src/main/java/io/avaje/http/inject/DefaultResolverProvider.java +++ b/http-inject-plugin/src/main/java/io/avaje/http/inject/DefaultResolverProvider.java @@ -4,10 +4,10 @@ import io.avaje.http.api.context.ThreadLocalRequestContextResolver; import io.avaje.inject.BeanScopeBuilder; import io.avaje.inject.spi.InjectPlugin; -import io.avaje.spi.ServiceProvider; +import io.avaje.inject.spi.PluginProvides; /** Plugin for avaje inject that provides a default RequestContextResolver instance. */ -@ServiceProvider +@PluginProvides public final class DefaultResolverProvider implements InjectPlugin { @Override diff --git a/http-inject-plugin/src/main/java/io/avaje/http/inject/HelidonHandler.java b/http-inject-plugin/src/main/java/io/avaje/http/inject/HelidonHandler.java new file mode 100644 index 000000000..c18f97b8f --- /dev/null +++ b/http-inject-plugin/src/main/java/io/avaje/http/inject/HelidonHandler.java @@ -0,0 +1,32 @@ +package io.avaje.http.inject; + +import java.io.IOException; +import java.io.UncheckedIOException; + +import io.avaje.http.api.ValidationException; +import io.helidon.common.Weight; +import io.helidon.webserver.http.HttpFeature; +import io.helidon.webserver.http.HttpRouting.Builder; +import io.helidon.webserver.http.ServerRequest; +import io.helidon.webserver.http.ServerResponse; + +@Weight(-67) // execute first so that it can be overridden by a custom error handler. +final class HelidonHandler implements HttpFeature { + + @Override + public void setup(Builder routing) { + + routing.error(ValidationException.class, this::handle); + } + + private void handle(ServerRequest req, ServerResponse res, ValidationException ex) { + try (var os = + res.status(ex.getStatus()) + .header("Content-Type", "application/problem+json") + .outputStream()) { + new ValidationResponse(ex.getStatus(), ex.getErrors(), req.path().rawPath()).toJson(os); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/http-inject-plugin/src/main/java/io/avaje/http/inject/HttpValidatorErrorPlugin.java b/http-inject-plugin/src/main/java/io/avaje/http/inject/HttpValidatorErrorPlugin.java new file mode 100644 index 000000000..627daad64 --- /dev/null +++ b/http-inject-plugin/src/main/java/io/avaje/http/inject/HttpValidatorErrorPlugin.java @@ -0,0 +1,56 @@ +package io.avaje.http.inject; + +import io.avaje.http.api.AvajeJavalinPlugin; +import io.avaje.inject.BeanScopeBuilder; +import io.avaje.inject.spi.InjectPlugin; +import io.avaje.inject.spi.PluginProvides; +import io.avaje.jex.Routing.HttpService; +import io.helidon.webserver.http.HttpFeature; + +/** Plugin for avaje inject that provides a default Validator Handler */ +@PluginProvides( + providesStrings = { + "io.helidon.webserver.http.HttpFeature", + "io.avaje.http.api.AvajeJavalinPlugin", + "io.avaje.jex.Routing.HttpService", + }) +public final class HttpValidatorErrorPlugin implements InjectPlugin { + + @Override + public void apply(BeanScopeBuilder builder) { + + ModuleLayer bootLayer = ModuleLayer.boot(); + + bootLayer + .findModule("io.avaje.http.plugin") + .ifPresentOrElse( + m -> { + if (bootLayer.findModule("io.avaje.jex").isPresent()) { + builder.provideDefault(HttpService.class, JexHandler::new); + } else if (bootLayer.findModule("io.helidon.webserver").isPresent()) { + builder.provideDefault(HttpFeature.class, HelidonHandler::new); + } else if (bootLayer.findModule("io.javalin").isPresent()) { + builder.provideDefault(AvajeJavalinPlugin.class, JavalinHandler::new); + } + }, + () -> { + try { + builder.provideDefault(HttpService.class, JexHandler::new); + return; + } catch (NoClassDefFoundError e) { + // not present + } + try { + builder.provideDefault(HttpFeature.class, HelidonHandler::new); + return; + } catch (NoClassDefFoundError e) { + // not present + } + try { + builder.provideDefault(AvajeJavalinPlugin.class, JavalinHandler::new); + } catch (NoClassDefFoundError e) { + // not present + } + }); + } +} diff --git a/http-inject-plugin/src/main/java/io/avaje/http/inject/JavalinHandler.java b/http-inject-plugin/src/main/java/io/avaje/http/inject/JavalinHandler.java new file mode 100644 index 000000000..84dc6ca8b --- /dev/null +++ b/http-inject-plugin/src/main/java/io/avaje/http/inject/JavalinHandler.java @@ -0,0 +1,25 @@ +package io.avaje.http.inject; + +import java.io.IOException; +import java.io.UncheckedIOException; + +import io.avaje.http.api.AvajeJavalinPlugin; +import io.avaje.http.api.ValidationException; +import io.javalin.config.JavalinConfig; +import io.javalin.http.Context; + +final class JavalinHandler extends AvajeJavalinPlugin { + @Override + public void onStart(JavalinConfig config) { + config.router.mount(r -> r.exception(ValidationException.class, this::handler)); + } + + private void handler(ValidationException ex, Context ctx) { + try (var os = + ctx.contentType("application/problem+json").status(ex.getStatus()).outputStream()) { + new ValidationResponse(ex.getStatus(), ex.getErrors(), ctx.path()).toJson(os); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/http-inject-plugin/src/main/java/io/avaje/http/inject/JexHandler.java b/http-inject-plugin/src/main/java/io/avaje/http/inject/JexHandler.java new file mode 100644 index 000000000..a89678fad --- /dev/null +++ b/http-inject-plugin/src/main/java/io/avaje/http/inject/JexHandler.java @@ -0,0 +1,27 @@ +package io.avaje.http.inject; + +import java.io.IOException; +import java.io.UncheckedIOException; + +import io.avaje.http.api.ValidationException; +import io.avaje.jex.Routing; +import io.avaje.jex.Routing.HttpService; +import io.avaje.jex.http.Context; + +final class JexHandler implements HttpService { + + @Override + public void add(Routing arg0) { + arg0.error(ValidationException.class, this::handler); + } + + private void handler(Context ctx, ValidationException ex) { + + try (var os = + ctx.contentType("application/problem+json").status(ex.getStatus()).outputStream()) { + new ValidationResponse(ex.getStatus(), ex.getErrors(), ctx.path()).toJson(os); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/http-inject-plugin/src/main/java/io/avaje/http/inject/ValidationResponse.java b/http-inject-plugin/src/main/java/io/avaje/http/inject/ValidationResponse.java new file mode 100644 index 000000000..c0f4b50ea --- /dev/null +++ b/http-inject-plugin/src/main/java/io/avaje/http/inject/ValidationResponse.java @@ -0,0 +1,120 @@ +package io.avaje.http.inject; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.util.List; + +import io.avaje.http.api.ValidationException.Violation; + +public class ValidationResponse { + + private static String type = "tag:io.avaje.http.api.ValidationException"; + private static String title = "Request Failed Validation"; + private static String detail = "You tried to call this endpoint, but your data failed validation"; + private final int status; + private final List errors; + private final String instance; + + public ValidationResponse(int status, List errors, String instance) { + this.status = status; + this.errors = errors; + this.instance = instance; + } + + // custom serialize as this is a simple class + public void toJson(OutputStream os) throws IOException { + try (Writer writer = new OutputStreamWriter(os, "UTF-8")) { + writeJsonInternal(writer); + } + } + + private void writeJsonInternal(Writer writer) throws IOException { + writer.write('{'); + writeKeyValue("type", type, writer); + writer.write(','); + writeKeyValue("title", title, writer); + writer.write(','); + writeKeyValue("detail", detail, writer); + writer.write(','); + writeKeyValue("instance", instance, writer); + writer.write(','); + // status is a number, so no quotes or escaping needed + writer.write("\"status\":"); + writer.write(String.valueOf(status)); + writer.write(",\"errors\":["); + for (int i = 0; i < errors.size(); i++) { + if (i > 0) { + writer.write(','); + } + var e = errors.get(i); + writer.write('{'); + writeKeyValue("path", e.getPath(), writer); + writer.write(','); + writeKeyValue("field", e.getField(), writer); + writer.write(','); + writeKeyValue("message", e.getMessage(), writer); + writer.write('}'); + } + + writer.write(']'); + writer.write('}'); + writer.flush(); + } + + /** Writes a JSON key-value pair where the value is a string, handling quotes and escaping. */ + private void writeKeyValue(String key, String value, Writer writer) throws IOException { + writer.write('"'); + writer.write(key); + writer.write("\":"); + writeEscapedJsonString(value, writer); + } + + /** + * Writes the given string to the writer, JSON-escaping it and wrapping it in quotes. Writes + * 'null' (the JSON literal) if the input string is null. + */ + private static void writeEscapedJsonString(String s, Writer writer) throws IOException { + if (s == null) { + writer.write("null"); + return; + } + + writer.write('"'); + for (int i = 0; i < s.length(); i++) { + char ch = s.charAt(i); + switch (ch) { + case '"': + writer.write("\\\""); + break; + case '\\': + writer.write("\\\\"); + break; + case '\b': + writer.write("\\b"); + break; + case '\f': + writer.write("\\f"); + break; + case '\n': + writer.write("\\n"); + break; + case '\r': + writer.write("\\r"); + break; + case '\t': + writer.write("\\t"); + break; + default: + // Check for control characters that must be escaped + if (ch < ' ' || ch >= 0x7F && ch <= 0x9F) { + writer.write(String.format("\\u%04x", (int) ch)); + } else { + writer.write(ch); + } + } + } + writer.write('"'); + } +} diff --git a/http-inject-plugin/src/main/java/module-info.java b/http-inject-plugin/src/main/java/module-info.java index e13c5e735..8c6058dfd 100644 --- a/http-inject-plugin/src/main/java/module-info.java +++ b/http-inject-plugin/src/main/java/module-info.java @@ -1,8 +1,12 @@ +import io.avaje.http.inject.DefaultResolverProvider; +import io.avaje.http.inject.HttpValidatorErrorPlugin; + module io.avaje.http.plugin { requires io.avaje.http.api; requires io.avaje.inject; - requires static io.avaje.spi; - - provides io.avaje.inject.spi.InjectExtension with io.avaje.http.inject.DefaultResolverProvider; + requires static io.avaje.jex; + requires static io.javalin; + requires static io.helidon.webserver; + provides io.avaje.inject.spi.InjectExtension with DefaultResolverProvider, HttpValidatorErrorPlugin; } diff --git a/pom.xml b/pom.xml index f426ba264..ed05fd10b 100644 --- a/pom.xml +++ b/pom.xml @@ -44,7 +44,6 @@ http-client http-client-gson-adapter http-client-moshi-adapter - http-inject-plugin http-generator-core http-generator-javalin http-generator-sigma @@ -68,6 +67,7 @@ [21,) + http-inject-plugin htmx-nima htmx-nima-jstache http-generator-helidon