Skip to content

Commit ad7335a

Browse files
disintegratorclaude
andcommitted
feat(http): bind response cookie attributes to result fields
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>
1 parent 69d2ca5 commit ad7335a

17 files changed

Lines changed: 1077 additions & 1 deletion

dsl/http.go

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package dsl
22

33
import (
4+
"fmt"
45
"strconv"
56
"strings"
67

@@ -633,6 +634,177 @@ func CookieSameSite(s expr.CookieSameSiteValue) {
633634
cookieAttribute("same-site", string(s))
634635
}
635636

637+
// cookieAttrBindingsExpr is the transient eval context opened by
638+
// CookieAttributes. It wraps the cookie's underlying AttributeExpr inside the
639+
// response Cookies object so the Cookie...From binders can write per-cookie
640+
// binding metadata onto the right attribute.
641+
type cookieAttrBindingsExpr struct {
642+
// Attr is the cookie attribute that binders annotate.
643+
Attr *expr.AttributeExpr
644+
// Name is the cookie attribute name used for diagnostics.
645+
Name string
646+
}
647+
648+
// EvalName returns the qualified name of the cookie binding context.
649+
func (c *cookieAttrBindingsExpr) EvalName() string {
650+
return fmt.Sprintf("CookieAttributes(%q)", c.Name)
651+
}
652+
653+
// CookieAttributes opens a per-cookie attribute binding context for the named
654+
// cookie defined in the enclosing Response. Inside the closure, the
655+
// MaxAgeFrom, DomainFrom, PathFrom, SecureFrom, HTTPOnlyFrom and SameSiteFrom
656+
// functions bind cookie attributes (Max-Age, Domain, Path, Secure, HttpOnly,
657+
// SameSite) to result type attributes computed at runtime by the service
658+
// method. The bindings apply only to the named cookie. The server populates
659+
// the cookie attributes from the bound result fields when emitting the
660+
// response, and the client decodes the corresponding HTTP cookie attributes
661+
// back into the same result fields.
662+
//
663+
// Bindings are additive to and take precedence over the response-wide literal
664+
// metadata set by CookieMaxAge, CookieDomain, CookiePath, CookieSecure,
665+
// CookieHTTPOnly and CookieSameSite.
666+
//
667+
// CookieAttributes must appear in a Response expression. The first argument is
668+
// the attribute-side cookie name (the part before the colon in
669+
// `Cookie("attr:cookie")`).
670+
//
671+
// Example:
672+
//
673+
// var LoginResult = ResultType("application/vnd.login", func() {
674+
// Attributes(func() {
675+
// Attribute("sessionID", String)
676+
// Attribute("expiresIn", Int)
677+
// Attribute("cookieDomain", String)
678+
// Attribute("cookiePath", String)
679+
// Attribute("isSecure", Boolean)
680+
// Attribute("isHTTPOnly", Boolean)
681+
// Attribute("sameSite", String)
682+
// })
683+
// Required("sessionID", "expiresIn", "cookieDomain", "cookiePath",
684+
// "isSecure", "isHTTPOnly", "sameSite")
685+
// })
686+
//
687+
// Method("login", func() {
688+
// Result(LoginResult)
689+
// HTTP(func() {
690+
// POST("/login")
691+
// Response(StatusOK, func() {
692+
// Cookie("sessionID:SID", String)
693+
// CookieAttributes("sessionID", func() {
694+
// MaxAgeFrom("expiresIn")
695+
// DomainFrom("cookieDomain")
696+
// PathFrom("cookiePath")
697+
// SecureFrom("isSecure")
698+
// HTTPOnlyFrom("isHTTPOnly")
699+
// SameSiteFrom("sameSite")
700+
// })
701+
// })
702+
// })
703+
// })
704+
func CookieAttributes(name string, fn func()) {
705+
r, ok := eval.Current().(*expr.HTTPResponseExpr)
706+
if !ok {
707+
eval.IncompatibleDSL()
708+
return
709+
}
710+
if name == "" {
711+
eval.ReportError("cookie name cannot be empty")
712+
return
713+
}
714+
if r.Cookies == nil {
715+
eval.ReportError("CookieAttributes references cookie %q but no cookie has been declared in the response", name)
716+
return
717+
}
718+
obj := expr.AsObject(r.Cookies.Type)
719+
if obj == nil {
720+
eval.ReportError("CookieAttributes references cookie %q but the response cookie set is not an object", name)
721+
return
722+
}
723+
attr := obj.Attribute(name)
724+
if attr == nil {
725+
eval.ReportError("CookieAttributes references cookie %q which has not been declared with Cookie() in the same Response", name)
726+
return
727+
}
728+
eval.Execute(fn, &cookieAttrBindingsExpr{Attr: attr, Name: name})
729+
}
730+
731+
// MaxAgeFrom binds the enclosing cookie's "Max-Age" attribute to a result
732+
// type attribute. The referenced attribute must be of an integer primitive
733+
// type. The server populates http.Cookie.MaxAge from this result field; the
734+
// client decodes c.MaxAge back into the same field.
735+
//
736+
// MaxAgeFrom must appear in a CookieAttributes expression.
737+
func MaxAgeFrom(attr string) {
738+
cookieFromBinding("max-age", attr)
739+
}
740+
741+
// DomainFrom binds the enclosing cookie's "Domain" attribute to a result
742+
// type attribute. The referenced attribute must be of type String. The server
743+
// populates http.Cookie.Domain from this result field; the client decodes
744+
// c.Domain back into the same field.
745+
//
746+
// DomainFrom must appear in a CookieAttributes expression.
747+
func DomainFrom(attr string) {
748+
cookieFromBinding("domain", attr)
749+
}
750+
751+
// PathFrom binds the enclosing cookie's "Path" attribute to a result type
752+
// attribute. The referenced attribute must be of type String. The server
753+
// populates http.Cookie.Path from this result field; the client decodes
754+
// c.Path back into the same field.
755+
//
756+
// PathFrom must appear in a CookieAttributes expression.
757+
func PathFrom(attr string) {
758+
cookieFromBinding("path", attr)
759+
}
760+
761+
// SecureFrom binds the enclosing cookie's "Secure" attribute to a result
762+
// type attribute. The referenced attribute must be of type Boolean. The
763+
// server populates http.Cookie.Secure from this result field; the client
764+
// decodes c.Secure back into the same field.
765+
//
766+
// SecureFrom must appear in a CookieAttributes expression.
767+
func SecureFrom(attr string) {
768+
cookieFromBinding("secure", attr)
769+
}
770+
771+
// HTTPOnlyFrom binds the enclosing cookie's "HttpOnly" attribute to a result
772+
// type attribute. The referenced attribute must be of type Boolean. The
773+
// server populates http.Cookie.HttpOnly from this result field; the client
774+
// decodes c.HttpOnly back into the same field.
775+
//
776+
// HTTPOnlyFrom must appear in a CookieAttributes expression.
777+
func HTTPOnlyFrom(attr string) {
778+
cookieFromBinding("http-only", attr)
779+
}
780+
781+
// SameSiteFrom binds the enclosing cookie's "SameSite" attribute to a result
782+
// type attribute. The referenced attribute must be of type String and at
783+
// runtime must hold one of the values of CookieSameSiteStrict,
784+
// CookieSameSiteLax, CookieSameSiteNone or CookieSameSiteDefault. The server
785+
// populates http.Cookie.SameSite from this result field; the client decodes
786+
// c.SameSite back into the same field.
787+
//
788+
// SameSiteFrom must appear in a CookieAttributes expression.
789+
func SameSiteFrom(attr string) {
790+
cookieFromBinding("same-site", attr)
791+
}
792+
793+
// cookieFromBinding records a per-cookie attribute binding on the cookie
794+
// attribute carried by the surrounding CookieAttributes context.
795+
func cookieFromBinding(kind, attr string) {
796+
c, ok := eval.Current().(*cookieAttrBindingsExpr)
797+
if !ok {
798+
eval.IncompatibleDSL()
799+
return
800+
}
801+
if attr == "" {
802+
eval.ReportError("attribute name cannot be empty")
803+
return
804+
}
805+
c.Attr.AddMeta("cookie:"+kind+":from", attr)
806+
}
807+
636808
// Params groups a set of Param expressions. It makes it possible to list
637809
// required parameters using the Required function.
638810
//

expr/http_cookie_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package expr_test
22

33
import (
4+
"errors"
45
"fmt"
6+
"strings"
57
"testing"
68

9+
"goa.design/goa/v3/eval"
710
"goa.design/goa/v3/expr"
811
"goa.design/goa/v3/expr/testdata"
912
)
@@ -48,3 +51,77 @@ func TestHTTPResponseCookie(t *testing.T) {
4851
})
4952
}
5053
}
54+
55+
func TestHTTPResponseCookieAttrBindings(t *testing.T) {
56+
root := expr.RunDSL(t, testdata.CookieAttrBindingsDSL)
57+
cookies := root.API.HTTP.Services[len(root.API.HTTP.Services)-1].HTTPEndpoints[0].Responses[0].Cookies
58+
obj := expr.AsObject(cookies.Type)
59+
if len(*obj) != 1 {
60+
t.Fatalf("got %d cookies, expected 1", len(*obj))
61+
}
62+
cookie := (*obj)[0].Attribute
63+
cases := map[string]string{
64+
"cookie:max-age:from": "expiresIn",
65+
"cookie:domain:from": "cookieDomain",
66+
"cookie:path:from": "cookiePath",
67+
"cookie:secure:from": "isSecure",
68+
"cookie:http-only:from": "isHTTPOnly",
69+
"cookie:same-site:from": "sameSite",
70+
}
71+
for k, want := range cases {
72+
got, ok := cookie.Meta[k]
73+
if !ok {
74+
t.Errorf("cookie metadata %q missing", k)
75+
continue
76+
}
77+
if len(got) != 1 || got[0] != want {
78+
t.Errorf("cookie metadata %q = %v, want [%q]", k, got, want)
79+
}
80+
}
81+
}
82+
83+
func TestHTTPResponseCookieAttrBindingValidation(t *testing.T) {
84+
cases := []struct {
85+
Name string
86+
DSL func()
87+
Want string
88+
}{
89+
{
90+
"missing-attr",
91+
testdata.CookieAttrBindingMissingAttrDSL,
92+
"binds Max-Age to attribute \"doesNotExist\"",
93+
},
94+
{
95+
"wrong-type",
96+
testdata.CookieAttrBindingWrongTypeDSL,
97+
"binds Max-Age to attribute \"expiresIn\" but it must be an integer",
98+
},
99+
{
100+
"undeclared-cookie",
101+
testdata.CookieAttrBindingUndeclaredDSL,
102+
"CookieAttributes references cookie \"notDeclared\"",
103+
},
104+
}
105+
for _, c := range cases {
106+
t.Run(c.Name, func(t *testing.T) {
107+
err := expr.RunInvalidDSL(t, c.DSL)
108+
if err == nil {
109+
t.Fatalf("expected validation error containing %q", c.Want)
110+
}
111+
var msg string
112+
var verr *eval.ValidationErrors
113+
if errors.As(err, &verr) {
114+
msgs := make([]string, len(verr.Errors))
115+
for i, e := range verr.Errors {
116+
msgs[i] = e.Error()
117+
}
118+
msg = strings.Join(msgs, "\n")
119+
} else {
120+
msg = err.Error()
121+
}
122+
if !strings.Contains(msg, c.Want) {
123+
t.Fatalf("expected error to contain %q, got: %s", c.Want, msg)
124+
}
125+
})
126+
}
127+
}

expr/http_response.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ func (r *HTTPResponseExpr) Validate(e *HTTPEndpointExpr) *eval.ValidationErrors
229229
if !IsPrimitive(t) {
230230
verr.Add(e, "attribute %q used in HTTP cookies must be a primitive type.", c.Name)
231231
}
232+
verr.Merge(validateCookieAttrBindings(r, c.Name, c.Attribute, resultAttributeType, inview))
232233
}
233234
default:
234235
if len(*AsObject(r.Cookies.Type)) > 1 {
@@ -391,6 +392,60 @@ func (r *HTTPResponseExpr) mapUnmappedAttrs(svcAtt *AttributeExpr) {
391392
}
392393
}
393394

395+
// validateCookieAttrBindings validates the per-cookie attribute bindings
396+
// (Max-Age, Domain, Path, Secure, HttpOnly, SameSite) recorded as
397+
// "cookie:<kind>:from" metadata on the cookie attribute. It checks that each
398+
// referenced result attribute exists and is of the kind expected by the bound
399+
// cookie property.
400+
func validateCookieAttrBindings(r *HTTPResponseExpr, cookieName string, cookieAttr *AttributeExpr, resultAttributeType func(string) DataType, inview string) *eval.ValidationErrors {
401+
verr := new(eval.ValidationErrors)
402+
if cookieAttr == nil || len(cookieAttr.Meta) == 0 {
403+
return verr
404+
}
405+
bindings := []struct {
406+
key string
407+
kind string
408+
want string
409+
ok func(DataType) bool
410+
}{
411+
{"cookie:max-age:from", "Max-Age", "an integer", func(t DataType) bool {
412+
k := t.Kind()
413+
return k == IntKind || k == Int32Kind || k == Int64Kind || k == UIntKind || k == UInt32Kind || k == UInt64Kind
414+
}},
415+
{"cookie:domain:from", "Domain", "a string", func(t DataType) bool {
416+
return t.Kind() == StringKind
417+
}},
418+
{"cookie:path:from", "Path", "a string", func(t DataType) bool {
419+
return t.Kind() == StringKind
420+
}},
421+
{"cookie:secure:from", "Secure", "a boolean", func(t DataType) bool {
422+
return t.Kind() == BooleanKind
423+
}},
424+
{"cookie:http-only:from", "HttpOnly", "a boolean", func(t DataType) bool {
425+
return t.Kind() == BooleanKind
426+
}},
427+
{"cookie:same-site:from", "SameSite", "a string", func(t DataType) bool {
428+
return t.Kind() == StringKind
429+
}},
430+
}
431+
for _, b := range bindings {
432+
v, ok := cookieAttr.Meta[b.key]
433+
if !ok || len(v) == 0 {
434+
continue
435+
}
436+
attrName := v[0]
437+
t := resultAttributeType(attrName)
438+
if t == nil {
439+
verr.Add(r, "cookie %q binds %s to attribute %q which has no equivalent attribute in%s result type", cookieName, b.kind, attrName, inview)
440+
continue
441+
}
442+
if !b.ok(t) {
443+
verr.Add(r, "cookie %q binds %s to attribute %q but it must be %s", cookieName, b.kind, attrName, b.want)
444+
}
445+
}
446+
return verr
447+
}
448+
394449
// bodyAllowedForStatus reports whether a given response status code
395450
// permits a body. See RFC 2616, section 4.4.
396451
// See https://golang.org/src/net/http/transfer.go

0 commit comments

Comments
 (0)