Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,39 @@ default Size maxRequestBodySize() {
}

HttpServerTelemetryConfig telemetry();

HttpServerCorsConfig cors();

@ConfigValueExtractor
interface HttpServerCorsConfig {

default boolean enabled() {
return false;
}

@Nullable
default String allowOrigin() {
return null;
}

default List<String> allowHeaders() {
return List.of("*");
}

default List<String> allowMethods() {
return List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD");
}

default boolean allowCredentials() {
return true;
}

default List<String> exposeHeaders() {
return List.of();
}

default Duration maxAge() {
return Duration.ofHours(1);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
package io.koraframework.http.server.common.router;

import io.koraframework.http.server.common.$HttpServerConfig_HttpServerCorsConfig_ConfigValueExtractor;
import io.koraframework.http.server.common.telemetry.*;
import io.koraframework.http.server.common.telemetry.impl.NoopHttpServerTelemetry;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mockito;
import io.koraframework.application.graph.All;
import io.koraframework.common.util.Size;
import io.koraframework.http.common.body.HttpBody;
Expand Down Expand Up @@ -160,7 +168,8 @@ private HttpServerConfig config(boolean ignoreTrailingSlash) {
new $HttpServerTelemetryConfig_HttpServerLoggingConfig_ConfigValueExtractor.HttpServerLoggingConfig_Defaults(),
new $HttpServerTelemetryConfig_HttpServerMetricsConfig_ConfigValueExtractor.HttpServerMetricsConfig_Defaults(),
new $HttpServerTelemetryConfig_HttpServerTracingConfig_ConfigValueExtractor.HttpServerTracingConfig_Defaults()
)
),
new $HttpServerConfig_HttpServerCorsConfig_ConfigValueExtractor.HttpServerCorsConfig_Defaults()
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
package io.koraframework.http.server.common.router;

import io.koraframework.http.server.common.$HttpServerConfig_HttpServerCorsConfig_ConfigValueExtractor;
import io.koraframework.http.server.common.telemetry.*;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import io.koraframework.common.util.Size;
import io.koraframework.http.server.common.$HttpServerConfig_ConfigValueExtractor;
import io.koraframework.http.server.common.HttpServerConfig;
Expand Down Expand Up @@ -155,7 +160,8 @@ private HttpServerConfig config(boolean ignoreTrailingSlash) {
new $HttpServerTelemetryConfig_HttpServerLoggingConfig_ConfigValueExtractor.HttpServerLoggingConfig_Defaults(),
new $HttpServerTelemetryConfig_HttpServerMetricsConfig_ConfigValueExtractor.HttpServerMetricsConfig_Defaults(),
new $HttpServerTelemetryConfig_HttpServerTracingConfig_ConfigValueExtractor.HttpServerTracingConfig_Defaults()
)
),
new $HttpServerConfig_HttpServerCorsConfig_ConfigValueExtractor.HttpServerCorsConfig_Defaults()
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -979,7 +979,8 @@ protected void startServer(boolean ignoreTrailingSlash, List<HttpServerIntercept
new $HttpServerTelemetryConfig_HttpServerLoggingConfig_ConfigValueExtractor.HttpServerLoggingConfig_Defaults(),
new $HttpServerTelemetryConfig_HttpServerMetricsConfig_ConfigValueExtractor.HttpServerMetricsConfig_Defaults(),
new $HttpServerTelemetryConfig_HttpServerTracingConfig_ConfigValueExtractor.HttpServerTracingConfig_Defaults()
)
),
new $HttpServerConfig_HttpServerCorsConfig_ConfigValueExtractor.HttpServerCorsConfig_Defaults()
);
var publicApiHandler = new HttpServerHandler(List.of(handlers), interceptors, config);
this.httpServer = this.httpServer(valueOf(config), publicApiHandler, this.telemetry);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import io.koraframework.http.server.common.HttpServerConfig;
import io.koraframework.http.server.common.router.HttpServerHandler;
import io.koraframework.http.server.common.telemetry.HttpServerTelemetryFactory;
import io.koraframework.http.server.undertow.handler.KoraCorsHttpHandler;
import io.koraframework.http.server.undertow.handler.KoraRequestProcessingHttpHandler;
import io.koraframework.http.server.undertow.handler.KoraVirtualThreadDispatchHttpHandler;
import io.undertow.Undertow;
Expand All @@ -34,6 +35,9 @@ default HttpHandler undertowPublicHttpHandler(HttpServerConfig config,
HttpServerTelemetryFactory telemetryFactory) {
var telemetry = telemetryFactory.get(config.telemetry());
var handler = (HttpHandler) new KoraRequestProcessingHttpHandler(telemetry, publicApiHandler);
if (config.cors().enabled()) {
handler = new KoraCorsHttpHandler(handler, config.cors());
}
handler = new KoraVirtualThreadDispatchHttpHandler("kora-undertow", handler);
return handler;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package io.koraframework.http.server.undertow.handler;

import io.koraframework.http.server.common.HttpServerConfig;
import io.undertow.server.HttpHandler;
import io.undertow.server.HttpServerExchange;
import io.undertow.util.Headers;
import io.undertow.util.HttpString;
import io.undertow.util.Methods;
import org.jspecify.annotations.Nullable;

public final class KoraCorsHttpHandler implements HttpHandler {

public static final HttpString ACCESS_CONTROL_ALLOW_ORIGIN = HttpString.tryFromString("Access-Control-Allow-Origin");
public static final HttpString ACCESS_CONTROL_ALLOW_CREDENTIALS = HttpString.tryFromString("Access-Control-Allow-Credentials");
public static final HttpString ACCESS_CONTROL_ALLOW_HEADERS = HttpString.tryFromString("Access-Control-Allow-Headers");
public static final HttpString ACCESS_CONTROL_ALLOW_METHODS = HttpString.tryFromString("Access-Control-Allow-Methods");
public static final HttpString ACCESS_CONTROL_EXPOSE_HEADERS = HttpString.tryFromString("Access-Control-Expose-Headers");
public static final HttpString ACCESS_CONTROL_MAX_AGE = HttpString.tryFromString("Access-Control-Max-Age");
public static final HttpString ACCESS_CONTROL_REQUEST_METHOD = HttpString.tryFromString("Access-Control-Request-Method");

private final HttpHandler next;
private final HttpServerConfig.HttpServerCorsConfig config;
private final String allowMethods;
private final String allowHeaders;
private final String allowCredentials;
@Nullable
private final String exposeHeaders;
private final String maxAge;

public KoraCorsHttpHandler(HttpHandler next, HttpServerConfig.HttpServerCorsConfig config) {
this.next = next;
this.config = config;
this.allowMethods = String.join(",", config.allowMethods());
this.allowHeaders = String.join(",", config.allowHeaders());
this.allowCredentials = String.valueOf(config.allowCredentials());
this.exposeHeaders = config.exposeHeaders().isEmpty()
? null
: String.join(",", config.exposeHeaders());
this.maxAge = String.valueOf(config.maxAge().getSeconds());
}

@Override
public void handleRequest(HttpServerExchange exchange) throws Exception {
var origin = exchange.getRequestHeaders().getFirst(Headers.ORIGIN);
if (origin == null) {
this.next.handleRequest(exchange);
return;
}

this.applyCorsPolicy(exchange, origin);
if (isPreflight(exchange)) {
exchange.setStatusCode(204);
exchange.endExchange();
return;
}

this.next.handleRequest(exchange);
}

private void applyCorsPolicy(HttpServerExchange exchange, String origin) {
var allowOrigin = this.config.allowOrigin();
if (allowOrigin == null) {
allowOrigin = origin;
this.addVaryOrigin(exchange);
}

this.putIfAbsent(exchange, ACCESS_CONTROL_ALLOW_ORIGIN, allowOrigin);
this.putIfAbsent(exchange, ACCESS_CONTROL_ALLOW_HEADERS, this.allowHeaders);
this.putIfAbsent(exchange, ACCESS_CONTROL_ALLOW_CREDENTIALS, this.allowCredentials);
this.putIfAbsent(exchange, ACCESS_CONTROL_ALLOW_METHODS, this.allowMethods);
this.putIfAbsent(exchange, ACCESS_CONTROL_MAX_AGE, this.maxAge);
if (this.exposeHeaders != null) {
this.putIfAbsent(exchange, ACCESS_CONTROL_EXPOSE_HEADERS, this.exposeHeaders);
}
}

private static boolean isPreflight(HttpServerExchange exchange) {
return exchange.getRequestMethod().equals(Methods.OPTIONS)
&& exchange.getRequestHeaders().contains(ACCESS_CONTROL_REQUEST_METHOD);
}

private void putIfAbsent(HttpServerExchange exchange, HttpString name, String value) {
if (!exchange.getResponseHeaders().contains(name)) {
exchange.getResponseHeaders().put(name, value);
}
}

private void addVaryOrigin(HttpServerExchange exchange) {
var vary = exchange.getResponseHeaders().get(Headers.VARY);
if (vary == null || !vary.contains(Headers.ORIGIN_STRING)) {
exchange.getResponseHeaders().add(Headers.VARY, Headers.ORIGIN_STRING);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
package io.koraframework.http.server.undertow;

import io.koraframework.application.graph.ValueOf;
import io.koraframework.common.util.Size;
import io.koraframework.http.common.body.HttpBody;
import io.koraframework.http.server.common.HttpServer;
import io.koraframework.http.server.common.HttpServerConfig;
import io.koraframework.http.server.common.HttpServerTestKit;
import io.koraframework.http.server.common.request.HttpServerRequestHandlerImpl;
import io.koraframework.http.server.common.response.HttpServerResponse;
import io.koraframework.http.server.common.router.HttpServerHandler;
import io.koraframework.http.server.common.telemetry.HttpServerTelemetry;
import io.koraframework.http.server.common.telemetry.*;
import io.koraframework.http.server.common.telemetry.impl.NoopHttpServerTelemetry;
import io.koraframework.http.server.undertow.handler.KoraCorsHttpHandler;
import io.koraframework.http.server.undertow.handler.KoraRequestProcessingHttpHandler;
import io.koraframework.http.server.undertow.handler.KoraVirtualThreadDispatchHttpHandler;
import okhttp3.Request;
import org.junit.jupiter.api.Test;

import java.time.Duration;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import io.koraframework.http.server.undertow.handler.KoraRequestProcessingHttpHandler;
import io.koraframework.http.server.undertow.handler.KoraVirtualThreadDispatchHttpHandler;

Expand All @@ -22,4 +37,113 @@ protected HttpServer httpServer(ValueOf<? extends HttpServerConfig> config, Http
null
);
}

@Test
void corsActualRequest() throws Exception {
var server = this.corsServer();
try {
server.init();

var request = new Request.Builder()
.url("http://localhost:%d/".formatted(server.port()))
.get()
.header("Origin", "https://example.com")
.build();

try (var response = client.newCall(request).execute()) {
assertThat(response.code()).isEqualTo(200);
assertThat(response.header("Access-Control-Allow-Origin")).isEqualTo("https://example.com");
assertThat(response.header("Access-Control-Allow-Credentials")).isEqualTo("true");
assertThat(response.header("Vary")).isEqualTo("Origin");
assertThat(response.body().string()).isEqualTo("ok");
}
} finally {
server.release();
}
}

@Test
void corsPreflightRequest() throws Exception {
var server = this.corsServer();
try {
server.init();

var request = new Request.Builder()
.url("http://localhost:%d/".formatted(server.port()))
.method("OPTIONS", null)
.header("Origin", "https://example.com")
.header("Access-Control-Request-Method", "POST")
.build();

try (var response = client.newCall(request).execute()) {
assertThat(response.code()).isEqualTo(204);
assertThat(response.header("Access-Control-Allow-Origin")).isEqualTo("https://example.com");
assertThat(response.header("Access-Control-Allow-Methods")).isEqualTo("GET,POST,PUT,DELETE,PATCH,OPTIONS,HEAD");
assertThat(response.body().string()).isEmpty();
}
} finally {
server.release();
}
}

private UndertowHttpServer corsServer() {
var config = new TestHttpServerConfig(new TestHttpServerCorsConfig());
var handler = new HttpServerHandler(List.of(new HttpServerRequestHandlerImpl(
"GET",
"/",
request -> HttpServerResponse.of(200, HttpBody.plaintext("ok"))
)), List.of(), config);
var processingHandler = new KoraRequestProcessingHttpHandler(NoopHttpServerTelemetry.INSTANCE, handler);
return new UndertowHttpServer(
"test-cors",
valueOf(new KoraCorsHttpHandler(processingHandler, config.cors())),
null,
valueOf(config),
null,
null
);
}

private record TestHttpServerConfig(HttpServerCorsConfig cors) implements HttpServerConfig {
@Override
public int port() {
return 0;
}

@Override
public Duration socketReadTimeout() {
return Duration.ofSeconds(1);
}

@Override
public Duration socketWriteTimeout() {
return Duration.ofSeconds(1);
}

@Override
public Duration shutdownWait() {
return Duration.ofMillis(1);
}

@Override
public HttpServerTelemetryConfig telemetry() {
return new $HttpServerTelemetryConfig_ConfigValueExtractor.HttpServerTelemetryConfig_Impl(
new $HttpServerTelemetryConfig_HttpServerLoggingConfig_ConfigValueExtractor.HttpServerLoggingConfig_Defaults(),
new $HttpServerTelemetryConfig_HttpServerMetricsConfig_ConfigValueExtractor.HttpServerMetricsConfig_Defaults(),
new $HttpServerTelemetryConfig_HttpServerTracingConfig_ConfigValueExtractor.HttpServerTracingConfig_Defaults()
);
}

@Override
public Size maxRequestBodySize() {
return Size.of(1, Size.Type.GiB);
}
}

private static final class TestHttpServerCorsConfig implements HttpServerConfig.HttpServerCorsConfig {
@Override
public boolean enabled() {
return true;
}
}
}
Loading