From 17c7c7b1397b009c670cd3341fef97cc810c6309 Mon Sep 17 00:00:00 2001 From: joelei Date: Wed, 10 Dec 2025 23:34:57 +0800 Subject: [PATCH] update codec --- cli/cli.go | 3 +- rest/codec/codec.go | 123 ++++++++++++++++++++++--------------- rest/codec/form.go | 12 ++-- rest/codec/header.go | 7 +-- rest/codec/json.go | 28 +++------ rest/codec/path.go | 23 +------ rest/codec/query.go | 23 +------ rest/codec/tag.go | 39 ++++++------ rest/codec/value_parser.go | 18 +----- rest/generic.go | 14 ++--- rest/response.go | 10 ++- storage/redis.go | 3 +- task/handler.go | 24 ++++---- util/util.go | 14 +++++ validator/validator.go | 22 ++++--- 15 files changed, 168 insertions(+), 195 deletions(-) create mode 100644 util/util.go diff --git a/cli/cli.go b/cli/cli.go index 6737020..e4a1a9b 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -9,8 +9,9 @@ import ( "os/signal" "syscall" - "git.ifooth.com/common/pkg/version" "github.com/spf13/cobra" + + "git.ifooth.com/common/pkg/version" ) var ( diff --git a/rest/codec/codec.go b/rest/codec/codec.go index 47e2d32..ba05f85 100644 --- a/rest/codec/codec.go +++ b/rest/codec/codec.go @@ -1,19 +1,3 @@ -/* - * Tencent is pleased to support the open source community by making - * 蓝鲸智云 - 配置平台 (BlueKing - CMDB) available. - * Copyright (C) 2025 Tencent. All rights reserved. - * Licensed under the MIT License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at http://opensource.org/licenses/MIT - * 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. - * We undertake not to change the open source license (MIT license) applicable - * to the current version of the project delivered to anyone in the future. - */ - // Package codec provides encoding and decoding utilities across various formats package codec @@ -28,9 +12,8 @@ func decodeTo(r *http.Request, val any) error { rt := reflect.TypeOf(val).Elem() rv := reflect.ValueOf(val).Elem() - // json 整个解析 - jsonCodec := NewJsonCodec(r) - if err := jsonCodec.Decode(val); err != nil { + fields, err := getStructFields(rt, rv) + if err != nil { return err } @@ -41,38 +24,37 @@ func decodeTo(r *http.Request, val any) error { pathCodec := NewPathCodec(r) queryCodec := NewQueryCodec(r) + headerCodec := NewHeaderCodec(r) + for _, f := range fields { + switch f.tag.In { + case pathOptName: + if err := pathCodec.Decode(f.field, f.fv, f.tag); err != nil { + return fmt.Errorf("field[%s] decode path: %w", f.field.Name, err) + } + case queryOptName: + if err := queryCodec.Decode(f.field, f.fv, f.tag); err != nil { + return fmt.Errorf("field[%s] decode query: %w", f.field.Name, err) + } + case formOptName: + if err := formCodec.Decode(f.field, f.fv, f.tag); err != nil { + return fmt.Errorf("field[%s] decode form: %w", f.field.Name, err) + } + case headerOptName: + if err := headerCodec.Decode(f.field, f.fv, f.tag); err != nil { + return fmt.Errorf("field[%s] decode header: %w", f.field.Name, err) + } + case "": + return fmt.Errorf("field[%s] in option is required", f.field.Name) + default: + return fmt.Errorf("field[%s] in[%s] option not valid", f.field.Name, f.tag.In) + } + } - for i := 0; i < rt.NumField(); i++ { - field := rt.Field(i) - - // 非导出需要跳过, 无法设置值 - if !field.IsExported() { - continue - } - - tagStr := field.Tag.Get(tagName) - if tagStr == "" { - continue - } - tag, err := parseTag(tagStr) - if err != nil { - return err - } - - fv := rv.Field(i) - if err := formCodec.Decode(field, fv, tag); err != nil { - return err - } - if err := queryCodec.Decode(field, fv, tag); err != nil { - return err - } - if err := headerCodec.Decode(field, fv, tag); err != nil { - return err - } - if err := pathCodec.Decode(field, fv, tag); err != nil { - return err - } + // json 整个解析 + jsonCodec := NewJsonCodec(r) + if err := jsonCodec.Decode(val); err != nil { + return fmt.Errorf("decode json: %w", err) } return nil @@ -88,12 +70,53 @@ func Decode[T any](r *http.Request) (*T, error) { t := new(T) err := decodeTo(r, t) if err != nil { - return nil, fmt.Errorf("codec decode: %w", err) + return nil, fmt.Errorf("decode req: %w", err) } return t, nil } +type structField struct { + field reflect.StructField // 结构体字段 + fv reflect.Value // 字段的值 + tag *Tag // 字段解析后的req tag +} + +// getStructFields 获取字段列表, 校验json/req的唯一性 +func getStructFields(rt reflect.Type, rv reflect.Value) ([]structField, error) { + fields := make([]structField, 0) + for i := 0; i < rt.NumField(); i++ { + field := rt.Field(i) + + // 非导出需要跳过, 无法设置值 + if !field.IsExported() { + continue + } + + reqTagStr := field.Tag.Get(tagName) + if reqTagStr == "" { + continue + } + tag, err := parseTag(reqTagStr) + if err != nil { + return nil, fmt.Errorf("field[%s] %w", field.Name, err) + } + // tag name为空或者-忽略 + if tag.Name == "" || tag.Name == "-" { + continue + } + + // 可以重复 + // jsonTagName := util.GetTagName(field, "json") + // if jsonTagName != "" && jsonTagName != "-" { + // return nil, fmt.Errorf("field[%s] req and json tag are mutually exclusive", field.Name) + // } + + fields = append(fields, structField{field: field, fv: rv.Field(i), tag: tag}) + } + return fields, nil +} + // getFieldValue 获取字段值 func getFieldValue(field reflect.Type, tag *Tag, values []string) (reflect.Value, error) { // 指针类型 diff --git a/rest/codec/form.go b/rest/codec/form.go index cd1f47a..5ff0d92 100644 --- a/rest/codec/form.go +++ b/rest/codec/form.go @@ -34,10 +34,13 @@ func NewFormCodec(r *http.Request) (*formCodec, error) { isForm := false contentType := r.Header.Get("Content-Type") - if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") { + if (r.Method == "POST" || r.Method == "PUT" || r.Method == "PATCH") && + strings.HasPrefix(contentType, "application/x-www-form-urlencoded") { + if err := r.ParseForm(); err != nil { return nil, err } + isForm = true } @@ -54,12 +57,7 @@ func (c *formCodec) Decode(field reflect.StructField, fv reflect.Value, tag *Tag return nil } - formTag, ok := tag.Option["form"] - if !ok { - return nil - } - - v := c.values[formTag] + v := c.values[tag.Name] if len(v) == 0 { return nil } diff --git a/rest/codec/header.go b/rest/codec/header.go index 251e72f..8030156 100644 --- a/rest/codec/header.go +++ b/rest/codec/header.go @@ -34,13 +34,8 @@ func NewHeaderCodec(r *http.Request) *headerCodec { // Decode ... func (c *headerCodec) Decode(field reflect.StructField, fv reflect.Value, tag *Tag) error { - headTag, ok := tag.Option["header"] - if !ok { - return nil - } - // header统一格式 - key := http.CanonicalHeaderKey(headTag) + key := http.CanonicalHeaderKey(tag.Name) v := c.values[key] if len(v) == 0 { return nil diff --git a/rest/codec/json.go b/rest/codec/json.go index fcfc336..09f58ad 100644 --- a/rest/codec/json.go +++ b/rest/codec/json.go @@ -1,19 +1,3 @@ -/* - * Tencent is pleased to support the open source community by making - * 蓝鲸智云 - 配置平台 (BlueKing - CMDB) available. - * Copyright (C) 2025 Tencent. All rights reserved. - * Licensed under the MIT License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at http://opensource.org/licenses/MIT - * 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. - * We undertake not to change the open source license (MIT license) applicable - * to the current version of the project delivered to anyone in the future. - */ - package codec import ( @@ -33,8 +17,10 @@ type jsonCodec struct { func NewJsonCodec(r *http.Request) *jsonCodec { isJson := false + // 限制Method, 同ParseForm的一致 contentType := r.Header.Get("Content-Type") - if strings.HasPrefix(contentType, "application/json") { + if (r.Method == "POST" || r.Method == "PUT" || r.Method == "PATCH") && + strings.HasPrefix(contentType, "application/json") { isJson = true } @@ -51,11 +37,15 @@ func (j *jsonCodec) Decode(val any) error { if err != nil { return err } + + // body等于空时,可能其他解析场景,直接正常返回 + // 如果需要判断是否有值,可通过指针处理 if len(body) == 0 { - return fmt.Errorf("json body is empty") + return nil } + if err := json.Unmarshal(body, val); err != nil { - return fmt.Errorf("unmarshal json body: %s", err) + return fmt.Errorf("unmarshal json body: %w", err) } return nil } diff --git a/rest/codec/path.go b/rest/codec/path.go index d423714..025ad33 100644 --- a/rest/codec/path.go +++ b/rest/codec/path.go @@ -1,19 +1,3 @@ -/* - * Tencent is pleased to support the open source community by making - * 蓝鲸智云 - 配置平台 (BlueKing - CMDB) available. - * Copyright (C) 2025 Tencent. All rights reserved. - * Licensed under the MIT License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at http://opensource.org/licenses/MIT - * 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. - * We undertake not to change the open source license (MIT license) applicable - * to the current version of the project delivered to anyone in the future. - */ - package codec import ( @@ -34,12 +18,7 @@ func NewPathCodec(r *http.Request) *pathCodec { // Decode ... func (c *pathCodec) Decode(field reflect.StructField, fv reflect.Value, tag *Tag) error { - pathTag, ok := tag.Option["path"] - if !ok { - return nil - } - - pv := c.req.PathValue(pathTag) + pv := c.req.PathValue(tag.Name) if pv == "" { return nil } diff --git a/rest/codec/query.go b/rest/codec/query.go index ed5d773..a96ef7a 100644 --- a/rest/codec/query.go +++ b/rest/codec/query.go @@ -1,19 +1,3 @@ -/* - * Tencent is pleased to support the open source community by making - * 蓝鲸智云 - 配置平台 (BlueKing - CMDB) available. - * Copyright (C) 2025 Tencent. All rights reserved. - * Licensed under the MIT License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at http://opensource.org/licenses/MIT - * 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. - * We undertake not to change the open source license (MIT license) applicable - * to the current version of the project delivered to anyone in the future. - */ - package codec import ( @@ -35,12 +19,7 @@ func NewQueryCodec(r *http.Request) *queryCodec { // Decode ... func (c *queryCodec) Decode(field reflect.StructField, fv reflect.Value, tag *Tag) error { - queryTag, ok := tag.Option["query"] - if !ok { - return nil - } - - v := c.values[queryTag] + v := c.values[tag.Name] if len(v) == 0 { return nil } diff --git a/rest/codec/tag.go b/rest/codec/tag.go index 540c2d2..e5e792c 100644 --- a/rest/codec/tag.go +++ b/rest/codec/tag.go @@ -1,19 +1,3 @@ -/* - * Tencent is pleased to support the open source community by making - * 蓝鲸智云 - 配置平台 (BlueKing - CMDB) available. - * Copyright (C) 2025 Tencent. All rights reserved. - * Licensed under the MIT License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at http://opensource.org/licenses/MIT - * 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. - * We undertake not to change the open source license (MIT license) applicable - * to the current version of the project delivered to anyone in the future. - */ - package codec import ( @@ -24,12 +8,19 @@ import ( const ( // tagName 结构体tag名称 // 格式参考 https://pkg.go.dev/encoding/json/v2#example-package-FormatFlags - tagName = "req" + tagName = "req" + inOptName = "in" + queryOptName = "query" + pathOptName = "path" + formOptName = "form" + headerOptName = "header" ) // Tag is a struct tag type Tag struct { - Option map[string]string + Name string // tag name + In string // query/path参数中 + Option map[string]string // 自定义参数 } func parseTag(tagStr string) (*Tag, error) { @@ -39,11 +30,14 @@ func parseTag(tagStr string) (*Tag, error) { } parts := strings.Split(tagStr, ",") + name := strings.TrimSpace(parts[0]) + t := &Tag{ + Name: name, Option: map[string]string{}, } - for _, part := range parts { + for _, part := range parts[1:] { part = strings.TrimSpace(part) if part == "" { return nil, fmt.Errorf("tag option not valid") @@ -55,7 +49,12 @@ func parseTag(tagStr string) (*Tag, error) { if len(opt) == 2 { val = opt[1] } - t.Option[key] = val + + if key == inOptName { + t.In = val + } else { + t.Option[key] = val + } } return t, nil diff --git a/rest/codec/value_parser.go b/rest/codec/value_parser.go index 60cbd28..ba90bbe 100644 --- a/rest/codec/value_parser.go +++ b/rest/codec/value_parser.go @@ -1,19 +1,3 @@ -/* - * Tencent is pleased to support the open source community by making - * 蓝鲸智云 - 配置平台 (BlueKing - CMDB) available. - * Copyright (C) 2025 Tencent. All rights reserved. - * Licensed under the MIT License (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at http://opensource.org/licenses/MIT - * 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. - * We undertake not to change the open source license (MIT license) applicable - * to the current version of the project delivered to anyone in the future. - */ - package codec import ( @@ -175,7 +159,7 @@ func (t *Time) New(opt map[string]string) Parser { } func init() { - // buildin parser + // builtin parser RegisterParser[string](ParserFunc(StringParser)) RegisterParser[bool](ParserFunc(BoolParser)) RegisterParser[int](Int[int]{0}) diff --git a/rest/generic.go b/rest/generic.go index fbe6d3e..cc6a69a 100644 --- a/rest/generic.go +++ b/rest/generic.go @@ -51,23 +51,23 @@ func Handle[Req, Resp any](fn UnaryFunc[Req, Resp]) func(w http.ResponseWriter, in, err := decodeReq[Req](r) if err != nil { slog.Error("handle decode request failed", "err", err) - _ = APIError(err).Render(w, r) + APIError(err).Render(w, r) return } // 参数校验 if err = validateReq(r.Context(), in); err != nil { slog.Error("validate req failed", "err", err) - _ = APIError(err).Render(w, r) + APIError(err).Render(w, r) return } out, err := fn(r.Context(), in) if err != nil { - _ = APIError(err).Render(w, r) + APIError(err).Render(w, r) return } - _ = APIOK(out).Render(w, r) + APIOK(out).Render(w, r) } return f } @@ -98,14 +98,14 @@ func Stream[Req any](fn StreamFunc[Req]) func(w http.ResponseWriter, r *http.Req in, err := decodeReq[Req](r) if err != nil { slog.Error("handle decode stream request failed", "err", err) - _ = APIError(err).Render(w, r) + APIError(err).Render(w, r) return } // 参数校验 if err = validateReq(r.Context(), in); err != nil { slog.Error("validate stream req failed", "err", err) - _ = APIError(err).Render(w, r) + APIError(err).Render(w, r) return } @@ -117,7 +117,7 @@ func Stream[Req any](fn StreamFunc[Req]) func(w http.ResponseWriter, r *http.Req err = fn(in, svr) if err != nil { - _ = APIError(err).Render(w, r) + APIError(err).Render(w, r) } } return f diff --git a/rest/response.go b/rest/response.go index 390e40c..23d8268 100644 --- a/rest/response.go +++ b/rest/response.go @@ -18,12 +18,13 @@ package rest import ( "encoding/json/v2" + "log/slog" "net/http" ) // Renderer interface for managing response payloads. type Renderer interface { - Render(w http.ResponseWriter, r *http.Request) error + Render(w http.ResponseWriter, r *http.Request) } // APIResponse response for api request @@ -35,12 +36,15 @@ type APIResponse struct { } // Render chi render interface implementation -func (e *APIResponse) Render(w http.ResponseWriter, r *http.Request) error { +func (e *APIResponse) Render(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(e.HTTPCode) - return json.MarshalWrite(w, e) + err := json.MarshalWrite(w, e) + if err != nil { + slog.Error("render resp failed", "err", err) + } } // APIOK 正常返回 diff --git a/storage/redis.go b/storage/redis.go index 546b61e..a231bd7 100644 --- a/storage/redis.go +++ b/storage/redis.go @@ -5,8 +5,9 @@ import ( "strconv" "time" - "git.ifooth.com/common/pkg/config" redis "github.com/redis/go-redis/v9" + + "git.ifooth.com/common/pkg/config" ) // RedisSession : diff --git a/task/handler.go b/task/handler.go index cc7d6a6..21c285f 100644 --- a/task/handler.go +++ b/task/handler.go @@ -58,17 +58,17 @@ func NewHandler(mgr *TaskManager, host string, routePrefix string) http.Handler // Task bcs_task task with execution duration type Task struct { *itypes.Task - Steps []*Step `json:"steps,omitempty"` - ExecutionDuration time.Duration `json:"executionDuration" swaggertype:"string"` - MaxExecutionDuration time.Duration `json:"maxExecutionDuration" swaggertype:"string"` - DetailURL string `json:"detailURL,omitempty"` + Steps []*Step `json:"steps,omitempty"` + ExecutionDuration string `json:"executionDuration" swaggertype:"string"` + MaxExecutionDuration string `json:"maxExecutionDuration" swaggertype:"string"` + DetailURL string `json:"detailURL,omitempty"` } // Step bcs_task step with execution duration type Step struct { *itypes.Step - ExecutionDuration time.Duration `json:"executionDuration" swaggertype:"string"` - MaxExecutionDuration time.Duration `json:"maxExecutionDuration" swaggertype:"string"` + ExecutionDuration string `json:"executionDuration,fmt" swaggertype:"string"` + MaxExecutionDuration string `json:"maxExecutionDuration" swaggertype:"string"` } // StepReq ... @@ -320,16 +320,16 @@ func (s *service) Status(ctx context.Context, req *commonReq) (*Task, error) { steps := lo.Map(taskData.Steps, func(v *itypes.Step, _ int) *Step { return &Step{ Step: v, - ExecutionDuration: time.Duration(v.ExecutionTime) * time.Millisecond, - MaxExecutionDuration: time.Duration(v.MaxExecutionSeconds) * time.Second, + ExecutionDuration: (time.Duration(v.ExecutionTime) * time.Millisecond).String(), + MaxExecutionDuration: (time.Duration(v.MaxExecutionSeconds) * time.Second).String(), } }) t := &Task{ Task: taskData, Steps: steps, - ExecutionDuration: time.Duration(taskData.ExecutionTime) * time.Millisecond, - MaxExecutionDuration: time.Duration(taskData.MaxExecutionSeconds) * time.Second, + ExecutionDuration: (time.Duration(taskData.ExecutionTime) * time.Millisecond).String(), + MaxExecutionDuration: (time.Duration(taskData.MaxExecutionSeconds) * time.Second).String(), } return t, nil @@ -404,8 +404,8 @@ func (s *service) List(ctx context.Context, req *ListReq) (*rest.PaginationResp[ items := lo.Map(result.Items, func(v *itypes.Task, _ int) Task { return Task{ Task: v, - ExecutionDuration: time.Duration(v.ExecutionTime) * time.Millisecond, - MaxExecutionDuration: time.Duration(v.MaxExecutionSeconds) * time.Second, + ExecutionDuration: (time.Duration(v.ExecutionTime) * time.Millisecond).String(), + MaxExecutionDuration: (time.Duration(v.MaxExecutionSeconds) * time.Second).String(), DetailURL: fmt.Sprintf("%s?taskID=%s", detailURL, v.TaskID), } }) diff --git a/util/util.go b/util/util.go new file mode 100644 index 0000000..76f6a45 --- /dev/null +++ b/util/util.go @@ -0,0 +1,14 @@ +// Package util provides common utility functions and helpers for various operations across the project. +package util + +import ( + "reflect" + "strings" +) + +// GetTagName extracts the first comma-separated value from a struct field's tag. +// For example, given a tag `json:"name,omitempty"`, it returns "name". +func GetTagName(field reflect.StructField, tag string) string { + name := strings.SplitN(field.Tag.Get(tag), ",", 2)[0] + return name +} diff --git a/validator/validator.go b/validator/validator.go index 8f17051..6697841 100644 --- a/validator/validator.go +++ b/validator/validator.go @@ -4,7 +4,6 @@ package validator import ( "context" "reflect" - "strings" "github.com/go-playground/locales/en" "github.com/go-playground/locales/zh" @@ -15,6 +14,7 @@ import ( "github.com/samber/lo" "git.ifooth.com/common/pkg/i18n" + "git.ifooth.com/common/pkg/util" ) var ( @@ -73,18 +73,24 @@ func Struct(ctx context.Context, s any) error { return nil } -// tagNameFunc 优先从 json tag 获取名称 -func tagNameFunc(fld reflect.StructField) string { - name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0] - if name == "-" { - return "" +// readableTagName 返回可读的json/req校验字段名称, 唯一性由codec校验 +func readableTagName(field reflect.StructField) string { + name := util.GetTagName(field, "json") + if name != "" && name != "-" { + return name } - return name + + name = util.GetTagName(field, "req") + if name != "" && name != "-" { + return name + } + + return "" } func init() { validate = validator.New(validator.WithRequiredStructEnabled()) - validate.RegisterTagNameFunc(tagNameFunc) + validate.RegisterTagNameFunc(readableTagName) // 默认使用英文 en := en.New()