feat(core): add HTTP/2 response path

This commit is contained in:
Zakaria El Orche
2026-08-13 18:04:22 +00:00
parent cfa192e689
commit 9391f80f76
20 changed files with 1684 additions and 663 deletions
@@ -0,0 +1,34 @@
package dev.relism.flash.http;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.relism.flash.http2.hpack.HpackDecoder;
import dev.relism.fpr.core.ByteView;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
class ContentTypeHpackTest {
@Test
void everyNonEmptyTypeHasAValidPrecompiledField() {
for (ContentType type : ContentType.values()) {
if (type == ContentType.NONE) continue;
AtomicReference<String> decoded = new AtomicReference<>();
byte[] block = type.getHpackBytes();
new HpackDecoder()
.decode(
block,
0,
block.length,
(name, value, never) -> decoded.set(text(name) + "=" + text(value)));
assertEquals(
"content-type=" + new String(type.getBytes(), StandardCharsets.US_ASCII), decoded.get());
}
}
private static String text(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,15 @@
package dev.relism.flash.http;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import org.junit.jupiter.api.Test;
class DateHeaderTest {
@Test
void imfFixdateAlwaysUsesTwoDigitDayOfMonth() {
ZonedDateTime thirdOfMonth = ZonedDateTime.of(2026, 8, 3, 7, 5, 9, 0, ZoneOffset.UTC);
assertEquals("Mon, 03 Aug 2026 07:05:09 GMT", DateHeader.format(thirdOfMonth));
}
}
@@ -0,0 +1,38 @@
package dev.relism.flash.http;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.relism.flash.http2.hpack.HpackDecoder;
import dev.relism.fpr.core.ByteView;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
class HttpStatusHpackTest {
@Test
void everyStatusHasAValidPrecompiledField() {
for (HttpStatus status : HttpStatus.values()) {
AtomicReference<String> decoded = new AtomicReference<>();
byte[] block = status.hpackBytes();
new HpackDecoder()
.decode(
block,
0,
block.length,
(name, value, never) -> decoded.set(text(name) + "=" + text(value)));
assertEquals(":status=" + status.code(), decoded.get());
}
}
@Test
void commonStaticStatusIsOneByte() {
assertEquals(1, HttpStatus.OK.hpackBytes().length);
assertEquals(0x88, HttpStatus.OK.hpackBytes()[0] & 0xff);
}
private static String text(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,80 @@
package dev.relism.flash.http2.hpack;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.fpr.core.ByteView;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
class HpackEncoderTest {
@Test
void status200IsOneIndexedByte() {
ByteWriter out = new ByteWriter(16);
HpackEncoder.writeIndexed(out, 8);
assertEquals(1, out.length());
assertEquals(0x88, out.array()[0] & 0xff);
}
@Test
void representationsRoundTripThroughDecoder() {
ByteWriter out = new ByteWriter(128);
HpackEncoder.writeDynamicTableSizeUpdateZero(out);
HpackEncoder.writeIndexed(out, 8);
HpackEncoder.writeLiteralWithNameIndex(out, 31, ascii("application/json"), true);
HpackEncoder.writeLiteral(out, ascii("X-Trace"), ascii("abc123"));
HpackEncoder.writeLiteralNeverIndexed(out, ascii("authorization"), ascii("secret"), false);
List<String> fields = new ArrayList<>();
List<Boolean> sensitive = new ArrayList<>();
new HpackDecoder()
.decode(
out.array(),
0,
out.length(),
(name, value, never) -> {
fields.add(text(name) + "=" + text(value));
sensitive.add(never);
});
assertEquals(
List.of(
":status=200",
"content-type=application/json",
"x-trace=abc123",
"authorization=secret"),
fields);
assertEquals(List.of(false, false, false, true), sensitive);
}
@Test
void tableSizeUpdateZeroIsCanonical() {
ByteWriter out = new ByteWriter(16);
HpackEncoder.writeDynamicTableSizeUpdateZero(out);
assertArrayEquals(new byte[] {0x20}, java.util.Arrays.copyOf(out.array(), out.length()));
}
@Test
void huffmanLiteralIsSmallerForTypicalValue() {
byte[] value = ascii("application/json");
ByteWriter raw = new ByteWriter(32);
ByteWriter compressed = new ByteWriter(32);
HpackEncoder.writeLiteralWithNameIndex(raw, 31, value, false);
HpackEncoder.writeLiteralWithNameIndex(compressed, 31, value, true);
assertTrue(compressed.length() < raw.length());
}
private static byte[] ascii(String value) {
return value.getBytes(StandardCharsets.US_ASCII);
}
private static String text(ByteView value) {
byte[] bytes = new byte[value.length()];
for (int i = 0; i < bytes.length; i++) bytes[i] = value.byteAt(i);
return new String(bytes, StandardCharsets.US_ASCII);
}
}
@@ -0,0 +1,159 @@
package dev.relism.flash.http2.message;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http2.Http2StreamException;
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.models.Response;
import dev.relism.fpr.core.ByteView;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.Test;
class Http2ResponseWriterTest {
@Test
void serializesOrderedHeadersAndOneDataFrame() {
Response response =
new Response(200, "hello", ContentType.TEXT_PLAIN)
.header("X-Trace", "abc")
.header("Connection", "close")
.header("Upgrade", "websocket");
Http2ResponseWriter writer = new Http2ResponseWriter();
assertTrue(writer.prepare(response, 3, false, false, true, false, true, 16_384, 4096, 65_535));
Parsed parsed = parse(writer);
assertEquals(List.of(FrameType.HEADERS, FrameType.DATA), parsed.types);
assertEquals(FrameFlags.END_HEADERS, parsed.flags.get(0));
assertEquals(FrameFlags.END_STREAM, parsed.flags.get(1));
assertEquals("hello", new String(parsed.data, StandardCharsets.US_ASCII));
assertEquals(
List.of(":status=200", "content-type=text/plain", "content-length=5", "x-trace=abc"),
decode(parsed.headerBlock));
}
@Test
void splitsHeaderBlockIntoAdjacentContinuationFrames() {
Response response =
new Response(200, ContentType.NONE)
.header("x-long", "abcdefghijklmnopqrstuvwxyz0123456789");
Http2ResponseWriter writer = new Http2ResponseWriter();
assertTrue(writer.prepare(response, 1, false, false, false, false, false, 12, 4096, 65_535));
Parsed parsed = parse(writer);
assertTrue(parsed.types.size() > 1);
assertEquals(FrameType.HEADERS, parsed.types.get(0));
for (int i = 1; i < parsed.types.size(); i++) {
assertEquals(FrameType.CONTINUATION, parsed.types.get(i));
}
assertEquals(0, parsed.flags.get(0) & FrameFlags.END_HEADERS);
assertTrue((parsed.flags.get(parsed.flags.size() - 1) & FrameFlags.END_HEADERS) != 0);
assertEquals(
List.of(":status=200", "x-long=abcdefghijklmnopqrstuvwxyz0123456789"),
decode(parsed.headerBlock));
}
@Test
void headAndBodyForbiddenStatusesEndOnHeaders() {
for (Response response :
List.of(
new Response(200, "body", ContentType.TEXT_PLAIN),
new Response(204, "body", ContentType.TEXT_PLAIN),
new Response(304, "body", ContentType.TEXT_PLAIN))) {
boolean head = response.getStatusCode() == 200;
Http2ResponseWriter writer = new Http2ResponseWriter();
assertTrue(
writer.prepare(response, 1, head, false, true, false, false, 16_384, 4096, 65_535));
Parsed parsed = parse(writer);
assertEquals(List.of(FrameType.HEADERS), parsed.types);
assertTrue((parsed.flags.get(0) & FrameFlags.END_STREAM) != 0);
List<String> fields = decode(parsed.headerBlock);
if (head) assertTrue(fields.contains("content-length=4"));
else assertFalse(fields.stream().anyMatch(value -> value.startsWith("content-length=")));
}
}
@Test
void insufficientWindowDefersWithoutProducingPartialResponse() {
Response response = new Response(200, "body", ContentType.TEXT_PLAIN);
Http2ResponseWriter writer = new Http2ResponseWriter();
assertFalse(writer.prepare(response, 1, false, false, true, false, false, 16_384, 3, 3));
assertEquals(0, writer.length());
}
@Test
void peerHeaderListLimitFailsTheStream() {
Response response = new Response(200, ContentType.TEXT_PLAIN);
Http2ResponseWriter writer = new Http2ResponseWriter();
assertThrows(
Http2StreamException.class,
() -> writer.prepare(response, 5, false, false, true, false, false, 16_384, 41, 65_535));
}
private static Parsed parse(Http2ResponseWriter writer) {
Parsed parsed = new Parsed();
byte[] wire = writer.buffer();
int position = 0;
while (position < writer.length()) {
int length =
((wire[position] & 0xff) << 16)
| ((wire[position + 1] & 0xff) << 8)
| (wire[position + 2] & 0xff);
FrameType type = FrameType.fromCode(wire[position + 3] & 0xff);
int flags = wire[position + 4] & 0xff;
byte[] payload = Arrays.copyOfRange(wire, position + 9, position + 9 + length);
parsed.types.add(type);
parsed.flags.add(flags);
if (type == FrameType.HEADERS || type == FrameType.CONTINUATION) {
parsed.appendHeaders(payload);
} else if (type == FrameType.DATA) {
parsed.data = payload;
}
position += 9 + length;
}
parsed.headerBlock = Arrays.copyOf(parsed.headerBlock, parsed.headerLength);
return parsed;
}
private static List<String> decode(byte[] block) {
List<String> fields = new ArrayList<>();
new HpackDecoder()
.decode(
block,
0,
block.length,
(name, value, never) -> fields.add(text(name) + "=" + text(value)));
return fields;
}
private static String text(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);
}
private static final class Parsed {
final List<FrameType> types = new ArrayList<>();
final List<Integer> flags = new ArrayList<>();
byte[] headerBlock = new byte[64];
int headerLength;
byte[] data = new byte[0];
void appendHeaders(byte[] fragment) {
if (headerLength + fragment.length > headerBlock.length) {
headerBlock = Arrays.copyOf(headerBlock, (headerLength + fragment.length) * 2);
}
System.arraycopy(fragment, 0, headerBlock, headerLength, fragment.length);
headerLength += fragment.length;
}
}
}
@@ -0,0 +1,66 @@
package dev.relism.flash.models;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.http1.Http1ResponseWriter;
import dev.relism.flash.http2.hpack.HpackDecoder;
import dev.relism.flash.http2.message.Http2ResponseWriter;
import dev.relism.flash.transport.ScratchPool;
import dev.relism.fpr.core.ByteView;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
class ResponseSerializerParityTest {
@Test
void bothProtocolsRenderTheSameResponseFields() throws Exception {
Response response =
new Response(201, "created", ContentType.JSON)
.header("Cache-Control", "no-store")
.header(new PreEncodedHeader("X-Trace", "abc"));
ByteArrayOutputStream http1 = new ByteArrayOutputStream();
Http1ResponseWriter.writeResponse(
http1, response, HttpMethod.GET, true, false, new ScratchPool().acquire());
Map<String, String> http1Fields = parseHttp1(http1.toString(StandardCharsets.US_ASCII));
http1Fields.remove("connection");
Http2ResponseWriter writer = new Http2ResponseWriter();
writer.prepare(response, 1, false, false, true, false, false, 16_384, 4096, 65_535);
int headerLength =
((writer.buffer()[0] & 0xff) << 16)
| ((writer.buffer()[1] & 0xff) << 8)
| (writer.buffer()[2] & 0xff);
Map<String, String> http2Fields = new LinkedHashMap<>();
new HpackDecoder()
.decode(
writer.buffer(),
9,
headerLength,
(name, value, never) -> http2Fields.put(text(name), text(value)));
http2Fields.remove(":status");
assertEquals(http1Fields, http2Fields);
}
private static Map<String, String> parseHttp1(String message) {
Map<String, String> fields = new LinkedHashMap<>();
int end = message.indexOf("\r\n\r\n");
String[] lines = message.substring(0, end).split("\r\n");
for (int i = 1; i < lines.length; i++) {
int colon = lines[i].indexOf(':');
fields.put(lines[i].substring(0, colon).toLowerCase(), lines[i].substring(colon + 2));
}
return fields;
}
private static String text(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);
}
}