From 5a235048be4433fe94e0a6b67965a421e11f5bf5 Mon Sep 17 00:00:00 2001 From: Ming Deng Date: Mon, 1 Jan 2024 22:56:05 +0800 Subject: [PATCH] =?UTF-8?q?httpx:=20Request=20=E5=92=8C=20Response=20?= =?UTF-8?q?=E5=88=9D=E6=AD=A5=E8=AE=BE=E8=AE=A1=20(#236)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * httpx Request 和 Response 初步设计 * 支持 LogRoundTrip --- net/httpx/log_round_trip.go | 67 +++++++++++++++++++ net/httpx/log_round_trip_test.go | 57 ++++++++++++++++ net/httpx/request.go | 72 ++++++++++++++++++++ net/httpx/request_test.go | 110 +++++++++++++++++++++++++++++++ net/httpx/response.go | 34 ++++++++++ net/httpx/response_test.go | 54 +++++++++++++++ 6 files changed, 394 insertions(+) create mode 100644 net/httpx/log_round_trip.go create mode 100644 net/httpx/log_round_trip_test.go create mode 100644 net/httpx/request.go create mode 100644 net/httpx/request_test.go create mode 100644 net/httpx/response.go create mode 100644 net/httpx/response_test.go diff --git a/net/httpx/log_round_trip.go b/net/httpx/log_round_trip.go new file mode 100644 index 0000000..2dca4be --- /dev/null +++ b/net/httpx/log_round_trip.go @@ -0,0 +1,67 @@ +// Copyright 2021 ecodeclub +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpx + +import ( + "bytes" + "io" + "net/http" +) + +type LogRoundTrip struct { + delegate http.RoundTripper + // l 绝对不会为 nil + log func(l Log, err error) +} + +func NewLogRoundTrip(rp http.RoundTripper, log func(l Log, err error)) *LogRoundTrip { + return &LogRoundTrip{ + delegate: rp, + log: log, + } +} + +func (l *LogRoundTrip) RoundTrip(request *http.Request) (resp *http.Response, err error) { + log := Log{ + URL: request.URL.String(), + } + defer func() { + if resp != nil { + log.RespStatus = resp.Status + if resp.Body != nil { + // 出现 error 了这里也不知道怎么处理,暂时忽略 + body, _ := io.ReadAll(resp.Body) + resp.Body = io.NopCloser(bytes.NewReader(body)) + log.RespBody = string(body) + } + } + l.log(log, err) + }() + if request.Body != nil { + // 出现 error 了这里也不知道怎么处理,暂时忽略 + body, _ := io.ReadAll(request.Body) + request.Body = io.NopCloser(bytes.NewReader(body)) + log.ReqBody = string(body) + } + resp, err = l.delegate.RoundTrip(request) + return +} + +type Log struct { + URL string + ReqBody string + RespBody string + RespStatus string +} diff --git a/net/httpx/log_round_trip_test.go b/net/httpx/log_round_trip_test.go new file mode 100644 index 0000000..80d1b72 --- /dev/null +++ b/net/httpx/log_round_trip_test.go @@ -0,0 +1,57 @@ +// Copyright 2021 ecodeclub +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpx + +import ( + "bytes" + "context" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLogRoundTrip(t *testing.T) { + client := http.DefaultClient + var acceptLog Log + var acceptError error + client.Transport = NewLogRoundTrip(&doNothingRoundTrip{}, func(l Log, err error) { + acceptLog = l + acceptError = err + }) + NewRequest(context.Background(), + http.MethodGet, "http://localhost/test"). + JSONBody(User{Name: "Tom"}). + Client(client). + Do() + assert.Equal(t, nil, acceptError) + assert.Equal(t, Log{ + URL: "http://localhost/test", + ReqBody: `{"Name":"Tom"}`, + RespBody: "resp body", + RespStatus: "200 OK", + }, acceptLog) +} + +type doNothingRoundTrip struct { +} + +func (d *doNothingRoundTrip) RoundTrip(request *http.Request) (*http.Response, error) { + return &http.Response{ + Status: "200 OK", + Body: io.NopCloser(bytes.NewBuffer([]byte("resp body"))), + }, nil +} diff --git a/net/httpx/request.go b/net/httpx/request.go new file mode 100644 index 0000000..36b0aa1 --- /dev/null +++ b/net/httpx/request.go @@ -0,0 +1,72 @@ +// Copyright 2021 ecodeclub +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpx + +import ( + "context" + "io" + "net/http" + + "github.com/ecodeclub/ekit/iox" +) + +type Request struct { + req *http.Request + err error + client *http.Client +} + +func NewRequest(ctx context.Context, method, url string) *Request { + req, err := http.NewRequestWithContext(ctx, method, url, nil) + return &Request{ + req: req, + err: err, + client: http.DefaultClient, + } +} + +// JSONBody 使用 JSON body +func (req *Request) JSONBody(val any) *Request { + req.req.Body = io.NopCloser(iox.NewJSONReader(val)) + req.req.Header.Set("Content-Type", "application/json") + return req +} + +func (req *Request) Client(cli *http.Client) *Request { + req.client = cli + return req +} + +// AddParam 添加查询参数 +// 这个方法性能不好,但是好用 +func (req *Request) AddParam(key string, value string) *Request { + q := req.req.URL.Query() + q.Add(key, value) + req.req.URL.RawQuery = q.Encode() + return req +} + +func (req *Request) Do() *Response { + if req.err != nil { + return &Response{ + err: req.err, + } + } + resp, err := req.client.Do(req.req) + return &Response{ + Response: resp, + err: err, + } +} diff --git a/net/httpx/request_test.go b/net/httpx/request_test.go new file mode 100644 index 0000000..2df5b1a --- /dev/null +++ b/net/httpx/request_test.go @@ -0,0 +1,110 @@ +// Copyright 2021 ecodeclub +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpx + +import ( + "context" + "errors" + "net" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRequest_Client(t *testing.T) { + req := NewRequest(context.Background(), http.MethodPost, "/abc") + assert.Equal(t, http.DefaultClient, req.client) + cli := &http.Client{} + req = req.Client(&http.Client{}) + assert.Equal(t, cli, req.client) +} + +func TestRequest_JSONBody(t *testing.T) { + req := NewRequest(context.Background(), http.MethodPost, "/abc") + assert.Nil(t, req.req.Body) + req = req.JSONBody(User{}) + assert.NotNil(t, req.req.Body) + assert.Equal(t, "application/json", req.req.Header.Get("Content-Type")) +} + +func TestRequest_Do(t *testing.T) { + l, err := net.Listen("unix", "/tmp/test.sock") + require.NoError(t, err) + server := http.Server{} + go func() { + http.HandleFunc("/hello", func(writer http.ResponseWriter, request *http.Request) { + _, _ = writer.Write([]byte("OK")) + }) + _ = server.Serve(l) + }() + defer func() { + _ = l.Close() + }() + testCases := []struct { + name string + req func() *Request + wantErr error + }{ + { + name: "构造请求的时候有 error", + req: func() *Request { + return &Request{ + err: errors.New("mock error"), + } + }, + wantErr: errors.New("mock error"), + }, + { + name: "成功", + req: func() *Request { + req := NewRequest(context.Background(), http.MethodGet, "http://localhost:8081/hello") + return req.Client(&http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, + network, addr string) (net.Conn, error) { + return net.Dial("unix", "/tmp/test.sock") + }, + }, + }) + }, + }, + } + + // 确保前面的 http 端口启动成功 + time.Sleep(time.Second) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req := tc.req() + resp := req.Do() + assert.Equal(t, tc.wantErr, resp.err) + }) + } +} + +func TestRequest_AddParam(t *testing.T) { + req := NewRequest(context.Background(), + http.MethodGet, "http://localhost"). + AddParam("key1", "value1"). + AddParam("key2", "value2") + assert.Equal(t, "http://localhost?key1=value1&key2=value2", req.req.URL.String()) +} + +type User struct { + Name string +} diff --git a/net/httpx/response.go b/net/httpx/response.go new file mode 100644 index 0000000..be6c437 --- /dev/null +++ b/net/httpx/response.go @@ -0,0 +1,34 @@ +// Copyright 2021 ecodeclub +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpx + +import ( + "encoding/json" + "net/http" +) + +type Response struct { + *http.Response + err error +} + +// JSONScan 将 Body 按照 JSON 反序列化为结构体 +func (r *Response) JSONScan(val any) error { + if r.err != nil { + return r.err + } + err := json.NewDecoder(r.Body).Decode(val) + return err +} diff --git a/net/httpx/response_test.go b/net/httpx/response_test.go new file mode 100644 index 0000000..a1e2fa2 --- /dev/null +++ b/net/httpx/response_test.go @@ -0,0 +1,54 @@ +// Copyright 2021 ecodeclub +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpx + +import ( + "io" + "net/http" + "testing" + + "github.com/ecodeclub/ekit/iox" + "github.com/stretchr/testify/assert" +) + +func TestResponse_JSONScan(t *testing.T) { + testCases := []struct { + name string + resp *Response + wantVal User + wantErr error + }{ + { + name: "scan成功", + resp: &Response{ + Response: &http.Response{ + Body: io.NopCloser(iox.NewJSONReader(User{Name: "Tom"})), + }, + }, + wantVal: User{ + Name: "Tom", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var u User + err := tc.resp.JSONScan(&u) + assert.Equal(t, tc.wantErr, err) + assert.Equal(t, tc.wantVal, u) + }) + } +}