Skip to content

Commit 6e6b574

Browse files
committed
TASK-001-003: Add H2ResponseBuilder + H3ResponseBuilder fluent frame builders
Add fluent test helpers for constructing HTTP/2 and HTTP/3 frame-level byte arrays, reducing verbosity and error-proneness in byte-level acceptance tests. - H2ResponseBuilder: Settings, SettingsAck, Headers (HPACK), Data, WindowUpdate, Ping, GoAway, RstStream with Build() → byte[] - H3ResponseBuilder: Settings, Headers (QPACK), Data, GoAway, MaxPushId, CancelPush with Build() → byte[] - 7 unit tests per builder verifying round-trip through existing FrameDecoder
1 parent 7027a04 commit 6e6b574

6 files changed

Lines changed: 598 additions & 6 deletions

File tree

.maggus/daemon.stop-after-task

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
38564

.maggus/features/feature_001.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,12 @@ Migrate all 84 IntegrationTest files (83 acceptance + 1 unit) to deterministic S
7676
**Model:** opus
7777

7878
**Acceptance Criteria:**
79-
- [ ] `H2ResponseBuilder` in `StreamTests/Acceptance/Shared/` with fluent API: `.Settings()`, `.Headers(streamId, status, headers)`, `.Data(streamId, body, endStream)`, `.WindowUpdate()`, `.Build()` returning `byte[]`
80-
- [ ] `H3ResponseBuilder` in `StreamTests/Acceptance/Shared/` with equivalent fluent API adapted for HTTP/3 frames (QPACK-encoded headers)
81-
- [ ] Builders produce valid frames decodable by existing `FrameDecoder` implementations
82-
- [ ] Unit test: H2 builder produces valid SETTINGS + HEADERS + DATA sequence
83-
- [ ] Unit test: H3 builder produces valid SETTINGS + HEADERS + DATA sequence
84-
- [ ] Build passes
79+
- [x] `H2ResponseBuilder` in `StreamTests/Acceptance/Shared/` with fluent API: `.Settings()`, `.Headers(streamId, status, headers)`, `.Data(streamId, body, endStream)`, `.WindowUpdate()`, `.Build()` returning `byte[]`
80+
- [x] `H3ResponseBuilder` in `StreamTests/Acceptance/Shared/` with equivalent fluent API adapted for HTTP/3 frames (QPACK-encoded headers)
81+
- [x] Builders produce valid frames decodable by existing `FrameDecoder` implementations
82+
- [x] Unit test: H2 builder produces valid SETTINGS + HEADERS + DATA sequence
83+
- [x] Unit test: H3 builder produces valid SETTINGS + HEADERS + DATA sequence
84+
- [x] Build passes
8585

8686
---
8787

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
using System.Text;
2+
using TurboHTTP.Protocol.Http2;
3+
using TurboHTTP.Protocol.Http2.Hpack;
4+
5+
namespace TurboHTTP.StreamTests.Acceptance.Shared;
6+
7+
/// <summary>
8+
/// Fluent builder for constructing HTTP/2 frame-level byte arrays.
9+
/// Produces valid frame sequences decodable by <see cref="FrameDecoder"/>.
10+
/// Intended for byte-level acceptance tests where hand-crafting frames is verbose.
11+
/// </summary>
12+
public sealed class H2ResponseBuilder
13+
{
14+
private readonly List<Http2Frame> _frames = [];
15+
private readonly HpackEncoder _encoder;
16+
17+
public H2ResponseBuilder(bool useHuffman = false)
18+
{
19+
_encoder = new HpackEncoder(useHuffman: useHuffman);
20+
}
21+
22+
/// <summary>
23+
/// Appends a SETTINGS frame with the given parameters on stream 0.
24+
/// </summary>
25+
public H2ResponseBuilder Settings(params (SettingsParameter Key, uint Value)[] parameters)
26+
{
27+
_frames.Add(new SettingsFrame(parameters));
28+
return this;
29+
}
30+
31+
/// <summary>
32+
/// Appends a SETTINGS ACK frame on stream 0.
33+
/// </summary>
34+
public H2ResponseBuilder SettingsAck()
35+
{
36+
_frames.Add(new SettingsFrame([], isAck: true));
37+
return this;
38+
}
39+
40+
/// <summary>
41+
/// Appends a HEADERS frame with HPACK-encoded pseudo-headers and regular headers.
42+
/// </summary>
43+
public H2ResponseBuilder Headers(int streamId, int status, IReadOnlyList<(string Name, string Value)>? headers = null, bool endStream = false)
44+
{
45+
var allHeaders = new List<(string, string)>
46+
{
47+
(":status", status.ToString())
48+
};
49+
50+
if (headers != null)
51+
{
52+
allHeaders.AddRange(headers);
53+
}
54+
55+
var encoded = _encoder.Encode(allHeaders);
56+
_frames.Add(new HeadersFrame(streamId, encoded, endStream: endStream, endHeaders: true));
57+
return this;
58+
}
59+
60+
/// <summary>
61+
/// Appends a DATA frame with the given body bytes.
62+
/// </summary>
63+
public H2ResponseBuilder Data(int streamId, ReadOnlyMemory<byte> body, bool endStream = true)
64+
{
65+
_frames.Add(new DataFrame(streamId, body, endStream: endStream));
66+
return this;
67+
}
68+
69+
/// <summary>
70+
/// Appends a DATA frame with a UTF-8 string body.
71+
/// </summary>
72+
public H2ResponseBuilder Data(int streamId, string body, bool endStream = true)
73+
{
74+
return Data(streamId, Encoding.UTF8.GetBytes(body), endStream);
75+
}
76+
77+
/// <summary>
78+
/// Appends a WINDOW_UPDATE frame.
79+
/// </summary>
80+
public H2ResponseBuilder WindowUpdate(int streamId, int increment)
81+
{
82+
_frames.Add(new WindowUpdateFrame(streamId, increment));
83+
return this;
84+
}
85+
86+
/// <summary>
87+
/// Appends a PING frame (8 bytes of opaque data).
88+
/// </summary>
89+
public H2ResponseBuilder Ping(ReadOnlyMemory<byte>? data = null, bool isAck = false)
90+
{
91+
var pingData = data ?? new byte[8];
92+
_frames.Add(new PingFrame(pingData, isAck: isAck));
93+
return this;
94+
}
95+
96+
/// <summary>
97+
/// Appends a GOAWAY frame on stream 0.
98+
/// </summary>
99+
public H2ResponseBuilder GoAway(int lastStreamId, Http2ErrorCode errorCode = Http2ErrorCode.NoError)
100+
{
101+
_frames.Add(new GoAwayFrame(lastStreamId, errorCode));
102+
return this;
103+
}
104+
105+
/// <summary>
106+
/// Appends a RST_STREAM frame.
107+
/// </summary>
108+
public H2ResponseBuilder RstStream(int streamId, Http2ErrorCode errorCode)
109+
{
110+
_frames.Add(new RstStreamFrame(streamId, errorCode));
111+
return this;
112+
}
113+
114+
/// <summary>
115+
/// Serializes all appended frames into a single contiguous byte array.
116+
/// </summary>
117+
public byte[] Build()
118+
{
119+
var totalSize = 0;
120+
foreach (var frame in _frames)
121+
{
122+
totalSize += frame.SerializedSize;
123+
}
124+
125+
var result = new byte[totalSize];
126+
var span = result.AsSpan();
127+
128+
foreach (var frame in _frames)
129+
{
130+
frame.WriteTo(ref span);
131+
}
132+
133+
return result;
134+
}
135+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
using TurboHTTP.Protocol.Http2;
2+
using TurboHTTP.Protocol.Http2.Hpack;
3+
4+
namespace TurboHTTP.StreamTests.Acceptance.Shared;
5+
6+
/// <summary>
7+
/// Verifies that <see cref="H2ResponseBuilder"/> produces valid HTTP/2 frame sequences
8+
/// decodable by <see cref="FrameDecoder"/>.
9+
/// </summary>
10+
public sealed class H2ResponseBuilderSpec
11+
{
12+
[Fact(Timeout = 5000)]
13+
[Trait("RFC", "RFC9113-4.1")]
14+
public void Build_should_produce_valid_settings_headers_data_sequence()
15+
{
16+
var bytes = new H2ResponseBuilder()
17+
.Settings(
18+
(SettingsParameter.MaxConcurrentStreams, 100),
19+
(SettingsParameter.InitialWindowSize, 65535))
20+
.SettingsAck()
21+
.Headers(1, 200, [("content-type", "text/plain")])
22+
.Data(1, "hello")
23+
.Build();
24+
25+
using var decoder = new FrameDecoder();
26+
var frames = decoder.Decode(new ReadOnlyMemory<byte>(bytes));
27+
28+
Assert.Equal(4, frames.Count);
29+
30+
var settings = Assert.IsType<SettingsFrame>(frames[0]);
31+
Assert.False(settings.IsAck);
32+
Assert.Equal(2, settings.Parameters.Count);
33+
Assert.Equal(SettingsParameter.MaxConcurrentStreams, settings.Parameters[0].Item1);
34+
Assert.Equal(100u, settings.Parameters[0].Item2);
35+
36+
var settingsAck = Assert.IsType<SettingsFrame>(frames[1]);
37+
Assert.True(settingsAck.IsAck);
38+
39+
var headers = Assert.IsType<HeadersFrame>(frames[2]);
40+
Assert.Equal(1, headers.StreamId);
41+
Assert.True(headers.EndHeaders);
42+
Assert.False(headers.EndStream);
43+
44+
var hpackDecoder = new HpackDecoder();
45+
var decoded = hpackDecoder.Decode(headers.HeaderBlockFragment.Span);
46+
Assert.Contains(decoded, h => h.Name == ":status" && h.Value == "200");
47+
Assert.Contains(decoded, h => h.Name == "content-type" && h.Value == "text/plain");
48+
49+
var data = Assert.IsType<DataFrame>(frames[3]);
50+
Assert.Equal(1, data.StreamId);
51+
Assert.True(data.EndStream);
52+
Assert.Equal("hello"u8.ToArray(), data.Data.ToArray());
53+
}
54+
55+
[Fact(Timeout = 5000)]
56+
[Trait("RFC", "RFC9113-6.5")]
57+
public void Build_should_produce_valid_empty_settings_ack()
58+
{
59+
var bytes = new H2ResponseBuilder()
60+
.SettingsAck()
61+
.Build();
62+
63+
using var decoder = new FrameDecoder();
64+
var frames = decoder.Decode(new ReadOnlyMemory<byte>(bytes));
65+
66+
Assert.Single(frames);
67+
var settings = Assert.IsType<SettingsFrame>(frames[0]);
68+
Assert.True(settings.IsAck);
69+
Assert.Empty(settings.Parameters);
70+
}
71+
72+
[Fact(Timeout = 5000)]
73+
[Trait("RFC", "RFC9113-6.9")]
74+
public void Build_should_produce_valid_window_update()
75+
{
76+
var bytes = new H2ResponseBuilder()
77+
.WindowUpdate(0, 65535)
78+
.WindowUpdate(1, 32768)
79+
.Build();
80+
81+
using var decoder = new FrameDecoder();
82+
var frames = decoder.Decode(new ReadOnlyMemory<byte>(bytes));
83+
84+
Assert.Equal(2, frames.Count);
85+
86+
var wu0 = Assert.IsType<WindowUpdateFrame>(frames[0]);
87+
Assert.Equal(0, wu0.StreamId);
88+
Assert.Equal(65535, wu0.Increment);
89+
90+
var wu1 = Assert.IsType<WindowUpdateFrame>(frames[1]);
91+
Assert.Equal(1, wu1.StreamId);
92+
Assert.Equal(32768, wu1.Increment);
93+
}
94+
95+
[Fact(Timeout = 5000)]
96+
[Trait("RFC", "RFC9113-6.1")]
97+
public void Build_should_produce_headers_only_response_with_end_stream()
98+
{
99+
var bytes = new H2ResponseBuilder()
100+
.Headers(1, 204, endStream: true)
101+
.Build();
102+
103+
using var decoder = new FrameDecoder();
104+
var frames = decoder.Decode(new ReadOnlyMemory<byte>(bytes));
105+
106+
Assert.Single(frames);
107+
var headers = Assert.IsType<HeadersFrame>(frames[0]);
108+
Assert.Equal(1, headers.StreamId);
109+
Assert.True(headers.EndStream);
110+
Assert.True(headers.EndHeaders);
111+
112+
var hpackDecoder = new HpackDecoder();
113+
var decoded = hpackDecoder.Decode(headers.HeaderBlockFragment.Span);
114+
Assert.Single(decoded);
115+
Assert.Equal(":status", decoded[0].Name);
116+
Assert.Equal("204", decoded[0].Value);
117+
}
118+
119+
[Fact(Timeout = 5000)]
120+
[Trait("RFC", "RFC9113-6.8")]
121+
public void Build_should_produce_valid_goaway_frame()
122+
{
123+
var bytes = new H2ResponseBuilder()
124+
.GoAway(3, Http2ErrorCode.NoError)
125+
.Build();
126+
127+
using var decoder = new FrameDecoder();
128+
var frames = decoder.Decode(new ReadOnlyMemory<byte>(bytes));
129+
130+
Assert.Single(frames);
131+
var goaway = Assert.IsType<GoAwayFrame>(frames[0]);
132+
Assert.Equal(3, goaway.LastStreamId);
133+
Assert.Equal(Http2ErrorCode.NoError, goaway.ErrorCode);
134+
}
135+
136+
[Fact(Timeout = 5000)]
137+
[Trait("RFC", "RFC9113-6.4")]
138+
public void Build_should_produce_valid_rst_stream_frame()
139+
{
140+
var bytes = new H2ResponseBuilder()
141+
.RstStream(1, Http2ErrorCode.Cancel)
142+
.Build();
143+
144+
using var decoder = new FrameDecoder();
145+
var frames = decoder.Decode(new ReadOnlyMemory<byte>(bytes));
146+
147+
Assert.Single(frames);
148+
var rst = Assert.IsType<RstStreamFrame>(frames[0]);
149+
Assert.Equal(1, rst.StreamId);
150+
Assert.Equal(Http2ErrorCode.Cancel, rst.ErrorCode);
151+
}
152+
153+
[Fact(Timeout = 5000)]
154+
[Trait("RFC", "RFC9113-4.1")]
155+
public void Build_should_produce_byte_exact_round_trip_through_decoder()
156+
{
157+
var builder = new H2ResponseBuilder();
158+
var bytes = builder
159+
.Settings((SettingsParameter.HeaderTableSize, 4096))
160+
.SettingsAck()
161+
.Headers(1, 200)
162+
.Data(1, "body")
163+
.WindowUpdate(0, 100)
164+
.Build();
165+
166+
using var decoder = new FrameDecoder();
167+
var frames = decoder.Decode(new ReadOnlyMemory<byte>(bytes));
168+
169+
Assert.Equal(5, frames.Count);
170+
Assert.IsType<SettingsFrame>(frames[0]);
171+
Assert.IsType<SettingsFrame>(frames[1]);
172+
Assert.IsType<HeadersFrame>(frames[2]);
173+
Assert.IsType<DataFrame>(frames[3]);
174+
Assert.IsType<WindowUpdateFrame>(frames[4]);
175+
}
176+
}

0 commit comments

Comments
 (0)