feat(http): bind response cookie attributes to result fields#3920
feat(http): bind response cookie attributes to result fields#3920disintegrator wants to merge 10 commits intogoadesign:v3from
Conversation
|
Thanks for putting this together. I like the direction, especially keeping the existing literal cookie setters intact and making the dynamic behavior per-cookie. I noticed a few behavioral issues that I think need tightening before this lands:
I think adding focused golden/tests for lower-case SameSite values, omitted optional bound fields, default body computation, and an error-response binding would make the intended contracts much clearer. |
Add CookieAttributes(name, fn) DSL and the per-cookie binders MaxAgeFrom,
DomainFrom, PathFrom, SecureFrom, HTTPOnlyFrom, SameSiteFrom. Each binder
takes a result-type attribute name; the server populates the corresponding
http.Cookie field from the bound result attribute when emitting the
response, and the generated client decoder writes the matching
*http.Cookie field back into the same result attribute.
The existing literal setters (CookieMaxAge, CookieDomain, CookiePath,
CookieSecure, CookieHTTPOnly, CookieSameSite) remain unchanged and write
response-global metadata as before. Bindings are stored as per-cookie
metadata (cookie:<kind>:from) on the cookie attribute and take precedence
over the response-global literals on a per-cookie basis. Cookies without
bindings are unaffected.
Validation rejects bindings to attributes that do not exist on the result
type or whose primitive kind does not match the cookie attribute (Int*
for Max-Age, String for Domain/Path/SameSite, Boolean for Secure/HttpOnly).
Example - pure bindings:
Method("login", func() {
Result(LoginResult)
HTTP(func() {
POST("/login")
Response(StatusOK, func() {
Cookie("sessionID:SID", String)
CookieAttributes("sessionID", func() {
MaxAgeFrom("expiresIn")
DomainFrom("cookieDomain")
SecureFrom("isSecure")
SameSiteFrom("sameSite")
})
})
})
})
Example - mixing bindings with the existing literal setters. The session
cookie's Max-Age comes from a per-user "expiresIn" result attribute, while
the CSRF cookie keeps the fixed literal Max-Age. Domain, Secure and
HttpOnly are shared by both cookies via the existing response-wide
setters:
Method("login", func() {
Result(LoginResult) // sessionID, csrfToken, expiresIn
HTTP(func() {
POST("/login")
Response(StatusOK, func() {
Cookie("sessionID:SID", String)
Cookie("csrfToken:CSRF", String)
CookieAttributes("sessionID", func() {
MaxAgeFrom("expiresIn") // overrides 3600 below
})
CookieMaxAge(3600) // applies to CSRF
CookieDomain("goa.design") // applies to both
CookieSecure() // applies to both
CookieHTTPOnly() // applies to both
})
})
})
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8d91377 to
ad7335a
Compare
The response decoder dropped CookieAttributes bindings when the response matched an error: partial_single_response captured c.MaxAge / c.Domain / c.Secure / etc. into locals for both success and error branches, but only the success branch wrote them back into the constructed result. Errors received the bare ResultInit(...) and lost the decoded values. The copy-back is now emitted in both error branches of the response decoder. The duplicated copy-back blocks are also collapsed into a new partial_cookie_attr_bindings partial parameterised on the target local. HTTPErrorExpr.Validate now runs cookie:*:from validation against the error type so that bindings to missing or wrong-typed error attributes fail at design-time instead of being silently dropped during codegen. The validateCookieAttrBindings helper takes a target-noun parameter so diagnostics read "result type" or "error type" as appropriate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
buildHTTPResponseBody only stripped the cookie value attributes (the
ones declared via Cookie("attr:Header")), not the result-type attributes
referenced by per-cookie CookieAttributes bindings. Without an explicit
Body(...) the source attributes for Max-Age, Domain, Path, Secure,
HttpOnly and SameSite would land in both the cookie attributes on the
response and the JSON body — duplicated transport state and a confusing
default schema.
Strip attributes referenced by cookie:<kind>:from meta from the body
(and from each computed view) before building the body type. The
existing all-bindings, optional and mixed fixture goldens collapse to
empty bodies; the new "body" fixture mixes bound (expiresIn) and
unbound (userID, displayName) result fields and proves the body
roundtrip and type still include the unbound fields while expiresIn is
gone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…decode The cookie-bindings copy-back partial unconditionally took the address of the binding local for pointer fields, so an absent cookie or an absent cookie sub-attribute (Max-Age omitted, empty Domain/Path, Secure/HttpOnly unset, SameSite Default) still produced &0 / &"" / &false on the result struct. Clients had no way to distinguish "attribute not present" from "attribute set to its zero value". The pointer-bound branches now gate each assignment on a per-attribute presence proxy: MaxAge != 0 Domain != "" Path != "" Secure == true HttpOnly == true SameSite != "" && SameSite != "Default" When the proxy fails the bound result field stays nil. Required (non- pointer) bindings are unaffected and continue to assign the zero value. This is a best-effort presence reconstruction: net/http parses the Set-Cookie header into http.Cookie with zero defaults, so an explicit "Max-Age=0" or "Secure=false" looks the same as the attribute being absent. The CookieAttributes godoc documents that conflation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The expr.CookieSameSiteStrict/Lax/None/Default constants are the lower-
case strings "strict", "lax", "none" and "default" — the same values a
service is expected to put on a SameSiteFrom-bound result attribute.
The http codegen partials, however, were keyed on the title-case
"Strict", "Lax", "None" and "Default":
- response.go.tpl (server encoder) switched on the title-case strings
when mapping to http.SameSiteStrictMode etc., so a service returning
string(CookieSameSiteStrict) would fall through to the default arm
and be downgraded to SameSiteDefaultMode.
- single_response.go.tpl (client decoder) wrote the title-case strings
back when reversing http.SameSite values into the bound attribute,
breaking round-trips and the SameSiteFrom presence gate.
- cookie_attr_bindings.go.tpl gated the SameSite presence check on
"Default", which never matched after the decoder fix anyway.
All three partials now key on the lower-case constant values. A new
TestCookieSameSiteConstantsAreLowercase pins the contract at the expr
level so future drift is caught at design-time test failure rather than
silent policy weakening on the wire.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The per-attribute presence gates that the response decoder partial used to inline (if x != 0, if x != "", if x, if x != "" && x != "default") move into four small helpers in the goa http runtime: CookieIntAttr – Max-Age (zero -> nil) CookieStringAttr – Domain, Path (empty -> nil) CookieBoolAttr – Secure, HttpOnly (false -> nil) CookieSameSiteAttr – SameSite (empty or "default" -> nil) CookieIntAttr is generic over the six integer kinds that the binding validator allows (int, int32, int64, uint, uint32, uint64), matching the runtime type chosen for the bound attribute. The cookie_attr_bindings partial now emits one helper call per pointer- bound assignment, collapsing each three-line if block to a single line. Generated optional-bound goldens shift accordingly: res.ExpiresIn = goahttp.CookieIntAttr(sessionIDExpiresIn) res.CookieDomain = goahttp.CookieStringAttr(sessionIDCookieDomain) res.IsSecure = goahttp.CookieBoolAttr(sessionIDIsSecure) res.SameSite = goahttp.CookieSameSiteAttr(sessionIDSameSite) The behavioural contract for omitted optional bound cookie attributes (scenario 1 in the test coverage audit) is now a normal Go test, table-driven over each helper. Previously the contract was pinned only structurally via golden files; a regression in the gate condition would have produced compiling code with the wrong runtime semantics. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
validateCookieAttrBindings allocated an empty *ValidationErrors and returned it on the early-out when the cookie attribute carries no binding metadata. Callers route the result through verr.Merge which is nil-safe, and the surrounding codebase (e.g. http_sse.go) returns nil on the empty path. Move the allocation past the guard so the no-error path returns nil and matches convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…field The six *From binding fields on CookieData take precedence over their literal counterparts (MaxAge, Domain, Path, Secure, HTTPOnly, SameSite) on a per-cookie basis: response.go.tpl emits the bound expression and omits the literal for that cookie when both are set, so exactly one of the two ever reaches the wire. The contract was previously asserted only in commit messages and the CookieAttributes DSL godoc; bring it onto the type itself. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks @raphael! I think I've addressed all the review points. I've also included the output from a local
/review |
| Scenario | Coverage |
|---|---|
| DSL meta propagation (success) | TestHTTPResponseCookieAttrBindings (positive) + 3 negative cases |
| DSL meta propagation (error) | TestHTTPErrorCookieAttrBindings + 2 negative cases |
| Default body excludes bound attrs | TestHTTPResponseBodyExcludesCookieAttrBindings (direct) + 4 goldens |
| Decoder shape — required bindings | cookie-attr-bindings + -mixed + -body goldens |
| Decoder shape — optional bindings (all 4 gate shapes) | cookie-attr-bindings-optional-all golden |
| Error-response decoder copy-back | cookie-attr-bindings-error goldens |
| Presence helpers (behavioural) | http/cookie_test.go — table-driven, covers each helper × {zero, non-zero, edge} |
| SameSite lower-case constants | TestCookieSameSiteConstantsAreLowercase |
Coverage is strong. The presence-helper extraction was specifically motivated by closing a structural-only gap and the table-driven helper tests now lock the contract independently of golden output.
Security considerations
SameSiteFromfalling throughdefaultfor unknown values silently downgrades toSameSiteDefaultMode. With the lowercase fix in place, this only triggers if a service explicitly returns a string outside{strict,lax,none,default,""}. Worth either: (a) validating runtime values via an enum on the bound attribute in the design (Enum(...)), or (b) documenting the silent-default behaviour as a known sharp edge.- Bindings can overwrite response-global literals on a per-cookie basis. A binding to a missing result attribute is stripped silently in
extractCookies(theif battr == nil { continue }guard). Validation now catches this at design time, but if validation is bypassed (e.g., the user uses an internal API to construct expressions), the cookie is emitted without the literal either. Defense in depth would be: emit a code-generation warning. Non-blocking.
Bottom line
The original feature plus the five fixes have converged on a tight, well-tested design. The presence-helpers refactor in particular is a meaningful cleanup that locks the runtime contract independently of the codegen pipeline.
Rename the per-cookie binders inside CookieAttributes to MaxAge, Domain, Path, Secure, HTTPOnly and SameSite. Path collides with the existing API/Service base-path DSL, so its switch now also handles *cookieAttrBindingsExpr by delegating to the cookie binding helper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add CookieAttributes(name, fn) DSL and the per-cookie binders CookieMaxAgeFrom, CookieDomainFrom, CookiePathFrom, CookieSecureFrom, CookieHTTPOnlyFrom, CookieSameSiteFrom. Each binder takes a result-type attribute name; the server populates the corresponding http.Cookie field from the bound result attribute when emitting the response, and the generated client decoder writes the matching *http.Cookie field back into the same result attribute.
The existing literal setters (CookieMaxAge, CookieDomain, CookiePath, CookieSecure, CookieHTTPOnly, CookieSameSite) remain unchanged and write response-global metadata as before. Bindings are stored as per-cookie metadata (cookie::from) on the cookie attribute and take precedence over the response-global literals on a per-cookie basis. Cookies without bindings are unaffected.
Validation rejects bindings to attributes that do not exist on the result type or whose primitive kind does not match the cookie attribute (Int* for Max-Age, String for Domain/Path/SameSite, Boolean for Secure/HttpOnly).
Example - pure bindings:
Example - mixing bindings with the existing literal setters. The session cookie's Max-Age comes from a per-user "expiresIn" result attribute, while the CSRF cookie keeps the fixed literal Max-Age. Domain, Secure and HttpOnly are shared by both cookies via the existing response-wide setters:
Coding agent disclosure
I used Claude Opus 4.7 to plan and implement this feature. I have reviewed the code it produced as well as driving it to produce some more interesting test cases.
Manual verification process
In my project, I am building an OAuth flow using Goa DSL where:
/admin/auth.loginwhich redirects to external provider with a state query param and sets the same state as a cookie./admin/auth.callback?code=...&state=...I was able to confirm this flow can be built with this new DSL. In particular, during local dev, some of our developers run the service behind HTTPS and others don't. Previously in Go it was not easily possible toggle a cookie's
Securedirective based on the local dev setup and, withCookieAttributes+SecureForm, it's now very easy.