feat(core): add HTTP/2 response path
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user