diff --git a/dsl/http.go b/dsl/http.go index 40fd5c90f1..ad2cb87108 100644 --- a/dsl/http.go +++ b/dsl/http.go @@ -1,6 +1,7 @@ package dsl import ( + "fmt" "strconv" "strings" @@ -234,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: @@ -257,6 +262,8 @@ func Path(val string) { } } def.Paths = append(def.Paths, val) + case *cookieAttrBindingsExpr: + cookieFromBinding("path", val) default: eval.IncompatibleDSL() } @@ -633,6 +640,175 @@ 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 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 +// 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. +// +// 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() { +// MaxAge("expiresIn") +// Domain("cookieDomain") +// Path("cookiePath") +// Secure("isSecure") +// HTTPOnly("isHTTPOnly") +// SameSite("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}) +} + +// 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. +// +// MaxAge must appear in a CookieAttributes expression. +func MaxAge(attr string) { + cookieFromBinding("max-age", attr) +} + +// 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. +// +// Domain must appear in a CookieAttributes expression. +func Domain(attr string) { + cookieFromBinding("domain", attr) +} + +// 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. +// +// Secure must appear in a CookieAttributes expression. +func Secure(attr string) { + cookieFromBinding("secure", attr) +} + +// 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. +// +// HTTPOnly must appear in a CookieAttributes expression. +func HTTPOnly(attr string) { + cookieFromBinding("http-only", attr) +} + +// 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) +} + +// 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_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 0f1289e0db..eadb32db42 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,144 @@ 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 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 SameSite 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] + 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] + 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 + 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\"", + }, + { + "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) { + 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_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 a6bb093beb..9df26e5b2d 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, "result type", inview)) } default: if len(*AsObject(r.Cookies.Type)) > 1 { @@ -391,6 +392,61 @@ 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 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 { + if cookieAttr == nil || len(cookieAttr.Meta) == 0 { + return nil + } + verr := new(eval.ValidationErrors) + 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 := attributeType(attrName) + if t == nil { + 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) { + 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..6a8244b8fe 100644 --- a/expr/testdata/cookie_dsls.go +++ b/expr/testdata/cookie_dsls.go @@ -143,3 +143,167 @@ 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() { + MaxAge("expiresIn") + Domain("cookieDomain") + Path("cookiePath") + Secure("isSecure") + HTTPOnly("isHTTPOnly") + SameSite("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() { + MaxAge("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() { + MaxAge("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() { + MaxAge("cookie") + }) + }) + }) + }) + }) +} + +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() { + MaxAge("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() { + MaxAge("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() { + MaxAge("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 398b149ea2..9a346c081c 100644 --- a/http/codegen/client_decode_test.go +++ b/http/codegen/client_decode_test.go @@ -32,6 +32,13 @@ 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-optional-all", testdata.ResultCookieAttrBindingsOptionalAllDSL}, + {"cookie-attr-bindings-mixed", testdata.ResultCookieAttrBindingsMixedDSL}, + {"cookie-attr-bindings-body", testdata.ResultCookieAttrBindingsBodyDSL}, + {"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 31b712c194..6af70929fa 100644 --- a/http/codegen/server_encode_test.go +++ b/http/codegen/server_encode_test.go @@ -87,6 +87,13 @@ 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-optional-all", testdata.ResultCookieAttrBindingsOptionalAllDSL}, + {"cookie-attr-bindings-mixed", testdata.ResultCookieAttrBindingsMixedDSL}, + {"cookie-attr-bindings-body", testdata.ResultCookieAttrBindingsBodyDSL}, + {"cookie-attr-bindings-error", testdata.ResultCookieAttrBindingsErrorDSL}, } 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..3c38a4f39f 100644 --- a/http/codegen/service_data.go +++ b/http/codegen/service_data.go @@ -528,6 +528,59 @@ 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. + // 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. 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. 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. 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. 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. When set it takes precedence over the SameSite + // literal; exactly one of the two reaches the wire. + 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 +2663,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 +2734,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.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..fb04bead54 --- /dev/null +++ b/http/codegen/templates/partial/cookie_attr_bindings.go.tpl @@ -0,0 +1,44 @@ +{{- range .Cookies }} + {{- if .MaxAgeFrom }} + {{- if .MaxAgeFrom.FieldPointer }} + {{ $.Target }}.{{ .MaxAgeFrom.FieldName }} = goahttp.CookieIntAttr({{ .MaxAgeFrom.VarName }}) + {{- else }} + {{ $.Target }}.{{ .MaxAgeFrom.FieldName }} = {{ .MaxAgeFrom.VarName }} + {{- end }} + {{- end }} + {{- if .DomainFrom }} + {{- if .DomainFrom.FieldPointer }} + {{ $.Target }}.{{ .DomainFrom.FieldName }} = goahttp.CookieStringAttr({{ .DomainFrom.VarName }}) + {{- else }} + {{ $.Target }}.{{ .DomainFrom.FieldName }} = {{ .DomainFrom.VarName }} + {{- end }} + {{- end }} + {{- if .PathFrom }} + {{- if .PathFrom.FieldPointer }} + {{ $.Target }}.{{ .PathFrom.FieldName }} = goahttp.CookieStringAttr({{ .PathFrom.VarName }}) + {{- else }} + {{ $.Target }}.{{ .PathFrom.FieldName }} = {{ .PathFrom.VarName }} + {{- end }} + {{- end }} + {{- if .SecureFrom }} + {{- if .SecureFrom.FieldPointer }} + {{ $.Target }}.{{ .SecureFrom.FieldName }} = goahttp.CookieBoolAttr({{ .SecureFrom.VarName }}) + {{- else }} + {{ $.Target }}.{{ .SecureFrom.FieldName }} = {{ .SecureFrom.VarName }} + {{- end }} + {{- end }} + {{- if .HTTPOnlyFrom }} + {{- if .HTTPOnlyFrom.FieldPointer }} + {{ $.Target }}.{{ .HTTPOnlyFrom.FieldName }} = goahttp.CookieBoolAttr({{ .HTTPOnlyFrom.VarName }}) + {{- else }} + {{ $.Target }}.{{ .HTTPOnlyFrom.FieldName }} = {{ .HTTPOnlyFrom.VarName }} + {{- end }} + {{- end }} + {{- if .SameSiteFrom }} + {{- if .SameSiteFrom.FieldPointer }} + {{ $.Target }}.{{ .SameSiteFrom.FieldName }} = goahttp.CookieSameSiteAttr({{ .SameSiteFrom.VarName }}) + {{- else }} + {{ $.Target }}.{{ .SameSiteFrom.FieldName }} = {{ .SameSiteFrom.VarName }} + {{- end }} + {{- end }} +{{- end }} diff --git a/http/codegen/templates/partial/response.go.tpl b/http/codegen/templates/partial/response.go.tpl index 895c35517c..5d98b835b3 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..1149e590db 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..6429d57aab 100644 --- a/http/codegen/templates/response_decoder.go.tpl +++ b/http/codegen/templates/response_decoder.go.tpl @@ -31,6 +31,7 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor {{- if .ResultInit }} {{- if .ViewedResult }} p := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + {{- template "partial_cookie_attr_bindings" (cookieBindingsArgs .Cookies "p") }} {{- if .TagName }} tmp := {{ printf "%q" .TagValue }} p.{{ .TagName }} = &tmp @@ -49,6 +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 }}) + {{- template "partial_cookie_attr_bindings" (cookieBindingsArgs .Cookies "res") }} {{- end }} {{- if and .TagName (not .ViewedResult) }} {{- if .TagPointer }} @@ -79,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 }} @@ -95,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-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-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/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..11d9f82f89 --- /dev/null +++ b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-mixed.go.golden @@ -0,0 +1,59 @@ +// 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 ( + a string + aRaw string + aExpiresIn int + b string + bRaw string + + cookies = resp.Cookies() + err error + ) + 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(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-all.go.golden b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional-all.go.golden new file mode 100644 index 0000000000..fb58a972dd --- /dev/null +++ b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional-all.go.golden @@ -0,0 +1,75 @@ +// 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) + 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) + 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 new file mode 100644 index 0000000000..2f013b6a5b --- /dev/null +++ b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings-optional.go.golden @@ -0,0 +1,54 @@ +// 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 ( + sessionID string + sessionIDRaw string + sessionIDExpiresIn int + sessionIDCookieDomain 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 + } + } + if sessionIDRaw == "" { + err = goa.MergeErrors(err, goa.MissingFieldError("sessionID", "cookie")) + } + sessionID = sessionIDRaw + if err != nil { + return nil, goahttp.ErrValidationError("ServiceCookieAttrBindingsOptional", "MethodCookieAttrBindingsOptional", err) + } + res := NewMethodCookieAttrBindingsOptionalResultOK(sessionID) + res.ExpiresIn = goahttp.CookieIntAttr(sessionIDExpiresIn) + res.CookieDomain = goahttp.CookieStringAttr(sessionIDCookieDomain) + 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..3eb66e93cc --- /dev/null +++ b/http/codegen/testdata/golden/client_decode_cookie-attr-bindings.go.golden @@ -0,0 +1,75 @@ +// 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 ( + 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("ServiceCookieAttrBindings", "MethodCookieAttrBindings", err) + } + res := NewMethodCookieAttrBindingsResultOK(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-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-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/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..38cf98a4ab --- /dev/null +++ b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-mixed.go.golden @@ -0,0 +1,27 @@ +// 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) + 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 nil + } +} 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..9da6c55618 --- /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/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..9107f7591a --- /dev/null +++ b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings-optional.go.golden @@ -0,0 +1,22 @@ +// 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) + 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 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 new file mode 100644 index 0000000000..5db59bbb57 --- /dev/null +++ b/http/codegen/testdata/golden/server_encode_cookie-attr-bindings.go.golden @@ -0,0 +1,30 @@ +// 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) + 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 nil + } +} diff --git a/http/codegen/testdata/result_dsls.go b/http/codegen/testdata/result_dsls.go index bb8647d6ed..a07ea9b68e 100644 --- a/http/codegen/testdata/result_dsls.go +++ b/http/codegen/testdata/result_dsls.go @@ -1640,3 +1640,164 @@ 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() { + MaxAge("expiresIn") + Domain("cookieDomain") + Path("cookiePath") + Secure("isSecure") + HTTPOnly("isHTTPOnly") + SameSite("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() { + MaxAge("expiresIn") + Domain("cookieDomain") + }) + }) + }) + }) + }) +} + +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() { + MaxAge("expiresIn") + Domain("cookieDomain") + Path("cookiePath") + Secure("isSecure") + HTTPOnly("isHTTPOnly") + SameSite("sameSite") + }) + }) + }) + }) + }) +} + +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() { + MaxAge("expiresIn") + }) + CookieMaxAge(3600) + CookieDomain("goa.design") + CookieSecure() + }) + }) + }) + }) +} + +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() { + MaxAge("expiresIn") + }) + }) + }) + }) + }) +} + +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() { + MaxAge("retryAfter") + Path("loginPath") + }) + }) + }) + }) + }) +} diff --git a/http/cookie.go b/http/cookie.go new file mode 100644 index 0000000000..6a034b3468 --- /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 +// SameSite 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..9a7352762b --- /dev/null +++ b/http/cookie_test.go @@ -0,0 +1,116 @@ +package http + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func ptr[T any](v T) *T { return &v } + +func TestCookieIntAttr(t *testing.T) { + cases := []struct { + name string + in int + want *int + }{ + {"zero-yields-nil", 0, nil}, + {"positive-yields-pointer", 60, ptr(60)}, + {"negative-yields-pointer", -1, ptr(-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", ptr("example.com")}, + {"whitespace-yields-pointer", " ", ptr(" ")}, + } + 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, ptr(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", 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) { + got := CookieSameSiteAttr(c.in) + if c.want == nil { + require.Nil(t, got) + return + } + require.NotNil(t, got) + require.Equal(t, *c.want, *got) + }) + } +}