Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 177 additions & 1 deletion dsl/http.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dsl

import (
"fmt"
"strconv"
"strings"

Expand Down Expand Up @@ -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:
Expand All @@ -257,6 +262,8 @@ func Path(val string) {
}
}
def.Paths = append(def.Paths, val)
case *cookieAttrBindingsExpr:
cookieFromBinding("path", val)
default:
eval.IncompatibleDSL()
}
Expand Down Expand Up @@ -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.
//
Expand Down
33 changes: 33 additions & 0 deletions expr/http_body_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:<kind>: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 {
Expand Down
Loading
Loading