feat(core): add HTTP/2 stream dispatch
This commit is contained in:
@@ -2,22 +2,32 @@ package dev.relism.flash.http2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import dev.relism.flash.bytes.ByteWriter;
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.http2.frame.FrameFlags;
|
||||
import dev.relism.flash.http2.frame.FrameType;
|
||||
import dev.relism.flash.http2.hpack.HpackDecoder;
|
||||
import dev.relism.flash.http2.hpack.HpackEncoder;
|
||||
import dev.relism.flash.tls.TestKeystores;
|
||||
import dev.relism.flash.tls.TlsConfig;
|
||||
import java.io.EOFException;
|
||||
import java.io.InputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import javax.net.ssl.SSLParameters;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -32,6 +42,171 @@ class Http2ConnectionIntegrationTest {
|
||||
if (app != null) app.stop().join();
|
||||
}
|
||||
|
||||
@Test
|
||||
void javaHttpClientUsesHttp2AgainstAnExistingParameterizedRoute(@TempDir Path directory)
|
||||
throws Exception {
|
||||
int port = freePort();
|
||||
Path keystore =
|
||||
TestKeystores.build(
|
||||
directory,
|
||||
"http2-route.p12",
|
||||
"changeit",
|
||||
TestKeystores.Entry.of("server", "localhost", "localhost"));
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.host("127.0.0.1")
|
||||
.tls(TlsConfig.keystore(keystore, "changeit"))
|
||||
.http2Enabled(true)
|
||||
.build());
|
||||
app.get(
|
||||
"/users/{id}", (request, response) -> request.param("id") + ":" + request.header("host"));
|
||||
app.start();
|
||||
|
||||
HttpClient client =
|
||||
HttpClient.newBuilder()
|
||||
.sslContext(TestKeystores.trustAllClientContext())
|
||||
.version(HttpClient.Version.HTTP_2)
|
||||
.build();
|
||||
HttpResponse<String> response =
|
||||
client.send(
|
||||
HttpRequest.newBuilder(URI.create("https://localhost:" + port + "/users/42"))
|
||||
.GET()
|
||||
.build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
assertEquals(HttpClient.Version.HTTP_2, response.version());
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("42:localhost:" + port, response.body());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bodylessGetRunsExistingRouteAndReturnsHeadersAndData() throws Exception {
|
||||
int port = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder().port(port).host("127.0.0.1").http2Enabled(true).build());
|
||||
app.get("/api/ping", (request, response) -> "pong");
|
||||
app.start();
|
||||
|
||||
ByteWriter block = new ByteWriter(64);
|
||||
HpackEncoder.writeIndexed(block, 2);
|
||||
HpackEncoder.writeIndexed(block, 6);
|
||||
HpackEncoder.writeLiteralWithNameIndex(
|
||||
block, 4, "/api/ping".getBytes(StandardCharsets.US_ASCII), false);
|
||||
HpackEncoder.writeLiteralWithNameIndex(
|
||||
block, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false);
|
||||
|
||||
try (Socket socket = new Socket("127.0.0.1", port)) {
|
||||
socket.setSoTimeout(5_000);
|
||||
socket
|
||||
.getOutputStream()
|
||||
.write(
|
||||
Http2TestFrames.concat(
|
||||
Http2TestFrames.PREFACE,
|
||||
Http2TestFrames.settings(),
|
||||
Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]),
|
||||
Http2TestFrames.frame(
|
||||
FrameType.HEADERS,
|
||||
FrameFlags.END_HEADERS | FrameFlags.END_STREAM,
|
||||
1,
|
||||
Arrays.copyOf(block.array(), block.length()))));
|
||||
socket.getOutputStream().flush();
|
||||
|
||||
ByteWriter responseBlock = new ByteWriter(128);
|
||||
byte[] body = null;
|
||||
for (int i = 0; i < 10 && body == null; i++) {
|
||||
Http2TestFrames.WireFrame frame = readFrame(socket.getInputStream());
|
||||
if (frame.streamId() != 1) continue;
|
||||
if (frame.type() == FrameType.HEADERS.code()
|
||||
|| frame.type() == FrameType.CONTINUATION.code()) {
|
||||
responseBlock.writeBytes(frame.payload());
|
||||
} else if (frame.type() == FrameType.DATA.code()) {
|
||||
body = frame.payload();
|
||||
}
|
||||
}
|
||||
|
||||
List<String> fields = new ArrayList<>();
|
||||
new HpackDecoder()
|
||||
.decode(
|
||||
responseBlock.array(),
|
||||
0,
|
||||
responseBlock.length(),
|
||||
(name, value, never) -> fields.add(ascii(name) + "=" + ascii(value)));
|
||||
assertTrue(fields.contains(":status=200"));
|
||||
assertTrue(fields.contains("content-length=4"));
|
||||
assertEquals("pong", new String(body, StandardCharsets.US_ASCII));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetQueuedStreamCannotReleaseOrReuseItBeforeDispatchObservesCancellation()
|
||||
throws Exception {
|
||||
int port = freePort();
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder().port(port).host("127.0.0.1").http2Enabled(true).build());
|
||||
app.get(
|
||||
"/queued",
|
||||
(request, response) -> {
|
||||
calls.incrementAndGet();
|
||||
return "ok";
|
||||
});
|
||||
app.start();
|
||||
|
||||
ByteWriter block = new ByteWriter(64);
|
||||
HpackEncoder.writeIndexed(block, 2);
|
||||
HpackEncoder.writeIndexed(block, 6);
|
||||
HpackEncoder.writeLiteralWithNameIndex(
|
||||
block, 4, "/queued".getBytes(StandardCharsets.US_ASCII), false);
|
||||
HpackEncoder.writeLiteralWithNameIndex(
|
||||
block, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false);
|
||||
byte[] headers = Arrays.copyOf(block.array(), block.length());
|
||||
byte[] cancel = {0, 0, 0, 8};
|
||||
|
||||
try (Socket socket = new Socket("127.0.0.1", port)) {
|
||||
socket.setSoTimeout(5_000);
|
||||
socket
|
||||
.getOutputStream()
|
||||
.write(
|
||||
Http2TestFrames.concat(
|
||||
Http2TestFrames.PREFACE,
|
||||
Http2TestFrames.settings(),
|
||||
Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]),
|
||||
Http2TestFrames.frame(
|
||||
FrameType.HEADERS,
|
||||
FrameFlags.END_HEADERS | FrameFlags.END_STREAM,
|
||||
1,
|
||||
headers),
|
||||
Http2TestFrames.frame(FrameType.RST_STREAM, 0, 1, cancel),
|
||||
Http2TestFrames.frame(
|
||||
FrameType.HEADERS,
|
||||
FrameFlags.END_HEADERS | FrameFlags.END_STREAM,
|
||||
3,
|
||||
headers)));
|
||||
socket.getOutputStream().flush();
|
||||
|
||||
Http2TestFrames.WireFrame response = null;
|
||||
for (int i = 0; i < 10; i++) {
|
||||
Http2TestFrames.WireFrame frame = readFrame(socket.getInputStream());
|
||||
assertFalse(
|
||||
frame.streamId() == 1
|
||||
&& (frame.type() == FrameType.HEADERS.code()
|
||||
|| frame.type() == FrameType.DATA.code()),
|
||||
"a reset request must not produce a response");
|
||||
if (frame.streamId() == 3 && frame.type() == FrameType.DATA.code()) {
|
||||
response = frame;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assertNotNull(response);
|
||||
assertEquals("ok", new String(response.payload(), StandardCharsets.US_ASCII));
|
||||
assertEquals(1, calls.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void h2cTransportDispatchesControlFramesWithoutRunningApplicationWork() throws Exception {
|
||||
int port = freePort();
|
||||
@@ -244,4 +419,10 @@ class Http2ConnectionIntegrationTest {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
|
||||
private static String ascii(dev.relism.fpr.core.ByteView view) {
|
||||
byte[] bytes = new byte[view.length()];
|
||||
for (int i = 0; i < bytes.length; i++) bytes[i] = view.byteAt(i);
|
||||
return new String(bytes, StandardCharsets.US_ASCII);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package dev.relism.flash.http2.message;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import dev.relism.flash.bytes.PooledSlice;
|
||||
import dev.relism.flash.http2.Http2StreamException;
|
||||
import dev.relism.flash.http2.hpack.HpackHeaderBlock;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PseudoHeaderValidationTest {
|
||||
@Test
|
||||
void validRequest() {
|
||||
assertDoesNotThrow(
|
||||
() ->
|
||||
validate(
|
||||
":method", "GET", ":scheme", "https", ":path", "/", ":authority", "example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPseudoAfterRegular() {
|
||||
rejects("x", "1", ":method", "GET", ":scheme", "https", ":path", "/", ":authority", "x");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownAndDuplicatePseudoHeaders() {
|
||||
rejects(":method", "GET", ":scheme", "https", ":path", "/", ":authority", "x", ":other", "x");
|
||||
rejects(
|
||||
":method", "GET", ":method", "POST", ":scheme", "https", ":path", "/", ":authority", "x");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingAndEmptyPseudoHeaders() {
|
||||
rejects(":method", "GET", ":scheme", "https", ":path", "/");
|
||||
rejects(":method", "GET", ":scheme", "https", ":path", "", ":authority", "x");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesConnectShape() {
|
||||
assertDoesNotThrow(() -> validate(":method", "CONNECT", ":authority", "example.com:443"));
|
||||
rejects(":method", "CONNECT", ":scheme", "https", ":authority", "example.com:443");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUppercaseForbiddenAndInvalidTeFields() {
|
||||
rejects(validWith("X-Test", "1"));
|
||||
rejects(validWith("connection", "close"));
|
||||
rejects(validWith("keep-alive", "timeout=5"));
|
||||
rejects(validWith("proxy-connection", "close"));
|
||||
rejects(validWith("transfer-encoding", "chunked"));
|
||||
rejects(validWith("upgrade", "websocket"));
|
||||
rejects(validWith("te", "gzip"));
|
||||
assertDoesNotThrow(() -> validate(validWith("te", "trailers")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsHostAuthorityConflict() {
|
||||
rejects(validWith("host", "other.example"));
|
||||
assertDoesNotThrow(() -> validate(validWith("host", "example.com")));
|
||||
}
|
||||
|
||||
private static String[] validWith(String name, String value) {
|
||||
return new String[] {
|
||||
":method", "GET", ":scheme", "https", ":path", "/", ":authority", "example.com", name, value
|
||||
};
|
||||
}
|
||||
|
||||
private static void rejects(String... fields) {
|
||||
assertThrows(Http2StreamException.class, () -> validate(fields));
|
||||
}
|
||||
|
||||
private static void validate(String... fields) {
|
||||
HpackHeaderBlock block = new HpackHeaderBlock();
|
||||
PooledSlice name = new PooledSlice();
|
||||
PooledSlice value = new PooledSlice();
|
||||
for (int i = 0; i < fields.length; i += 2) {
|
||||
byte[] nameBytes = fields[i].getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] valueBytes = fields[i + 1].getBytes(StandardCharsets.US_ASCII);
|
||||
name.reset(nameBytes, 0, nameBytes.length);
|
||||
value.reset(valueBytes, 0, valueBytes.length);
|
||||
block.accept(name, value, false);
|
||||
}
|
||||
new PseudoHeaders().validate(block, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package dev.relism.flash.http2.stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
import dev.relism.flash.bytes.PooledSlice;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.Request;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class Http2RequestAssemblyTest {
|
||||
@Test
|
||||
void assemblesProtocolNeutralRequestWithQueryAndAuthorityAlias() {
|
||||
Http2StreamTable table = new Http2StreamTable(1);
|
||||
Http2Stream stream = table.acquire(1);
|
||||
field(stream, ":method", "GET");
|
||||
field(stream, ":scheme", "https");
|
||||
field(stream, ":path", "/users/42?verbose=true");
|
||||
field(stream, ":authority", "example.com");
|
||||
field(stream, "x-trace", "abc");
|
||||
|
||||
Request request = stream.assembleRequest(null, null);
|
||||
|
||||
assertEquals(HttpMethod.GET, request.method());
|
||||
assertEquals("/users/42", request.path());
|
||||
assertEquals("true", request.query("verbose"));
|
||||
assertEquals("example.com", request.header("host"));
|
||||
assertEquals("example.com", request.header(":authority"));
|
||||
assertEquals("abc", request.header("X-Trace"));
|
||||
assertNull(request.remoteAddress());
|
||||
}
|
||||
|
||||
private static void field(Http2Stream stream, String name, String value) {
|
||||
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] valueBytes = value.getBytes(StandardCharsets.US_ASCII);
|
||||
PooledSlice nameView = new PooledSlice();
|
||||
PooledSlice valueView = new PooledSlice();
|
||||
nameView.reset(nameBytes, 0, nameBytes.length);
|
||||
valueView.reset(valueBytes, 0, valueBytes.length);
|
||||
stream.headerBlock().accept(nameView, valueView, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package dev.relism.flash.http2.stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class Http2StreamLeakTest {
|
||||
@Test
|
||||
void oneHundredThousandAcquireReleaseCyclesReuseOneStream() {
|
||||
Http2StreamTable table = new Http2StreamTable(100);
|
||||
for (int i = 0; i < 100_000; i++) {
|
||||
Http2Stream stream = table.acquire((i << 1) | 1);
|
||||
table.remove(stream.id());
|
||||
table.release(stream);
|
||||
}
|
||||
assertEquals(1, table.createdCount());
|
||||
assertEquals(1, table.freeCount());
|
||||
assertEquals(0, table.size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package dev.relism.flash.http2.stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import dev.relism.flash.http2.Http2StreamException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class Http2StreamStateTest {
|
||||
@Test
|
||||
void everyTransitionCellIsExecutableOrTypedError() {
|
||||
for (Http2StreamState state : Http2StreamState.values()) {
|
||||
for (Http2StreamState.Event event : Http2StreamState.Event.values()) {
|
||||
if (Http2StreamState.isValid(state, event)) {
|
||||
Http2StreamState next = state.transition(1, event);
|
||||
assertEquals(true, next != null);
|
||||
} else {
|
||||
assertThrows(Http2StreamException.class, () -> state.transition(1, event));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void bodylessRequestAndResponseCloseStream() {
|
||||
Http2StreamState state =
|
||||
Http2StreamState.IDLE.transition(1, Http2StreamState.Event.RECV_HEADERS_ES);
|
||||
assertEquals(Http2StreamState.HALF_CLOSED_REMOTE, state);
|
||||
assertEquals(
|
||||
Http2StreamState.CLOSED, state.transition(1, Http2StreamState.Event.SEND_HEADERS_ES));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package dev.relism.flash.http2.stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class Http2StreamTableTest {
|
||||
@Test
|
||||
void insertLookupRemoveAtCapacityAndAcrossProbeClusters() {
|
||||
Http2StreamTable table = new Http2StreamTable(8);
|
||||
Http2Stream[] streams = new Http2Stream[8];
|
||||
for (int i = 0; i < streams.length; i++) {
|
||||
streams[i] = table.acquire(i * 2 + 1);
|
||||
assertSame(streams[i], table.get(i * 2 + 1));
|
||||
}
|
||||
assertNull(table.acquire(99));
|
||||
for (int i = 0; i < streams.length; i += 2) {
|
||||
assertSame(streams[i], table.remove(streams[i].id()));
|
||||
table.release(streams[i]);
|
||||
}
|
||||
for (int i = 1; i < streams.length; i += 2) {
|
||||
assertSame(streams[i], table.get(streams[i].id()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user