From b67467c0b401627128f7784ab203bb9ef9f44f48 Mon Sep 17 00:00:00 2001 From: joelei Date: Sat, 28 Dec 2024 20:01:56 +0800 Subject: [PATCH] feat: add generic i18n and validator --- .vscode/settings.json | 6 +- apis/generic.go | 169 +++++++++++++++++++++++++++++ apis/generic_test.go | 103 ++++++++++++++++++ apis/metrics.go | 60 ++++++++++ apis/middleware.go | 8 ++ apis/{rest/rest.go => response.go} | 28 +++-- go.mod | 25 ++++- go.sum | 55 +++++++++- i18n/i18n.go | 17 +++ validator/validator.go | 98 +++++++++++++++++ validator/validator_test.go | 61 +++++++++++ 11 files changed, 611 insertions(+), 19 deletions(-) create mode 100644 apis/generic.go create mode 100644 apis/generic_test.go create mode 100644 apis/metrics.go rename apis/{rest/rest.go => response.go} (78%) create mode 100644 i18n/i18n.go create mode 100644 validator/validator.go create mode 100644 validator/validator_test.go diff --git a/.vscode/settings.json b/.vscode/settings.json index 575a838..7efe88f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,4 @@ { - "go.goroot": "/opt/gvm/gos/go1.20", - "go.gopath": "/opt/gvm/pkgsets/go1.20/global" -} + "go.goroot": "/opt/go/sdk/go", + "go.gopath": "/root/.go" +} \ No newline at end of file diff --git a/apis/generic.go b/apis/generic.go new file mode 100644 index 0000000..0a4b3f2 --- /dev/null +++ b/apis/generic.go @@ -0,0 +1,169 @@ +package apis + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "time" + + "github.com/ggicci/httpin" + httpin_integration "github.com/ggicci/httpin/integration" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + + "git.ifooth.com/common/pkg/i18n" +) + +// UnaryFunc Unary or ClientStreaming handle function +type UnaryFunc[In, Out any] func(context.Context, *In) (*Out, error) + +// StreamingServer server or bidi streaming server +type StreamingServer interface { + http.ResponseWriter + Context() context.Context +} + +// StreamFunc ServerStreaming or BidiStreaming handle function +type StreamFunc[In any] func(*In, StreamingServer) error + +// Handle Composable HTTP Handlers using generics +func Handle[In any, Out any](fn UnaryFunc[In, Out]) func(w http.ResponseWriter, r *http.Request) { + handleName := getHandleName(fn) + + f := func(w http.ResponseWriter, r *http.Request) { + r.Header.Set("Content-Type", "application/json") + st := time.Now() + var err error + defer func() { + collectHandleMetrics(handleName, r.Method, st, err) + }() + + in, err := decodeReq[In](r) + if err != nil { + slog.Error("handle decode request failed", "err", err) + _ = render.Render(w, r, APIError(err)) + return + } + + // 设置语言 + ctx := i18n.SetLang(r.Context(), r.Header.Get("Accept-Language")) + ctx = context.WithValue(ctx, reqCtxKey, r) + + out, err := fn(ctx, in) + if err != nil { + _ = render.Render(w, r, APIError(err)) + return + } + _ = render.Render(w, r, APIOK(out)) + } + return f +} + +type streamingServer struct { + http.ResponseWriter + *http.ResponseController + ctx context.Context +} + +// Context return svr's context +func (s *streamingServer) Context() context.Context { + return s.ctx +} + +// Stream Composable HTTP Handlers using generics +func Stream[In any](fn StreamFunc[In]) func(w http.ResponseWriter, r *http.Request) { + handleName := getHandleName(fn) + + f := func(w http.ResponseWriter, r *http.Request) { + st := time.Now() + var err error + defer func() { + collectHandleMetrics(handleName, r.Method, st, err) + }() + + in, err := decodeReq[In](r) + if err != nil { + slog.Error("handle decode request failed ", "err", err) + _ = render.Render(w, r, APIError(err)) + return + } + + // 设置语言 + ctx := i18n.SetLang(r.Context(), r.Header.Get("Accept-Language")) + ctx = context.WithValue(ctx, reqCtxKey, r) + + svr := &streamingServer{ + ResponseWriter: w, + ResponseController: http.NewResponseController(w), + ctx: ctx, + } + + err = fn(in, svr) + if err != nil { + _ = render.Render(w, r, APIError(err)) + } + } + return f +} + +// decodeReq ... +func decodeReq[T any](r *http.Request) (*T, error) { + in := new(T) + var err error + + // http.Request 直接返回 + if _, ok := any(in).(*http.Request); ok { + return any(r).(*T), nil + } + + // 空值不需要反序列化 + if _, ok := any(in).(*EmptyReq); ok { + return in, nil + } + + in, err = httpin.Decode[T](r) + if err != nil { + return nil, err + } + + // Get/Delete 请求, 请求参数从url中获取 + if r.Method == http.MethodGet || r.Method == http.MethodDelete { + return in, nil + } + + // Post 请求等, 从body中获取 + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + + if err = json.Unmarshal(body, in); err != nil { + return nil, fmt.Errorf("unmarshal json body: %s", err) + } + return in, nil +} + +// EmptyReq 空的请求 +type EmptyReq struct{} + +// EmptyResp 空的返回 +type EmptyResp struct{} + +// PaginationReq 分页接口通用请求 +type PaginationReq struct { + Offset int `json:"offset" in:"query=offset" validate:"gte=0"` + Limit int `json:"limit" in:"query=limit" validate:"gte=0"` +} + +// PaginationResp 分页接口通用返回 +type PaginationResp[T any] struct { + Count int64 `json:"count"` + Items []*T `json:"items"` +} + +func init() { + httpin_integration.UseGochiURLParam("path", chi.URLParam) +} diff --git a/apis/generic_test.go b/apis/generic_test.go new file mode 100644 index 0000000..7190b9e --- /dev/null +++ b/apis/generic_test.go @@ -0,0 +1,103 @@ +package apis + +import ( + "bytes" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// NewMockRequest creates a new mock request. +func NewMockRequest(t *testing.T, method string, body io.ReadCloser) *http.Request { + req, err := http.NewRequest(method, "/vm/xxx?name=alice", body) + require.NoError(t, err) + return req +} + +// TestDecodeReq tests the decodeReq function. +func TestDecodeReq(t *testing.T) { + // Define a test struct + type TestStruct struct { + Field string `json:"field"` + EnvID string `json:"env_id" in:"path=env_id" validate:"required"` + Name string `json:"name" in:"query=name"` + } + + // Test case 1: GET request with no body + t.Run("GET request with no body", func(t *testing.T) { + req := NewMockRequest(t, http.MethodGet, nil) + result, err := decodeReq[TestStruct](req) + assert.NoError(t, err) + assert.Equal(t, "alice", result.Name) + }) + + // Test case 2: POST request with valid JSON body + t.Run("POST request with valid JSON body", func(t *testing.T) { + jsonBody := `{"field": "value", "env_id": "test", "name": "alice1"}` + body := io.NopCloser(bytes.NewBufferString(jsonBody)) + req := NewMockRequest(t, http.MethodPost, body) + result, err := decodeReq[TestStruct](req) + assert.NoError(t, err) + assert.Equal(t, "value", result.Field) + assert.Equal(t, "test", result.EnvID) + assert.Equal(t, "alice1", result.Name) + }) + + // Test case 3: POST request with invalid JSON body + t.Run("POST request with invalid JSON body", func(t *testing.T) { + jsonBody := `{"field": "value"` // Invalid JSON + body := io.NopCloser(bytes.NewBufferString(jsonBody)) + req := NewMockRequest(t, http.MethodPost, body) + result, err := decodeReq[TestStruct](req) + assert.Error(t, err) + assert.Nil(t, result) + }) + + // Test case 4: Invalid request header + t.Run("invalid request header", func(t *testing.T) { + jsonBody := `{"field": "value", "env_id": "test", "name": "alice1"}` + body := io.NopCloser(bytes.NewBufferString(jsonBody)) + req := NewMockRequest(t, http.MethodPost, body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + result, err := decodeReq[TestStruct](req) + assert.Error(t, err) + assert.Nil(t, result) + }) + + // Test case 5: EmptyReq type check + t.Run("EmptyReq type check", func(t *testing.T) { + req := NewMockRequest(t, http.MethodPost, nil) + result, err := decodeReq[EmptyReq](req) + assert.NoError(t, err) + assert.NotNil(t, result) + }) + + // Test case 5: EmptyReq type check + t.Run("HttpRequest type check", func(t *testing.T) { + req := &http.Request{ + Method: http.MethodPost, + } + result, err := decodeReq[http.Request](req) + assert.NoError(t, err) + assert.Equal(t, req.Method, result.Method) + }) +} + +func BenchmarkDecodeReq(b *testing.B) { + r := &http.Request{ + Method: http.MethodPost, + } + + for i := 0; i < b.N; i++ { + result, err := decodeReq[http.Request](r) + if err != nil { + b.Error(err) + } + if result.Method != http.MethodPost { + b.Error("invalid result") + } + } +} diff --git a/apis/metrics.go b/apis/metrics.go new file mode 100644 index 0000000..ca6dbae --- /dev/null +++ b/apis/metrics.go @@ -0,0 +1,60 @@ +package apis + +import ( + "reflect" + "runtime" + "strconv" + "strings" + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +var ( + requestCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "http_requests_total", + Help: "Number of get requests.", + }, + []string{"handler", "method", "code"}, + ) + responseTimeDuration = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "http_request_duration_seconds", + Help: "Histogram of response time for HTTP requests.", + Buckets: []float64{0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60}, + }, + []string{"handler", "method", "code"}, + ) +) + +// getHandleName 获取FuncHandle/StreamHandle函数名 +func getHandleName(fn any) string { + fullName := runtime.FuncForPC(reflect.ValueOf(fn).Pointer()).Name() + if fullName == "" { + panic("get func name is empty") + } + + parts := strings.Split(fullName, ".") + lastPart := parts[len(parts)-1] + name := strings.TrimSuffix(lastPart, "-fm") + return name +} + +// collectHandleMetrics api指标数据 +func collectHandleMetrics(funcName, method string, st time.Time, err error) { + code := 200 + if err != nil { + code = APIError(err).(*APIResponse).HTTPStatusCode + } + + codeStr := strconv.Itoa(code) + requestCounter.WithLabelValues(funcName, method, codeStr).Inc() + duration := time.Since(st).Seconds() + responseTimeDuration.WithLabelValues(funcName, method, codeStr).Observe(duration) +} + +func init() { + prometheus.MustRegister(requestCounter) + prometheus.MustRegister(responseTimeDuration) +} diff --git a/apis/middleware.go b/apis/middleware.go index 362908c..cfe0293 100644 --- a/apis/middleware.go +++ b/apis/middleware.go @@ -13,6 +13,14 @@ import ( "git.ifooth.com/common/pkg/components" ) +type contextKey struct { + name string +} + +var ( + reqCtxKey = &contextKey{"HTTPRequest"} +) + // RequestIdGenerator request_id func RequestIdGenerator() string { uid := uuid.New().String() diff --git a/apis/rest/rest.go b/apis/response.go similarity index 78% rename from apis/rest/rest.go rename to apis/response.go index 0a7bbee..c1ae75c 100644 --- a/apis/rest/rest.go +++ b/apis/response.go @@ -1,4 +1,4 @@ -package rest +package apis import ( "net/http" @@ -36,8 +36,8 @@ func (h RestHandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } -// Result 返回的标准结构 -type Response struct { +// APIResponse 返回的标准结构 +type APIResponse struct { Err error `json:"-"` // low-level runtime error HTTPStatusCode int `json:"-"` // http response status code Code int `json:"code"` @@ -47,7 +47,7 @@ type Response struct { } // Render chi Render 实现 -func (res *Response) Render(w http.ResponseWriter, r *http.Request) error { +func (res *APIResponse) Render(w http.ResponseWriter, r *http.Request) error { statusCode := res.HTTPStatusCode if statusCode == 0 { statusCode = http.StatusOK @@ -61,7 +61,7 @@ func (res *Response) Render(w http.ResponseWriter, r *http.Request) error { // UnauthorizedErrRender 未登入返回 func AbortWithUnauthorizedError(err error) render.Renderer { - return &Response{ + return &APIResponse{ Code: 1401, Message: err.Error(), HTTPStatusCode: http.StatusUnauthorized, @@ -70,7 +70,17 @@ func AbortWithUnauthorizedError(err error) render.Renderer { // AbortWithBadRequestError 错误 func AbortWithBadRequestError(err error) render.Renderer { - return &Response{ + return &APIResponse{ + Code: 1400, + Message: err.Error(), + HTTPStatusCode: http.StatusBadRequest, + } + +} + +// AbortWithBadRequestError 错误 +func APIError(err error) render.Renderer { + return &APIResponse{ Code: 1400, Message: err.Error(), HTTPStatusCode: http.StatusBadRequest, @@ -80,7 +90,7 @@ func AbortWithBadRequestError(err error) render.Renderer { // AbortWithWithForbiddenError 没有权限 func AbortWithWithForbiddenError(err error) render.Renderer { - return &Response{ + return &APIResponse{ Code: 1403, Message: err.Error(), HTTPStatusCode: http.StatusForbidden, @@ -88,6 +98,6 @@ func AbortWithWithForbiddenError(err error) render.Renderer { } // OKRender 正常返回 -func OKRender(data interface{}) render.Renderer { - return &Response{Data: data} +func APIOK(data interface{}) render.Renderer { + return &APIResponse{Data: data} } diff --git a/go.mod b/go.mod index b0d42e3..46a9e53 100644 --- a/go.mod +++ b/go.mod @@ -1,18 +1,24 @@ module git.ifooth.com/common/pkg -go 1.22.0 +go 1.21 require ( github.com/dustin/go-humanize v1.0.1 github.com/felixge/fgprof v0.9.3 - github.com/go-chi/chi/v5 v5.0.8 + github.com/ggicci/httpin v0.19.0 + github.com/go-chi/chi/v5 v5.0.11 github.com/go-chi/render v1.0.2 + github.com/go-playground/locales v0.14.1 + github.com/go-playground/universal-translator v0.18.1 + github.com/go-playground/validator/v10 v10.23.0 github.com/go-resty/resty/v2 v2.7.0 github.com/google/uuid v1.6.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.14.0 github.com/redis/go-redis/v9 v9.0.3 + github.com/samber/lo v1.47.0 + github.com/stretchr/testify v1.9.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 go.opentelemetry.io/otel v1.27.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 @@ -27,23 +33,36 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/ggicci/owl v0.8.2 // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/pprof v0.0.0-20211214055906-6f57359322fd // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect + github.com/labstack/echo/v4 v4.12.0 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.3.0 // indirect github.com/prometheus/common v0.42.0 // indirect github.com/prometheus/procfs v0.9.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect go.opentelemetry.io/otel/metric v1.27.0 // indirect go.opentelemetry.io/otel/trace v1.27.0 // indirect go.opentelemetry.io/proto/otlp v1.2.0 // indirect + golang.org/x/crypto v0.23.0 // indirect golang.org/x/net v0.25.0 // indirect golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect + golang.org/x/text v0.16.0 // indirect google.golang.org/genproto v0.0.0-20230403163135-c38d8f061ccd // indirect google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 5e54546..15ee35a 100644 --- a/go.sum +++ b/go.sum @@ -24,8 +24,14 @@ github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0= -github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/ggicci/httpin v0.19.0 h1:p0B3SWLVgg770VirYiHB14M5wdRx3zR8mCTzM/TkTQ8= +github.com/ggicci/httpin v0.19.0/go.mod h1:hzsQHcbqLabmGOycf7WNw6AAzcVbsMeoOp46bWAbIWc= +github.com/ggicci/owl v0.8.2 h1:og+lhqpzSMPDdEB+NJfzoAJARP7qCG3f8uUC3xvGukA= +github.com/ggicci/owl v0.8.2/go.mod h1:PHRD57u41vFN5UtFz2SF79yTVoM3HlWpjMiE+ZU2dj4= +github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA= +github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/render v1.0.2 h1:4ER/udB0+fMWB2Jlf15RV3F4A2FDuYi/9f+lFttR/Lg= github.com/go-chi/render v1.0.2/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -34,6 +40,14 @@ github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY= github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -46,9 +60,28 @@ github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8 github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= +github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo= +github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= +github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= @@ -67,12 +100,20 @@ github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJf github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= github.com/redis/go-redis/v9 v9.0.3 h1:+7mmR26M0IvyLxGZUHxu4GiBkJkVDid0Un+j4ScYu4k= github.com/redis/go-redis/v9 v9.0.3/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= +github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 h1:9l89oX4ba9kHbBol3Xin3leYJ+252h0zszDtBwyKe2A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0/go.mod h1:XLZfZboOJWHNKUv7eH0inh0E9VV6eWDFB/9yJyTLPp0= go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg= @@ -91,6 +132,8 @@ go.opentelemetry.io/proto/otlp v1.2.0 h1:pVeZGk7nXDC9O2hncA6nHldxEjm6LByfA2aN8IO go.opentelemetry.io/proto/otlp v1.2.0/go.mod h1:gGpR8txAl5M03pDhMC79G6SdqNV26naRm/KDsgaHD8A= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= @@ -98,12 +141,14 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= google.golang.org/genproto v0.0.0-20230403163135-c38d8f061ccd h1:sLpv7bNL1AsX3fdnWh9WVh7ejIzXdOc1RRHGeAmeStU= google.golang.org/genproto v0.0.0-20230403163135-c38d8f061ccd/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= @@ -112,6 +157,8 @@ google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLp google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/i18n/i18n.go b/i18n/i18n.go new file mode 100644 index 0000000..c774c20 --- /dev/null +++ b/i18n/i18n.go @@ -0,0 +1,17 @@ +// Package i18n is used to localize for different languages +package i18n + +import "context" + +type acceptLangKey struct{} + +// SetLang 设定语言 +func SetLang(ctx context.Context, val string) context.Context { + return context.WithValue(ctx, acceptLangKey{}, val) +} + +// GetLang 获取语言 +func GetLang(ctx context.Context) (string, bool) { + value, ok := ctx.Value(acceptLangKey{}).(string) + return value, ok +} diff --git a/validator/validator.go b/validator/validator.go new file mode 100644 index 0000000..8f17051 --- /dev/null +++ b/validator/validator.go @@ -0,0 +1,98 @@ +// Package validator use for valiate struct +package validator + +import ( + "context" + "reflect" + "strings" + + "github.com/go-playground/locales/en" + "github.com/go-playground/locales/zh" + ut "github.com/go-playground/universal-translator" + "github.com/go-playground/validator/v10" + en_translations "github.com/go-playground/validator/v10/translations/en" + zh_translations "github.com/go-playground/validator/v10/translations/zh" + "github.com/samber/lo" + + "git.ifooth.com/common/pkg/i18n" +) + +var ( + validate *validator.Validate + uni *ut.UniversalTranslator + defaultTrans ut.Translator + zhTrans ut.Translator +) + +// ValidationError 校验错误 +type ValidationError struct { + ctx context.Context + rawErr error +} + +// Validator 实现了 Validate 接口自定义调用 +type Validator interface { + Validate() error +} + +func (e *ValidationError) getTranslator() ut.Translator { + acceptLang, ok := i18n.GetLang(e.ctx) + if ok && acceptLang == "zh" { + return zhTrans + } + return defaultTrans +} + +// Error error iface +func (e *ValidationError) Error() string { + if _, ok := e.rawErr.(*validator.InvalidValidationError); ok { + return e.rawErr.Error() + } + + errs := e.rawErr.(validator.ValidationErrors) + // 只返回单个错误 + for _, ve := range errs { + return ve.Translate(e.getTranslator()) + } + + return e.rawErr.Error() +} + +// Struct 通过 validate tag 校验结构体, Validate 校验需要传入指针类型 +func Struct(ctx context.Context, s any) error { + err := validate.StructCtx(ctx, s) + if err != nil { + return &ValidationError{ctx: ctx, rawErr: err} + } + + // 实现了 Validate 接口自定义调用 + if v, ok := s.(Validator); ok { + return v.Validate() + } + + return nil +} + +// tagNameFunc 优先从 json tag 获取名称 +func tagNameFunc(fld reflect.StructField) string { + name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0] + if name == "-" { + return "" + } + return name +} + +func init() { + validate = validator.New(validator.WithRequiredStructEnabled()) + validate.RegisterTagNameFunc(tagNameFunc) + + // 默认使用英文 + en := en.New() + zh := zh.New() + uni = ut.New(en, en, zh) + defaultTrans, _ = uni.GetTranslator("en") + lo.Must0(en_translations.RegisterDefaultTranslations(validate, defaultTrans)) + + zhTrans, _ = uni.GetTranslator("zh") + lo.Must0(zh_translations.RegisterDefaultTranslations(validate, zhTrans)) +} diff --git a/validator/validator_test.go b/validator/validator_test.go new file mode 100644 index 0000000..88f1d13 --- /dev/null +++ b/validator/validator_test.go @@ -0,0 +1,61 @@ +package validator + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +type testStruct struct { + EnvID string `json:"uid" validate:"required"` + Force bool `json:"-" validate:"required"` + Operator string `validate:"required"` +} + +type testStruct2 struct { + EnvID string `json:"uid" validate:"required"` + Force bool `json:"-"` + Operator string `validate:"required"` +} + +type testStruct3 struct { + Name string `json:"name" validate:"required,gt=1"` + Count int `json:"count" validate:"required,gt=0"` +} + +func (s *testStruct2) Validate() error { + return fmt.Errorf("hit validate") +} + +func TestValidate(t *testing.T) { + + d := testStruct{} + err := Struct(context.Background(), d) + assert.Equal(t, err.Error(), "uid is a required field") + + t2 := &testStruct2{ + EnvID: "abc", + Force: false, + Operator: "ab", + } + + err = Struct(context.Background(), t2) + if assert.Error(t, err) { + assert.Equal(t, err.Error(), "hit validate") + } + + t3 := testStruct3{ + Name: "dd_dabc", + Count: 1, + } + err = Struct(context.Background(), t3) + assert.NoError(t, err) + + name := "testValidate" + err = Struct(context.Background(), name) + assert.Equal(t, err.Error(), "validator: (nil string)") + // assert.Equal(t, err.Error(), "Force is required") + // assert.Equal(t, err.Error(), "Operator is required") +}