Repository: swaggest/rest (go)

Issue:
Add support for cookies in response
Similar to having `header` field tags in output structure, we can have `cookie`. However, cookies have more additional parameters that perhaps can be configured as field tag options, e.g. `cookie="foo,httponly,path=/foo"`.

Pull request description:
Add cookies to response
Resolves #98.

Cookies can be added with field tags:
```go
// Declare output port type.
type helloOutput struct {
    Now     time.Time `header:"X-Now" json:"-"`
    Message string    `json:"message"`
    Sess    string    `cookie:"sess,httponly,secure,max-age=86400,samesite=lax"`
}
```

Patch:
diff --git a/README.md b/README.md
index 1c6f870..b5edc27 100644
--- a/README.md
+++ b/README.md
@@ -146,12 +146,14 @@ Response encoder writes data from output to `http.ResponseWriter` after use case
 type helloOutput struct {
     Now     time.Time `header:"X-Now" json:"-"`
     Message string    `json:"message"`
+    Sess    string    `cookie:"sess,httponly,secure,max-age:86400,samesite:lax"`
 }
 ```
 
 Output data can be located in:
 * `json` for response body with `application/json` content,
-* `header` for values in response header.
+* `header` for values in response header,
+* `cookie` for cookie values, cookie fields can have configuration in field tag (same as in actual cookie, but with comma separation).
 
 For more explicit separation of concerns between use case and transport it is possible to provide response header mapping 
 separately when initializing handler.
diff --git a/_examples/advanced-generic/output_headers.go b/_examples/advanced-generic/output_headers.go
index 592a2ef..dfba834 100644
--- a/_examples/advanced-generic/output_headers.go
+++ b/_examples/advanced-generic/output_headers.go
@@ -12,11 +12,13 @@ func outputHeaders() usecase.Interactor {
 	type headerOutput struct {
 		Header string `header:"X-Header" json:"-" description:"Sample response header."`
 		InBody string `json:"inBody" deprecated:"true"`
+		Cookie int    `cookie:"coo,httponly,path:/foo" json:"-"`
 	}
 
 	u := usecase.NewInteractor(func(ctx context.Context, _ struct{}, out *headerOutput) (err error) {
 		out.Header = "abc"
 		out.InBody = "def"
+		out.Cookie = 123
 
 		return nil
 	})
diff --git a/response/encoder.go b/response/encoder.go
index 32ef032..341ef5d 100644
--- a/response/encoder.go
+++ b/response/encoder.go
@@ -7,6 +7,7 @@ import (
 	"net/http"
 	"reflect"
 	"strconv"
+	"strings"
 	"sync"
 
 	"github.com/swaggest/form/v5"
@@ -22,6 +23,8 @@ type Encoder struct {
 
 	outputBufferType     reflect.Type
 	outputHeadersEncoder *form.Encoder
+	outputCookiesEncoder *form.Encoder
+	outputCookieBase     []http.Cookie
 	skipRendering        bool
 	outputWithWriter     bool
 	unwrapInterface      bool
@@ -59,6 +62,8 @@ func (h *Encoder) setupHeadersEncoder(output interface{}, ht *rest.HandlerTrait)
 		enc.SetTagName(string(rest.ParamInHeader))
 
 		h.outputHeadersEncoder = enc
+
+		return
 	}
 
 	respHeaderMapping := ht.RespHeaderMapping
@@ -85,6 +90,61 @@ func (h *Encoder) setupHeadersEncoder(output interface{}, ht *rest.HandlerTrait)
 	}
 }
 
+func (h *Encoder) setupCookiesEncoder(output interface{}, ht *rest.HandlerTrait) {
+	// Enable dynamic headers check in interface mode.
+	if h.unwrapInterface = reflect.ValueOf(output).Elem().Kind() == reflect.Interface; h.unwrapInterface {
+		enc := form.NewEncoder()
+		enc.SetMode(form.ModeExplicit)
+		enc.SetTagName(string(rest.ParamInCookie))
+
+		h.outputCookiesEncoder = enc
+
+		return
+	}
+
+	respCookieMapping := ht.RespCookieMapping
+	if len(respCookieMapping) == 0 && refl.HasTaggedFields(output, string(rest.ParamInCookie)) {
+		respCookieMapping = make(map[string]http.Cookie)
+		h.outputCookieBase = make([]http.Cookie, 0)
+
+		refl.WalkTaggedFields(reflect.ValueOf(output), func(v reflect.Value, sf reflect.StructField, tag string) {
+			c := http.Cookie{
+				Name: tag,
+			}
+
+			options := strings.Split(sf.Tag.Get("cookie"), ",")[1:]
+			if len(options) > 0 {
+				resp := http.Response{}
+				resp.Header = make(http.Header)
+				resp.Header.Add("Set-Cookie", tag+"=x;"+strings.Join(options, ";"))
+				cc := resp.Cookies()
+				if len(cc) == 1 {
+					c = *cc[0]
+				}
+			}
+			c.Value = ""
+			c.Raw = ""
+
+			h.outputCookieBase = append(h.outputCookieBase, c)
+			respCookieMapping[sf.Name] = c
+		}, string(rest.ParamInCookie))
+	}
+
+	if len(respCookieMapping) > 0 {
+		enc := form.NewEncoder()
+		enc.SetMode(form.ModeExplicit)
+		enc.RegisterTagNameFunc(func(field reflect.StructField) string {
+			if c, ok := respCookieMapping[field.Name]; ok {
+				return c.Name
+			}
+
+			return "-"
+		})
+
+		h.outputCookiesEncoder = enc
+	}
+}
+
 // SetupOutput configures encoder with and instance of use case output.
 func (h *Encoder) SetupOutput(output interface{}, ht *rest.HandlerTrait) {
 	h.outputBufferType = reflect.TypeOf(output)
@@ -98,6 +158,7 @@ func (h *Encoder) SetupOutput(output interface{}, ht *rest.HandlerTrait) {
 	output = addressable(output)
 
 	h.setupHeadersEncoder(output, ht)
+	h.setupCookiesEncoder(output, ht)
 
 	if h.outputBufferType.Kind() == reflect.Ptr {
 		h.outputBufferType = h.outputBufferType.Elem()
@@ -251,7 +312,11 @@ func (h *Encoder) WriteSuccessfulResponse(
 		}
 	}
 
-	if h.outputHeadersEncoder != nil && !h.whiteHeader(w, r, output, ht) {
+	if !h.whiteHeader(w, r, output, ht) {
+		return
+	}
+
+	if !h.writeCookies(w, r, output, ht) {
 		return
 	}
 
@@ -291,12 +356,16 @@ func (h *Encoder) writeError(err error, w http.ResponseWriter, r *http.Request,
 }
 
 func (h *Encoder) whiteHeader(w http.ResponseWriter, r *http.Request, output interface{}, ht rest.HandlerTrait) bool {
-	var headerValues map[string]interface{}
+	if h.outputHeadersEncoder == nil {
+		return true
+	}
+
+	var goValues map[string]interface{}
 	if ht.RespValidator != nil {
-		headerValues = make(map[string]interface{})
+		goValues = make(map[string]interface{})
 	}
 
-	headers, err := h.outputHeadersEncoder.Encode(output, headerValues)
+	headers, err := h.outputHeadersEncoder.Encode(output, goValues)
 	if err != nil {
 		h.writeError(err, w, r, ht)
 
@@ -304,8 +373,7 @@ func (h *Encoder) whiteHeader(w http.ResponseWriter, r *http.Request, output int
 	}
 
 	if ht.RespValidator != nil {
-		err = ht.RespValidator.ValidateData(rest.ParamInHeader, headerValues)
-		if err != nil {
+		if err := ht.RespValidator.ValidateData(rest.ParamInHeader, goValues); err != nil {
 			h.writeError(status.Wrap(fmt.Errorf("bad response: %w", err), status.Internal), w, r, ht)
 
 			return false
@@ -321,6 +389,40 @@ func (h *Encoder) whiteHeader(w http.ResponseWriter, r *http.Request, output int
 	return true
 }
 
+func (h *Encoder) writeCookies(w http.ResponseWriter, r *http.Request, output interface{}, ht rest.HandlerTrait) bool {
+	if h.outputCookiesEncoder == nil {
+		return true
+	}
+
+	cookies, err := h.outputCookiesEncoder.Encode(output, nil)
+	if err != nil {
+		h.writeError(err, w, r, ht)
+
+		return false
+	}
+
+	if h.outputCookieBase != nil {
+		for _, c := range h.outputCookieBase {
+			if val, ok := cookies[c.Name]; ok && len(val) == 1 && val[0] != "" {
+				c := c
+				c.Value = val[0]
+
+				http.SetCookie(w, &c)
+			}
+		}
+	} else {
+		for cookie, val := range cookies {
+			c := http.Cookie{}
+			c.Name = cookie
+			c.Value = val[0]
+
+			http.SetCookie(w, &c)
+		}
+	}
+
+	return true
+}
+
 // MakeOutput instantiates a value for use case output port.
 func (h *Encoder) MakeOutput(w http.ResponseWriter, ht rest.HandlerTrait) interface{} {
 	if h.outputBufferType == nil {
diff --git a/trait.go b/trait.go
index c197605..e9c819d 100644
--- a/trait.go
+++ b/trait.go
@@ -3,6 +3,7 @@ package rest
 import (
 	"context"
 	"encoding/json"
+	"net/http"
 	"reflect"
 
 	"github.com/swaggest/openapi-go/openapi3"
@@ -29,6 +30,7 @@ type HandlerTrait struct {
 	ReqMapping RequestMapping
 
 	RespHeaderMapping map[string]string
+	RespCookieMapping map[string]http.Cookie
 
 	// ReqValidator validates decoded request data.
 	ReqValidator Validator


Test patch:
diff --git a/_examples/advanced-generic/output_headers_test.go b/_examples/advanced-generic/output_headers_test.go
index 1736619..b412e1f 100644
--- a/_examples/advanced-generic/output_headers_test.go
+++ b/_examples/advanced-generic/output_headers_test.go
@@ -46,5 +46,6 @@ func Test_outputHeaders(t *testing.T) {
 	assert.NoError(t, resp.Body.Close())
 
 	assert.Equal(t, "abc", resp.Header.Get("X-Header"))
+	assert.Equal(t, []string{"coo=123; HttpOnly"}, resp.Header.Values("Set-Cookie"))
 	assertjson.Equal(t, []byte(`{"inBody":"def"}`), body)
 }
diff --git a/response/encoder_test.go b/response/encoder_test.go
index 5a1d1c6..5fdcdea 100644
--- a/response/encoder_test.go
+++ b/response/encoder_test.go
@@ -17,8 +17,10 @@ func TestEncoder_SetupOutput(t *testing.T) {
 	e := response.Encoder{}
 
 	type outputPort struct {
-		Name  string   `header:"X-Name" json:"-"`
-		Items []string `json:"items"`
+		Name    string   `header:"X-Name" json:"-"`
+		Items   []string `json:"items"`
+		Cookie  int      `cookie:"coo,httponly,path=/foo" json:"-"`
+		Cookie2 bool     `cookie:"coo2,httponly,secure,samesite=lax,path=/foo,max-age=86400" json:"-"`
 	}
 
 	ht := rest.HandlerTrait{
@@ -35,7 +37,7 @@ func TestEncoder_SetupOutput(t *testing.T) {
 
 	ht.RespValidator = &validator
 
-	e.SetupOutput(new(outputPort), &ht)
+	e.SetupOutput(outputPort{}, &ht)
 
 	r, err := http.NewRequest(http.MethodGet, "/", nil)
 	require.NoError(t, err)
@@ -48,10 +50,16 @@ func TestEncoder_SetupOutput(t *testing.T) {
 
 	out.Name = "Jane"
 	out.Items = []string{"one", "two", "three"}
+	out.Cookie = 123
+	out.Cookie2 = true
 
 	e.WriteSuccessfulResponse(w, r, output, ht)
 	assert.Equal(t, http.StatusOK, w.Code)
 	assert.Equal(t, "Jane", w.Header().Get("X-Name"))
+	assert.Equal(t, []string{
+		"coo=123; Path=/foo; HttpOnly",
+		"coo2=true; Path=/foo; Max-Age=86400; HttpOnly; Secure; SameSite=Lax",
+	}, w.Header()["Set-Cookie"])
 	assert.Equal(t, "application/x-vnd-json", w.Header().Get("Content-Type"))
 	assert.Equal(t, "32", w.Header().Get("Content-Length"))
 	assert.Equal(t, `{"items":["one","two","three"]}`+"\n", w.Body.String())