From ad7335a9fbae95af69fc7d21bb5ff3d26a0f0d1c Mon Sep 17 00:00:00 2001 From: Georges Haidar Date: Wed, 29 Apr 2026 13:06:35 +0100 Subject: [PATCH 01/10] 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::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) --- dsl/http.go | 172 ++++++++++++++++++ expr/http_cookie_test.go | 77 ++++++++ expr/http_response.go | 55 ++++++ expr/testdata/cookie_dsls.go | 93 ++++++++++ http/codegen/client_decode_test.go | 4 + http/codegen/server_encode_test.go | 4 + http/codegen/service_data.go | 78 +++++++- .../codegen/templates/partial/response.go.tpl | 89 +++++++++ .../templates/partial/single_response.go.tpl | 45 +++++ .../codegen/templates/response_decoder.go.tpl | 76 ++++++++ ...ecode_cookie-attr-bindings-mixed.go.golden | 70 +++++++ ...de_cookie-attr-bindings-optional.go.golden | 63 +++++++ ...ient_decode_cookie-attr-bindings.go.golden | 86 +++++++++ ...ncode_cookie-attr-bindings-mixed.go.golden | 29 +++ ...de_cookie-attr-bindings-optional.go.golden | 24 +++ ...rver_encode_cookie-attr-bindings.go.golden | 32 ++++ http/codegen/testdata/result_dsls.go | 81 +++++++++ 17 files changed, 1077 insertions(+), 1 deletion(-) create mode 100644 http/codegen/testdata/golden/client_decode_cookie-attr-bindings-mixed.go.golden create mode 100644 http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional.go.golden create mode 100644 http/codegen/testdata/golden/client_decode_cookie-attr-bindings.go.golden create mode 100644 http/codegen/testdata/golden/server_encode_cookie-attr-bindings-mixed.go.golden create mode 100644 http/codegen/testdata/golden/server_encode_cookie-attr-bindings-optional.go.golden create mode 100644 http/codegen/testdata/golden/server_encode_cookie-attr-bindings.go.golden diff --git a/dsl/http.go b/dsl/http.go index 40fd5c90f1..d749ffd291 100644 --- a/dsl/http.go +++ b/dsl/http.go @@ -1,6 +1,7 @@ package dsl import ( + "fmt" "strconv" "strings" @@ -633,6 +634,177 @@ func CookieSameSite(s expr.CookieSameSiteValue) { cookieAttribute("same-site", string(s)) } +// cookieAttrBindingsExpr is the transient eval context opened by +// CookieAttributes. It wraps the cookie's underlying AttributeExpr inside the +// response Cookies object so the Cookie...From binders can write per-cookie +// binding metadata onto the right attribute. +type cookieAttrBindingsExpr struct { + // Attr is the cookie attribute that binders annotate. + Attr *expr.AttributeExpr + // Name is the cookie attribute name used for diagnostics. + Name string +} + +// EvalName returns the qualified name of the cookie binding context. +func (c *cookieAttrBindingsExpr) EvalName() string { + return fmt.Sprintf("CookieAttributes(%q)", c.Name) +} + +// CookieAttributes opens a per-cookie attribute binding context for the named +// cookie defined in the enclosing Response. Inside the closure, the +// MaxAgeFrom, DomainFrom, PathFrom, SecureFrom, HTTPOnlyFrom and SameSiteFrom +// functions bind cookie attributes (Max-Age, Domain, Path, Secure, HttpOnly, +// SameSite) to result type attributes computed at runtime by the service +// method. The bindings apply only to the named cookie. The server populates +// the cookie attributes from the bound result fields when emitting the +// response, and the client decodes the corresponding HTTP cookie attributes +// back into the same result fields. +// +// Bindings are additive to and take precedence over the response-wide literal +// metadata set by CookieMaxAge, CookieDomain, CookiePath, CookieSecure, +// CookieHTTPOnly and CookieSameSite. +// +// CookieAttributes must appear in a Response expression. The first argument is +// the attribute-side cookie name (the part before the colon in +// `Cookie("attr:cookie")`). +// +// Example: +// +// var LoginResult = ResultType("application/vnd.login", func() { +// Attributes(func() { +// Attribute("sessionID", String) +// Attribute("expiresIn", Int) +// Attribute("cookieDomain", String) +// Attribute("cookiePath", String) +// Attribute("isSecure", Boolean) +// Attribute("isHTTPOnly", Boolean) +// Attribute("sameSite", String) +// }) +// Required("sessionID", "expiresIn", "cookieDomain", "cookiePath", +// "isSecure", "isHTTPOnly", "sameSite") +// }) +// +// Method("login", func() { +// Result(LoginResult) +// HTTP(func() { +// POST("/login") +// Response(StatusOK, func() { +// Cookie("sessionID:SID", String) +// CookieAttributes("sessionID", func() { +// MaxAgeFrom("expiresIn") +// DomainFrom("cookieDomain") +// PathFrom("cookiePath") +// SecureFrom("isSecure") +// HTTPOnlyFrom("isHTTPOnly") +// SameSiteFrom("sameSite") +// }) +// }) +// }) +// }) +func CookieAttributes(name string, fn func()) { + r, ok := eval.Current().(*expr.HTTPResponseExpr) + if !ok { + eval.IncompatibleDSL() + return + } + if name == "" { + eval.ReportError("cookie name cannot be empty") + return + } + if r.Cookies == nil { + eval.ReportError("CookieAttributes references cookie %q but no cookie has been declared in the response", name) + return + } + obj := expr.AsObject(r.Cookies.Type) + if obj == nil { + eval.ReportError("CookieAttributes references cookie %q but the response cookie set is not an object", name) + return + } + attr := obj.Attribute(name) + if attr == nil { + eval.ReportError("CookieAttributes references cookie %q which has not been declared with Cookie() in the same Response", name) + return + } + eval.Execute(fn, &cookieAttrBindingsExpr{Attr: attr, Name: name}) +} + +// MaxAgeFrom binds the enclosing cookie's "Max-Age" attribute to a result +// type attribute. The referenced attribute must be of an integer primitive +// type. The server populates http.Cookie.MaxAge from this result field; the +// client decodes c.MaxAge back into the same field. +// +// MaxAgeFrom must appear in a CookieAttributes expression. +func MaxAgeFrom(attr string) { + cookieFromBinding("max-age", attr) +} + +// DomainFrom binds the enclosing cookie's "Domain" attribute to a result +// type attribute. The referenced attribute must be of type String. The server +// populates http.Cookie.Domain from this result field; the client decodes +// c.Domain back into the same field. +// +// DomainFrom must appear in a CookieAttributes expression. +func DomainFrom(attr string) { + cookieFromBinding("domain", attr) +} + +// PathFrom binds the enclosing cookie's "Path" attribute to a result type +// attribute. The referenced attribute must be of type String. The server +// populates http.Cookie.Path from this result field; the client decodes +// c.Path back into the same field. +// +// PathFrom must appear in a CookieAttributes expression. +func PathFrom(attr string) { + cookieFromBinding("path", attr) +} + +// SecureFrom binds the enclosing cookie's "Secure" attribute to a result +// type attribute. The referenced attribute must be of type Boolean. The +// server populates http.Cookie.Secure from this result field; the client +// decodes c.Secure back into the same field. +// +// SecureFrom must appear in a CookieAttributes expression. +func SecureFrom(attr string) { + cookieFromBinding("secure", attr) +} + +// HTTPOnlyFrom binds the enclosing cookie's "HttpOnly" attribute to a result +// type attribute. The referenced attribute must be of type Boolean. The +// server populates http.Cookie.HttpOnly from this result field; the client +// decodes c.HttpOnly back into the same field. +// +// HTTPOnlyFrom must appear in a CookieAttributes expression. +func HTTPOnlyFrom(attr string) { + cookieFromBinding("http-only", attr) +} + +// SameSiteFrom binds the enclosing cookie's "SameSite" attribute to a result +// type attribute. The referenced attribute must be of type String and at +// runtime must hold one of the values of CookieSameSiteStrict, +// CookieSameSiteLax, CookieSameSiteNone or CookieSameSiteDefault. The server +// populates http.Cookie.SameSite from this result field; the client decodes +// c.SameSite back into the same field. +// +// SameSiteFrom must appear in a CookieAttributes expression. +func SameSiteFrom(attr string) { + cookieFromBinding("same-site", attr) +} + +// cookieFromBinding records a per-cookie attribute binding on the cookie +// attribute carried by the surrounding CookieAttributes context. +func cookieFromBinding(kind, attr string) { + c, ok := eval.Current().(*cookieAttrBindingsExpr) + if !ok { + eval.IncompatibleDSL() + return + } + if attr == "" { + eval.ReportError("attribute name cannot be empty") + return + } + c.Attr.AddMeta("cookie:"+kind+":from", attr) +} + // Params groups a set of Param expressions. It makes it possible to list // required parameters using the Required function. // diff --git a/expr/http_cookie_test.go b/expr/http_cookie_test.go index 0f1289e0db..ae6312cc2d 100644 --- a/expr/http_cookie_test.go +++ b/expr/http_cookie_test.go @@ -1,9 +1,12 @@ package expr_test import ( + "errors" "fmt" + "strings" "testing" + "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" "goa.design/goa/v3/expr/testdata" ) @@ -48,3 +51,77 @@ func TestHTTPResponseCookie(t *testing.T) { }) } } + +func TestHTTPResponseCookieAttrBindings(t *testing.T) { + root := expr.RunDSL(t, testdata.CookieAttrBindingsDSL) + cookies := root.API.HTTP.Services[len(root.API.HTTP.Services)-1].HTTPEndpoints[0].Responses[0].Cookies + obj := expr.AsObject(cookies.Type) + if len(*obj) != 1 { + t.Fatalf("got %d cookies, expected 1", len(*obj)) + } + cookie := (*obj)[0].Attribute + cases := map[string]string{ + "cookie:max-age:from": "expiresIn", + "cookie:domain:from": "cookieDomain", + "cookie:path:from": "cookiePath", + "cookie:secure:from": "isSecure", + "cookie:http-only:from": "isHTTPOnly", + "cookie:same-site:from": "sameSite", + } + for k, want := range cases { + got, ok := cookie.Meta[k] + if !ok { + t.Errorf("cookie metadata %q missing", k) + continue + } + if len(got) != 1 || got[0] != want { + t.Errorf("cookie metadata %q = %v, want [%q]", k, got, want) + } + } +} + +func TestHTTPResponseCookieAttrBindingValidation(t *testing.T) { + cases := []struct { + Name string + DSL func() + Want string + }{ + { + "missing-attr", + testdata.CookieAttrBindingMissingAttrDSL, + "binds Max-Age to attribute \"doesNotExist\"", + }, + { + "wrong-type", + testdata.CookieAttrBindingWrongTypeDSL, + "binds Max-Age to attribute \"expiresIn\" but it must be an integer", + }, + { + "undeclared-cookie", + testdata.CookieAttrBindingUndeclaredDSL, + "CookieAttributes references cookie \"notDeclared\"", + }, + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, c.DSL) + if err == nil { + t.Fatalf("expected validation error containing %q", c.Want) + } + var msg string + var verr *eval.ValidationErrors + if errors.As(err, &verr) { + msgs := make([]string, len(verr.Errors)) + for i, e := range verr.Errors { + msgs[i] = e.Error() + } + msg = strings.Join(msgs, "\n") + } else { + msg = err.Error() + } + if !strings.Contains(msg, c.Want) { + t.Fatalf("expected error to contain %q, got: %s", c.Want, msg) + } + }) + } +} diff --git a/expr/http_response.go b/expr/http_response.go index a6bb093beb..83c67557a0 100644 --- a/expr/http_response.go +++ b/expr/http_response.go @@ -229,6 +229,7 @@ func (r *HTTPResponseExpr) Validate(e *HTTPEndpointExpr) *eval.ValidationErrors if !IsPrimitive(t) { verr.Add(e, "attribute %q used in HTTP cookies must be a primitive type.", c.Name) } + verr.Merge(validateCookieAttrBindings(r, c.Name, c.Attribute, resultAttributeType, inview)) } default: if len(*AsObject(r.Cookies.Type)) > 1 { @@ -391,6 +392,60 @@ func (r *HTTPResponseExpr) mapUnmappedAttrs(svcAtt *AttributeExpr) { } } +// validateCookieAttrBindings validates the per-cookie attribute bindings +// (Max-Age, Domain, Path, Secure, HttpOnly, SameSite) recorded as +// "cookie::from" metadata on the cookie attribute. It checks that each +// referenced result attribute exists and is of the kind expected by the bound +// cookie property. +func validateCookieAttrBindings(r *HTTPResponseExpr, cookieName string, cookieAttr *AttributeExpr, resultAttributeType func(string) DataType, inview string) *eval.ValidationErrors { + verr := new(eval.ValidationErrors) + if cookieAttr == nil || len(cookieAttr.Meta) == 0 { + return verr + } + bindings := []struct { + key string + kind string + want string + ok func(DataType) bool + }{ + {"cookie:max-age:from", "Max-Age", "an integer", func(t DataType) bool { + k := t.Kind() + return k == IntKind || k == Int32Kind || k == Int64Kind || k == UIntKind || k == UInt32Kind || k == UInt64Kind + }}, + {"cookie:domain:from", "Domain", "a string", func(t DataType) bool { + return t.Kind() == StringKind + }}, + {"cookie:path:from", "Path", "a string", func(t DataType) bool { + return t.Kind() == StringKind + }}, + {"cookie:secure:from", "Secure", "a boolean", func(t DataType) bool { + return t.Kind() == BooleanKind + }}, + {"cookie:http-only:from", "HttpOnly", "a boolean", func(t DataType) bool { + return t.Kind() == BooleanKind + }}, + {"cookie:same-site:from", "SameSite", "a string", func(t DataType) bool { + return t.Kind() == StringKind + }}, + } + for _, b := range bindings { + v, ok := cookieAttr.Meta[b.key] + if !ok || len(v) == 0 { + continue + } + attrName := v[0] + t := resultAttributeType(attrName) + if t == nil { + verr.Add(r, "cookie %q binds %s to attribute %q which has no equivalent attribute in%s result type", cookieName, b.kind, attrName, inview) + continue + } + if !b.ok(t) { + verr.Add(r, "cookie %q binds %s to attribute %q but it must be %s", cookieName, b.kind, attrName, b.want) + } + } + return verr +} + // bodyAllowedForStatus reports whether a given response status code // permits a body. See RFC 2616, section 4.4. // See https://golang.org/src/net/http/transfer.go diff --git a/expr/testdata/cookie_dsls.go b/expr/testdata/cookie_dsls.go index 3fb93fb337..21cf989143 100644 --- a/expr/testdata/cookie_dsls.go +++ b/expr/testdata/cookie_dsls.go @@ -143,3 +143,96 @@ var CookieSameSiteDSL = func() { }) }) } + +var CookieAttrBindingsDSL = func() { + Service("CookieSvc", func() { + Method("Method", func() { + Result(func() { + Attribute("cookie", String) + Attribute("expiresIn", Int) + Attribute("cookieDomain", String) + Attribute("cookiePath", String) + Attribute("isSecure", Boolean) + Attribute("isHTTPOnly", Boolean) + Attribute("sameSite", String) + Required("cookie", "expiresIn", "cookieDomain", "cookiePath", + "isSecure", "isHTTPOnly", "sameSite") + }) + HTTP(func() { + POST("/") + Response(StatusOK, func() { + Cookie("cookie") + CookieAttributes("cookie", func() { + MaxAgeFrom("expiresIn") + DomainFrom("cookieDomain") + PathFrom("cookiePath") + SecureFrom("isSecure") + HTTPOnlyFrom("isHTTPOnly") + SameSiteFrom("sameSite") + }) + }) + }) + }) + }) +} + +var CookieAttrBindingMissingAttrDSL = func() { + Service("CookieSvc", func() { + Method("Method", func() { + Result(func() { + Attribute("cookie", String) + Required("cookie") + }) + HTTP(func() { + POST("/") + Response(StatusOK, func() { + Cookie("cookie") + CookieAttributes("cookie", func() { + MaxAgeFrom("doesNotExist") + }) + }) + }) + }) + }) +} + +var CookieAttrBindingWrongTypeDSL = func() { + Service("CookieSvc", func() { + Method("Method", func() { + Result(func() { + Attribute("cookie", String) + Attribute("expiresIn", String) + Required("cookie", "expiresIn") + }) + HTTP(func() { + POST("/") + Response(StatusOK, func() { + Cookie("cookie") + CookieAttributes("cookie", func() { + MaxAgeFrom("expiresIn") + }) + }) + }) + }) + }) +} + +var CookieAttrBindingUndeclaredDSL = func() { + Service("CookieSvc", func() { + Method("Method", func() { + Result(func() { + Attribute("cookie", String) + Required("cookie") + }) + HTTP(func() { + POST("/") + Response(StatusOK, func() { + Cookie("cookie") + CookieAttributes("notDeclared", func() { + MaxAgeFrom("cookie") + }) + }) + }) + }) + }) +} diff --git a/http/codegen/client_decode_test.go b/http/codegen/client_decode_test.go index 398b149ea2..30c502fd5e 100644 --- a/http/codegen/client_decode_test.go +++ b/http/codegen/client_decode_test.go @@ -32,6 +32,10 @@ func TestClientDecode(t *testing.T) { {"with-headers-dsl-viewed-result", testdata.WithHeadersBlockViewedResultDSL}, {"validate-error-response-type", testdata.ValidateErrorResponseTypeDSL}, {"empty-error-response-body", testdata.EmptyErrorResponseBodyDSL}, + + {"cookie-attr-bindings", testdata.ResultCookieAttrBindingsDSL}, + {"cookie-attr-bindings-optional", testdata.ResultCookieAttrBindingsOptionalDSL}, + {"cookie-attr-bindings-mixed", testdata.ResultCookieAttrBindingsMixedDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { diff --git a/http/codegen/server_encode_test.go b/http/codegen/server_encode_test.go index 31b712c194..517254ce74 100644 --- a/http/codegen/server_encode_test.go +++ b/http/codegen/server_encode_test.go @@ -87,6 +87,10 @@ func TestEncode(t *testing.T) { {"result-with-custom-pkg-type", testdata.ResultWithCustomPkgTypeDSL}, {"result-with-embedded-custom-pkg-type", testdata.EmbeddedCustomPkgTypeDSL}, + + {"cookie-attr-bindings", testdata.ResultCookieAttrBindingsDSL}, + {"cookie-attr-bindings-optional", testdata.ResultCookieAttrBindingsOptionalDSL}, + {"cookie-attr-bindings-mixed", testdata.ResultCookieAttrBindingsMixedDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { diff --git a/http/codegen/service_data.go b/http/codegen/service_data.go index 3ae2bf8e76..64f528baf9 100644 --- a/http/codegen/service_data.go +++ b/http/codegen/service_data.go @@ -528,6 +528,51 @@ type ( HTTPOnly bool // SameSite sets the cookie "same-site" attribute to the given value. SameSite string + // MaxAgeFrom binds the cookie "Max-Age" attribute to a result + // type attribute populated at runtime by the service method. + MaxAgeFrom *CookieAttrBinding + // DomainFrom binds the cookie "Domain" attribute to a result + // type attribute. + DomainFrom *CookieAttrBinding + // PathFrom binds the cookie "Path" attribute to a result type + // attribute. + PathFrom *CookieAttrBinding + // SecureFrom binds the cookie "Secure" attribute to a result + // type attribute. + SecureFrom *CookieAttrBinding + // HTTPOnlyFrom binds the cookie "HttpOnly" attribute to a result + // type attribute. + HTTPOnlyFrom *CookieAttrBinding + // SameSiteFrom binds the cookie "SameSite" attribute to a result + // type attribute. + SameSiteFrom *CookieAttrBinding + } + + // CookieAttrBinding describes a per-cookie attribute (Max-Age, Domain, + // Path, Secure, HttpOnly, SameSite) bound to a result-type attribute via + // the CookieAttributes / Cookie...From DSL. The server populates the + // http.Cookie field from the bound result attribute and the client + // decoder writes the corresponding *http.Cookie field back into the same + // result attribute. + CookieAttrBinding struct { + // AttributeName is the result-type attribute name. + AttributeName string + // FieldName is the Go struct field name on the result type. + FieldName string + // FieldPointer reports whether the Go field is a pointer + // (i.e. the attribute is optional in the result type). + FieldPointer bool + // Type is the bound attribute's primitive data type. + Type expr.DataType + // TypeRef is the Go type reference for the bound attribute + // (without leading pointer star). It is used to cast the + // http.Cookie integer attribute into the result attribute's + // type when it is non-int (e.g. int32, int64). + TypeRef string + // VarName is a unique local variable name used by the client + // response decoder to capture the decoded cookie attribute + // before assigning it onto the result struct. + VarName string } // TypeData contains the data needed to render a type definition. @@ -2610,7 +2655,7 @@ func (sds *ServicesData) extractHeaders(a *expr.MappedAttributeExpr, svcAtt *exp func (sds *ServicesData) extractCookies(a *expr.MappedAttributeExpr, svcAtt *expr.AttributeExpr, svcCtx *codegen.AttributeContext, scope *codegen.NameScope) []*CookieData { var cookies []*CookieData - codegen.WalkMappedAttr(a, func(name, elem string, required bool, _ *expr.AttributeExpr) error { // nolint: errcheck + codegen.WalkMappedAttr(a, func(name, elem string, required bool, cattr *expr.AttributeExpr) error { // nolint: errcheck var hattr *expr.AttributeExpr if hattr = svcAtt.Find(name); hattr == nil { hattr = svcAtt @@ -2681,6 +2726,37 @@ func (sds *ServicesData) extractCookies(a *expr.MappedAttributeExpr, svcAtt *exp } } } + if cattr != nil && expr.IsObject(svcAtt.Type) { + for _, b := range []struct { + key string + dest **CookieAttrBinding + }{ + {"cookie:max-age:from", &c.MaxAgeFrom}, + {"cookie:domain:from", &c.DomainFrom}, + {"cookie:path:from", &c.PathFrom}, + {"cookie:secure:from", &c.SecureFrom}, + {"cookie:http-only:from", &c.HTTPOnlyFrom}, + {"cookie:same-site:from", &c.SameSiteFrom}, + } { + vals, ok := cattr.Meta[b.key] + if !ok || len(vals) == 0 { + continue + } + attrName := vals[0] + battr := svcAtt.Find(attrName) + if battr == nil { + continue + } + *b.dest = &CookieAttrBinding{ + AttributeName: attrName, + FieldName: codegen.GoifyAtt(battr, attrName, true), + FieldPointer: svcCtx.IsPrimitivePointer(attrName, svcAtt), + Type: battr.Type, + TypeRef: scope.GoTypeRef(battr), + VarName: scope.Name(codegen.Goify(name+"_"+attrName, false)), + } + } + } cookies = append(cookies, c) return nil }) diff --git a/http/codegen/templates/partial/response.go.tpl b/http/codegen/templates/partial/response.go.tpl index 895c35517c..0f6c0149ad 100644 --- a/http/codegen/templates/partial/response.go.tpl +++ b/http/codegen/templates/partial/response.go.tpl @@ -85,6 +85,94 @@ {{ if $checkNil }} } else { {{ else }}if res{{ if $.ViewedResult }}.Projected{{ end }}.{{ .FieldName }} == nil { {{ end }} {{ .VarName }} := "{{ printValue .Type .DefaultValue }}" {{- end }} + {{- $hasBindings := or .MaxAgeFrom .DomainFrom .PathFrom .SecureFrom .HTTPOnlyFrom .SameSiteFrom }} + {{- if $hasBindings }} + cookie{{ .VarName }} := &http.Cookie{ + Name: {{ printf "%q" .HTTPName }}, + Value: {{ .VarName }}, + {{- if and .MaxAgeFrom (not .MaxAgeFrom.FieldPointer) }} + MaxAge: int(res{{ if $.ViewedResult }}.Projected{{ end }}.{{ .MaxAgeFrom.FieldName }}), + {{- else if and (not .MaxAgeFrom) .MaxAge }} + MaxAge: {{ .MaxAge }}, + {{- end }} + {{- if and .PathFrom (not .PathFrom.FieldPointer) }} + Path: res{{ if $.ViewedResult }}.Projected{{ end }}.{{ .PathFrom.FieldName }}, + {{- else if and (not .PathFrom) .Path }} + Path: {{ printf "%q" .Path }}, + {{- end }} + {{- if and .DomainFrom (not .DomainFrom.FieldPointer) }} + Domain: res{{ if $.ViewedResult }}.Projected{{ end }}.{{ .DomainFrom.FieldName }}, + {{- else if and (not .DomainFrom) .Domain }} + Domain: {{ printf "%q" .Domain }}, + {{- end }} + {{- if and .SecureFrom (not .SecureFrom.FieldPointer) }} + Secure: res{{ if $.ViewedResult }}.Projected{{ end }}.{{ .SecureFrom.FieldName }}, + {{- else if and (not .SecureFrom) .Secure }} + Secure: true, + {{- end }} + {{- if and .HTTPOnlyFrom (not .HTTPOnlyFrom.FieldPointer) }} + HttpOnly: res{{ if $.ViewedResult }}.Projected{{ end }}.{{ .HTTPOnlyFrom.FieldName }}, + {{- else if and (not .HTTPOnlyFrom) .HTTPOnly }} + HttpOnly: true, + {{- end }} + {{- if and (not .SameSiteFrom) .SameSite }} + SameSite: {{ .SameSite }}, + {{- end }} + } + {{- if and .MaxAgeFrom .MaxAgeFrom.FieldPointer }} + if res{{ if $.ViewedResult }}.Projected{{ end }}.{{ .MaxAgeFrom.FieldName }} != nil { + cookie{{ .VarName }}.MaxAge = int(*res{{ if $.ViewedResult }}.Projected{{ end }}.{{ .MaxAgeFrom.FieldName }}) + } + {{- end }} + {{- if and .PathFrom .PathFrom.FieldPointer }} + if res{{ if $.ViewedResult }}.Projected{{ end }}.{{ .PathFrom.FieldName }} != nil { + cookie{{ .VarName }}.Path = *res{{ if $.ViewedResult }}.Projected{{ end }}.{{ .PathFrom.FieldName }} + } + {{- end }} + {{- if and .DomainFrom .DomainFrom.FieldPointer }} + if res{{ if $.ViewedResult }}.Projected{{ end }}.{{ .DomainFrom.FieldName }} != nil { + cookie{{ .VarName }}.Domain = *res{{ if $.ViewedResult }}.Projected{{ end }}.{{ .DomainFrom.FieldName }} + } + {{- end }} + {{- if and .SecureFrom .SecureFrom.FieldPointer }} + if res{{ if $.ViewedResult }}.Projected{{ end }}.{{ .SecureFrom.FieldName }} != nil { + cookie{{ .VarName }}.Secure = *res{{ if $.ViewedResult }}.Projected{{ end }}.{{ .SecureFrom.FieldName }} + } + {{- end }} + {{- if and .HTTPOnlyFrom .HTTPOnlyFrom.FieldPointer }} + if res{{ if $.ViewedResult }}.Projected{{ end }}.{{ .HTTPOnlyFrom.FieldName }} != nil { + cookie{{ .VarName }}.HttpOnly = *res{{ if $.ViewedResult }}.Projected{{ end }}.{{ .HTTPOnlyFrom.FieldName }} + } + {{- end }} + {{- if .SameSiteFrom }} + {{- if .SameSiteFrom.FieldPointer }} + if res{{ if $.ViewedResult }}.Projected{{ end }}.{{ .SameSiteFrom.FieldName }} != nil { + switch *res{{ if $.ViewedResult }}.Projected{{ end }}.{{ .SameSiteFrom.FieldName }} { + case "Strict": + cookie{{ .VarName }}.SameSite = http.SameSiteStrictMode + case "Lax": + cookie{{ .VarName }}.SameSite = http.SameSiteLaxMode + case "None": + cookie{{ .VarName }}.SameSite = http.SameSiteNoneMode + default: + cookie{{ .VarName }}.SameSite = http.SameSiteDefaultMode + } + } + {{- else }} + switch res{{ if $.ViewedResult }}.Projected{{ end }}.{{ .SameSiteFrom.FieldName }} { + case "Strict": + cookie{{ .VarName }}.SameSite = http.SameSiteStrictMode + case "Lax": + cookie{{ .VarName }}.SameSite = http.SameSiteLaxMode + case "None": + cookie{{ .VarName }}.SameSite = http.SameSiteNoneMode + default: + cookie{{ .VarName }}.SameSite = http.SameSiteDefaultMode + } + {{- end }} + {{- end }} + http.SetCookie(w, cookie{{ .VarName }}) + {{- else }} http.SetCookie(w, &http.Cookie{ Name: {{ printf "%q" .HTTPName }}, Value: {{ .VarName }}, @@ -107,6 +195,7 @@ SameSite: {{ .SameSite }}, {{- end }} }) + {{- end }} {{- if or $checkNil $initDef }} } {{- end }} diff --git a/http/codegen/templates/partial/single_response.go.tpl b/http/codegen/templates/partial/single_response.go.tpl index 764446207d..176a7a872d 100644 --- a/http/codegen/templates/partial/single_response.go.tpl +++ b/http/codegen/templates/partial/single_response.go.tpl @@ -114,6 +114,24 @@ {{- range .Cookies }} {{ .VarName }} {{ .TypeRef }} {{ .VarName }}Raw string + {{- if .MaxAgeFrom }} + {{ .MaxAgeFrom.VarName }} {{ .MaxAgeFrom.TypeRef }} + {{- end }} + {{- if .DomainFrom }} + {{ .DomainFrom.VarName }} {{ .DomainFrom.TypeRef }} + {{- end }} + {{- if .PathFrom }} + {{ .PathFrom.VarName }} {{ .PathFrom.TypeRef }} + {{- end }} + {{- if .SecureFrom }} + {{ .SecureFrom.VarName }} {{ .SecureFrom.TypeRef }} + {{- end }} + {{- if .HTTPOnlyFrom }} + {{ .HTTPOnlyFrom.VarName }} {{ .HTTPOnlyFrom.TypeRef }} + {{- end }} + {{- if .SameSiteFrom }} + {{ .SameSiteFrom.VarName }} {{ .SameSiteFrom.TypeRef }} + {{- end }} {{- end }} cookies = resp.Cookies() @@ -130,6 +148,33 @@ {{- range .Cookies }} case {{ printf "%q" .HTTPName }}: {{ .VarName }}Raw = c.Value + {{- if .MaxAgeFrom }} + {{ .MaxAgeFrom.VarName }} = {{ .MaxAgeFrom.TypeRef }}(c.MaxAge) + {{- end }} + {{- if .DomainFrom }} + {{ .DomainFrom.VarName }} = c.Domain + {{- end }} + {{- if .PathFrom }} + {{ .PathFrom.VarName }} = c.Path + {{- end }} + {{- if .SecureFrom }} + {{ .SecureFrom.VarName }} = c.Secure + {{- end }} + {{- if .HTTPOnlyFrom }} + {{ .HTTPOnlyFrom.VarName }} = c.HttpOnly + {{- end }} + {{- if .SameSiteFrom }} + switch c.SameSite { + case http.SameSiteStrictMode: + {{ .SameSiteFrom.VarName }} = "Strict" + case http.SameSiteLaxMode: + {{ .SameSiteFrom.VarName }} = "Lax" + case http.SameSiteNoneMode: + {{ .SameSiteFrom.VarName }} = "None" + default: + {{ .SameSiteFrom.VarName }} = "Default" + } + {{- end }} {{- end }} } } diff --git a/http/codegen/templates/response_decoder.go.tpl b/http/codegen/templates/response_decoder.go.tpl index 17ea5eeae8..a705482c86 100644 --- a/http/codegen/templates/response_decoder.go.tpl +++ b/http/codegen/templates/response_decoder.go.tpl @@ -31,6 +31,44 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor {{- if .ResultInit }} {{- if .ViewedResult }} p := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + {{- range .Cookies }} + {{- if .MaxAgeFrom }}{{ if .MaxAgeFrom.FieldPointer }} + {{ .MaxAgeFrom.VarName }}Tmp := {{ .MaxAgeFrom.VarName }} + p.{{ .MaxAgeFrom.FieldName }} = &{{ .MaxAgeFrom.VarName }}Tmp + {{- else }} + p.{{ .MaxAgeFrom.FieldName }} = {{ .MaxAgeFrom.VarName }} + {{- end }}{{- end }} + {{- if .DomainFrom }}{{ if .DomainFrom.FieldPointer }} + {{ .DomainFrom.VarName }}Tmp := {{ .DomainFrom.VarName }} + p.{{ .DomainFrom.FieldName }} = &{{ .DomainFrom.VarName }}Tmp + {{- else }} + p.{{ .DomainFrom.FieldName }} = {{ .DomainFrom.VarName }} + {{- end }}{{- end }} + {{- if .PathFrom }}{{ if .PathFrom.FieldPointer }} + {{ .PathFrom.VarName }}Tmp := {{ .PathFrom.VarName }} + p.{{ .PathFrom.FieldName }} = &{{ .PathFrom.VarName }}Tmp + {{- else }} + p.{{ .PathFrom.FieldName }} = {{ .PathFrom.VarName }} + {{- end }}{{- end }} + {{- if .SecureFrom }}{{ if .SecureFrom.FieldPointer }} + {{ .SecureFrom.VarName }}Tmp := {{ .SecureFrom.VarName }} + p.{{ .SecureFrom.FieldName }} = &{{ .SecureFrom.VarName }}Tmp + {{- else }} + p.{{ .SecureFrom.FieldName }} = {{ .SecureFrom.VarName }} + {{- end }}{{- end }} + {{- if .HTTPOnlyFrom }}{{ if .HTTPOnlyFrom.FieldPointer }} + {{ .HTTPOnlyFrom.VarName }}Tmp := {{ .HTTPOnlyFrom.VarName }} + p.{{ .HTTPOnlyFrom.FieldName }} = &{{ .HTTPOnlyFrom.VarName }}Tmp + {{- else }} + p.{{ .HTTPOnlyFrom.FieldName }} = {{ .HTTPOnlyFrom.VarName }} + {{- end }}{{- end }} + {{- if .SameSiteFrom }}{{ if .SameSiteFrom.FieldPointer }} + {{ .SameSiteFrom.VarName }}Tmp := {{ .SameSiteFrom.VarName }} + p.{{ .SameSiteFrom.FieldName }} = &{{ .SameSiteFrom.VarName }}Tmp + {{- else }} + p.{{ .SameSiteFrom.FieldName }} = {{ .SameSiteFrom.VarName }} + {{- end }}{{- end }} + {{- end }} {{- if .TagName }} tmp := {{ printf "%q" .TagValue }} p.{{ .TagName }} = &tmp @@ -49,6 +87,44 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor res := {{ $.ServicePkgName }}.{{ $.Method.ViewedResult.ResultInit.Name }}(vres) {{- else }} res := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + {{- range .Cookies }} + {{- if .MaxAgeFrom }}{{ if .MaxAgeFrom.FieldPointer }} + {{ .MaxAgeFrom.VarName }}Tmp := {{ .MaxAgeFrom.VarName }} + res.{{ .MaxAgeFrom.FieldName }} = &{{ .MaxAgeFrom.VarName }}Tmp + {{- else }} + res.{{ .MaxAgeFrom.FieldName }} = {{ .MaxAgeFrom.VarName }} + {{- end }}{{- end }} + {{- if .DomainFrom }}{{ if .DomainFrom.FieldPointer }} + {{ .DomainFrom.VarName }}Tmp := {{ .DomainFrom.VarName }} + res.{{ .DomainFrom.FieldName }} = &{{ .DomainFrom.VarName }}Tmp + {{- else }} + res.{{ .DomainFrom.FieldName }} = {{ .DomainFrom.VarName }} + {{- end }}{{- end }} + {{- if .PathFrom }}{{ if .PathFrom.FieldPointer }} + {{ .PathFrom.VarName }}Tmp := {{ .PathFrom.VarName }} + res.{{ .PathFrom.FieldName }} = &{{ .PathFrom.VarName }}Tmp + {{- else }} + res.{{ .PathFrom.FieldName }} = {{ .PathFrom.VarName }} + {{- end }}{{- end }} + {{- if .SecureFrom }}{{ if .SecureFrom.FieldPointer }} + {{ .SecureFrom.VarName }}Tmp := {{ .SecureFrom.VarName }} + res.{{ .SecureFrom.FieldName }} = &{{ .SecureFrom.VarName }}Tmp + {{- else }} + res.{{ .SecureFrom.FieldName }} = {{ .SecureFrom.VarName }} + {{- end }}{{- end }} + {{- if .HTTPOnlyFrom }}{{ if .HTTPOnlyFrom.FieldPointer }} + {{ .HTTPOnlyFrom.VarName }}Tmp := {{ .HTTPOnlyFrom.VarName }} + res.{{ .HTTPOnlyFrom.FieldName }} = &{{ .HTTPOnlyFrom.VarName }}Tmp + {{- else }} + res.{{ .HTTPOnlyFrom.FieldName }} = {{ .HTTPOnlyFrom.VarName }} + {{- end }}{{- end }} + {{- if .SameSiteFrom }}{{ if .SameSiteFrom.FieldPointer }} + {{ .SameSiteFrom.VarName }}Tmp := {{ .SameSiteFrom.VarName }} + res.{{ .SameSiteFrom.FieldName }} = &{{ .SameSiteFrom.VarName }}Tmp + {{- else }} + res.{{ .SameSiteFrom.FieldName }} = {{ .SameSiteFrom.VarName }} + {{- end }}{{- end }} + {{- end }} {{- end }} {{- if and .TagName (not .ViewedResult) }} {{- if .TagPointer }} diff --git a/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-mixed.go.golden b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-mixed.go.golden new file mode 100644 index 0000000000..fc8a7e14f6 --- /dev/null +++ b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-mixed.go.golden @@ -0,0 +1,70 @@ +// DecodeMethodCookieAttrBindingsMixedResponse returns a decoder for responses +// returned by the ServiceCookieAttrBindingsMixed MethodCookieAttrBindingsMixed +// endpoint. restoreBody controls whether the response body should be restored +// after having been read. +func DecodeMethodCookieAttrBindingsMixedResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (any, error) { + if restoreBody { + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer resp.Body.Close() + } + switch resp.StatusCode { + case http.StatusOK: + var ( + body MethodCookieAttrBindingsMixedResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceCookieAttrBindingsMixed", "MethodCookieAttrBindingsMixed", err) + } + err = ValidateMethodCookieAttrBindingsMixedResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("ServiceCookieAttrBindingsMixed", "MethodCookieAttrBindingsMixed", err) + } + var ( + a string + aRaw string + aExpiresIn int + b string + bRaw string + + cookies = resp.Cookies() + ) + for _, c := range cookies { + switch c.Name { + case "A": + aRaw = c.Value + aExpiresIn = int(c.MaxAge) + case "B": + bRaw = c.Value + } + } + if aRaw == "" { + err = goa.MergeErrors(err, goa.MissingFieldError("a", "cookie")) + } + a = aRaw + if bRaw == "" { + err = goa.MergeErrors(err, goa.MissingFieldError("b", "cookie")) + } + b = bRaw + if err != nil { + return nil, goahttp.ErrValidationError("ServiceCookieAttrBindingsMixed", "MethodCookieAttrBindingsMixed", err) + } + res := NewMethodCookieAttrBindingsMixedResultOK(&body, a, b) + res.ExpiresIn = aExpiresIn + return res, nil + default: + body, _ := io.ReadAll(resp.Body) + return nil, goahttp.ErrInvalidResponse("ServiceCookieAttrBindingsMixed", "MethodCookieAttrBindingsMixed", resp.StatusCode, string(body)) + } + } +} diff --git a/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional.go.golden b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional.go.golden new file mode 100644 index 0000000000..b3feacd92a --- /dev/null +++ b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional.go.golden @@ -0,0 +1,63 @@ +// DecodeMethodCookieAttrBindingsOptionalResponse returns a decoder for +// responses returned by the ServiceCookieAttrBindingsOptional +// MethodCookieAttrBindingsOptional endpoint. restoreBody controls whether the +// response body should be restored after having been read. +func DecodeMethodCookieAttrBindingsOptionalResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (any, error) { + if restoreBody { + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer resp.Body.Close() + } + switch resp.StatusCode { + case http.StatusOK: + var ( + body MethodCookieAttrBindingsOptionalResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceCookieAttrBindingsOptional", "MethodCookieAttrBindingsOptional", err) + } + var ( + sessionID string + sessionIDRaw string + sessionIDExpiresIn int + sessionIDCookieDomain string + + cookies = resp.Cookies() + ) + for _, c := range cookies { + switch c.Name { + case "SID": + sessionIDRaw = c.Value + sessionIDExpiresIn = int(c.MaxAge) + sessionIDCookieDomain = c.Domain + } + } + if sessionIDRaw == "" { + err = goa.MergeErrors(err, goa.MissingFieldError("sessionID", "cookie")) + } + sessionID = sessionIDRaw + if err != nil { + return nil, goahttp.ErrValidationError("ServiceCookieAttrBindingsOptional", "MethodCookieAttrBindingsOptional", err) + } + res := NewMethodCookieAttrBindingsOptionalResultOK(&body, sessionID) + sessionIDExpiresInTmp := sessionIDExpiresIn + res.ExpiresIn = &sessionIDExpiresInTmp + sessionIDCookieDomainTmp := sessionIDCookieDomain + res.CookieDomain = &sessionIDCookieDomainTmp + return res, nil + default: + body, _ := io.ReadAll(resp.Body) + return nil, goahttp.ErrInvalidResponse("ServiceCookieAttrBindingsOptional", "MethodCookieAttrBindingsOptional", resp.StatusCode, string(body)) + } + } +} diff --git a/http/codegen/testdata/golden/client_decode_cookie-attr-bindings.go.golden b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings.go.golden new file mode 100644 index 0000000000..37002884b1 --- /dev/null +++ b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings.go.golden @@ -0,0 +1,86 @@ +// DecodeMethodCookieAttrBindingsResponse returns a decoder for responses +// returned by the ServiceCookieAttrBindings MethodCookieAttrBindings endpoint. +// restoreBody controls whether the response body should be restored after +// having been read. +func DecodeMethodCookieAttrBindingsResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (any, error) { + if restoreBody { + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer resp.Body.Close() + } + switch resp.StatusCode { + case http.StatusOK: + var ( + body MethodCookieAttrBindingsResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceCookieAttrBindings", "MethodCookieAttrBindings", err) + } + err = ValidateMethodCookieAttrBindingsResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("ServiceCookieAttrBindings", "MethodCookieAttrBindings", err) + } + var ( + sessionID string + sessionIDRaw string + sessionIDExpiresIn int + sessionIDCookieDomain string + sessionIDCookiePath string + sessionIDIsSecure bool + sessionIDIsHTTPOnly bool + sessionIDSameSite string + + cookies = resp.Cookies() + ) + for _, c := range cookies { + switch c.Name { + case "SID": + sessionIDRaw = c.Value + sessionIDExpiresIn = int(c.MaxAge) + sessionIDCookieDomain = c.Domain + sessionIDCookiePath = c.Path + sessionIDIsSecure = c.Secure + sessionIDIsHTTPOnly = c.HttpOnly + switch c.SameSite { + case http.SameSiteStrictMode: + sessionIDSameSite = "Strict" + case http.SameSiteLaxMode: + sessionIDSameSite = "Lax" + case http.SameSiteNoneMode: + sessionIDSameSite = "None" + default: + sessionIDSameSite = "Default" + } + } + } + if sessionIDRaw == "" { + err = goa.MergeErrors(err, goa.MissingFieldError("sessionID", "cookie")) + } + sessionID = sessionIDRaw + if err != nil { + return nil, goahttp.ErrValidationError("ServiceCookieAttrBindings", "MethodCookieAttrBindings", err) + } + res := NewMethodCookieAttrBindingsResultOK(&body, sessionID) + res.ExpiresIn = sessionIDExpiresIn + res.CookieDomain = sessionIDCookieDomain + res.CookiePath = sessionIDCookiePath + res.IsSecure = sessionIDIsSecure + res.IsHTTPOnly = sessionIDIsHTTPOnly + res.SameSite = sessionIDSameSite + return res, nil + default: + body, _ := io.ReadAll(resp.Body) + return nil, goahttp.ErrInvalidResponse("ServiceCookieAttrBindings", "MethodCookieAttrBindings", resp.StatusCode, string(body)) + } + } +} diff --git a/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-mixed.go.golden b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-mixed.go.golden new file mode 100644 index 0000000000..c44acee1be --- /dev/null +++ b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-mixed.go.golden @@ -0,0 +1,29 @@ +// EncodeMethodCookieAttrBindingsMixedResponse returns an encoder for responses +// returned by the ServiceCookieAttrBindingsMixed MethodCookieAttrBindingsMixed +// endpoint. +func EncodeMethodCookieAttrBindingsMixedResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { + return func(ctx context.Context, w http.ResponseWriter, v any) error { + res, _ := v.(*servicecookieattrbindingsmixed.MethodCookieAttrBindingsMixedResult) + enc := encoder(ctx, w) + body := NewMethodCookieAttrBindingsMixedResponseBody(res) + a := res.A + cookiea := &http.Cookie{ + Name: "A", + Value: a, + MaxAge: int(res.ExpiresIn), + Domain: "goa.design", + Secure: true, + } + http.SetCookie(w, cookiea) + b := res.B + http.SetCookie(w, &http.Cookie{ + Name: "B", + Value: b, + MaxAge: 3600, + Domain: "goa.design", + Secure: true, + }) + w.WriteHeader(http.StatusOK) + return enc.Encode(body) + } +} diff --git a/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-optional.go.golden b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-optional.go.golden new file mode 100644 index 0000000000..02c59ebc8e --- /dev/null +++ b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-optional.go.golden @@ -0,0 +1,24 @@ +// EncodeMethodCookieAttrBindingsOptionalResponse returns an encoder for +// responses returned by the ServiceCookieAttrBindingsOptional +// MethodCookieAttrBindingsOptional endpoint. +func EncodeMethodCookieAttrBindingsOptionalResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { + return func(ctx context.Context, w http.ResponseWriter, v any) error { + res, _ := v.(*servicecookieattrbindingsoptional.MethodCookieAttrBindingsOptionalResult) + enc := encoder(ctx, w) + body := NewMethodCookieAttrBindingsOptionalResponseBody(res) + sessionID := res.SessionID + cookiesessionID := &http.Cookie{ + Name: "SID", + Value: sessionID, + } + if res.ExpiresIn != nil { + cookiesessionID.MaxAge = int(*res.ExpiresIn) + } + if res.CookieDomain != nil { + cookiesessionID.Domain = *res.CookieDomain + } + http.SetCookie(w, cookiesessionID) + w.WriteHeader(http.StatusOK) + return enc.Encode(body) + } +} diff --git a/http/codegen/testdata/golden/server_encode_cookie-attr-bindings.go.golden b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings.go.golden new file mode 100644 index 0000000000..488effe7a3 --- /dev/null +++ b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings.go.golden @@ -0,0 +1,32 @@ +// EncodeMethodCookieAttrBindingsResponse returns an encoder for responses +// returned by the ServiceCookieAttrBindings MethodCookieAttrBindings endpoint. +func EncodeMethodCookieAttrBindingsResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { + return func(ctx context.Context, w http.ResponseWriter, v any) error { + res, _ := v.(*servicecookieattrbindings.MethodCookieAttrBindingsResult) + enc := encoder(ctx, w) + body := NewMethodCookieAttrBindingsResponseBody(res) + sessionID := res.SessionID + cookiesessionID := &http.Cookie{ + Name: "SID", + Value: sessionID, + MaxAge: int(res.ExpiresIn), + Path: res.CookiePath, + Domain: res.CookieDomain, + Secure: res.IsSecure, + HttpOnly: res.IsHTTPOnly, + } + switch res.SameSite { + case "Strict": + cookiesessionID.SameSite = http.SameSiteStrictMode + case "Lax": + cookiesessionID.SameSite = http.SameSiteLaxMode + case "None": + cookiesessionID.SameSite = http.SameSiteNoneMode + default: + cookiesessionID.SameSite = http.SameSiteDefaultMode + } + http.SetCookie(w, cookiesessionID) + w.WriteHeader(http.StatusOK) + return enc.Encode(body) + } +} diff --git a/http/codegen/testdata/result_dsls.go b/http/codegen/testdata/result_dsls.go index bb8647d6ed..ece3d7cf86 100644 --- a/http/codegen/testdata/result_dsls.go +++ b/http/codegen/testdata/result_dsls.go @@ -1640,3 +1640,84 @@ var ResultBodyUnionCustomKeysMultiDSL = func() { }) }) } + +var ResultCookieAttrBindingsDSL = func() { + Service("ServiceCookieAttrBindings", func() { + Method("MethodCookieAttrBindings", func() { + Result(func() { + Attribute("sessionID", String) + Attribute("expiresIn", Int) + Attribute("cookieDomain", String) + Attribute("cookiePath", String) + Attribute("isSecure", Boolean) + Attribute("isHTTPOnly", Boolean) + Attribute("sameSite", String) + Required("sessionID", "expiresIn", "cookieDomain", + "cookiePath", "isSecure", "isHTTPOnly", "sameSite") + }) + HTTP(func() { + POST("/login") + Response(StatusOK, func() { + Cookie("sessionID:SID", String) + CookieAttributes("sessionID", func() { + MaxAgeFrom("expiresIn") + DomainFrom("cookieDomain") + PathFrom("cookiePath") + SecureFrom("isSecure") + HTTPOnlyFrom("isHTTPOnly") + SameSiteFrom("sameSite") + }) + }) + }) + }) + }) +} + +var ResultCookieAttrBindingsOptionalDSL = func() { + Service("ServiceCookieAttrBindingsOptional", func() { + Method("MethodCookieAttrBindingsOptional", func() { + Result(func() { + Attribute("sessionID", String) + Attribute("expiresIn", Int) + Attribute("cookieDomain", String) + Required("sessionID") + }) + HTTP(func() { + POST("/login") + Response(StatusOK, func() { + Cookie("sessionID:SID", String) + CookieAttributes("sessionID", func() { + MaxAgeFrom("expiresIn") + DomainFrom("cookieDomain") + }) + }) + }) + }) + }) +} + +var ResultCookieAttrBindingsMixedDSL = func() { + Service("ServiceCookieAttrBindingsMixed", func() { + Method("MethodCookieAttrBindingsMixed", func() { + Result(func() { + Attribute("a", String) + Attribute("b", String) + Attribute("expiresIn", Int) + Required("a", "b", "expiresIn") + }) + HTTP(func() { + POST("/login") + Response(StatusOK, func() { + Cookie("a:A", String) + Cookie("b:B", String) + CookieAttributes("a", func() { + MaxAgeFrom("expiresIn") + }) + CookieMaxAge(3600) + CookieDomain("goa.design") + CookieSecure() + }) + }) + }) + }) +} From da28e356d3957da7a43cbff4a2f784da7aa5b6c5 Mon Sep 17 00:00:00 2001 From: Georges Haidar Date: Thu, 30 Apr 2026 10:55:40 +0100 Subject: [PATCH 02/10] fix(http): copy decoded cookie bindings into error results 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) --- expr/http_cookie_test.go | 27 ++++++ expr/http_error.go | 14 +++ expr/http_response.go | 13 +-- expr/testdata/cookie_dsls.go | 71 +++++++++++++++ http/codegen/client.go | 29 +++++- http/codegen/client_decode_test.go | 1 + http/codegen/server_encode_test.go | 1 + http/codegen/templates.go | 1 + .../partial/cookie_attr_bindings.go.tpl | 38 ++++++++ .../codegen/templates/response_decoder.go.tpl | 90 +++---------------- ...ecode_cookie-attr-bindings-error.go.golden | 70 +++++++++++++++ ...ncode_cookie-attr-bindings-error.go.golden | 9 ++ http/codegen/testdata/result_dsls.go | 26 ++++++ 13 files changed, 306 insertions(+), 84 deletions(-) create mode 100644 http/codegen/templates/partial/cookie_attr_bindings.go.tpl create mode 100644 http/codegen/testdata/golden/client_decode_cookie-attr-bindings-error.go.golden create mode 100644 http/codegen/testdata/golden/server_encode_cookie-attr-bindings-error.go.golden diff --git a/expr/http_cookie_test.go b/expr/http_cookie_test.go index ae6312cc2d..1cf6dcfea4 100644 --- a/expr/http_cookie_test.go +++ b/expr/http_cookie_test.go @@ -80,6 +80,23 @@ func TestHTTPResponseCookieAttrBindings(t *testing.T) { } } +func TestHTTPErrorCookieAttrBindings(t *testing.T) { + root := expr.RunDSL(t, testdata.CookieAttrBindingErrorDSL) + httpErr := root.API.HTTP.Services[len(root.API.HTTP.Services)-1].HTTPEndpoints[0].HTTPErrors[0] + obj := expr.AsObject(httpErr.Response.Cookies.Type) + if len(*obj) != 1 { + t.Fatalf("got %d cookies, expected 1", len(*obj)) + } + cookie := (*obj)[0].Attribute + got, ok := cookie.Meta["cookie:max-age:from"] + if !ok { + t.Fatalf("cookie metadata %q missing", "cookie:max-age:from") + } + if len(got) != 1 || got[0] != "retryAfter" { + t.Errorf("cookie metadata %q = %v, want [%q]", "cookie:max-age:from", got, "retryAfter") + } +} + func TestHTTPResponseCookieAttrBindingValidation(t *testing.T) { cases := []struct { Name string @@ -101,6 +118,16 @@ func TestHTTPResponseCookieAttrBindingValidation(t *testing.T) { testdata.CookieAttrBindingUndeclaredDSL, "CookieAttributes references cookie \"notDeclared\"", }, + { + "error-missing-attr", + testdata.CookieAttrBindingErrorMissingAttrDSL, + "binds Max-Age to attribute \"doesNotExist\" which has no equivalent attribute in error type", + }, + { + "error-wrong-type", + testdata.CookieAttrBindingErrorWrongTypeDSL, + "binds Max-Age to attribute \"retryAfter\" but it must be an integer", + }, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { diff --git a/expr/http_error.go b/expr/http_error.go index 9f2ef4d1be..7909670caa 100644 --- a/expr/http_error.go +++ b/expr/http_error.go @@ -52,6 +52,20 @@ func (e *HTTPErrorExpr) Validate() *eval.ValidationErrors { ee = Root.Error(e.Name) } + // validate cookie attribute bindings against the error type + if e.Response.Cookies != nil && !e.Response.Cookies.IsEmpty() && ee != nil && IsObject(ee.Type) { + errAttributeType := func(name string) DataType { + att := ee.Find(name) + if att == nil { + return nil + } + return att.Type + } + for _, c := range *AsObject(e.Response.Cookies.Type) { + verr.Merge(validateCookieAttrBindings(e.Response, c.Name, c.Attribute, errAttributeType, "error type", "")) + } + } + // validate headers if e.Response.Headers != nil && !e.Response.Headers.IsEmpty() { verr.Merge(e.Response.Headers.Validate("HTTP error response headers", e.Response)) diff --git a/expr/http_response.go b/expr/http_response.go index 83c67557a0..930de5d72c 100644 --- a/expr/http_response.go +++ b/expr/http_response.go @@ -229,7 +229,7 @@ func (r *HTTPResponseExpr) Validate(e *HTTPEndpointExpr) *eval.ValidationErrors if !IsPrimitive(t) { verr.Add(e, "attribute %q used in HTTP cookies must be a primitive type.", c.Name) } - verr.Merge(validateCookieAttrBindings(r, c.Name, c.Attribute, resultAttributeType, inview)) + verr.Merge(validateCookieAttrBindings(r, c.Name, c.Attribute, resultAttributeType, "result type", inview)) } default: if len(*AsObject(r.Cookies.Type)) > 1 { @@ -395,9 +395,10 @@ func (r *HTTPResponseExpr) mapUnmappedAttrs(svcAtt *AttributeExpr) { // validateCookieAttrBindings validates the per-cookie attribute bindings // (Max-Age, Domain, Path, Secure, HttpOnly, SameSite) recorded as // "cookie::from" metadata on the cookie attribute. It checks that each -// referenced result attribute exists and is of the kind expected by the bound -// cookie property. -func validateCookieAttrBindings(r *HTTPResponseExpr, cookieName string, cookieAttr *AttributeExpr, resultAttributeType func(string) DataType, inview string) *eval.ValidationErrors { +// referenced attribute exists in the surrounding result or error type and is +// of the kind expected by the bound cookie property. typeNoun is the noun used +// in diagnostic messages ("result type" or "error type"). +func validateCookieAttrBindings(r eval.Expression, cookieName string, cookieAttr *AttributeExpr, attributeType func(string) DataType, typeNoun, inview string) *eval.ValidationErrors { verr := new(eval.ValidationErrors) if cookieAttr == nil || len(cookieAttr.Meta) == 0 { return verr @@ -434,9 +435,9 @@ func validateCookieAttrBindings(r *HTTPResponseExpr, cookieName string, cookieAt continue } attrName := v[0] - t := resultAttributeType(attrName) + t := attributeType(attrName) if t == nil { - verr.Add(r, "cookie %q binds %s to attribute %q which has no equivalent attribute in%s result type", cookieName, b.kind, attrName, inview) + verr.Add(r, "cookie %q binds %s to attribute %q which has no equivalent attribute in%s %s", cookieName, b.kind, attrName, inview, typeNoun) continue } if !b.ok(t) { diff --git a/expr/testdata/cookie_dsls.go b/expr/testdata/cookie_dsls.go index 21cf989143..57ca0a5d59 100644 --- a/expr/testdata/cookie_dsls.go +++ b/expr/testdata/cookie_dsls.go @@ -236,3 +236,74 @@ var CookieAttrBindingUndeclaredDSL = func() { }) }) } + +var CookieAttrBindingErrorDSL = func() { + var SessionInvalid = Type("SessionInvalid", func() { + ErrorName("name") + Attribute("name", String) + Attribute("reason", String) + Attribute("retryAfter", Int) + Required("name", "reason", "retryAfter") + }) + Service("CookieSvc", func() { + Method("Method", func() { + Error("session_invalid", SessionInvalid) + HTTP(func() { + GET("/") + Response("session_invalid", StatusUnauthorized, func() { + Cookie("reason") + CookieAttributes("reason", func() { + MaxAgeFrom("retryAfter") + }) + }) + }) + }) + }) +} + +var CookieAttrBindingErrorMissingAttrDSL = func() { + var SessionInvalid = Type("SessionInvalid", func() { + ErrorName("name") + Attribute("name", String) + Attribute("reason", String) + Required("name", "reason") + }) + Service("CookieSvc", func() { + Method("Method", func() { + Error("session_invalid", SessionInvalid) + HTTP(func() { + GET("/") + Response("session_invalid", StatusUnauthorized, func() { + Cookie("reason") + CookieAttributes("reason", func() { + MaxAgeFrom("doesNotExist") + }) + }) + }) + }) + }) +} + +var CookieAttrBindingErrorWrongTypeDSL = func() { + var SessionInvalid = Type("SessionInvalid", func() { + ErrorName("name") + Attribute("name", String) + Attribute("reason", String) + Attribute("retryAfter", String) + Required("name", "reason", "retryAfter") + }) + Service("CookieSvc", func() { + Method("Method", func() { + Error("session_invalid", SessionInvalid) + HTTP(func() { + GET("/") + Response("session_invalid", StatusUnauthorized, func() { + Cookie("reason") + CookieAttributes("reason", func() { + MaxAgeFrom("retryAfter") + }) + }) + }) + }) + }) +} diff --git a/http/codegen/client.go b/http/codegen/client.go index ee6cf77d4a..bf0fef387b 100644 --- a/http/codegen/client.go +++ b/http/codegen/client.go @@ -99,13 +99,15 @@ func ClientEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * if e.Result != nil || len(e.Errors) > 0 { sections = append(sections, &codegen.SectionTemplate{ Name: "response-decoder", - Source: httpTemplates.Read(responseDecoderT, singleResponseP, queryTypeConversionP, elementSliceConversionP, sliceItemConversionP), + Source: httpTemplates.Read(responseDecoderT, singleResponseP, queryTypeConversionP, elementSliceConversionP, sliceItemConversionP, cookieAttrBindingsP), Data: e, FuncMap: map[string]any{ "goTypeRef": func(dt expr.DataType) string { return services.ServicesData.Get(svc.Name()).Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) }, - "buildResponseData": buildResponseData, + "buildResponseData": buildResponseData, + "hasCookieBindings": hasCookieBindings, + "cookieBindingsArgs": cookieBindingsArgs, }, }) } @@ -273,6 +275,29 @@ func buildResponseData(data *ResponseData, serviceName string, method *service.M } } +// hasCookieBindings reports whether any cookie in the slice carries a +// CookieAttributes binding that the response decoder must copy back into the +// constructed result or error. +func hasCookieBindings(cookies []*CookieData) bool { + for _, c := range cookies { + if c.MaxAgeFrom != nil || c.DomainFrom != nil || c.PathFrom != nil || + c.SecureFrom != nil || c.HTTPOnlyFrom != nil || c.SameSiteFrom != nil { + return true + } + } + return false +} + +// cookieBindingsArgs builds the data passed to the "cookie_attr_bindings" +// partial. Target is the local Go variable to assign decoded cookie attributes +// into (e.g. "p", "res", or an error-result local). +func cookieBindingsArgs(cookies []*CookieData, target string) map[string]any { + return map[string]any{ + "Cookies": cookies, + "Target": target, + } +} + func fieldType(ft expr.DataType) expr.DataType { ut, isut := ft.(expr.UserType) if isut { diff --git a/http/codegen/client_decode_test.go b/http/codegen/client_decode_test.go index 30c502fd5e..76cbfe1347 100644 --- a/http/codegen/client_decode_test.go +++ b/http/codegen/client_decode_test.go @@ -36,6 +36,7 @@ func TestClientDecode(t *testing.T) { {"cookie-attr-bindings", testdata.ResultCookieAttrBindingsDSL}, {"cookie-attr-bindings-optional", testdata.ResultCookieAttrBindingsOptionalDSL}, {"cookie-attr-bindings-mixed", testdata.ResultCookieAttrBindingsMixedDSL}, + {"cookie-attr-bindings-error", testdata.ResultCookieAttrBindingsErrorDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { diff --git a/http/codegen/server_encode_test.go b/http/codegen/server_encode_test.go index 517254ce74..12572646ae 100644 --- a/http/codegen/server_encode_test.go +++ b/http/codegen/server_encode_test.go @@ -91,6 +91,7 @@ func TestEncode(t *testing.T) { {"cookie-attr-bindings", testdata.ResultCookieAttrBindingsDSL}, {"cookie-attr-bindings-optional", testdata.ResultCookieAttrBindingsOptionalDSL}, {"cookie-attr-bindings-mixed", testdata.ResultCookieAttrBindingsMixedDSL}, + {"cookie-attr-bindings-error", testdata.ResultCookieAttrBindingsErrorDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { diff --git a/http/codegen/templates.go b/http/codegen/templates.go index d0315dbbbc..fee969341d 100644 --- a/http/codegen/templates.go +++ b/http/codegen/templates.go @@ -107,6 +107,7 @@ const ( requestElementsP = "request_elements" queryMapConversionP = "query_map_conversion" pathConversionP = "path_conversion" + cookieAttrBindingsP = "cookie_attr_bindings" ) //go:embed templates/* diff --git a/http/codegen/templates/partial/cookie_attr_bindings.go.tpl b/http/codegen/templates/partial/cookie_attr_bindings.go.tpl new file mode 100644 index 0000000000..398f662be0 --- /dev/null +++ b/http/codegen/templates/partial/cookie_attr_bindings.go.tpl @@ -0,0 +1,38 @@ +{{- range .Cookies }} + {{- if .MaxAgeFrom }}{{ if .MaxAgeFrom.FieldPointer }} + {{ .MaxAgeFrom.VarName }}Tmp := {{ .MaxAgeFrom.VarName }} + {{ $.Target }}.{{ .MaxAgeFrom.FieldName }} = &{{ .MaxAgeFrom.VarName }}Tmp + {{- else }} + {{ $.Target }}.{{ .MaxAgeFrom.FieldName }} = {{ .MaxAgeFrom.VarName }} + {{- end }}{{- end }} + {{- if .DomainFrom }}{{ if .DomainFrom.FieldPointer }} + {{ .DomainFrom.VarName }}Tmp := {{ .DomainFrom.VarName }} + {{ $.Target }}.{{ .DomainFrom.FieldName }} = &{{ .DomainFrom.VarName }}Tmp + {{- else }} + {{ $.Target }}.{{ .DomainFrom.FieldName }} = {{ .DomainFrom.VarName }} + {{- end }}{{- end }} + {{- if .PathFrom }}{{ if .PathFrom.FieldPointer }} + {{ .PathFrom.VarName }}Tmp := {{ .PathFrom.VarName }} + {{ $.Target }}.{{ .PathFrom.FieldName }} = &{{ .PathFrom.VarName }}Tmp + {{- else }} + {{ $.Target }}.{{ .PathFrom.FieldName }} = {{ .PathFrom.VarName }} + {{- end }}{{- end }} + {{- if .SecureFrom }}{{ if .SecureFrom.FieldPointer }} + {{ .SecureFrom.VarName }}Tmp := {{ .SecureFrom.VarName }} + {{ $.Target }}.{{ .SecureFrom.FieldName }} = &{{ .SecureFrom.VarName }}Tmp + {{- else }} + {{ $.Target }}.{{ .SecureFrom.FieldName }} = {{ .SecureFrom.VarName }} + {{- end }}{{- end }} + {{- if .HTTPOnlyFrom }}{{ if .HTTPOnlyFrom.FieldPointer }} + {{ .HTTPOnlyFrom.VarName }}Tmp := {{ .HTTPOnlyFrom.VarName }} + {{ $.Target }}.{{ .HTTPOnlyFrom.FieldName }} = &{{ .HTTPOnlyFrom.VarName }}Tmp + {{- else }} + {{ $.Target }}.{{ .HTTPOnlyFrom.FieldName }} = {{ .HTTPOnlyFrom.VarName }} + {{- end }}{{- end }} + {{- if .SameSiteFrom }}{{ if .SameSiteFrom.FieldPointer }} + {{ .SameSiteFrom.VarName }}Tmp := {{ .SameSiteFrom.VarName }} + {{ $.Target }}.{{ .SameSiteFrom.FieldName }} = &{{ .SameSiteFrom.VarName }}Tmp + {{- else }} + {{ $.Target }}.{{ .SameSiteFrom.FieldName }} = {{ .SameSiteFrom.VarName }} + {{- end }}{{- end }} +{{- end }} diff --git a/http/codegen/templates/response_decoder.go.tpl b/http/codegen/templates/response_decoder.go.tpl index a705482c86..6429d57aab 100644 --- a/http/codegen/templates/response_decoder.go.tpl +++ b/http/codegen/templates/response_decoder.go.tpl @@ -31,44 +31,7 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor {{- if .ResultInit }} {{- if .ViewedResult }} p := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) - {{- range .Cookies }} - {{- if .MaxAgeFrom }}{{ if .MaxAgeFrom.FieldPointer }} - {{ .MaxAgeFrom.VarName }}Tmp := {{ .MaxAgeFrom.VarName }} - p.{{ .MaxAgeFrom.FieldName }} = &{{ .MaxAgeFrom.VarName }}Tmp - {{- else }} - p.{{ .MaxAgeFrom.FieldName }} = {{ .MaxAgeFrom.VarName }} - {{- end }}{{- end }} - {{- if .DomainFrom }}{{ if .DomainFrom.FieldPointer }} - {{ .DomainFrom.VarName }}Tmp := {{ .DomainFrom.VarName }} - p.{{ .DomainFrom.FieldName }} = &{{ .DomainFrom.VarName }}Tmp - {{- else }} - p.{{ .DomainFrom.FieldName }} = {{ .DomainFrom.VarName }} - {{- end }}{{- end }} - {{- if .PathFrom }}{{ if .PathFrom.FieldPointer }} - {{ .PathFrom.VarName }}Tmp := {{ .PathFrom.VarName }} - p.{{ .PathFrom.FieldName }} = &{{ .PathFrom.VarName }}Tmp - {{- else }} - p.{{ .PathFrom.FieldName }} = {{ .PathFrom.VarName }} - {{- end }}{{- end }} - {{- if .SecureFrom }}{{ if .SecureFrom.FieldPointer }} - {{ .SecureFrom.VarName }}Tmp := {{ .SecureFrom.VarName }} - p.{{ .SecureFrom.FieldName }} = &{{ .SecureFrom.VarName }}Tmp - {{- else }} - p.{{ .SecureFrom.FieldName }} = {{ .SecureFrom.VarName }} - {{- end }}{{- end }} - {{- if .HTTPOnlyFrom }}{{ if .HTTPOnlyFrom.FieldPointer }} - {{ .HTTPOnlyFrom.VarName }}Tmp := {{ .HTTPOnlyFrom.VarName }} - p.{{ .HTTPOnlyFrom.FieldName }} = &{{ .HTTPOnlyFrom.VarName }}Tmp - {{- else }} - p.{{ .HTTPOnlyFrom.FieldName }} = {{ .HTTPOnlyFrom.VarName }} - {{- end }}{{- end }} - {{- if .SameSiteFrom }}{{ if .SameSiteFrom.FieldPointer }} - {{ .SameSiteFrom.VarName }}Tmp := {{ .SameSiteFrom.VarName }} - p.{{ .SameSiteFrom.FieldName }} = &{{ .SameSiteFrom.VarName }}Tmp - {{- else }} - p.{{ .SameSiteFrom.FieldName }} = {{ .SameSiteFrom.VarName }} - {{- end }}{{- end }} - {{- end }} + {{- template "partial_cookie_attr_bindings" (cookieBindingsArgs .Cookies "p") }} {{- if .TagName }} tmp := {{ printf "%q" .TagValue }} p.{{ .TagName }} = &tmp @@ -87,44 +50,7 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor res := {{ $.ServicePkgName }}.{{ $.Method.ViewedResult.ResultInit.Name }}(vres) {{- else }} res := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) - {{- range .Cookies }} - {{- if .MaxAgeFrom }}{{ if .MaxAgeFrom.FieldPointer }} - {{ .MaxAgeFrom.VarName }}Tmp := {{ .MaxAgeFrom.VarName }} - res.{{ .MaxAgeFrom.FieldName }} = &{{ .MaxAgeFrom.VarName }}Tmp - {{- else }} - res.{{ .MaxAgeFrom.FieldName }} = {{ .MaxAgeFrom.VarName }} - {{- end }}{{- end }} - {{- if .DomainFrom }}{{ if .DomainFrom.FieldPointer }} - {{ .DomainFrom.VarName }}Tmp := {{ .DomainFrom.VarName }} - res.{{ .DomainFrom.FieldName }} = &{{ .DomainFrom.VarName }}Tmp - {{- else }} - res.{{ .DomainFrom.FieldName }} = {{ .DomainFrom.VarName }} - {{- end }}{{- end }} - {{- if .PathFrom }}{{ if .PathFrom.FieldPointer }} - {{ .PathFrom.VarName }}Tmp := {{ .PathFrom.VarName }} - res.{{ .PathFrom.FieldName }} = &{{ .PathFrom.VarName }}Tmp - {{- else }} - res.{{ .PathFrom.FieldName }} = {{ .PathFrom.VarName }} - {{- end }}{{- end }} - {{- if .SecureFrom }}{{ if .SecureFrom.FieldPointer }} - {{ .SecureFrom.VarName }}Tmp := {{ .SecureFrom.VarName }} - res.{{ .SecureFrom.FieldName }} = &{{ .SecureFrom.VarName }}Tmp - {{- else }} - res.{{ .SecureFrom.FieldName }} = {{ .SecureFrom.VarName }} - {{- end }}{{- end }} - {{- if .HTTPOnlyFrom }}{{ if .HTTPOnlyFrom.FieldPointer }} - {{ .HTTPOnlyFrom.VarName }}Tmp := {{ .HTTPOnlyFrom.VarName }} - res.{{ .HTTPOnlyFrom.FieldName }} = &{{ .HTTPOnlyFrom.VarName }}Tmp - {{- else }} - res.{{ .HTTPOnlyFrom.FieldName }} = {{ .HTTPOnlyFrom.VarName }} - {{- end }}{{- end }} - {{- if .SameSiteFrom }}{{ if .SameSiteFrom.FieldPointer }} - {{ .SameSiteFrom.VarName }}Tmp := {{ .SameSiteFrom.VarName }} - res.{{ .SameSiteFrom.FieldName }} = &{{ .SameSiteFrom.VarName }}Tmp - {{- else }} - res.{{ .SameSiteFrom.FieldName }} = {{ .SameSiteFrom.VarName }} - {{- end }}{{- end }} - {{- end }} + {{- template "partial_cookie_attr_bindings" (cookieBindingsArgs .Cookies "res") }} {{- end }} {{- if and .TagName (not .ViewedResult) }} {{- if .TagPointer }} @@ -155,7 +81,13 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor {{- with .Response }} {{- template "partial_single_response" (buildResponseData . $.ServiceName $.Method) }} {{- if .ResultInit }} + {{- if hasCookieBindings .Cookies }} + errres := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + {{- template "partial_cookie_attr_bindings" (cookieBindingsArgs .Cookies "errres") }} + return nil, errres + {{- else }} return nil, {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + {{- end }} {{- else if .ClientBody }} return nil, body {{- else }} @@ -171,7 +103,13 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor {{- with (index .Errors 0).Response }} {{- template "partial_single_response" (buildResponseData . $.ServiceName $.Method) }} {{- if .ResultInit }} + {{- if hasCookieBindings .Cookies }} + errres := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + {{- template "partial_cookie_attr_bindings" (cookieBindingsArgs .Cookies "errres") }} + return nil, errres + {{- else }} return nil, {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + {{- end }} {{- else if .ClientBody }} return nil, body {{- else }} diff --git a/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-error.go.golden b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-error.go.golden new file mode 100644 index 0000000000..f38c713cdf --- /dev/null +++ b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-error.go.golden @@ -0,0 +1,70 @@ +// DecodeMethodCookieAttrBindingsErrorResponse returns a decoder for responses +// returned by the ServiceCookieAttrBindingsError MethodCookieAttrBindingsError +// endpoint. restoreBody controls whether the response body should be restored +// after having been read. +// DecodeMethodCookieAttrBindingsErrorResponse may return the following errors: +// - "session_invalid" (type *servicecookieattrbindingserror.SessionInvalid): http.StatusUnauthorized +// - error: internal error +func DecodeMethodCookieAttrBindingsErrorResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (any, error) { + if restoreBody { + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer resp.Body.Close() + } + switch resp.StatusCode { + case http.StatusNoContent: + return nil, nil + case http.StatusUnauthorized: + var ( + body MethodCookieAttrBindingsErrorSessionInvalidResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceCookieAttrBindingsError", "MethodCookieAttrBindingsError", err) + } + err = ValidateMethodCookieAttrBindingsErrorSessionInvalidResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("ServiceCookieAttrBindingsError", "MethodCookieAttrBindingsError", err) + } + var ( + reason string + reasonRaw string + reasonRetryAfter int + reasonLoginPath string + + cookies = resp.Cookies() + ) + for _, c := range cookies { + switch c.Name { + case "Reason": + reasonRaw = c.Value + reasonRetryAfter = int(c.MaxAge) + reasonLoginPath = c.Path + } + } + if reasonRaw == "" { + err = goa.MergeErrors(err, goa.MissingFieldError("reason", "cookie")) + } + reason = reasonRaw + if err != nil { + return nil, goahttp.ErrValidationError("ServiceCookieAttrBindingsError", "MethodCookieAttrBindingsError", err) + } + errres := NewMethodCookieAttrBindingsErrorSessionInvalid(&body, reason) + errres.RetryAfter = reasonRetryAfter + errres.LoginPath = reasonLoginPath + return nil, errres + default: + body, _ := io.ReadAll(resp.Body) + return nil, goahttp.ErrInvalidResponse("ServiceCookieAttrBindingsError", "MethodCookieAttrBindingsError", resp.StatusCode, string(body)) + } + } +} diff --git a/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-error.go.golden b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-error.go.golden new file mode 100644 index 0000000000..9965b7cbd5 --- /dev/null +++ b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-error.go.golden @@ -0,0 +1,9 @@ +// EncodeMethodCookieAttrBindingsErrorResponse returns an encoder for responses +// returned by the ServiceCookieAttrBindingsError MethodCookieAttrBindingsError +// endpoint. +func EncodeMethodCookieAttrBindingsErrorResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { + return func(ctx context.Context, w http.ResponseWriter, v any) error { + w.WriteHeader(http.StatusNoContent) + return nil + } +} diff --git a/http/codegen/testdata/result_dsls.go b/http/codegen/testdata/result_dsls.go index ece3d7cf86..95ce60d1bb 100644 --- a/http/codegen/testdata/result_dsls.go +++ b/http/codegen/testdata/result_dsls.go @@ -1721,3 +1721,29 @@ var ResultCookieAttrBindingsMixedDSL = func() { }) }) } + +var ResultCookieAttrBindingsErrorDSL = func() { + var SessionInvalid = Type("SessionInvalid", func() { + ErrorName("name") + Attribute("name", String) + Attribute("reason", String) + Attribute("retryAfter", Int) + Attribute("loginPath", String) + Required("name", "reason", "retryAfter", "loginPath") + }) + Service("ServiceCookieAttrBindingsError", func() { + Method("MethodCookieAttrBindingsError", func() { + Error("session_invalid", SessionInvalid) + HTTP(func() { + GET("/") + Response("session_invalid", StatusUnauthorized, func() { + Cookie("reason:Reason", String) + CookieAttributes("reason", func() { + MaxAgeFrom("retryAfter") + PathFrom("loginPath") + }) + }) + }) + }) + }) +} From d7408052e6a5f7ae231b388e866f04b0155f57fc Mon Sep 17 00:00:00 2001 From: Georges Haidar Date: Thu, 30 Apr 2026 11:00:10 +0100 Subject: [PATCH 03/10] fix(http): exclude cookie-bound result attributes from default body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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::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) --- expr/http_body_types.go | 33 ++++++++++ expr/http_cookie_test.go | 26 ++++++++ http/codegen/client_decode_test.go | 1 + http/codegen/server_encode_test.go | 1 + ...decode_cookie-attr-bindings-body.go.golden | 62 +++++++++++++++++++ ...ecode_cookie-attr-bindings-mixed.go.golden | 15 +---- ...de_cookie-attr-bindings-optional.go.golden | 11 +--- ...ient_decode_cookie-attr-bindings.go.golden | 15 +---- ...encode_cookie-attr-bindings-body.go.golden | 19 ++++++ ...ncode_cookie-attr-bindings-mixed.go.golden | 4 +- ...de_cookie-attr-bindings-optional.go.golden | 4 +- ...rver_encode_cookie-attr-bindings.go.golden | 4 +- http/codegen/testdata/result_dsls.go | 23 +++++++ 13 files changed, 174 insertions(+), 44 deletions(-) create mode 100644 http/codegen/testdata/golden/client_decode_cookie-attr-bindings-body.go.golden create mode 100644 http/codegen/testdata/golden/server_encode_cookie-attr-bindings-body.go.golden diff --git a/expr/http_body_types.go b/expr/http_body_types.go index ca075435c4..51d1b1626f 100644 --- a/expr/http_body_types.go +++ b/expr/http_body_types.go @@ -309,6 +309,7 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE // 4. Remove header and cookie attributes removeAttributes(body, resp.Headers) removeAttributes(body, resp.Cookies) + removeCookieAttrBindings(body, resp.Cookies) // 4. Return empty type if no attribute left if len(*AsObject(body.Type)) == 0 { @@ -350,6 +351,7 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE mv := NewMappedAttributeExpr(v.AttributeExpr) removeAttributes(mv, resp.Headers) removeAttributes(mv, resp.Cookies) + removeCookieAttrBindings(mv, resp.Cookies) nv := &ViewExpr{ AttributeExpr: mv.Attribute(), Name: v.Name, @@ -471,6 +473,37 @@ func removeAttributes(attr, sub *MappedAttributeExpr) { } } +// removeCookieAttrBindings strips from body the result-type attributes that +// are referenced by per-cookie CookieAttributes bindings (cookie::from +// meta keys on each cookie attribute). These attributes carry runtime values +// for the cookie's Max-Age, Domain, Path, Secure, HttpOnly or SameSite fields +// and would otherwise leak into the default JSON body. +func removeCookieAttrBindings(body, cookies *MappedAttributeExpr) { + if cookies == nil || cookies.IsEmpty() { + return + } + bindingKeys := []string{ + "cookie:max-age:from", + "cookie:domain:from", + "cookie:path:from", + "cookie:secure:from", + "cookie:http-only:from", + "cookie:same-site:from", + } + for _, nat := range *AsObject(cookies.Type) { + if nat.Attribute == nil { + continue + } + for _, k := range bindingKeys { + vals, ok := nat.Attribute.Meta[k] + if !ok || len(vals) == 0 { + continue + } + removeAttribute(body, vals[0]) + } + } +} + func removeAttribute(attr *MappedAttributeExpr, name string) { attr.Delete(name) if attr.Validation != nil { diff --git a/expr/http_cookie_test.go b/expr/http_cookie_test.go index 1cf6dcfea4..804c767d7e 100644 --- a/expr/http_cookie_test.go +++ b/expr/http_cookie_test.go @@ -80,6 +80,32 @@ func TestHTTPResponseCookieAttrBindings(t *testing.T) { } } +func TestHTTPResponseBodyExcludesCookieAttrBindings(t *testing.T) { + root := expr.RunDSL(t, testdata.CookieAttrBindingsDSL) + resp := root.API.HTTP.Services[len(root.API.HTTP.Services)-1].HTTPEndpoints[0].Responses[0] + if resp.Body == nil { + t.Fatalf("expected response body to be computed") + } + bound := []string{"expiresIn", "cookieDomain", "cookiePath", "isSecure", "isHTTPOnly", "sameSite"} + if obj := expr.AsObject(resp.Body.Type); obj != nil { + for _, nat := range *obj { + for _, b := range bound { + if nat.Name == b { + t.Errorf("response body still contains cookie-bound attribute %q", b) + } + } + } + } + cookieValue := "cookie" + if obj := expr.AsObject(resp.Body.Type); obj != nil { + for _, nat := range *obj { + if nat.Name == cookieValue { + t.Errorf("response body still contains cookie value attribute %q", cookieValue) + } + } + } +} + func TestHTTPErrorCookieAttrBindings(t *testing.T) { root := expr.RunDSL(t, testdata.CookieAttrBindingErrorDSL) httpErr := root.API.HTTP.Services[len(root.API.HTTP.Services)-1].HTTPEndpoints[0].HTTPErrors[0] diff --git a/http/codegen/client_decode_test.go b/http/codegen/client_decode_test.go index 76cbfe1347..2fba8383f0 100644 --- a/http/codegen/client_decode_test.go +++ b/http/codegen/client_decode_test.go @@ -36,6 +36,7 @@ func TestClientDecode(t *testing.T) { {"cookie-attr-bindings", testdata.ResultCookieAttrBindingsDSL}, {"cookie-attr-bindings-optional", testdata.ResultCookieAttrBindingsOptionalDSL}, {"cookie-attr-bindings-mixed", testdata.ResultCookieAttrBindingsMixedDSL}, + {"cookie-attr-bindings-body", testdata.ResultCookieAttrBindingsBodyDSL}, {"cookie-attr-bindings-error", testdata.ResultCookieAttrBindingsErrorDSL}, } for _, c := range cases { diff --git a/http/codegen/server_encode_test.go b/http/codegen/server_encode_test.go index 12572646ae..43c58adf79 100644 --- a/http/codegen/server_encode_test.go +++ b/http/codegen/server_encode_test.go @@ -91,6 +91,7 @@ func TestEncode(t *testing.T) { {"cookie-attr-bindings", testdata.ResultCookieAttrBindingsDSL}, {"cookie-attr-bindings-optional", testdata.ResultCookieAttrBindingsOptionalDSL}, {"cookie-attr-bindings-mixed", testdata.ResultCookieAttrBindingsMixedDSL}, + {"cookie-attr-bindings-body", testdata.ResultCookieAttrBindingsBodyDSL}, {"cookie-attr-bindings-error", testdata.ResultCookieAttrBindingsErrorDSL}, } for _, c := range cases { diff --git a/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-body.go.golden b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-body.go.golden new file mode 100644 index 0000000000..5ab3e443e0 --- /dev/null +++ b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-body.go.golden @@ -0,0 +1,62 @@ +// DecodeMethodCookieAttrBindingsBodyResponse returns a decoder for responses +// returned by the ServiceCookieAttrBindingsBody MethodCookieAttrBindingsBody +// endpoint. restoreBody controls whether the response body should be restored +// after having been read. +func DecodeMethodCookieAttrBindingsBodyResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (any, error) { + if restoreBody { + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer resp.Body.Close() + } + switch resp.StatusCode { + case http.StatusOK: + var ( + body MethodCookieAttrBindingsBodyResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceCookieAttrBindingsBody", "MethodCookieAttrBindingsBody", err) + } + err = ValidateMethodCookieAttrBindingsBodyResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("ServiceCookieAttrBindingsBody", "MethodCookieAttrBindingsBody", err) + } + var ( + sessionID string + sessionIDRaw string + sessionIDExpiresIn int + + cookies = resp.Cookies() + ) + for _, c := range cookies { + switch c.Name { + case "SID": + sessionIDRaw = c.Value + sessionIDExpiresIn = int(c.MaxAge) + } + } + if sessionIDRaw == "" { + err = goa.MergeErrors(err, goa.MissingFieldError("sessionID", "cookie")) + } + sessionID = sessionIDRaw + if err != nil { + return nil, goahttp.ErrValidationError("ServiceCookieAttrBindingsBody", "MethodCookieAttrBindingsBody", err) + } + res := NewMethodCookieAttrBindingsBodyResultOK(&body, sessionID) + res.ExpiresIn = sessionIDExpiresIn + return res, nil + default: + body, _ := io.ReadAll(resp.Body) + return nil, goahttp.ErrInvalidResponse("ServiceCookieAttrBindingsBody", "MethodCookieAttrBindingsBody", resp.StatusCode, string(body)) + } + } +} diff --git a/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-mixed.go.golden b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-mixed.go.golden index fc8a7e14f6..11d9f82f89 100644 --- a/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-mixed.go.golden +++ b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-mixed.go.golden @@ -18,18 +18,6 @@ func DecodeMethodCookieAttrBindingsMixedResponse(decoder func(*http.Response) go } switch resp.StatusCode { case http.StatusOK: - var ( - body MethodCookieAttrBindingsMixedResponseBody - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceCookieAttrBindingsMixed", "MethodCookieAttrBindingsMixed", err) - } - err = ValidateMethodCookieAttrBindingsMixedResponseBody(&body) - if err != nil { - return nil, goahttp.ErrValidationError("ServiceCookieAttrBindingsMixed", "MethodCookieAttrBindingsMixed", err) - } var ( a string aRaw string @@ -38,6 +26,7 @@ func DecodeMethodCookieAttrBindingsMixedResponse(decoder func(*http.Response) go bRaw string cookies = resp.Cookies() + err error ) for _, c := range cookies { switch c.Name { @@ -59,7 +48,7 @@ func DecodeMethodCookieAttrBindingsMixedResponse(decoder func(*http.Response) go if err != nil { return nil, goahttp.ErrValidationError("ServiceCookieAttrBindingsMixed", "MethodCookieAttrBindingsMixed", err) } - res := NewMethodCookieAttrBindingsMixedResultOK(&body, a, b) + res := NewMethodCookieAttrBindingsMixedResultOK(a, b) res.ExpiresIn = aExpiresIn return res, nil default: diff --git a/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional.go.golden b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional.go.golden index b3feacd92a..b27c6ebc29 100644 --- a/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional.go.golden +++ b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional.go.golden @@ -18,14 +18,6 @@ func DecodeMethodCookieAttrBindingsOptionalResponse(decoder func(*http.Response) } switch resp.StatusCode { case http.StatusOK: - var ( - body MethodCookieAttrBindingsOptionalResponseBody - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceCookieAttrBindingsOptional", "MethodCookieAttrBindingsOptional", err) - } var ( sessionID string sessionIDRaw string @@ -33,6 +25,7 @@ func DecodeMethodCookieAttrBindingsOptionalResponse(decoder func(*http.Response) sessionIDCookieDomain string cookies = resp.Cookies() + err error ) for _, c := range cookies { switch c.Name { @@ -49,7 +42,7 @@ func DecodeMethodCookieAttrBindingsOptionalResponse(decoder func(*http.Response) if err != nil { return nil, goahttp.ErrValidationError("ServiceCookieAttrBindingsOptional", "MethodCookieAttrBindingsOptional", err) } - res := NewMethodCookieAttrBindingsOptionalResultOK(&body, sessionID) + res := NewMethodCookieAttrBindingsOptionalResultOK(sessionID) sessionIDExpiresInTmp := sessionIDExpiresIn res.ExpiresIn = &sessionIDExpiresInTmp sessionIDCookieDomainTmp := sessionIDCookieDomain diff --git a/http/codegen/testdata/golden/client_decode_cookie-attr-bindings.go.golden b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings.go.golden index 37002884b1..88b8e6f2c9 100644 --- a/http/codegen/testdata/golden/client_decode_cookie-attr-bindings.go.golden +++ b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings.go.golden @@ -18,18 +18,6 @@ func DecodeMethodCookieAttrBindingsResponse(decoder func(*http.Response) goahttp } switch resp.StatusCode { case http.StatusOK: - var ( - body MethodCookieAttrBindingsResponseBody - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceCookieAttrBindings", "MethodCookieAttrBindings", err) - } - err = ValidateMethodCookieAttrBindingsResponseBody(&body) - if err != nil { - return nil, goahttp.ErrValidationError("ServiceCookieAttrBindings", "MethodCookieAttrBindings", err) - } var ( sessionID string sessionIDRaw string @@ -41,6 +29,7 @@ func DecodeMethodCookieAttrBindingsResponse(decoder func(*http.Response) goahttp sessionIDSameSite string cookies = resp.Cookies() + err error ) for _, c := range cookies { switch c.Name { @@ -70,7 +59,7 @@ func DecodeMethodCookieAttrBindingsResponse(decoder func(*http.Response) goahttp if err != nil { return nil, goahttp.ErrValidationError("ServiceCookieAttrBindings", "MethodCookieAttrBindings", err) } - res := NewMethodCookieAttrBindingsResultOK(&body, sessionID) + res := NewMethodCookieAttrBindingsResultOK(sessionID) res.ExpiresIn = sessionIDExpiresIn res.CookieDomain = sessionIDCookieDomain res.CookiePath = sessionIDCookiePath diff --git a/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-body.go.golden b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-body.go.golden new file mode 100644 index 0000000000..11f1e6397a --- /dev/null +++ b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-body.go.golden @@ -0,0 +1,19 @@ +// EncodeMethodCookieAttrBindingsBodyResponse returns an encoder for responses +// returned by the ServiceCookieAttrBindingsBody MethodCookieAttrBindingsBody +// endpoint. +func EncodeMethodCookieAttrBindingsBodyResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { + return func(ctx context.Context, w http.ResponseWriter, v any) error { + res, _ := v.(*servicecookieattrbindingsbody.MethodCookieAttrBindingsBodyResult) + enc := encoder(ctx, w) + body := NewMethodCookieAttrBindingsBodyResponseBody(res) + sessionID := res.SessionID + cookiesessionID := &http.Cookie{ + Name: "SID", + Value: sessionID, + MaxAge: int(res.ExpiresIn), + } + http.SetCookie(w, cookiesessionID) + w.WriteHeader(http.StatusOK) + return enc.Encode(body) + } +} diff --git a/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-mixed.go.golden b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-mixed.go.golden index c44acee1be..38cf98a4ab 100644 --- a/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-mixed.go.golden +++ b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-mixed.go.golden @@ -4,8 +4,6 @@ func EncodeMethodCookieAttrBindingsMixedResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { return func(ctx context.Context, w http.ResponseWriter, v any) error { res, _ := v.(*servicecookieattrbindingsmixed.MethodCookieAttrBindingsMixedResult) - enc := encoder(ctx, w) - body := NewMethodCookieAttrBindingsMixedResponseBody(res) a := res.A cookiea := &http.Cookie{ Name: "A", @@ -24,6 +22,6 @@ func EncodeMethodCookieAttrBindingsMixedResponse(encoder func(context.Context, h Secure: true, }) w.WriteHeader(http.StatusOK) - return enc.Encode(body) + return nil } } diff --git a/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-optional.go.golden b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-optional.go.golden index 02c59ebc8e..9107f7591a 100644 --- a/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-optional.go.golden +++ b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-optional.go.golden @@ -4,8 +4,6 @@ func EncodeMethodCookieAttrBindingsOptionalResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { return func(ctx context.Context, w http.ResponseWriter, v any) error { res, _ := v.(*servicecookieattrbindingsoptional.MethodCookieAttrBindingsOptionalResult) - enc := encoder(ctx, w) - body := NewMethodCookieAttrBindingsOptionalResponseBody(res) sessionID := res.SessionID cookiesessionID := &http.Cookie{ Name: "SID", @@ -19,6 +17,6 @@ func EncodeMethodCookieAttrBindingsOptionalResponse(encoder func(context.Context } http.SetCookie(w, cookiesessionID) w.WriteHeader(http.StatusOK) - return enc.Encode(body) + return nil } } diff --git a/http/codegen/testdata/golden/server_encode_cookie-attr-bindings.go.golden b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings.go.golden index 488effe7a3..0eb1b1183c 100644 --- a/http/codegen/testdata/golden/server_encode_cookie-attr-bindings.go.golden +++ b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings.go.golden @@ -3,8 +3,6 @@ func EncodeMethodCookieAttrBindingsResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { return func(ctx context.Context, w http.ResponseWriter, v any) error { res, _ := v.(*servicecookieattrbindings.MethodCookieAttrBindingsResult) - enc := encoder(ctx, w) - body := NewMethodCookieAttrBindingsResponseBody(res) sessionID := res.SessionID cookiesessionID := &http.Cookie{ Name: "SID", @@ -27,6 +25,6 @@ func EncodeMethodCookieAttrBindingsResponse(encoder func(context.Context, http.R } http.SetCookie(w, cookiesessionID) w.WriteHeader(http.StatusOK) - return enc.Encode(body) + return nil } } diff --git a/http/codegen/testdata/result_dsls.go b/http/codegen/testdata/result_dsls.go index 95ce60d1bb..067dec3555 100644 --- a/http/codegen/testdata/result_dsls.go +++ b/http/codegen/testdata/result_dsls.go @@ -1722,6 +1722,29 @@ var ResultCookieAttrBindingsMixedDSL = func() { }) } +var ResultCookieAttrBindingsBodyDSL = func() { + Service("ServiceCookieAttrBindingsBody", func() { + Method("MethodCookieAttrBindingsBody", func() { + Result(func() { + Attribute("sessionID", String) + Attribute("expiresIn", Int) + Attribute("userID", String) + Attribute("displayName", String) + Required("sessionID", "expiresIn", "userID", "displayName") + }) + HTTP(func() { + POST("/login") + Response(StatusOK, func() { + Cookie("sessionID:SID", String) + CookieAttributes("sessionID", func() { + MaxAgeFrom("expiresIn") + }) + }) + }) + }) + }) +} + var ResultCookieAttrBindingsErrorDSL = func() { var SessionInvalid = Type("SessionInvalid", func() { ErrorName("name") From a300ea9fec98dd35668eec919e37a69db08036ff Mon Sep 17 00:00:00 2001 From: Georges Haidar Date: Thu, 30 Apr 2026 11:08:46 +0100 Subject: [PATCH 04/10] fix(http): preserve presence for optional bound cookie attributes on 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) --- dsl/http.go | 8 ++ http/codegen/client_decode_test.go | 1 + http/codegen/server_encode_test.go | 1 + .../partial/cookie_attr_bindings.go.tpl | 84 +++++++++++------ ...ookie-attr-bindings-optional-all.go.golden | 93 +++++++++++++++++++ ...de_cookie-attr-bindings-optional.go.golden | 12 ++- ...ookie-attr-bindings-optional-all.go.golden | 43 +++++++++ http/codegen/testdata/result_dsls.go | 31 +++++++ 8 files changed, 239 insertions(+), 34 deletions(-) create mode 100644 http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional-all.go.golden create mode 100644 http/codegen/testdata/golden/server_encode_cookie-attr-bindings-optional-all.go.golden diff --git a/dsl/http.go b/dsl/http.go index d749ffd291..593cf2dc61 100644 --- a/dsl/http.go +++ b/dsl/http.go @@ -660,6 +660,14 @@ func (c *cookieAttrBindingsExpr) EvalName() string { // response, and the client decodes the corresponding HTTP cookie attributes // back into the same result fields. // +// Presence semantics on the client: when the bound result attribute is a +// pointer (optional, no default), the client decoder treats a zero-valued +// cookie attribute as absent and leaves the result field nil. That means +// MaxAge=0, empty Domain or Path, Secure/HttpOnly false, and SameSite=Default +// are all reported as nil. This conflates "explicit zero" with "attribute +// not set" — net/http does not preserve that distinction. Required (non- +// pointer) bound attributes are always assigned, including the zero value. +// // Bindings are additive to and take precedence over the response-wide literal // metadata set by CookieMaxAge, CookieDomain, CookiePath, CookieSecure, // CookieHTTPOnly and CookieSameSite. diff --git a/http/codegen/client_decode_test.go b/http/codegen/client_decode_test.go index 2fba8383f0..9a346c081c 100644 --- a/http/codegen/client_decode_test.go +++ b/http/codegen/client_decode_test.go @@ -35,6 +35,7 @@ func TestClientDecode(t *testing.T) { {"cookie-attr-bindings", testdata.ResultCookieAttrBindingsDSL}, {"cookie-attr-bindings-optional", testdata.ResultCookieAttrBindingsOptionalDSL}, + {"cookie-attr-bindings-optional-all", testdata.ResultCookieAttrBindingsOptionalAllDSL}, {"cookie-attr-bindings-mixed", testdata.ResultCookieAttrBindingsMixedDSL}, {"cookie-attr-bindings-body", testdata.ResultCookieAttrBindingsBodyDSL}, {"cookie-attr-bindings-error", testdata.ResultCookieAttrBindingsErrorDSL}, diff --git a/http/codegen/server_encode_test.go b/http/codegen/server_encode_test.go index 43c58adf79..6af70929fa 100644 --- a/http/codegen/server_encode_test.go +++ b/http/codegen/server_encode_test.go @@ -90,6 +90,7 @@ func TestEncode(t *testing.T) { {"cookie-attr-bindings", testdata.ResultCookieAttrBindingsDSL}, {"cookie-attr-bindings-optional", testdata.ResultCookieAttrBindingsOptionalDSL}, + {"cookie-attr-bindings-optional-all", testdata.ResultCookieAttrBindingsOptionalAllDSL}, {"cookie-attr-bindings-mixed", testdata.ResultCookieAttrBindingsMixedDSL}, {"cookie-attr-bindings-body", testdata.ResultCookieAttrBindingsBodyDSL}, {"cookie-attr-bindings-error", testdata.ResultCookieAttrBindingsErrorDSL}, diff --git a/http/codegen/templates/partial/cookie_attr_bindings.go.tpl b/http/codegen/templates/partial/cookie_attr_bindings.go.tpl index 398f662be0..5756dd7d49 100644 --- a/http/codegen/templates/partial/cookie_attr_bindings.go.tpl +++ b/http/codegen/templates/partial/cookie_attr_bindings.go.tpl @@ -1,38 +1,62 @@ {{- range .Cookies }} - {{- if .MaxAgeFrom }}{{ if .MaxAgeFrom.FieldPointer }} - {{ .MaxAgeFrom.VarName }}Tmp := {{ .MaxAgeFrom.VarName }} - {{ $.Target }}.{{ .MaxAgeFrom.FieldName }} = &{{ .MaxAgeFrom.VarName }}Tmp - {{- else }} + {{- if .MaxAgeFrom }} + {{- if .MaxAgeFrom.FieldPointer }} + if {{ .MaxAgeFrom.VarName }} != 0 { + {{ .MaxAgeFrom.VarName }}Tmp := {{ .MaxAgeFrom.VarName }} + {{ $.Target }}.{{ .MaxAgeFrom.FieldName }} = &{{ .MaxAgeFrom.VarName }}Tmp + } + {{- else }} {{ $.Target }}.{{ .MaxAgeFrom.FieldName }} = {{ .MaxAgeFrom.VarName }} - {{- end }}{{- end }} - {{- if .DomainFrom }}{{ if .DomainFrom.FieldPointer }} - {{ .DomainFrom.VarName }}Tmp := {{ .DomainFrom.VarName }} - {{ $.Target }}.{{ .DomainFrom.FieldName }} = &{{ .DomainFrom.VarName }}Tmp - {{- else }} + {{- end }} + {{- end }} + {{- if .DomainFrom }} + {{- if .DomainFrom.FieldPointer }} + if {{ .DomainFrom.VarName }} != "" { + {{ .DomainFrom.VarName }}Tmp := {{ .DomainFrom.VarName }} + {{ $.Target }}.{{ .DomainFrom.FieldName }} = &{{ .DomainFrom.VarName }}Tmp + } + {{- else }} {{ $.Target }}.{{ .DomainFrom.FieldName }} = {{ .DomainFrom.VarName }} - {{- end }}{{- end }} - {{- if .PathFrom }}{{ if .PathFrom.FieldPointer }} - {{ .PathFrom.VarName }}Tmp := {{ .PathFrom.VarName }} - {{ $.Target }}.{{ .PathFrom.FieldName }} = &{{ .PathFrom.VarName }}Tmp - {{- else }} + {{- end }} + {{- end }} + {{- if .PathFrom }} + {{- if .PathFrom.FieldPointer }} + if {{ .PathFrom.VarName }} != "" { + {{ .PathFrom.VarName }}Tmp := {{ .PathFrom.VarName }} + {{ $.Target }}.{{ .PathFrom.FieldName }} = &{{ .PathFrom.VarName }}Tmp + } + {{- else }} {{ $.Target }}.{{ .PathFrom.FieldName }} = {{ .PathFrom.VarName }} - {{- end }}{{- end }} - {{- if .SecureFrom }}{{ if .SecureFrom.FieldPointer }} - {{ .SecureFrom.VarName }}Tmp := {{ .SecureFrom.VarName }} - {{ $.Target }}.{{ .SecureFrom.FieldName }} = &{{ .SecureFrom.VarName }}Tmp - {{- else }} + {{- end }} + {{- end }} + {{- if .SecureFrom }} + {{- if .SecureFrom.FieldPointer }} + if {{ .SecureFrom.VarName }} { + {{ .SecureFrom.VarName }}Tmp := {{ .SecureFrom.VarName }} + {{ $.Target }}.{{ .SecureFrom.FieldName }} = &{{ .SecureFrom.VarName }}Tmp + } + {{- else }} {{ $.Target }}.{{ .SecureFrom.FieldName }} = {{ .SecureFrom.VarName }} - {{- end }}{{- end }} - {{- if .HTTPOnlyFrom }}{{ if .HTTPOnlyFrom.FieldPointer }} - {{ .HTTPOnlyFrom.VarName }}Tmp := {{ .HTTPOnlyFrom.VarName }} - {{ $.Target }}.{{ .HTTPOnlyFrom.FieldName }} = &{{ .HTTPOnlyFrom.VarName }}Tmp - {{- else }} + {{- end }} + {{- end }} + {{- if .HTTPOnlyFrom }} + {{- if .HTTPOnlyFrom.FieldPointer }} + if {{ .HTTPOnlyFrom.VarName }} { + {{ .HTTPOnlyFrom.VarName }}Tmp := {{ .HTTPOnlyFrom.VarName }} + {{ $.Target }}.{{ .HTTPOnlyFrom.FieldName }} = &{{ .HTTPOnlyFrom.VarName }}Tmp + } + {{- else }} {{ $.Target }}.{{ .HTTPOnlyFrom.FieldName }} = {{ .HTTPOnlyFrom.VarName }} - {{- end }}{{- end }} - {{- if .SameSiteFrom }}{{ if .SameSiteFrom.FieldPointer }} - {{ .SameSiteFrom.VarName }}Tmp := {{ .SameSiteFrom.VarName }} - {{ $.Target }}.{{ .SameSiteFrom.FieldName }} = &{{ .SameSiteFrom.VarName }}Tmp - {{- else }} + {{- end }} + {{- end }} + {{- if .SameSiteFrom }} + {{- if .SameSiteFrom.FieldPointer }} + if {{ .SameSiteFrom.VarName }} != "" && {{ .SameSiteFrom.VarName }} != "Default" { + {{ .SameSiteFrom.VarName }}Tmp := {{ .SameSiteFrom.VarName }} + {{ $.Target }}.{{ .SameSiteFrom.FieldName }} = &{{ .SameSiteFrom.VarName }}Tmp + } + {{- else }} {{ $.Target }}.{{ .SameSiteFrom.FieldName }} = {{ .SameSiteFrom.VarName }} - {{- end }}{{- end }} + {{- end }} + {{- end }} {{- end }} diff --git a/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional-all.go.golden b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional-all.go.golden new file mode 100644 index 0000000000..5d97825293 --- /dev/null +++ b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional-all.go.golden @@ -0,0 +1,93 @@ +// DecodeMethodCookieAttrBindingsOptionalAllResponse returns a decoder for +// responses returned by the ServiceCookieAttrBindingsOptionalAll +// MethodCookieAttrBindingsOptionalAll endpoint. restoreBody controls whether +// the response body should be restored after having been read. +func DecodeMethodCookieAttrBindingsOptionalAllResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (any, error) { + if restoreBody { + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer resp.Body.Close() + } + switch resp.StatusCode { + case http.StatusOK: + var ( + sessionID string + sessionIDRaw string + sessionIDExpiresIn int + sessionIDCookieDomain string + sessionIDCookiePath string + sessionIDIsSecure bool + sessionIDIsHTTPOnly bool + sessionIDSameSite string + + cookies = resp.Cookies() + err error + ) + for _, c := range cookies { + switch c.Name { + case "SID": + sessionIDRaw = c.Value + sessionIDExpiresIn = int(c.MaxAge) + sessionIDCookieDomain = c.Domain + sessionIDCookiePath = c.Path + sessionIDIsSecure = c.Secure + sessionIDIsHTTPOnly = c.HttpOnly + switch c.SameSite { + case http.SameSiteStrictMode: + sessionIDSameSite = "Strict" + case http.SameSiteLaxMode: + sessionIDSameSite = "Lax" + case http.SameSiteNoneMode: + sessionIDSameSite = "None" + default: + sessionIDSameSite = "Default" + } + } + } + if sessionIDRaw == "" { + err = goa.MergeErrors(err, goa.MissingFieldError("sessionID", "cookie")) + } + sessionID = sessionIDRaw + if err != nil { + return nil, goahttp.ErrValidationError("ServiceCookieAttrBindingsOptionalAll", "MethodCookieAttrBindingsOptionalAll", err) + } + res := NewMethodCookieAttrBindingsOptionalAllResultOK(sessionID) + if sessionIDExpiresIn != 0 { + sessionIDExpiresInTmp := sessionIDExpiresIn + res.ExpiresIn = &sessionIDExpiresInTmp + } + if sessionIDCookieDomain != "" { + sessionIDCookieDomainTmp := sessionIDCookieDomain + res.CookieDomain = &sessionIDCookieDomainTmp + } + if sessionIDCookiePath != "" { + sessionIDCookiePathTmp := sessionIDCookiePath + res.CookiePath = &sessionIDCookiePathTmp + } + if sessionIDIsSecure { + sessionIDIsSecureTmp := sessionIDIsSecure + res.IsSecure = &sessionIDIsSecureTmp + } + if sessionIDIsHTTPOnly { + sessionIDIsHTTPOnlyTmp := sessionIDIsHTTPOnly + res.IsHTTPOnly = &sessionIDIsHTTPOnlyTmp + } + if sessionIDSameSite != "" && sessionIDSameSite != "Default" { + sessionIDSameSiteTmp := sessionIDSameSite + res.SameSite = &sessionIDSameSiteTmp + } + return res, nil + default: + body, _ := io.ReadAll(resp.Body) + return nil, goahttp.ErrInvalidResponse("ServiceCookieAttrBindingsOptionalAll", "MethodCookieAttrBindingsOptionalAll", resp.StatusCode, string(body)) + } + } +} diff --git a/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional.go.golden b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional.go.golden index b27c6ebc29..0ca751a400 100644 --- a/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional.go.golden +++ b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional.go.golden @@ -43,10 +43,14 @@ func DecodeMethodCookieAttrBindingsOptionalResponse(decoder func(*http.Response) return nil, goahttp.ErrValidationError("ServiceCookieAttrBindingsOptional", "MethodCookieAttrBindingsOptional", err) } res := NewMethodCookieAttrBindingsOptionalResultOK(sessionID) - sessionIDExpiresInTmp := sessionIDExpiresIn - res.ExpiresIn = &sessionIDExpiresInTmp - sessionIDCookieDomainTmp := sessionIDCookieDomain - res.CookieDomain = &sessionIDCookieDomainTmp + if sessionIDExpiresIn != 0 { + sessionIDExpiresInTmp := sessionIDExpiresIn + res.ExpiresIn = &sessionIDExpiresInTmp + } + if sessionIDCookieDomain != "" { + sessionIDCookieDomainTmp := sessionIDCookieDomain + res.CookieDomain = &sessionIDCookieDomainTmp + } return res, nil default: body, _ := io.ReadAll(resp.Body) diff --git a/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-optional-all.go.golden b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-optional-all.go.golden new file mode 100644 index 0000000000..c81d17cfa0 --- /dev/null +++ b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-optional-all.go.golden @@ -0,0 +1,43 @@ +// EncodeMethodCookieAttrBindingsOptionalAllResponse returns an encoder for +// responses returned by the ServiceCookieAttrBindingsOptionalAll +// MethodCookieAttrBindingsOptionalAll endpoint. +func EncodeMethodCookieAttrBindingsOptionalAllResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { + return func(ctx context.Context, w http.ResponseWriter, v any) error { + res, _ := v.(*servicecookieattrbindingsoptionalall.MethodCookieAttrBindingsOptionalAllResult) + sessionID := res.SessionID + cookiesessionID := &http.Cookie{ + Name: "SID", + Value: sessionID, + } + if res.ExpiresIn != nil { + cookiesessionID.MaxAge = int(*res.ExpiresIn) + } + if res.CookiePath != nil { + cookiesessionID.Path = *res.CookiePath + } + if res.CookieDomain != nil { + cookiesessionID.Domain = *res.CookieDomain + } + if res.IsSecure != nil { + cookiesessionID.Secure = *res.IsSecure + } + if res.IsHTTPOnly != nil { + cookiesessionID.HttpOnly = *res.IsHTTPOnly + } + if res.SameSite != nil { + switch *res.SameSite { + case "Strict": + cookiesessionID.SameSite = http.SameSiteStrictMode + case "Lax": + cookiesessionID.SameSite = http.SameSiteLaxMode + case "None": + cookiesessionID.SameSite = http.SameSiteNoneMode + default: + cookiesessionID.SameSite = http.SameSiteDefaultMode + } + } + http.SetCookie(w, cookiesessionID) + w.WriteHeader(http.StatusOK) + return nil + } +} diff --git a/http/codegen/testdata/result_dsls.go b/http/codegen/testdata/result_dsls.go index 067dec3555..628c571ef0 100644 --- a/http/codegen/testdata/result_dsls.go +++ b/http/codegen/testdata/result_dsls.go @@ -1696,6 +1696,37 @@ var ResultCookieAttrBindingsOptionalDSL = func() { }) } +var ResultCookieAttrBindingsOptionalAllDSL = func() { + Service("ServiceCookieAttrBindingsOptionalAll", func() { + Method("MethodCookieAttrBindingsOptionalAll", func() { + Result(func() { + Attribute("sessionID", String) + Attribute("expiresIn", Int) + Attribute("cookieDomain", String) + Attribute("cookiePath", String) + Attribute("isSecure", Boolean) + Attribute("isHTTPOnly", Boolean) + Attribute("sameSite", String) + Required("sessionID") + }) + HTTP(func() { + POST("/login") + Response(StatusOK, func() { + Cookie("sessionID:SID", String) + CookieAttributes("sessionID", func() { + MaxAgeFrom("expiresIn") + DomainFrom("cookieDomain") + PathFrom("cookiePath") + SecureFrom("isSecure") + HTTPOnlyFrom("isHTTPOnly") + SameSiteFrom("sameSite") + }) + }) + }) + }) + }) +} + var ResultCookieAttrBindingsMixedDSL = func() { Service("ServiceCookieAttrBindingsMixed", func() { Method("MethodCookieAttrBindingsMixed", func() { From 402b800405afd5458be6ba5de57425aafae0bd93 Mon Sep 17 00:00:00 2001 From: Georges Haidar Date: Thu, 30 Apr 2026 11:16:22 +0100 Subject: [PATCH 05/10] fix(http): match lowercase CookieSameSite contract in cookie codegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- expr/http_cookie_test.go | 14 ++++++++++++++ .../templates/partial/cookie_attr_bindings.go.tpl | 2 +- http/codegen/templates/partial/response.go.tpl | 12 ++++++------ .../templates/partial/single_response.go.tpl | 8 ++++---- ...ode_cookie-attr-bindings-optional-all.go.golden | 10 +++++----- .../client_decode_cookie-attr-bindings.go.golden | 8 ++++---- ...ode_cookie-attr-bindings-optional-all.go.golden | 6 +++--- .../server_encode_cookie-attr-bindings.go.golden | 6 +++--- 8 files changed, 40 insertions(+), 26 deletions(-) diff --git a/expr/http_cookie_test.go b/expr/http_cookie_test.go index 804c767d7e..7b24f43505 100644 --- a/expr/http_cookie_test.go +++ b/expr/http_cookie_test.go @@ -80,6 +80,20 @@ func TestHTTPResponseCookieAttrBindings(t *testing.T) { } } +func TestCookieSameSiteConstantsAreLowercase(t *testing.T) { + cases := map[expr.CookieSameSiteValue]string{ + expr.CookieSameSiteStrict: "strict", + expr.CookieSameSiteLax: "lax", + expr.CookieSameSiteNone: "none", + expr.CookieSameSiteDefault: "default", + } + for got, want := range cases { + if string(got) != want { + t.Errorf("CookieSameSite constant = %q, want %q (the SameSiteFrom binding contract and the http codegen partials key on these exact lower-case values)", string(got), want) + } + } +} + func TestHTTPResponseBodyExcludesCookieAttrBindings(t *testing.T) { root := expr.RunDSL(t, testdata.CookieAttrBindingsDSL) resp := root.API.HTTP.Services[len(root.API.HTTP.Services)-1].HTTPEndpoints[0].Responses[0] diff --git a/http/codegen/templates/partial/cookie_attr_bindings.go.tpl b/http/codegen/templates/partial/cookie_attr_bindings.go.tpl index 5756dd7d49..6b69c9c783 100644 --- a/http/codegen/templates/partial/cookie_attr_bindings.go.tpl +++ b/http/codegen/templates/partial/cookie_attr_bindings.go.tpl @@ -51,7 +51,7 @@ {{- end }} {{- if .SameSiteFrom }} {{- if .SameSiteFrom.FieldPointer }} - if {{ .SameSiteFrom.VarName }} != "" && {{ .SameSiteFrom.VarName }} != "Default" { + if {{ .SameSiteFrom.VarName }} != "" && {{ .SameSiteFrom.VarName }} != "default" { {{ .SameSiteFrom.VarName }}Tmp := {{ .SameSiteFrom.VarName }} {{ $.Target }}.{{ .SameSiteFrom.FieldName }} = &{{ .SameSiteFrom.VarName }}Tmp } diff --git a/http/codegen/templates/partial/response.go.tpl b/http/codegen/templates/partial/response.go.tpl index 0f6c0149ad..5d98b835b3 100644 --- a/http/codegen/templates/partial/response.go.tpl +++ b/http/codegen/templates/partial/response.go.tpl @@ -148,11 +148,11 @@ {{- if .SameSiteFrom.FieldPointer }} if res{{ if $.ViewedResult }}.Projected{{ end }}.{{ .SameSiteFrom.FieldName }} != nil { switch *res{{ if $.ViewedResult }}.Projected{{ end }}.{{ .SameSiteFrom.FieldName }} { - case "Strict": + case "strict": cookie{{ .VarName }}.SameSite = http.SameSiteStrictMode - case "Lax": + case "lax": cookie{{ .VarName }}.SameSite = http.SameSiteLaxMode - case "None": + case "none": cookie{{ .VarName }}.SameSite = http.SameSiteNoneMode default: cookie{{ .VarName }}.SameSite = http.SameSiteDefaultMode @@ -160,11 +160,11 @@ } {{- else }} switch res{{ if $.ViewedResult }}.Projected{{ end }}.{{ .SameSiteFrom.FieldName }} { - case "Strict": + case "strict": cookie{{ .VarName }}.SameSite = http.SameSiteStrictMode - case "Lax": + case "lax": cookie{{ .VarName }}.SameSite = http.SameSiteLaxMode - case "None": + case "none": cookie{{ .VarName }}.SameSite = http.SameSiteNoneMode default: cookie{{ .VarName }}.SameSite = http.SameSiteDefaultMode diff --git a/http/codegen/templates/partial/single_response.go.tpl b/http/codegen/templates/partial/single_response.go.tpl index 176a7a872d..1149e590db 100644 --- a/http/codegen/templates/partial/single_response.go.tpl +++ b/http/codegen/templates/partial/single_response.go.tpl @@ -166,13 +166,13 @@ {{- if .SameSiteFrom }} switch c.SameSite { case http.SameSiteStrictMode: - {{ .SameSiteFrom.VarName }} = "Strict" + {{ .SameSiteFrom.VarName }} = "strict" case http.SameSiteLaxMode: - {{ .SameSiteFrom.VarName }} = "Lax" + {{ .SameSiteFrom.VarName }} = "lax" case http.SameSiteNoneMode: - {{ .SameSiteFrom.VarName }} = "None" + {{ .SameSiteFrom.VarName }} = "none" default: - {{ .SameSiteFrom.VarName }} = "Default" + {{ .SameSiteFrom.VarName }} = "default" } {{- end }} {{- end }} diff --git a/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional-all.go.golden b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional-all.go.golden index 5d97825293..ce852768ee 100644 --- a/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional-all.go.golden +++ b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional-all.go.golden @@ -42,13 +42,13 @@ func DecodeMethodCookieAttrBindingsOptionalAllResponse(decoder func(*http.Respon sessionIDIsHTTPOnly = c.HttpOnly switch c.SameSite { case http.SameSiteStrictMode: - sessionIDSameSite = "Strict" + sessionIDSameSite = "strict" case http.SameSiteLaxMode: - sessionIDSameSite = "Lax" + sessionIDSameSite = "lax" case http.SameSiteNoneMode: - sessionIDSameSite = "None" + sessionIDSameSite = "none" default: - sessionIDSameSite = "Default" + sessionIDSameSite = "default" } } } @@ -80,7 +80,7 @@ func DecodeMethodCookieAttrBindingsOptionalAllResponse(decoder func(*http.Respon sessionIDIsHTTPOnlyTmp := sessionIDIsHTTPOnly res.IsHTTPOnly = &sessionIDIsHTTPOnlyTmp } - if sessionIDSameSite != "" && sessionIDSameSite != "Default" { + if sessionIDSameSite != "" && sessionIDSameSite != "default" { sessionIDSameSiteTmp := sessionIDSameSite res.SameSite = &sessionIDSameSiteTmp } diff --git a/http/codegen/testdata/golden/client_decode_cookie-attr-bindings.go.golden b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings.go.golden index 88b8e6f2c9..3eb66e93cc 100644 --- a/http/codegen/testdata/golden/client_decode_cookie-attr-bindings.go.golden +++ b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings.go.golden @@ -42,13 +42,13 @@ func DecodeMethodCookieAttrBindingsResponse(decoder func(*http.Response) goahttp sessionIDIsHTTPOnly = c.HttpOnly switch c.SameSite { case http.SameSiteStrictMode: - sessionIDSameSite = "Strict" + sessionIDSameSite = "strict" case http.SameSiteLaxMode: - sessionIDSameSite = "Lax" + sessionIDSameSite = "lax" case http.SameSiteNoneMode: - sessionIDSameSite = "None" + sessionIDSameSite = "none" default: - sessionIDSameSite = "Default" + sessionIDSameSite = "default" } } } diff --git a/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-optional-all.go.golden b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-optional-all.go.golden index c81d17cfa0..9da6c55618 100644 --- a/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-optional-all.go.golden +++ b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-optional-all.go.golden @@ -26,11 +26,11 @@ func EncodeMethodCookieAttrBindingsOptionalAllResponse(encoder func(context.Cont } if res.SameSite != nil { switch *res.SameSite { - case "Strict": + case "strict": cookiesessionID.SameSite = http.SameSiteStrictMode - case "Lax": + case "lax": cookiesessionID.SameSite = http.SameSiteLaxMode - case "None": + case "none": cookiesessionID.SameSite = http.SameSiteNoneMode default: cookiesessionID.SameSite = http.SameSiteDefaultMode diff --git a/http/codegen/testdata/golden/server_encode_cookie-attr-bindings.go.golden b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings.go.golden index 0eb1b1183c..5db59bbb57 100644 --- a/http/codegen/testdata/golden/server_encode_cookie-attr-bindings.go.golden +++ b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings.go.golden @@ -14,11 +14,11 @@ func EncodeMethodCookieAttrBindingsResponse(encoder func(context.Context, http.R HttpOnly: res.IsHTTPOnly, } switch res.SameSite { - case "Strict": + case "strict": cookiesessionID.SameSite = http.SameSiteStrictMode - case "Lax": + case "lax": cookiesessionID.SameSite = http.SameSiteLaxMode - case "None": + case "none": cookiesessionID.SameSite = http.SameSiteNoneMode default: cookiesessionID.SameSite = http.SameSiteDefaultMode From 55cff6801b9eedcb2453071a4717955e1262174d Mon Sep 17 00:00:00 2001 From: Georges Haidar Date: Thu, 30 Apr 2026 11:26:29 +0100 Subject: [PATCH 06/10] refactor(http): extract cookie binding presence into testable helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../partial/cookie_attr_bindings.go.tpl | 30 +---- ...ookie-attr-bindings-optional-all.go.golden | 30 +---- ...de_cookie-attr-bindings-optional.go.golden | 10 +- http/cookie.go | 53 ++++++++ http/cookie_test.go | 118 ++++++++++++++++++ 5 files changed, 185 insertions(+), 56 deletions(-) create mode 100644 http/cookie.go create mode 100644 http/cookie_test.go diff --git a/http/codegen/templates/partial/cookie_attr_bindings.go.tpl b/http/codegen/templates/partial/cookie_attr_bindings.go.tpl index 6b69c9c783..fb04bead54 100644 --- a/http/codegen/templates/partial/cookie_attr_bindings.go.tpl +++ b/http/codegen/templates/partial/cookie_attr_bindings.go.tpl @@ -1,60 +1,42 @@ {{- range .Cookies }} {{- if .MaxAgeFrom }} {{- if .MaxAgeFrom.FieldPointer }} - if {{ .MaxAgeFrom.VarName }} != 0 { - {{ .MaxAgeFrom.VarName }}Tmp := {{ .MaxAgeFrom.VarName }} - {{ $.Target }}.{{ .MaxAgeFrom.FieldName }} = &{{ .MaxAgeFrom.VarName }}Tmp - } + {{ $.Target }}.{{ .MaxAgeFrom.FieldName }} = goahttp.CookieIntAttr({{ .MaxAgeFrom.VarName }}) {{- else }} {{ $.Target }}.{{ .MaxAgeFrom.FieldName }} = {{ .MaxAgeFrom.VarName }} {{- end }} {{- end }} {{- if .DomainFrom }} {{- if .DomainFrom.FieldPointer }} - if {{ .DomainFrom.VarName }} != "" { - {{ .DomainFrom.VarName }}Tmp := {{ .DomainFrom.VarName }} - {{ $.Target }}.{{ .DomainFrom.FieldName }} = &{{ .DomainFrom.VarName }}Tmp - } + {{ $.Target }}.{{ .DomainFrom.FieldName }} = goahttp.CookieStringAttr({{ .DomainFrom.VarName }}) {{- else }} {{ $.Target }}.{{ .DomainFrom.FieldName }} = {{ .DomainFrom.VarName }} {{- end }} {{- end }} {{- if .PathFrom }} {{- if .PathFrom.FieldPointer }} - if {{ .PathFrom.VarName }} != "" { - {{ .PathFrom.VarName }}Tmp := {{ .PathFrom.VarName }} - {{ $.Target }}.{{ .PathFrom.FieldName }} = &{{ .PathFrom.VarName }}Tmp - } + {{ $.Target }}.{{ .PathFrom.FieldName }} = goahttp.CookieStringAttr({{ .PathFrom.VarName }}) {{- else }} {{ $.Target }}.{{ .PathFrom.FieldName }} = {{ .PathFrom.VarName }} {{- end }} {{- end }} {{- if .SecureFrom }} {{- if .SecureFrom.FieldPointer }} - if {{ .SecureFrom.VarName }} { - {{ .SecureFrom.VarName }}Tmp := {{ .SecureFrom.VarName }} - {{ $.Target }}.{{ .SecureFrom.FieldName }} = &{{ .SecureFrom.VarName }}Tmp - } + {{ $.Target }}.{{ .SecureFrom.FieldName }} = goahttp.CookieBoolAttr({{ .SecureFrom.VarName }}) {{- else }} {{ $.Target }}.{{ .SecureFrom.FieldName }} = {{ .SecureFrom.VarName }} {{- end }} {{- end }} {{- if .HTTPOnlyFrom }} {{- if .HTTPOnlyFrom.FieldPointer }} - if {{ .HTTPOnlyFrom.VarName }} { - {{ .HTTPOnlyFrom.VarName }}Tmp := {{ .HTTPOnlyFrom.VarName }} - {{ $.Target }}.{{ .HTTPOnlyFrom.FieldName }} = &{{ .HTTPOnlyFrom.VarName }}Tmp - } + {{ $.Target }}.{{ .HTTPOnlyFrom.FieldName }} = goahttp.CookieBoolAttr({{ .HTTPOnlyFrom.VarName }}) {{- else }} {{ $.Target }}.{{ .HTTPOnlyFrom.FieldName }} = {{ .HTTPOnlyFrom.VarName }} {{- end }} {{- end }} {{- if .SameSiteFrom }} {{- if .SameSiteFrom.FieldPointer }} - if {{ .SameSiteFrom.VarName }} != "" && {{ .SameSiteFrom.VarName }} != "default" { - {{ .SameSiteFrom.VarName }}Tmp := {{ .SameSiteFrom.VarName }} - {{ $.Target }}.{{ .SameSiteFrom.FieldName }} = &{{ .SameSiteFrom.VarName }}Tmp - } + {{ $.Target }}.{{ .SameSiteFrom.FieldName }} = goahttp.CookieSameSiteAttr({{ .SameSiteFrom.VarName }}) {{- else }} {{ $.Target }}.{{ .SameSiteFrom.FieldName }} = {{ .SameSiteFrom.VarName }} {{- end }} diff --git a/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional-all.go.golden b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional-all.go.golden index ce852768ee..fb58a972dd 100644 --- a/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional-all.go.golden +++ b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional-all.go.golden @@ -60,30 +60,12 @@ func DecodeMethodCookieAttrBindingsOptionalAllResponse(decoder func(*http.Respon return nil, goahttp.ErrValidationError("ServiceCookieAttrBindingsOptionalAll", "MethodCookieAttrBindingsOptionalAll", err) } res := NewMethodCookieAttrBindingsOptionalAllResultOK(sessionID) - if sessionIDExpiresIn != 0 { - sessionIDExpiresInTmp := sessionIDExpiresIn - res.ExpiresIn = &sessionIDExpiresInTmp - } - if sessionIDCookieDomain != "" { - sessionIDCookieDomainTmp := sessionIDCookieDomain - res.CookieDomain = &sessionIDCookieDomainTmp - } - if sessionIDCookiePath != "" { - sessionIDCookiePathTmp := sessionIDCookiePath - res.CookiePath = &sessionIDCookiePathTmp - } - if sessionIDIsSecure { - sessionIDIsSecureTmp := sessionIDIsSecure - res.IsSecure = &sessionIDIsSecureTmp - } - if sessionIDIsHTTPOnly { - sessionIDIsHTTPOnlyTmp := sessionIDIsHTTPOnly - res.IsHTTPOnly = &sessionIDIsHTTPOnlyTmp - } - if sessionIDSameSite != "" && sessionIDSameSite != "default" { - sessionIDSameSiteTmp := sessionIDSameSite - res.SameSite = &sessionIDSameSiteTmp - } + res.ExpiresIn = goahttp.CookieIntAttr(sessionIDExpiresIn) + res.CookieDomain = goahttp.CookieStringAttr(sessionIDCookieDomain) + res.CookiePath = goahttp.CookieStringAttr(sessionIDCookiePath) + res.IsSecure = goahttp.CookieBoolAttr(sessionIDIsSecure) + res.IsHTTPOnly = goahttp.CookieBoolAttr(sessionIDIsHTTPOnly) + res.SameSite = goahttp.CookieSameSiteAttr(sessionIDSameSite) return res, nil default: body, _ := io.ReadAll(resp.Body) diff --git a/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional.go.golden b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional.go.golden index 0ca751a400..2f013b6a5b 100644 --- a/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional.go.golden +++ b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional.go.golden @@ -43,14 +43,8 @@ func DecodeMethodCookieAttrBindingsOptionalResponse(decoder func(*http.Response) return nil, goahttp.ErrValidationError("ServiceCookieAttrBindingsOptional", "MethodCookieAttrBindingsOptional", err) } res := NewMethodCookieAttrBindingsOptionalResultOK(sessionID) - if sessionIDExpiresIn != 0 { - sessionIDExpiresInTmp := sessionIDExpiresIn - res.ExpiresIn = &sessionIDExpiresInTmp - } - if sessionIDCookieDomain != "" { - sessionIDCookieDomainTmp := sessionIDCookieDomain - res.CookieDomain = &sessionIDCookieDomainTmp - } + res.ExpiresIn = goahttp.CookieIntAttr(sessionIDExpiresIn) + res.CookieDomain = goahttp.CookieStringAttr(sessionIDCookieDomain) return res, nil default: body, _ := io.ReadAll(resp.Body) diff --git a/http/cookie.go b/http/cookie.go new file mode 100644 index 0000000000..c483737715 --- /dev/null +++ b/http/cookie.go @@ -0,0 +1,53 @@ +package http + +// CookieIntAttr returns a pointer to v unless v is zero, in which case it +// returns nil. The HTTP transport calls this from generated client decoders +// for pointer-bound CookieAttributes Max-Age bindings: net/http parses an +// absent Max-Age cookie attribute as the zero value, so surfacing the +// attribute as nil rather than &0 lets clients tell "no Max-Age" apart from +// the unrelated semantic of "Max-Age explicitly set to zero". The two cases +// are inherently indistinguishable through net/http; treating zero as absent +// matches the more common server intent. +func CookieIntAttr[T ~int | ~int32 | ~int64 | ~uint | ~uint32 | ~uint64](v T) *T { + if v == 0 { + return nil + } + return &v +} + +// CookieStringAttr returns a pointer to v unless v is the empty string, in +// which case it returns nil. The HTTP transport calls this from generated +// client decoders for pointer-bound Domain and Path CookieAttributes +// bindings: net/http parses an absent attribute as the empty string, and the +// helper folds that into nil. +func CookieStringAttr(v string) *string { + if v == "" { + return nil + } + return &v +} + +// CookieBoolAttr returns a pointer to v unless v is false, in which case it +// returns nil. The HTTP transport calls this from generated client decoders +// for pointer-bound Secure and HttpOnly CookieAttributes bindings: those +// attributes are flag-only on the wire (Secure / HttpOnly is either present +// or absent — there is no "Secure=false"), so net/http reports false for an +// absent flag and the helper folds that into nil. +func CookieBoolAttr(v bool) *bool { + if !v { + return nil + } + return &v +} + +// CookieSameSiteAttr returns a pointer to v unless v is empty or the +// canonical "default" sentinel that net/http produces for cookies without a +// SameSite attribute (or with the literal SameSite=Default token). The HTTP +// transport calls this from generated client decoders for pointer-bound +// SameSiteFrom bindings. +func CookieSameSiteAttr(v string) *string { + if v == "" || v == "default" { + return nil + } + return &v +} diff --git a/http/cookie_test.go b/http/cookie_test.go new file mode 100644 index 0000000000..2f252a4789 --- /dev/null +++ b/http/cookie_test.go @@ -0,0 +1,118 @@ +package http + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCookieIntAttr(t *testing.T) { + cases := []struct { + name string + in int + want *int + }{ + {"zero-yields-nil", 0, nil}, + {"positive-yields-pointer", 60, intPtr(60)}, + {"negative-yields-pointer", -1, intPtr(-1)}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := CookieIntAttr(c.in) + if c.want == nil { + require.Nil(t, got) + return + } + require.NotNil(t, got) + require.Equal(t, *c.want, *got) + }) + } + + t.Run("works-for-int64", func(t *testing.T) { + require.Nil(t, CookieIntAttr[int64](0)) + got := CookieIntAttr[int64](42) + require.NotNil(t, got) + require.EqualValues(t, 42, *got) + }) + + t.Run("works-for-uint", func(t *testing.T) { + require.Nil(t, CookieIntAttr[uint](0)) + got := CookieIntAttr[uint](7) + require.NotNil(t, got) + require.EqualValues(t, 7, *got) + }) +} + +func TestCookieStringAttr(t *testing.T) { + cases := []struct { + name string + in string + want *string + }{ + {"empty-yields-nil", "", nil}, + {"non-empty-yields-pointer", "example.com", strPtr("example.com")}, + {"whitespace-yields-pointer", " ", strPtr(" ")}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := CookieStringAttr(c.in) + if c.want == nil { + require.Nil(t, got) + return + } + require.NotNil(t, got) + require.Equal(t, *c.want, *got) + }) + } +} + +func TestCookieBoolAttr(t *testing.T) { + cases := []struct { + name string + in bool + want *bool + }{ + {"false-yields-nil", false, nil}, + {"true-yields-pointer", true, boolPtr(true)}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := CookieBoolAttr(c.in) + if c.want == nil { + require.Nil(t, got) + return + } + require.NotNil(t, got) + require.Equal(t, *c.want, *got) + }) + } +} + +func TestCookieSameSiteAttr(t *testing.T) { + cases := []struct { + name string + in string + want *string + }{ + {"empty-yields-nil", "", nil}, + {"default-yields-nil", "default", nil}, + {"strict-yields-pointer", "strict", strPtr("strict")}, + {"lax-yields-pointer", "lax", strPtr("lax")}, + {"none-yields-pointer", "none", strPtr("none")}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := CookieSameSiteAttr(c.in) + if c.want == nil { + require.Nil(t, got) + return + } + require.NotNil(t, got) + require.Equal(t, *c.want, *got) + }) + } +} + +func intPtr(v int) *int { return &v } +func strPtr(v string) *string { return &v } +func boolPtr(v bool) *bool { return &v } From 56b9a530046a86b63eb7f6305043ad06f5a8ce43 Mon Sep 17 00:00:00 2001 From: Georges Haidar Date: Thu, 30 Apr 2026 11:38:24 +0100 Subject: [PATCH 07/10] style(expr): return nil from cookie binding validator on no-meta path 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) --- expr/http_response.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/expr/http_response.go b/expr/http_response.go index 930de5d72c..9df26e5b2d 100644 --- a/expr/http_response.go +++ b/expr/http_response.go @@ -399,10 +399,10 @@ func (r *HTTPResponseExpr) mapUnmappedAttrs(svcAtt *AttributeExpr) { // of the kind expected by the bound cookie property. typeNoun is the noun used // in diagnostic messages ("result type" or "error type"). func validateCookieAttrBindings(r eval.Expression, cookieName string, cookieAttr *AttributeExpr, attributeType func(string) DataType, typeNoun, inview string) *eval.ValidationErrors { - verr := new(eval.ValidationErrors) if cookieAttr == nil || len(cookieAttr.Meta) == 0 { - return verr + return nil } + verr := new(eval.ValidationErrors) bindings := []struct { key string kind string From c60287f68b8dccda6a3134e405cb58cf3c68a5ce Mon Sep 17 00:00:00 2001 From: Georges Haidar Date: Thu, 30 Apr 2026 12:26:07 +0100 Subject: [PATCH 08/10] consolidate utilities --- http/cookie_test.go | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/http/cookie_test.go b/http/cookie_test.go index 2f252a4789..9a7352762b 100644 --- a/http/cookie_test.go +++ b/http/cookie_test.go @@ -6,6 +6,8 @@ import ( "github.com/stretchr/testify/require" ) +func ptr[T any](v T) *T { return &v } + func TestCookieIntAttr(t *testing.T) { cases := []struct { name string @@ -13,8 +15,8 @@ func TestCookieIntAttr(t *testing.T) { want *int }{ {"zero-yields-nil", 0, nil}, - {"positive-yields-pointer", 60, intPtr(60)}, - {"negative-yields-pointer", -1, intPtr(-1)}, + {"positive-yields-pointer", 60, ptr(60)}, + {"negative-yields-pointer", -1, ptr(-1)}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -50,8 +52,8 @@ func TestCookieStringAttr(t *testing.T) { want *string }{ {"empty-yields-nil", "", nil}, - {"non-empty-yields-pointer", "example.com", strPtr("example.com")}, - {"whitespace-yields-pointer", " ", strPtr(" ")}, + {"non-empty-yields-pointer", "example.com", ptr("example.com")}, + {"whitespace-yields-pointer", " ", ptr(" ")}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -73,7 +75,7 @@ func TestCookieBoolAttr(t *testing.T) { want *bool }{ {"false-yields-nil", false, nil}, - {"true-yields-pointer", true, boolPtr(true)}, + {"true-yields-pointer", true, ptr(true)}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -96,9 +98,9 @@ func TestCookieSameSiteAttr(t *testing.T) { }{ {"empty-yields-nil", "", nil}, {"default-yields-nil", "default", nil}, - {"strict-yields-pointer", "strict", strPtr("strict")}, - {"lax-yields-pointer", "lax", strPtr("lax")}, - {"none-yields-pointer", "none", strPtr("none")}, + {"strict-yields-pointer", "strict", ptr("strict")}, + {"lax-yields-pointer", "lax", ptr("lax")}, + {"none-yields-pointer", "none", ptr("none")}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -112,7 +114,3 @@ func TestCookieSameSiteAttr(t *testing.T) { }) } } - -func intPtr(v int) *int { return &v } -func strPtr(v string) *string { return &v } -func boolPtr(v bool) *bool { return &v } From b287fcab4bbf73cda5fdaa3ca6343826476a491f Mon Sep 17 00:00:00 2001 From: Georges Haidar Date: Thu, 30 Apr 2026 12:34:53 +0100 Subject: [PATCH 09/10] docs(http): clarify CookieData binding-vs-literal precedence on each 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) --- http/codegen/service_data.go | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/http/codegen/service_data.go b/http/codegen/service_data.go index 64f528baf9..3c38a4f39f 100644 --- a/http/codegen/service_data.go +++ b/http/codegen/service_data.go @@ -530,21 +530,29 @@ type ( SameSite string // MaxAgeFrom binds the cookie "Max-Age" attribute to a result // type attribute populated at runtime by the service method. + // When set it takes precedence over the MaxAge literal: codegen + // emits the bound expression and ignores MaxAge for this cookie, + // so exactly one of the two ever reaches the wire. MaxAgeFrom *CookieAttrBinding // DomainFrom binds the cookie "Domain" attribute to a result - // type attribute. + // type attribute. When set it takes precedence over the Domain + // literal; exactly one of the two reaches the wire. DomainFrom *CookieAttrBinding // PathFrom binds the cookie "Path" attribute to a result type - // attribute. + // attribute. When set it takes precedence over the Path literal; + // exactly one of the two reaches the wire. PathFrom *CookieAttrBinding // SecureFrom binds the cookie "Secure" attribute to a result - // type attribute. + // type attribute. When set it takes precedence over the Secure + // literal; exactly one of the two reaches the wire. SecureFrom *CookieAttrBinding // HTTPOnlyFrom binds the cookie "HttpOnly" attribute to a result - // type attribute. + // type attribute. When set it takes precedence over the HTTPOnly + // literal; exactly one of the two reaches the wire. HTTPOnlyFrom *CookieAttrBinding // SameSiteFrom binds the cookie "SameSite" attribute to a result - // type attribute. + // type attribute. When set it takes precedence over the SameSite + // literal; exactly one of the two reaches the wire. SameSiteFrom *CookieAttrBinding } From 040bebe1d1534dbf960e5f2256984d9136a6663c Mon Sep 17 00:00:00 2001 From: Georges Haidar Date: Fri, 1 May 2026 13:10:02 +0100 Subject: [PATCH 10/10] refactor(dsl): drop From suffix from cookie attribute binders 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) --- dsl/http.go | 108 +++++++++++++-------------- expr/http_cookie_test.go | 2 +- expr/testdata/cookie_dsls.go | 24 +++--- http/codegen/testdata/result_dsls.go | 36 ++++----- http/cookie.go | 2 +- 5 files changed, 84 insertions(+), 88 deletions(-) diff --git a/dsl/http.go b/dsl/http.go index 593cf2dc61..ad2cb87108 100644 --- a/dsl/http.go +++ b/dsl/http.go @@ -235,8 +235,12 @@ func Produces(args ...string) { // GET("/./") to generate a path such as '/foo/'. // // Path must appear in an API HTTP expression or a Service HTTP expression. +// Path may also appear in a CookieAttributes expression to bind the enclosing +// cookie's "Path" attribute to a result type attribute (see CookieAttributes). // -// Path accepts one argument: the HTTP path prefix. +// Path accepts one argument: the HTTP path prefix, or — inside CookieAttributes +// — the name of a result type attribute of type String to bind the cookie's +// "Path" attribute to. func Path(val string) { switch def := eval.Current().(type) { case *expr.RootExpr: @@ -258,6 +262,8 @@ func Path(val string) { } } def.Paths = append(def.Paths, val) + case *cookieAttrBindingsExpr: + cookieFromBinding("path", val) default: eval.IncompatibleDSL() } @@ -651,14 +657,14 @@ func (c *cookieAttrBindingsExpr) EvalName() string { } // CookieAttributes opens a per-cookie attribute binding context for the named -// cookie defined in the enclosing Response. Inside the closure, the -// MaxAgeFrom, DomainFrom, PathFrom, SecureFrom, HTTPOnlyFrom and SameSiteFrom -// functions bind cookie attributes (Max-Age, Domain, Path, Secure, HttpOnly, -// SameSite) to result type attributes computed at runtime by the service -// method. The bindings apply only to the named cookie. The server populates -// the cookie attributes from the bound result fields when emitting the -// response, and the client decodes the corresponding HTTP cookie attributes -// back into the same result fields. +// cookie defined in the enclosing Response. Inside the closure, the MaxAge, +// Domain, Path, Secure, HTTPOnly and SameSite functions bind cookie +// attributes (Max-Age, Domain, Path, Secure, HttpOnly, SameSite) to result +// type attributes computed at runtime by the service method. The bindings +// apply only to the named cookie. The server populates the cookie attributes +// from the bound result fields when emitting the response, and the client +// decodes the corresponding HTTP cookie attributes back into the same result +// fields. // // Presence semantics on the client: when the bound result attribute is a // pointer (optional, no default), the client decoder treats a zero-valued @@ -699,12 +705,12 @@ func (c *cookieAttrBindingsExpr) EvalName() string { // Response(StatusOK, func() { // Cookie("sessionID:SID", String) // CookieAttributes("sessionID", func() { -// MaxAgeFrom("expiresIn") -// DomainFrom("cookieDomain") -// PathFrom("cookiePath") -// SecureFrom("isSecure") -// HTTPOnlyFrom("isHTTPOnly") -// SameSiteFrom("sameSite") +// MaxAge("expiresIn") +// Domain("cookieDomain") +// Path("cookiePath") +// Secure("isSecure") +// HTTPOnly("isHTTPOnly") +// SameSite("sameSite") // }) // }) // }) @@ -736,65 +742,55 @@ func CookieAttributes(name string, fn func()) { eval.Execute(fn, &cookieAttrBindingsExpr{Attr: attr, Name: name}) } -// MaxAgeFrom binds the enclosing cookie's "Max-Age" attribute to a result -// type attribute. The referenced attribute must be of an integer primitive -// type. The server populates http.Cookie.MaxAge from this result field; the -// client decodes c.MaxAge back into the same field. +// MaxAge binds the enclosing cookie's "Max-Age" attribute to a result type +// attribute. The referenced attribute must be of an integer primitive type. +// The server populates http.Cookie.MaxAge from this result field; the client +// decodes c.MaxAge back into the same field. // -// MaxAgeFrom must appear in a CookieAttributes expression. -func MaxAgeFrom(attr string) { +// MaxAge must appear in a CookieAttributes expression. +func MaxAge(attr string) { cookieFromBinding("max-age", attr) } -// DomainFrom binds the enclosing cookie's "Domain" attribute to a result -// type attribute. The referenced attribute must be of type String. The server +// Domain binds the enclosing cookie's "Domain" attribute to a result type +// attribute. The referenced attribute must be of type String. The server // populates http.Cookie.Domain from this result field; the client decodes // c.Domain back into the same field. // -// DomainFrom must appear in a CookieAttributes expression. -func DomainFrom(attr string) { +// Domain must appear in a CookieAttributes expression. +func Domain(attr string) { cookieFromBinding("domain", attr) } -// PathFrom binds the enclosing cookie's "Path" attribute to a result type -// attribute. The referenced attribute must be of type String. The server -// populates http.Cookie.Path from this result field; the client decodes -// c.Path back into the same field. -// -// PathFrom must appear in a CookieAttributes expression. -func PathFrom(attr string) { - cookieFromBinding("path", attr) -} - -// SecureFrom binds the enclosing cookie's "Secure" attribute to a result -// type attribute. The referenced attribute must be of type Boolean. The -// server populates http.Cookie.Secure from this result field; the client -// decodes c.Secure back into the same field. +// Secure binds the enclosing cookie's "Secure" attribute to a result type +// attribute. The referenced attribute must be of type Boolean. The server +// populates http.Cookie.Secure from this result field; the client decodes +// c.Secure back into the same field. // -// SecureFrom must appear in a CookieAttributes expression. -func SecureFrom(attr string) { +// Secure must appear in a CookieAttributes expression. +func Secure(attr string) { cookieFromBinding("secure", attr) } -// HTTPOnlyFrom binds the enclosing cookie's "HttpOnly" attribute to a result -// type attribute. The referenced attribute must be of type Boolean. The -// server populates http.Cookie.HttpOnly from this result field; the client -// decodes c.HttpOnly back into the same field. +// HTTPOnly binds the enclosing cookie's "HttpOnly" attribute to a result type +// attribute. The referenced attribute must be of type Boolean. The server +// populates http.Cookie.HttpOnly from this result field; the client decodes +// c.HttpOnly back into the same field. // -// HTTPOnlyFrom must appear in a CookieAttributes expression. -func HTTPOnlyFrom(attr string) { +// HTTPOnly must appear in a CookieAttributes expression. +func HTTPOnly(attr string) { cookieFromBinding("http-only", attr) } -// SameSiteFrom binds the enclosing cookie's "SameSite" attribute to a result -// type attribute. The referenced attribute must be of type String and at -// runtime must hold one of the values of CookieSameSiteStrict, -// CookieSameSiteLax, CookieSameSiteNone or CookieSameSiteDefault. The server -// populates http.Cookie.SameSite from this result field; the client decodes -// c.SameSite back into the same field. -// -// SameSiteFrom must appear in a CookieAttributes expression. -func SameSiteFrom(attr string) { +// SameSite binds the enclosing cookie's "SameSite" attribute to a result type +// attribute. The referenced attribute must be of type String and at runtime +// must hold one of the values of CookieSameSiteStrict, CookieSameSiteLax, +// CookieSameSiteNone or CookieSameSiteDefault. The server populates +// http.Cookie.SameSite from this result field; the client decodes c.SameSite +// back into the same field. +// +// SameSite must appear in a CookieAttributes expression. +func SameSite(attr string) { cookieFromBinding("same-site", attr) } diff --git a/expr/http_cookie_test.go b/expr/http_cookie_test.go index 7b24f43505..eadb32db42 100644 --- a/expr/http_cookie_test.go +++ b/expr/http_cookie_test.go @@ -89,7 +89,7 @@ func TestCookieSameSiteConstantsAreLowercase(t *testing.T) { } for got, want := range cases { if string(got) != want { - t.Errorf("CookieSameSite constant = %q, want %q (the SameSiteFrom binding contract and the http codegen partials key on these exact lower-case values)", string(got), want) + t.Errorf("CookieSameSite constant = %q, want %q (the SameSite binding contract and the http codegen partials key on these exact lower-case values)", string(got), want) } } } diff --git a/expr/testdata/cookie_dsls.go b/expr/testdata/cookie_dsls.go index 57ca0a5d59..6a8244b8fe 100644 --- a/expr/testdata/cookie_dsls.go +++ b/expr/testdata/cookie_dsls.go @@ -163,12 +163,12 @@ var CookieAttrBindingsDSL = func() { Response(StatusOK, func() { Cookie("cookie") CookieAttributes("cookie", func() { - MaxAgeFrom("expiresIn") - DomainFrom("cookieDomain") - PathFrom("cookiePath") - SecureFrom("isSecure") - HTTPOnlyFrom("isHTTPOnly") - SameSiteFrom("sameSite") + MaxAge("expiresIn") + Domain("cookieDomain") + Path("cookiePath") + Secure("isSecure") + HTTPOnly("isHTTPOnly") + SameSite("sameSite") }) }) }) @@ -188,7 +188,7 @@ var CookieAttrBindingMissingAttrDSL = func() { Response(StatusOK, func() { Cookie("cookie") CookieAttributes("cookie", func() { - MaxAgeFrom("doesNotExist") + MaxAge("doesNotExist") }) }) }) @@ -209,7 +209,7 @@ var CookieAttrBindingWrongTypeDSL = func() { Response(StatusOK, func() { Cookie("cookie") CookieAttributes("cookie", func() { - MaxAgeFrom("expiresIn") + MaxAge("expiresIn") }) }) }) @@ -229,7 +229,7 @@ var CookieAttrBindingUndeclaredDSL = func() { Response(StatusOK, func() { Cookie("cookie") CookieAttributes("notDeclared", func() { - MaxAgeFrom("cookie") + MaxAge("cookie") }) }) }) @@ -253,7 +253,7 @@ var CookieAttrBindingErrorDSL = func() { Response("session_invalid", StatusUnauthorized, func() { Cookie("reason") CookieAttributes("reason", func() { - MaxAgeFrom("retryAfter") + MaxAge("retryAfter") }) }) }) @@ -276,7 +276,7 @@ var CookieAttrBindingErrorMissingAttrDSL = func() { Response("session_invalid", StatusUnauthorized, func() { Cookie("reason") CookieAttributes("reason", func() { - MaxAgeFrom("doesNotExist") + MaxAge("doesNotExist") }) }) }) @@ -300,7 +300,7 @@ var CookieAttrBindingErrorWrongTypeDSL = func() { Response("session_invalid", StatusUnauthorized, func() { Cookie("reason") CookieAttributes("reason", func() { - MaxAgeFrom("retryAfter") + MaxAge("retryAfter") }) }) }) diff --git a/http/codegen/testdata/result_dsls.go b/http/codegen/testdata/result_dsls.go index 628c571ef0..a07ea9b68e 100644 --- a/http/codegen/testdata/result_dsls.go +++ b/http/codegen/testdata/result_dsls.go @@ -1660,12 +1660,12 @@ var ResultCookieAttrBindingsDSL = func() { Response(StatusOK, func() { Cookie("sessionID:SID", String) CookieAttributes("sessionID", func() { - MaxAgeFrom("expiresIn") - DomainFrom("cookieDomain") - PathFrom("cookiePath") - SecureFrom("isSecure") - HTTPOnlyFrom("isHTTPOnly") - SameSiteFrom("sameSite") + MaxAge("expiresIn") + Domain("cookieDomain") + Path("cookiePath") + Secure("isSecure") + HTTPOnly("isHTTPOnly") + SameSite("sameSite") }) }) }) @@ -1687,8 +1687,8 @@ var ResultCookieAttrBindingsOptionalDSL = func() { Response(StatusOK, func() { Cookie("sessionID:SID", String) CookieAttributes("sessionID", func() { - MaxAgeFrom("expiresIn") - DomainFrom("cookieDomain") + MaxAge("expiresIn") + Domain("cookieDomain") }) }) }) @@ -1714,12 +1714,12 @@ var ResultCookieAttrBindingsOptionalAllDSL = func() { Response(StatusOK, func() { Cookie("sessionID:SID", String) CookieAttributes("sessionID", func() { - MaxAgeFrom("expiresIn") - DomainFrom("cookieDomain") - PathFrom("cookiePath") - SecureFrom("isSecure") - HTTPOnlyFrom("isHTTPOnly") - SameSiteFrom("sameSite") + MaxAge("expiresIn") + Domain("cookieDomain") + Path("cookiePath") + Secure("isSecure") + HTTPOnly("isHTTPOnly") + SameSite("sameSite") }) }) }) @@ -1742,7 +1742,7 @@ var ResultCookieAttrBindingsMixedDSL = func() { Cookie("a:A", String) Cookie("b:B", String) CookieAttributes("a", func() { - MaxAgeFrom("expiresIn") + MaxAge("expiresIn") }) CookieMaxAge(3600) CookieDomain("goa.design") @@ -1768,7 +1768,7 @@ var ResultCookieAttrBindingsBodyDSL = func() { Response(StatusOK, func() { Cookie("sessionID:SID", String) CookieAttributes("sessionID", func() { - MaxAgeFrom("expiresIn") + MaxAge("expiresIn") }) }) }) @@ -1793,8 +1793,8 @@ var ResultCookieAttrBindingsErrorDSL = func() { Response("session_invalid", StatusUnauthorized, func() { Cookie("reason:Reason", String) CookieAttributes("reason", func() { - MaxAgeFrom("retryAfter") - PathFrom("loginPath") + MaxAge("retryAfter") + Path("loginPath") }) }) }) diff --git a/http/cookie.go b/http/cookie.go index c483737715..6a034b3468 100644 --- a/http/cookie.go +++ b/http/cookie.go @@ -44,7 +44,7 @@ func CookieBoolAttr(v bool) *bool { // canonical "default" sentinel that net/http produces for cookies without a // SameSite attribute (or with the literal SameSite=Default token). The HTTP // transport calls this from generated client decoders for pointer-bound -// SameSiteFrom bindings. +// SameSite bindings. func CookieSameSiteAttr(v string) *string { if v == "" || v == "default" { return nil