Skip to content

Commit 67db6a4

Browse files
committed
Merge TASK-012: http_response fluent with_* setters
2 parents 07af54a + 529d945 commit 67db6a4

7 files changed

Lines changed: 655 additions & 16 deletions

File tree

specs/tasks/M2-response/TASK-012.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88
Make `with_header` / `with_footer` / `with_cookie` / `with_status` return `http_response&` so factory chains work.
99

1010
**Action Items:**
11-
- [ ] `http_response& with_header(std::string key, std::string value) &;`
12-
- [ ] `http_response&& with_header(std::string key, std::string value) &&;` (rvalue overload to keep `http_response::string("hi").with_header(...)` zero-copy).
13-
- [ ] Same pattern for `with_footer`, `with_cookie`, `with_status(int code)`.
14-
- [ ] Cookie API takes a structured cookie type (name, value, attrs) or string-as-Set-Cookie; pick one and document.
15-
- [ ] Update v1 callers: `r.with_header(...)` chains now compile; previous `void`-returning calls still work (statement form is fine) but enable the fluent style.
11+
- [x] `http_response& with_header(std::string key, std::string value) &;`
12+
- [x] `http_response&& with_header(std::string key, std::string value) &&;` (rvalue overload to keep `http_response::string("hi").with_header(...)` zero-copy).
13+
- [x] Same pattern for `with_footer`, `with_cookie`, `with_status(int code)`.
14+
- [x] Cookie API takes a structured cookie type (name, value, attrs) or string-as-Set-Cookie; pick one and document. (Decision: keep v1 string-pair `(name, value)`; structured cookie type deferred to a follow-up task. Documented on `with_cookie` Doxygen.)
15+
- [x] Update v1 callers: `r.with_header(...)` chains now compile; previous `void`-returning calls still work (statement form is fine) but enable the fluent style.
1616

1717
**Dependencies:**
1818
- Blocked by: TASK-009
@@ -26,4 +26,4 @@ Make `with_header` / `with_footer` / `with_cookie` / `with_status` return `http_
2626
**Related Requirements:** PRD-RSP-REQ-004
2727
**Related Decisions:** §4.3
2828

29-
**Status:** Not Started
29+
**Status:** Done

specs/tasks/_index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ Nominally: **13 sequential tasks**, each S–XL. Most other tasks parallelize of
9494
| TASK-009 | `http_response` value type with SBO buffer | M2 | Done | TASK-008 |
9595
| TASK-010 | `http_response` factory functions | M2 | Done | TASK-008, TASK-009, TASK-004 |
9696
| TASK-011 | `http_response` const-correct accessors | M2 | Done | TASK-009 |
97-
| TASK-012 | `http_response` fluent `with_*` setters | M2 | Not Started | TASK-009 |
97+
| TASK-012 | `http_response` fluent `with_*` setters | M2 | Done | TASK-009 |
9898
| TASK-013 | Remove `*_response` subclasses and dispatch virtuals | M2 | Not Started | TASK-009, TASK-010, TASK-011, TASK-012 |
9999
| TASK-014 | `webserver_impl` skeleton (PIMPL prep) | M3 | Not Started | TASK-002 |
100100
| TASK-015 | `http_request_impl` skeleton (PIMPL split) | M3 | Not Started | TASK-002, TASK-014 |

specs/unworked_review_issues/2026-05-03_222849_task-012.md

Lines changed: 133 additions & 0 deletions
Large diffs are not rendered by default.

src/http_response.cpp

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,119 @@ void http_response::shoutCAST() {
189189
status_code_ |= http::http_utils::shoutcast_response;
190190
}
191191

192+
// -----------------------------------------------------------------------
193+
// Fluent with_* setters (TASK-012, PRD-RSP-REQ-004).
194+
//
195+
// Each setter has two ref-qualified overloads that delegate to a private
196+
// do_set_*() helper containing the validation + mutation logic. The
197+
// overloads differ only in their return statement: `& overload` returns
198+
// *this by lvalue reference; `&& overload` returns std::move(*this).
199+
// Centralising the mutation in a single helper means validation and
200+
// insert_or_assign are in exactly one place per setter, not duplicated
201+
// across every overload pair.
202+
//
203+
// Validation (security, TASK-012 review-pass):
204+
// * with_header / with_footer: reject key or value containing CR,
205+
// LF, or NUL — these characters can split an HTTP response and
206+
// inject additional headers (CWE-113).
207+
// * with_cookie: same CRLF/NUL rejection on name and value.
208+
// * with_status: code must be in [100, 599] per RFC 9110 §15.
209+
//
210+
// insert_or_assign — rather than `m[k] = v` — is used so the by-value
211+
// `std::string` parameters can be moved into the map slot directly.
212+
// -----------------------------------------------------------------------
213+
214+
// Shared forbidden-character set for header/footer/cookie field names
215+
// and values. The string_view spans all three bytes including the
216+
// embedded NUL.
217+
namespace {
218+
constexpr std::string_view kForbiddenFieldChars("\r\n\0", 3);
219+
220+
void validate_header_field(std::string_view context,
221+
std::string_view key,
222+
std::string_view value) {
223+
if (key.find_first_of(kForbiddenFieldChars) != std::string_view::npos) {
224+
throw std::invalid_argument(
225+
std::string(context) +
226+
": key contains forbidden control character (CR, LF, or NUL)");
227+
}
228+
if (value.find_first_of(kForbiddenFieldChars) != std::string_view::npos) {
229+
throw std::invalid_argument(
230+
std::string(context) +
231+
": value contains forbidden control character (CR, LF, or NUL)");
232+
}
233+
}
234+
} // namespace
235+
236+
void http_response::do_set_header(std::string key, std::string value) {
237+
validate_header_field("with_header", key, value);
238+
headers_.insert_or_assign(std::move(key), std::move(value));
239+
}
240+
241+
void http_response::do_set_footer(std::string key, std::string value) {
242+
validate_header_field("with_footer", key, value);
243+
footers_.insert_or_assign(std::move(key), std::move(value));
244+
}
245+
246+
void http_response::do_set_cookie(std::string key, std::string value) {
247+
validate_header_field("with_cookie", key, value);
248+
cookies_.insert_or_assign(std::move(key), std::move(value));
249+
}
250+
251+
void http_response::do_set_status(int code) {
252+
if (code < 100 || code > 599) {
253+
throw std::invalid_argument(
254+
"with_status: HTTP status code out of range [100, 599]");
255+
}
256+
status_code_ = code;
257+
}
258+
259+
http_response& http_response::with_header(std::string key,
260+
std::string value) & {
261+
do_set_header(std::move(key), std::move(value));
262+
return *this;
263+
}
264+
265+
http_response&& http_response::with_header(std::string key,
266+
std::string value) && {
267+
do_set_header(std::move(key), std::move(value));
268+
return std::move(*this);
269+
}
270+
271+
http_response& http_response::with_footer(std::string key,
272+
std::string value) & {
273+
do_set_footer(std::move(key), std::move(value));
274+
return *this;
275+
}
276+
277+
http_response&& http_response::with_footer(std::string key,
278+
std::string value) && {
279+
do_set_footer(std::move(key), std::move(value));
280+
return std::move(*this);
281+
}
282+
283+
http_response& http_response::with_cookie(std::string key,
284+
std::string value) & {
285+
do_set_cookie(std::move(key), std::move(value));
286+
return *this;
287+
}
288+
289+
http_response&& http_response::with_cookie(std::string key,
290+
std::string value) && {
291+
do_set_cookie(std::move(key), std::move(value));
292+
return std::move(*this);
293+
}
294+
295+
http_response& http_response::with_status(int code) & {
296+
do_set_status(code);
297+
return *this;
298+
}
299+
300+
http_response&& http_response::with_status(int code) && {
301+
do_set_status(code);
302+
return std::move(*this);
303+
}
304+
192305
// -----------------------------------------------------------------------
193306
// Const single-key accessors (TASK-011).
194307
//

src/httpserver/http_response.hpp

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -267,17 +267,60 @@ class http_response {
267267
return status_code_;
268268
}
269269

270-
void with_header(const std::string& key, const std::string& value) {
271-
headers_[key] = value;
272-
}
270+
// ------------------------------------------------------------------
271+
// Fluent setters (TASK-012, PRD-RSP-REQ-004).
272+
//
273+
// Each setter is overloaded on the value-category of *this so that
274+
// both lvalue and rvalue (factory) chains keep the response live
275+
// and zero-copy:
276+
//
277+
// * The `&` overload returns http_response& so that
278+
// r.with_header(k, v).with_status(s);
279+
// compiles and returns *this when `r` is an lvalue.
280+
// * The `&&` overload returns http_response&& so that
281+
// http_response::string("hi").with_header(...).with_status(...)
282+
// keeps the temporary as an rvalue end-to-end; the chain calls
283+
// successive `&&` overloads on the same SBO-inline body without
284+
// any intermediate move-construction or heap relocation.
285+
//
286+
// String parameters are taken by value: the body internally moves
287+
// them into the underlying header/footer/cookie maps via
288+
// insert_or_assign, so callers can either copy or move into the
289+
// setter without an extra allocation.
290+
//
291+
// Backward compatibility (constraint): pre-TASK-012 callers wrote
292+
// r.with_header(k, v);
293+
// in statement form, discarding the (then `void`) return. Switching
294+
// the return type to a non-`[[nodiscard]]` reference is strictly
295+
// source-compatible — the reference is silently ignored.
296+
//
297+
// Cookie API decision (action item #4 of TASK-012): the v2.0 cookie
298+
// surface is the v1 (name, value) string-pair shape. `with_cookie`
299+
// overwrites any prior entry for `name` (the cookie map is keyed
300+
// case-insensitively). The value is rendered verbatim into the
301+
// `Set-Cookie` header by decorate_response, so callers who need
302+
// attributes (Path, Secure, HttpOnly, SameSite, ...) pre-format the
303+
// value, e.g. with_cookie("sid", "abc; Path=/; Secure; HttpOnly").
304+
// A structured cookie type with first-class attribute fields is
305+
// intentionally deferred to a follow-up task; it can be added as a
306+
// non-breaking overload alongside this string-pair API.
307+
//
308+
// Note on with_status: status replaces the stored code outright,
309+
// including any flag bits set by shoutCAST() (which ORs
310+
// MHD_ICY_FLAG into status_code_). Callers wanting both write
311+
// with_status(...) first and shoutCAST() second.
312+
// ------------------------------------------------------------------
313+
http_response& with_header(std::string key, std::string value) &;
314+
http_response&& with_header(std::string key, std::string value) &&;
273315

274-
void with_footer(const std::string& key, const std::string& value) {
275-
footers_[key] = value;
276-
}
316+
http_response& with_footer(std::string key, std::string value) &;
317+
http_response&& with_footer(std::string key, std::string value) &&;
277318

278-
void with_cookie(const std::string& key, const std::string& value) {
279-
cookies_[key] = value;
280-
}
319+
http_response& with_cookie(std::string key, std::string value) &;
320+
http_response&& with_cookie(std::string key, std::string value) &&;
321+
322+
http_response& with_status(int code) &;
323+
http_response&& with_status(int code) &&;
281324

282325
void shoutCAST();
283326

@@ -310,6 +353,16 @@ class http_response {
310353
void destroy_body() noexcept;
311354
void adopt_body_from(http_response& o) noexcept;
312355

356+
// Shared mutation helpers for the fluent setters (TASK-012
357+
// review-pass). Each helper validates its inputs, then performs the
358+
// map mutation or scalar assignment. Centralising the logic here
359+
// means the & and && overloads only differ in their return
360+
// statement; the mutation + validation is in exactly one place.
361+
void do_set_header(std::string key, std::string value);
362+
void do_set_footer(std::string key, std::string value);
363+
void do_set_cookie(std::string key, std::string value);
364+
void do_set_status(int code);
365+
313366
// Placement-new a concrete detail::body subclass into the SBO
314367
// buffer (or, if T does not fit, onto the heap via the matched
315368
// ::operator new(sizeof(T))/::operator delete pairing the

test/unit/http_response_factories_test.cpp

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,24 @@ LT_BEGIN_AUTO_TEST(http_response_factories_suite,
440440
LT_CHECK_EQ(other.get_response_code(), 200);
441441
LT_END_AUTO_TEST(factory_move_preserves_kind_and_headers)
442442

443+
// -----------------------------------------------------------------------
444+
// TASK-012 zero-copy invariant: chained with_* calls on a factory's
445+
// rvalue must not perturb the SBO placement. http_response::string(...)
446+
// places a string_body inline in the SBO buffer; the && overloads of
447+
// with_header / with_status return http_response&& (i.e. propagate the
448+
// xvalue without an intermediate copy/move-construct), so the body
449+
// pointer must remain inline through the chain.
450+
// -----------------------------------------------------------------------
451+
LT_BEGIN_AUTO_TEST(http_response_factories_suite,
452+
factory_chain_keeps_body_inline_in_sbo)
453+
auto r = http_response::string("hi")
454+
.with_header("X-Foo", "bar")
455+
.with_status(201);
456+
LT_CHECK_EQ(SBO::body_inline(r), true);
457+
LT_CHECK_EQ(static_cast<int>(SBO::kind(r)),
458+
static_cast<int>(body_kind::string));
459+
LT_END_AUTO_TEST(factory_chain_keeps_body_inline_in_sbo)
460+
443461
LT_BEGIN_AUTO_TEST_ENV()
444462
AUTORUN_TESTS()
445463
LT_END_AUTO_TEST_ENV()

0 commit comments

Comments
 (0)