Repository: zeromicro/go-zero
Pull request #1693 (2022-03-22 03:40:34)

feat: add getset command in redis and kv


Patch:
diff --git a/core/prof/runtime.go b/core/prof/runtime.go
index 085440ea..17ede442 100644
--- a/core/prof/runtime.go
+++ b/core/prof/runtime.go
@@ -11,6 +11,7 @@ const (
 	mega            = 1024 * 1024
 )
 
+// DisplayStats prints the goroutine, memory, GC stats with given interval, default to 5 seconds.
 func DisplayStats(interval ...time.Duration) {
 	duration := defaultInterval
 	for _, val := range interval {
diff --git a/core/stores/kv/store.go b/core/stores/kv/store.go
index 603f14a6..2d665970 100644
--- a/core/stores/kv/store.go
+++ b/core/stores/kv/store.go
@@ -54,6 +54,7 @@ type (
 		Setex(key, value string, seconds int) error
 		Setnx(key, value string) (bool, error)
 		SetnxEx(key, value string, seconds int) (bool, error)
+		Getset(key, value string) (string, error)
 		Sismember(key string, value interface{}) (bool, error)
 		Smembers(key string) ([]string, error)
 		Spop(key string) (string, error)
@@ -459,6 +460,15 @@ func (cs clusterStore) SetnxEx(key, value string, seconds int) (bool, error) {
 	return node.SetnxEx(key, value, seconds)
 }
 
+func (cs clusterStore) Getset(key, value string) (string, error) {
+	node, err := cs.getRedis(key)
+	if err != nil {
+		return "", err
+	}
+
+	return node.GetSet(key, value)
+}
+
 func (cs clusterStore) Sismember(key string, value interface{}) (bool, error) {
 	node, err := cs.getRedis(key)
 	if err != nil {
diff --git a/core/stores/redis/redis.go b/core/stores/redis/redis.go
index aff71591..570bcaca 100644
--- a/core/stores/redis/redis.go
+++ b/core/stores/redis/redis.go
@@ -615,6 +615,31 @@ func (s *Redis) GetCtx(ctx context.Context, key string) (val string, err error)
 	return
 }
 
+// GetSet is the implementation of redis getset command.
+func (s *Redis) GetSet(key, value string) (string, error) {
+	return s.GetSetCtx(context.Background(), key, value)
+}
+
+// GetSetCtx is the implementation of redis getset command.
+func (s *Redis) GetSetCtx(ctx context.Context, key, value string) (val string, err error) {
+	err = s.brk.DoWithAcceptable(func() error {
+		conn, err := getRedis(s)
+		if err != nil {
+			return err
+		}
+
+		if val, err = conn.GetSet(ctx, key, value).Result(); err == red.Nil {
+			return nil
+		} else if err != nil {
+			return err
+		} else {
+			return nil
+		}
+	}, acceptable)
+
+	return
+}
+
 // GetBit is the implementation of redis getbit command.
 func (s *Redis) GetBit(key string, offset int64) (int, error) {
 	return s.GetBitCtx(context.Background(), key, offset)
diff --git a/rest/httpc/responses.go b/rest/httpc/responses.go
new file mode 100644
index 00000000..eb571e53
--- /dev/null
+++ b/rest/httpc/responses.go
@@ -0,0 +1,33 @@
+package httpc
+
+import (
+	"net/http"
+	"strings"
+
+	"github.com/zeromicro/go-zero/core/mapping"
+	"github.com/zeromicro/go-zero/rest/internal/encoding"
+)
+
+func Parse(resp *http.Response, val interface{}) error {
+	if err := ParseHeaders(resp, val); err != nil {
+		return err
+	}
+
+	return ParseJsonBody(resp, val)
+}
+
+func ParseHeaders(resp *http.Response, val interface{}) error {
+	return encoding.ParseHeaders(resp.Header, val)
+}
+
+func ParseJsonBody(resp *http.Response, val interface{}) error {
+	if withJsonBody(resp) {
+		return mapping.UnmarshalJsonReader(resp.Body, val)
+	}
+
+	return mapping.UnmarshalJsonMap(nil, val)
+}
+
+func withJsonBody(r *http.Response) bool {
+	return r.ContentLength > 0 && strings.Contains(r.Header.Get(contentType), applicationJson)
+}
diff --git a/rest/httpc/service.go b/rest/httpc/service.go
index db17b280..8297a3df 100644
--- a/rest/httpc/service.go
+++ b/rest/httpc/service.go
@@ -9,9 +9,6 @@ import (
 	"github.com/zeromicro/go-zero/rest/httpc/internal"
 )
 
-// ContentType means Content-Type.
-const ContentType = "Content-Type"
-
 var interceptors = []internal.Interceptor{
 	internal.LogInterceptor,
 }
@@ -86,13 +83,13 @@ func (s namedService) Get(url string) (*http.Response, error) {
 }
 
 // Post sends an HTTP POST request to the service.
-func (s namedService) Post(url, contentType string, body io.Reader) (*http.Response, error) {
+func (s namedService) Post(url, ctype string, body io.Reader) (*http.Response, error) {
 	r, err := http.NewRequest(http.MethodPost, url, body)
 	if err != nil {
 		return nil, err
 	}
 
-	r.Header.Set(ContentType, contentType)
+	r.Header.Set(contentType, ctype)
 	return s.Do(r)
 }
 
diff --git a/rest/httpc/vars.go b/rest/httpc/vars.go
new file mode 100644
index 00000000..32d68da9
--- /dev/null
+++ b/rest/httpc/vars.go
@@ -0,0 +1,6 @@
+package httpc
+
+const (
+	contentType     = "Content-Type"
+	applicationJson = "application/json"
+)
diff --git a/rest/httpx/requests.go b/rest/httpx/requests.go
index 46c879bb..4b3da146 100644
--- a/rest/httpx/requests.go
+++ b/rest/httpx/requests.go
@@ -3,17 +3,16 @@ package httpx
 import (
 	"io"
 	"net/http"
-	"net/textproto"
 	"strings"
 
 	"github.com/zeromicro/go-zero/core/mapping"
+	"github.com/zeromicro/go-zero/rest/internal/encoding"
 	"github.com/zeromicro/go-zero/rest/pathvar"
 )
 
 const (
 	formKey           = "form"
 	pathKey           = "path"
-	headerKey         = "header"
 	maxMemory         = 32 << 20 // 32MB
 	maxBodyLen        = 8 << 20  // 8MB
 	separator         = ";"
@@ -21,10 +20,8 @@ const (
 )
 
 var (
-	formUnmarshaler   = mapping.NewUnmarshaler(formKey, mapping.WithStringValues())
-	pathUnmarshaler   = mapping.NewUnmarshaler(pathKey, mapping.WithStringValues())
-	headerUnmarshaler = mapping.NewUnmarshaler(headerKey, mapping.WithStringValues(),
-		mapping.WithCanonicalKeyFunc(textproto.CanonicalMIMEHeaderKey))
+	formUnmarshaler = mapping.NewUnmarshaler(formKey, mapping.WithStringValues())
+	pathUnmarshaler = mapping.NewUnmarshaler(pathKey, mapping.WithStringValues())
 )
 
 // Parse parses the request.
@@ -46,16 +43,7 @@ func Parse(r *http.Request, v interface{}) error {
 
 // ParseHeaders parses the headers request.
 func ParseHeaders(r *http.Request, v interface{}) error {
-	m := map[string]interface{}{}
-	for k, v := range r.Header {
-		if len(v) == 1 {
-			m[k] = v[0]
-		} else {
-			m[k] = v
-		}
-	}
-
-	return headerUnmarshaler.Unmarshal(m, v)
+	return encoding.ParseHeaders(r.Header, v)
 }
 
 // ParseForm parses the form request.
diff --git a/rest/internal/encoding/parser.go b/rest/internal/encoding/parser.go
new file mode 100644
index 00000000..b9bfec05
--- /dev/null
+++ b/rest/internal/encoding/parser.go
@@ -0,0 +1,27 @@
+package encoding
+
+import (
+	"net/http"
+	"net/textproto"
+
+	"github.com/zeromicro/go-zero/core/mapping"
+)
+
+const headerKey = "header"
+
+var headerUnmarshaler = mapping.NewUnmarshaler(headerKey, mapping.WithStringValues(),
+	mapping.WithCanonicalKeyFunc(textproto.CanonicalMIMEHeaderKey))
+
+// ParseHeaders parses the headers request.
+func ParseHeaders(header http.Header, v interface{}) error {
+	m := map[string]interface{}{}
+	for k, v := range header {
+		if len(v) == 1 {
+			m[k] = v[0]
+		} else {
+			m[k] = v
+		}
+	}
+
+	return headerUnmarshaler.Unmarshal(m, v)
+}
diff --git a/tools/goctl/env/check.go b/tools/goctl/env/check.go
index 044e39e8..45849f8b 100644
--- a/tools/goctl/env/check.go
+++ b/tools/goctl/env/check.go
@@ -40,21 +40,23 @@ var bins = []bin{
 func Check(ctx *cli.Context) error {
 	install := ctx.Bool("install")
 	force := ctx.Bool("force")
-	return Prepare(install, force)
+	verbose := ctx.Bool("verbose")
+	return Prepare(install, force, verbose)
 }
 
-func Prepare(install, force bool) error {
+func Prepare(install, force, verbose bool) error {
+	log := console.NewColorConsole(verbose)
 	pending := true
-	console.Info("[goctl-env]: preparing to check env")
+	log.Info("[goctl-env]: preparing to check env")
 	defer func() {
 		if p := recover(); p != nil {
-			console.Error("%+v", p)
+			log.Error("%+v", p)
 			return
 		}
 		if pending {
-			console.Success("\n[goctl-env]: congratulations! your goctl environment is ready!")
+			log.Success("\n[goctl-env]: congratulations! your goctl environment is ready!")
 		} else {
-			console.Error(`
+			log.Error(`
 [goctl-env]: check env finish, some dependencies is not found in PATH, you can execute
 command 'goctl env check --install' to install it, for details, please execute command 
 'goctl env check --help'`)
@@ -62,29 +64,29 @@ command 'goctl env check --install' to install it, for details, please execute c
 	}()
 	for _, e := range bins {
 		time.Sleep(200 * time.Millisecond)
-		console.Info("")
-		console.Info("[goctl-env]: looking up %q", e.name)
+		log.Info("")
+		log.Info("[goctl-env]: looking up %q", e.name)
 		if e.exists {
-			console.Success("[goctl-env]: %q is installed", e.name)
+			log.Success("[goctl-env]: %q is installed", e.name)
 			continue
 		}
-		console.Warning("[goctl-env]: %q is not found in PATH", e.name)
+		log.Warning("[goctl-env]: %q is not found in PATH", e.name)
 		if install {
 			install := func() {
-				console.Info("[goctl-env]: preparing to install %q", e.name)
+				log.Info("[goctl-env]: preparing to install %q", e.name)
 				path, err := e.get(env.Get(env.GoctlCache))
 				if err != nil {
-					console.Error("[goctl-env]: an error interrupted the installation: %+v", err)
+					log.Error("[goctl-env]: an error interrupted the installation: %+v", err)
 					pending = false
 				} else {
-					console.Success("[goctl-env]: %q is already installed in %q", e.name, path)
+					log.Success("[goctl-env]: %q is already installed in %q", e.name, path)
 				}
 			}
 			if force {
 				install()
 				continue
 			}
-			console.Info("[goctl-env]: do you want to install %q [y: YES, n: No]", e.name)
+			log.Info("[goctl-env]: do you want to install %q [y: YES, n: No]", e.name)
 			for {
 				var in string
 				fmt.Scanln(&in)
@@ -95,10 +97,10 @@ command 'goctl env check --install' to install it, for details, please execute c
 					brk = true
 				case strings.EqualFold(in, "n"):
 					pending = false
-					console.Info("[goctl-env]: %q installation is ignored", e.name)
+					log.Info("[goctl-env]: %q installation is ignored", e.name)
 					brk = true
 				default:
-					console.Error("[goctl-env]: invalid input, input 'y' for yes, 'n' for no")
+					log.Error("[goctl-env]: invalid input, input 'y' for yes, 'n' for no")
 				}
 				if brk {
 					break
diff --git a/tools/goctl/goctl.go b/tools/goctl/goctl.go
index 308c5aab..e91d7f8f 100644
--- a/tools/goctl/goctl.go
+++ b/tools/goctl/goctl.go
@@ -23,7 +23,6 @@ import (
 	"github.com/zeromicro/go-zero/tools/goctl/completion"
 	"github.com/zeromicro/go-zero/tools/goctl/docker"
 	"github.com/zeromicro/go-zero/tools/goctl/env"
-	"github.com/zeromicro/go-zero/tools/goctl/internal/errorx"
 	"github.com/zeromicro/go-zero/tools/goctl/internal/version"
 	"github.com/zeromicro/go-zero/tools/goctl/kube"
 	"github.com/zeromicro/go-zero/tools/goctl/migrate"
@@ -70,6 +69,10 @@ var commands = []cli.Command{
 						Name:  "force, f",
 						Usage: "silent installation of non-existent dependencies",
 					},
+					cli.BoolFlag{
+						Name:  "verbose, v",
+						Usage: "enable log output",
+					},
 				},
 				Action: env.Check,
 			},
@@ -519,6 +522,10 @@ var commands = []cli.Command{
 						Name:  "branch",
 						Usage: "the branch of the remote repo, it does work with --remote",
 					},
+					cli.BoolFlag{
+						Name:  "verbose, v",
+						Usage: "enable log output",
+					},
 				},
 				Action: rpc.RPCNew,
 			},
@@ -601,6 +608,10 @@ var commands = []cli.Command{
 						Name:  "branch",
 						Usage: "the branch of the remote repo, it does work with --remote",
 					},
+					cli.BoolFlag{
+						Name:  "verbose, v",
+						Usage: "enable log output",
+					},
 				},
 			},
 		},
@@ -894,7 +905,7 @@ func main() {
 
 	// cli already print error messages.
 	if err := app.Run(os.Args); err != nil {
-		fmt.Println(aurora.Red(errorx.Wrap(err).Error()))
+		fmt.Println(aurora.Red(err.Error()))
 		os.Exit(codeFailure)
 	}
 }
diff --git a/tools/goctl/rpc/cli/cli.go b/tools/goctl/rpc/cli/cli.go
index b9716ac6..be98b315 100644
--- a/tools/goctl/rpc/cli/cli.go
+++ b/tools/goctl/rpc/cli/cli.go
@@ -23,6 +23,7 @@ func RPCNew(c *cli.Context) error {
 	home := c.String("home")
 	remote := c.String("remote")
 	branch := c.String("branch")
+	verbose := c.Bool("verbose")
 	if len(remote) > 0 {
 		repo, _ := util.CloneIntoGitHome(remote, branch)
 		if len(repo) > 0 {
@@ -52,7 +53,7 @@ func RPCNew(c *cli.Context) error {
 	ctx.IsGooglePlugin = true
 	ctx.Output = filepath.Dir(src)
 	ctx.ProtocCmd = fmt.Sprintf("protoc -I=%s %s --go_out=%s --go-grpc_out=%s", filepath.Dir(src), filepath.Base(src), filepath.Dir(src), filepath.Dir(src))
-	g := generator.NewGenerator(style)
+	g := generator.NewGenerator(style, verbose)
 	return g.Generate(&ctx)
 }
 
diff --git a/tools/goctl/rpc/cli/zrpc.go b/tools/goctl/rpc/cli/zrpc.go
index e0ab934b..fa84d8ea 100644
--- a/tools/goctl/rpc/cli/zrpc.go
+++ b/tools/goctl/rpc/cli/zrpc.go
@@ -42,6 +42,7 @@ func ZRPC(c *cli.Context) error {
 	home := c.String("home")
 	remote := c.String("remote")
 	branch := c.String("branch")
+	verbose := c.Bool("verbose")
 	if len(grpcOutList) == 0 {
 		return errInvalidGrpcOutput
 	}
@@ -107,7 +108,7 @@ func ZRPC(c *cli.Context) error {
 	ctx.IsGooglePlugin = isGooglePlugin
 	ctx.Output = zrpcOut
 	ctx.ProtocCmd = strings.Join(protocArgs, " ")
-	g := generator.NewGenerator(style)
+	g := generator.NewGenerator(style, verbose)
 	return g.Generate(&ctx)
 }
 
@@ -117,11 +118,13 @@ func removeGoctlFlag(args []string) []string {
 	for step < len(args) {
 		arg := args[step]
 		switch {
-		case arg == "--style", arg == "--home", arg == "--zrpc_out":
+		case arg == "--style", arg == "--home", arg == "--zrpc_out", arg == "--verbose", arg == "-v":
 			step += 2
 			continue
 		case strings.HasPrefix(arg, "--style="),
 			strings.HasPrefix(arg, "--home="),
+			strings.HasPrefix(arg, "--verbose="),
+			strings.HasPrefix(arg, "-v="),
 			strings.HasPrefix(arg, "--zrpc_out="):
 			step += 1
 			continue
diff --git a/tools/goctl/rpc/generator/generator.go b/tools/goctl/rpc/generator/generator.go
index c66627cc..3786e3f8 100644
--- a/tools/goctl/rpc/generator/generator.go
+++ b/tools/goctl/rpc/generator/generator.go
@@ -10,25 +10,27 @@ import (
 
 // Generator defines the environment needs of rpc service generation
 type Generator struct {
-	log console.Console
-	cfg *conf.Config
+	log     console.Console
+	cfg     *conf.Config
+	verbose bool
 }
 
 // NewGenerator returns an instance of Generator
-func NewGenerator(style string) *Generator {
+func NewGenerator(style string, verbose bool) *Generator {
 	cfg, err := conf.NewConfig(style)
 	if err != nil {
 		log.Fatalln(err)
 	}
-	log := console.NewColorConsole()
+	log := console.NewColorConsole(verbose)
 	return &Generator{
-		log: log,
-		cfg: cfg,
+		log:     log,
+		cfg:     cfg,
+		verbose: verbose,
 	}
 }
 
 // Prepare provides environment detection generated by rpc service,
 // including go environment, protoc, whether protoc-gen-go is installed or not
 func (g *Generator) Prepare() error {
-	return env.Prepare(true, true)
+	return env.Prepare(true, true, g.verbose)
 }
diff --git a/tools/goctl/util/console/console.go b/tools/goctl/util/console/console.go
index 7f160d9c..726a3f71 100644
--- a/tools/goctl/util/console/console.go
+++ b/tools/goctl/util/console/console.go
@@ -24,7 +24,9 @@ type (
 		Must(err error)
 	}
 
-	colorConsole struct{}
+	colorConsole struct {
+		enable bool
+	}
 
 	// for idea log
 	ideaConsole struct{}
@@ -39,45 +41,75 @@ func NewConsole(idea bool) Console {
 }
 
 // NewColorConsole returns an instance of colorConsole
-func NewColorConsole() Console {
-	return &colorConsole{}
+func NewColorConsole(enable ...bool) Console {
+	logEnable := true
+	for _, e := range enable {
+		logEnable = e
+	}
+	return &colorConsole{
+		enable: logEnable,
+	}
 }
 
 func (c *colorConsole) Info(format string, a ...interface{}) {
+	if !c.enable {
+		return
+	}
 	msg := fmt.Sprintf(format, a...)
 	fmt.Println(msg)
 }
 
 func (c *colorConsole) Debug(format string, a ...interface{}) {
+	if !c.enable {
+		return
+	}
 	msg := fmt.Sprintf(format, a...)
 	println(aurora.BrightCyan(msg))
 }
 
 func (c *colorConsole) Success(format string, a ...interface{}) {
+	if !c.enable {
+		return
+	}
 	msg := fmt.Sprintf(format, a...)
 	println(aurora.BrightGreen(msg))
 }
 
 func (c *colorConsole) Warning(format string, a ...interface{}) {
+	if !c.enable {
+		return
+	}
 	msg := fmt.Sprintf(format, a...)
 	println(aurora.BrightYellow(msg))
 }
 
 func (c *colorConsole) Error(format string, a ...interface{}) {
+	if !c.enable {
+		return
+	}
 	msg := fmt.Sprintf(format, a...)
 	println(aurora.BrightRed(msg))
 }
 
 func (c *colorConsole) Fatalln(format string, a ...interface{}) {
+	if !c.enable {
+		return
+	}
 	c.Error(format, a...)
 	os.Exit(1)
 }
 
 func (c *colorConsole) MarkDone() {
+	if !c.enable {
+		return
+	}
 	c.Success("Done.")
 }
 
 func (c *colorConsole) Must(err error) {
+	if !c.enable {
+		return
+	}
 	if err != nil {
 		c.Fatalln("%+v", err)
 	}


Test patch:
diff --git a/core/stores/kv/store_test.go b/core/stores/kv/store_test.go
index 0a8f44d2..cb49aec6 100644
--- a/core/stores/kv/store_test.go
+++ b/core/stores/kv/store_test.go
@@ -490,6 +490,29 @@ func TestRedis_SetExNx(t *testing.T) {
 	})
 }
 
+func TestRedis_Getset(t *testing.T) {
+	store := clusterStore{dispatcher: hash.NewConsistentHash()}
+	_, err := store.Getset("hello", "world")
+	assert.NotNil(t, err)
+
+	runOnCluster(t, func(client Store) {
+		val, err := client.Getset("hello", "world")
+		assert.Nil(t, err)
+		assert.Equal(t, "", val)
+		val, err = client.Get("hello")
+		assert.Nil(t, err)
+		assert.Equal(t, "world", val)
+		val, err = client.Getset("hello", "newworld")
+		assert.Nil(t, err)
+		assert.Equal(t, "world", val)
+		val, err = client.Get("hello")
+		assert.Nil(t, err)
+		assert.Equal(t, "newworld", val)
+		_, err = client.Del("hello")
+		assert.Nil(t, err)
+	})
+}
+
 func TestRedis_SetGetDelHashField(t *testing.T) {
 	store := clusterStore{dispatcher: hash.NewConsistentHash()}
 	err := store.Hset("key", "field", "value")
diff --git a/core/stores/redis/redis_test.go b/core/stores/redis/redis_test.go
index 1c196e85..59ab3e7e 100644
--- a/core/stores/redis/redis_test.go
+++ b/core/stores/redis/redis_test.go
@@ -701,6 +701,28 @@ func TestRedis_Set(t *testing.T) {
 	})
 }
 
+func TestRedis_GetSet(t *testing.T) {
+	runOnRedis(t, func(client *Redis) {
+		val, err := New(client.Addr, badType()).GetSet("hello", "world")
+		assert.NotNil(t, err)
+		val, err = client.GetSet("hello", "world")
+		assert.Nil(t, err)
+		assert.Equal(t, "", val)
+		val, err = client.Get("hello")
+		assert.Nil(t, err)
+		assert.Equal(t, "world", val)
+		val, err = client.GetSet("hello", "newworld")
+		assert.Nil(t, err)
+		assert.Equal(t, "world", val)
+		val, err = client.Get("hello")
+		assert.Nil(t, err)
+		assert.Equal(t, "newworld", val)
+		ret, err := client.Del("hello")
+		assert.Nil(t, err)
+		assert.Equal(t, 1, ret)
+	})
+}
+
 func TestRedis_SetGetDel(t *testing.T) {
 	runOnRedis(t, func(client *Redis) {
 		err := New(client.Addr, badType()).Set("hello", "world")
diff --git a/rest/httpc/internal/loginterceptor_test.go b/rest/httpc/internal/loginterceptor_test.go
index 108dfdee..39d445d3 100644
--- a/rest/httpc/internal/loginterceptor_test.go
+++ b/rest/httpc/internal/loginterceptor_test.go
@@ -11,6 +11,7 @@ import (
 func TestLogInterceptor(t *testing.T) {
 	svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 	}))
+	defer svr.Close()
 	req, err := http.NewRequest(http.MethodGet, svr.URL, nil)
 	assert.Nil(t, err)
 	req, handler := LogInterceptor(req)
@@ -24,6 +25,7 @@ func TestLogInterceptorServerError(t *testing.T) {
 	svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		w.WriteHeader(http.StatusInternalServerError)
 	}))
+	defer svr.Close()
 	req, err := http.NewRequest(http.MethodGet, svr.URL, nil)
 	assert.Nil(t, err)
 	req, handler := LogInterceptor(req)
diff --git a/rest/httpc/requests_test.go b/rest/httpc/requests_test.go
index 6696d20f..0deeff2a 100644
--- a/rest/httpc/requests_test.go
+++ b/rest/httpc/requests_test.go
@@ -11,6 +11,7 @@ import (
 func TestDo(t *testing.T) {
 	svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 	}))
+	defer svr.Close()
 	_, err := Get("foo", "tcp://bad request")
 	assert.NotNil(t, err)
 	resp, err := Get("foo", svr.URL)
@@ -20,6 +21,7 @@ func TestDo(t *testing.T) {
 
 func TestDoNotFound(t *testing.T) {
 	svr := httptest.NewServer(http.NotFoundHandler())
+	defer svr.Close()
 	_, err := Post("foo", "tcp://bad request", "application/json", nil)
 	assert.NotNil(t, err)
 	resp, err := Post("foo", svr.URL, "application/json", nil)
@@ -29,6 +31,7 @@ func TestDoNotFound(t *testing.T) {
 
 func TestDoMoved(t *testing.T) {
 	svr := httptest.NewServer(http.RedirectHandler("/foo", http.StatusMovedPermanently))
+	defer svr.Close()
 	req, err := http.NewRequest(http.MethodGet, svr.URL, nil)
 	assert.Nil(t, err)
 	_, err = Do("foo", req)
diff --git a/rest/httpc/responses_test.go b/rest/httpc/responses_test.go
new file mode 100644
index 00000000..13e4acc6
--- /dev/null
+++ b/rest/httpc/responses_test.go
@@ -0,0 +1,58 @@
+package httpc
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestParse(t *testing.T) {
+	var val struct {
+		Foo   string `header:"foo"`
+		Name  string `json:"name"`
+		Value int    `json:"value"`
+	}
+	svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("foo", "bar")
+		w.Header().Set(contentType, applicationJson)
+		w.Write([]byte(`{"name":"kevin","value":100}`))
+	}))
+	defer svr.Close()
+	resp, err := Get("foo", svr.URL)
+	assert.Nil(t, err)
+	assert.Nil(t, Parse(resp, &val))
+	assert.Equal(t, "bar", val.Foo)
+	assert.Equal(t, "kevin", val.Name)
+	assert.Equal(t, 100, val.Value)
+}
+
+func TestParseHeaderError(t *testing.T) {
+	var val struct {
+		Foo int `header:"foo"`
+	}
+	svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("foo", "bar")
+		w.Header().Set(contentType, applicationJson)
+	}))
+	defer svr.Close()
+	resp, err := Get("foo", svr.URL)
+	assert.Nil(t, err)
+	assert.NotNil(t, Parse(resp, &val))
+}
+
+func TestParseNoBody(t *testing.T) {
+	var val struct {
+		Foo string `header:"foo"`
+	}
+	svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("foo", "bar")
+		w.Header().Set(contentType, applicationJson)
+	}))
+	defer svr.Close()
+	resp, err := Get("foo", svr.URL)
+	assert.Nil(t, err)
+	assert.Nil(t, Parse(resp, &val))
+	assert.Equal(t, "bar", val.Foo)
+}
diff --git a/rest/httpc/service_test.go b/rest/httpc/service_test.go
index f619fe76..f92177a4 100644
--- a/rest/httpc/service_test.go
+++ b/rest/httpc/service_test.go
@@ -10,6 +10,7 @@ import (
 
 func TestNamedService_Do(t *testing.T) {
 	svr := httptest.NewServer(http.RedirectHandler("/foo", http.StatusMovedPermanently))
+	defer svr.Close()
 	req, err := http.NewRequest(http.MethodGet, svr.URL, nil)
 	assert.Nil(t, err)
 	service := NewService("foo")
@@ -22,6 +23,7 @@ func TestNamedService_Get(t *testing.T) {
 	svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		w.Header().Set("foo", r.Header.Get("foo"))
 	}))
+	defer svr.Close()
 	service := NewService("foo", func(r *http.Request) *http.Request {
 		r.Header.Set("foo", "bar")
 		return r
@@ -34,6 +36,7 @@ func TestNamedService_Get(t *testing.T) {
 
 func TestNamedService_Post(t *testing.T) {
 	svr := httptest.NewServer(http.NotFoundHandler())
+	defer svr.Close()
 	service := NewService("foo")
 	_, err := service.Post("tcp://bad request", "application/json", nil)
 	assert.NotNil(t, err)
diff --git a/rest/internal/encoding/parser_test.go b/rest/internal/encoding/parser_test.go
new file mode 100644
index 00000000..70e001f4
--- /dev/null
+++ b/rest/internal/encoding/parser_test.go
@@ -0,0 +1,40 @@
+package encoding
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestParseHeaders(t *testing.T) {
+	var val struct {
+		Foo string `header:"foo"`
+		Baz int    `header:"baz"`
+		Qux bool   `header:"qux,default=true"`
+	}
+	r := httptest.NewRequest(http.MethodGet, "/any", nil)
+	r.Header.Set("foo", "bar")
+	r.Header.Set("baz", "1")
+	assert.Nil(t, ParseHeaders(r.Header, &val))
+	assert.Equal(t, "bar", val.Foo)
+	assert.Equal(t, 1, val.Baz)
+	assert.True(t, val.Qux)
+}
+
+func TestParseHeadersMulti(t *testing.T) {
+	var val struct {
+		Foo []string `header:"foo"`
+		Baz int      `header:"baz"`
+		Qux bool     `header:"qux,default=true"`
+	}
+	r := httptest.NewRequest(http.MethodGet, "/any", nil)
+	r.Header.Set("foo", "bar")
+	r.Header.Add("foo", "bar1")
+	r.Header.Set("baz", "1")
+	assert.Nil(t, ParseHeaders(r.Header, &val))
+	assert.Equal(t, []string{"bar", "bar1"}, val.Foo)
+	assert.Equal(t, 1, val.Baz)
+	assert.True(t, val.Qux)
+}