diff --git a/api/components/parameters/ID.yaml b/api/components/parameters/ID.yaml index 2d4075f..22d7d73 100644 --- a/api/components/parameters/ID.yaml +++ b/api/components/parameters/ID.yaml @@ -4,4 +4,4 @@ required: true schema: type: integer format: int64 - + minimum: 1 diff --git a/apps/product-query-svc/adapters/inbound/http/handler_product_read.go b/apps/product-query-svc/adapters/inbound/http/handler_product_read.go index 54b5305..66c1e29 100644 --- a/apps/product-query-svc/adapters/inbound/http/handler_product_read.go +++ b/apps/product-query-svc/adapters/inbound/http/handler_product_read.go @@ -1,43 +1,29 @@ package httpadapter -import "net/http" +import "context" -func (s *Server) GetProductByID(w http.ResponseWriter, r *http.Request, id int64) { - if id <= 0 { - writeError(w, http.StatusBadRequest, "INVALID_ID", "id must be a positive integer") - return - } - p, err := s.products.FetchByID(r.Context(), id) +func (s *Server) GetProductByID(ctx context.Context, request GetProductByIDRequestObject) (GetProductByIDResponseObject, error) { + product, err := s.products.FetchByID(ctx, request.Id) if err != nil { - writeDomainError(w, err) - return + if resp, handled := getProductError(err); handled { + return resp, nil + } + return nil, err } - writeJSON(w, http.StatusOK, presentProduct(p)) + + return okGetProduct(product), nil } -func (s *Server) SearchProducts(w http.ResponseWriter, r *http.Request, params SearchProductsParams) { - q := "" - if params.Q != nil { - q = *params.Q - } - // Enforce OpenAPI minLength:3 for q when provided - if q != "" && len(q) < 3 { - writeError(w, http.StatusBadRequest, "INVALID_QUERY", "q must be at least 3 characters if provided") - return - } - page := 1 - if params.Page != nil { - page = *params.Page - } - pageSize := 20 - if params.PageSize != nil { - pageSize = *params.PageSize - } - items, total, err := s.products.Search(r.Context(), q, page, pageSize) +func (s *Server) SearchProducts(ctx context.Context, request SearchProductsRequestObject) (SearchProductsResponseObject, error) { + filters := newSearchFilters(request.Params) + + items, total, err := s.products.Search(ctx, filters.query, filters.page, filters.pageSize) if err != nil { - writeDomainError(w, err) - return + if resp, handled := searchProductsError(err); handled { + return resp, nil + } + return nil, err } - resp := ProductList{Items: presentProducts(items), Page: page, PageSize: pageSize, Total: total} - writeJSON(w, http.StatusOK, resp) + + return okSearchProducts(items, filters.page, filters.pageSize, total), nil } diff --git a/apps/product-query-svc/adapters/inbound/http/handler_product_write.go b/apps/product-query-svc/adapters/inbound/http/handler_product_write.go index 67dd04a..657d10d 100644 --- a/apps/product-query-svc/adapters/inbound/http/handler_product_write.go +++ b/apps/product-query-svc/adapters/inbound/http/handler_product_write.go @@ -1,70 +1,56 @@ package httpadapter -import ( - "encoding/json" - "net/http" +import "context" - "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" -) - -// DeleteProductByID implements OpenAPI operation: DELETE /products/{id} -func (s *Server) DeleteProductByID(w http.ResponseWriter, r *http.Request, id int64) { - if id <= 0 { - writeError(w, http.StatusBadRequest, "INVALID_ID", "id must be a positive integer") - return +func (s *Server) CreateProduct(ctx context.Context, request CreateProductRequestObject) (CreateProductResponseObject, error) { + product, err := newProductFromCreateBody(request.Body) + if err != nil { + if resp, handled := createProductError(err); handled { + return resp, nil + } + return nil, err } - if err := s.products.Remove(r.Context(), id); err != nil { - writeDomainError(w, err) - return + + id, err := s.products.Create(ctx, product) + if err != nil { + if resp, handled := createProductError(err); handled { + return resp, nil + } + return nil, err } - w.WriteHeader(http.StatusNoContent) + + product.ID = id + + return okCreateProduct(product), nil } -// CreateProduct implements POST /products -func (s *Server) CreateProduct(w http.ResponseWriter, r *http.Request) { - var in CreateProductJSONBody - if err := json.NewDecoder(r.Body).Decode(&in); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body") - return - } - cents := amountToCents(in.Price) - p, err := domain.NewProduct(in.Name, cents, nil) +func (s *Server) UpdateProduct(ctx context.Context, request UpdateProductRequestObject) (UpdateProductResponseObject, error) { + product, err := newProductFromUpdateBody(request.Id, request.Body) if err != nil { - writeDomainError(w, err) - return + if resp, handled := updateProductError(err); handled { + return resp, nil + } + return nil, err } - id, err := s.products.Create(r.Context(), p) + + updated, err := s.products.Update(ctx, product) if err != nil { - writeDomainError(w, err) - return + if resp, handled := updateProductError(err); handled { + return resp, nil + } + return nil, err } - created := *p - created.ID = id - writeJSON(w, http.StatusCreated, presentProduct(&created)) + + return okUpdateProduct(updated), nil } -// UpdateProduct implements OpenAPI operation: PUT /products/{id} -func (s *Server) UpdateProduct(w http.ResponseWriter, r *http.Request, id int64) { - if id <= 0 { - writeError(w, http.StatusBadRequest, "INVALID_ID", "id must be a positive integer") - return - } - var in UpdateProductJSONBody - if err := json.NewDecoder(r.Body).Decode(&in); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body") - return - } - cents := amountToCents(in.Price) - p, err := domain.NewProduct(in.Name, cents, nil) - if err != nil { - writeDomainError(w, err) - return +func (s *Server) DeleteProductByID(ctx context.Context, request DeleteProductByIDRequestObject) (DeleteProductByIDResponseObject, error) { + if err := s.products.Remove(ctx, request.Id); err != nil { + if resp, handled := deleteProductError(err); handled { + return resp, nil + } + return nil, err } - p.ID = id - updated, err := s.products.Update(r.Context(), p) - if err != nil { - writeDomainError(w, err) - return - } - writeJSON(w, http.StatusOK, presentProduct(updated)) + + return okDeleteProduct(), nil } diff --git a/apps/product-query-svc/adapters/inbound/http/handler_user_read.go b/apps/product-query-svc/adapters/inbound/http/handler_user_read.go index 4e68a0e..0cdf15f 100644 --- a/apps/product-query-svc/adapters/inbound/http/handler_user_read.go +++ b/apps/product-query-svc/adapters/inbound/http/handler_user_read.go @@ -1,16 +1,15 @@ package httpadapter -import "net/http" +import "context" -func (s *Server) GetUserByID(w http.ResponseWriter, r *http.Request, id int64) { - if id <= 0 { - writeError(w, http.StatusBadRequest, "INVALID_ID", "id must be a positive integer") - return - } - u, err := s.users.FetchByID(r.Context(), id) +func (s *Server) GetUserByID(ctx context.Context, request GetUserByIDRequestObject) (GetUserByIDResponseObject, error) { + user, err := s.users.FetchByID(ctx, request.Id) if err != nil { - writeDomainError(w, err) - return + if resp, handled := getUserError(err); handled { + return resp, nil + } + return nil, err } - writeJSON(w, http.StatusOK, presentUser(u)) + + return okGetUser(user), nil } diff --git a/apps/product-query-svc/adapters/inbound/http/marketplaceapi.gen.go b/apps/product-query-svc/adapters/inbound/http/marketplaceapi.gen.go index b7cca9c..2d2f737 100644 --- a/apps/product-query-svc/adapters/inbound/http/marketplaceapi.gen.go +++ b/apps/product-query-svc/adapters/inbound/http/marketplaceapi.gen.go @@ -912,23 +912,23 @@ func (sh *strictHandler) GetUserByID(w http.ResponseWriter, r *http.Request, id // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+xYS4/bNhD+KwLbo7rWbtwedEvqojCQoi4WOQWLBVcc20zFx5KjoK6h/16Q1NOiFtnE", - "azRtTrY5nOf3zWjkIymU0EqCREvyI9HUUAEIxv/qZfe94H69cjIuSU40xT1JiaQCSE44Iykx8FhxA4zk", - "aCpIiS32IKjT2CojKLp7En9akpTgQUP4CTswpK7TGY8buoPO52MF5tA71U42dMNgS6sSSX6dEsElF5Xw", - "35/l7Zb//aRHL496vclSIuhfjdss+8wg/pjz/jhyK7h8C3KHe5K/6qxbNFzuSO2sOzjA4hvFOJxiOpLd", - "b4xiVYE/G6AI4aZEkOi+Uq1LXlDkSi4+WCXdWR/E9wa2JCffLXrbiyDtPk+s+9AY2MJw7YySnDQXksLd", - "4Eommh5KRaeUCllZraSNZNSc3/9ijDJnzyJYjUTvBUnr3uPa6DjTTW6+wYzSYLABg7NPaowW/KNjVov3", - "9U3gVvd7gn9KtOEFNDwJHMy6W7ISDw0F+wK/D03s3bXqd52KevgABTrDTUZvuY1lhSDGX57BEGe9cUeN", - "oQefRtP/07roQa9OpaiQljHRac4+zrSdJYMGDxZiFXhnwYTGH/LAnSbaqC0vITGAlZHAkodDgntIXm/W", - "V76qw2J5vgN7jSMmMIrwA3KPggHKfpfloZ2oE5RBUF6O1MNJOqTLzY/LiOoMAWdcfikhn6BaG3FfjljR", - "x304IV6h2JAJfZoMkPJyzMmx6pZDyaK6BmgzK6bZTAI8Za4Aa8fknamFD72//1Ty/71pEn9ITPK7XNjP", - "jfh/PAfbsL/Nw4vPQ6fP5VZ5ZDmWTvYbNX8C6pIWkKxAKFdkkpKPYGxA5Poqu8pcxEqDpJqTnLzyR6nf", - "5j0GCx04GOakCtx2IPndac1ITkKPtlwd7pmHOaKP1s3FJ+6hp8veTXb9UrtpbK8LUbBE9025zLL5DJtI", - "F0/spP65QXfWIb5p63znTruyLyxQU+ydmx1Ein/rxZ1yOnptex+Prb+ymHvjqNPPVPXvZ1+k7cdMfTcB", - "O3spsP3EjgDuzhO1TboOuBDiR87qMD5LCM++MeQrf95ovzmsV2dCfb2KlX05neQhAHaWejgTy5coaRrv", - "l18BL1W57JLT6ZbLXQlnHU4vCI2uItC802z0HDkrMpd6JF0U9FAx9nWg7mZcZR1U7YCba1C3KX6F3enX", - "3vnWrCyYfyFCLmoPT392nPk/zv/9mFDJEkEl3YEAiQlIphWXfvdo/pfc9A/M6EsALQpVSXQvAYbDR1rG", - "jIS46rv6nwAAAP//Xglf8ZgWAAA=", + "H4sIAAAAAAAC/+xYS2/jNhD+KwLboxorWbcH3XbrojCwRV0Ee1oEASOObW7FR8jRoq6h/16Q1NOigm7W", + "MZq2p8QczvP7ZjTSkRRKaCVBoiX5kWhqqAAE43/1svtecL9eORmXJCea4p6kRFIBJCeckZQYeKy4AUZy", + "NBWkxBZ7ENRpbJURFN09iT8sSUoEl1xUguTXKcGDhiCCHRhS1+mM9w3dQef/sQJz6APQTjZ0yWBLqxK9", + "h2d7u+V/PunRy6Neb7KUCPpH4zbLnhnEb3PeH0duBZfvQe5wT/I3nXWLhssdqZ11Bw1YfKcYh1N8R7L7", + "jVGsKvBHAxQh3JQIEt2/VOuSFxS5kotPVkl31gfxrYEtyck3i972Iki7vyfWfWgMbGG4dkZJTpoLSeFu", + "cCUTTQ+lolN6haysVtJGMmrO738yRpmzZxGsRqL3gqR173FtdJzpJjffbEZpMNiAwVm0SU5Z0oJ/dMxq", + "8b6+Cdzqfk/wT4k2vICGJ4GDWXdLVuKhoWBf4I+hob27Vv2uU1EPn6BAZ7jJ6D23sawQxPifL2CIs964", + "o8bQg0+j6f9pXfSgV6dSVEjLmOg0Zx9n2s6SQYMHC7EKfLBgQuMPeeBOE23UlpeQGMDKSGDJwyHBPSRv", + "N+srX9VhsTzfgb3FERMYRfgOuUfBAGW/yvLQTtcJyiAoL0fq4SQd0uXm+2VEdYaAMy6/lpBPUK2NuC9H", + "rOjjPpwQr1BsyIQ+TQZIeTnm5Fh1y6FkUV0DtJkV02wmAZ4yV4C1Y/LO1MKH3t9/Kvl/3zSJPyQm+V0u", + "7C+N+D88B9uw/5+HF5+HTp/LrfLIciyd7BdqfgfUJS0gWYFQrsgkJZ/B2IDI9VV2lbmIlQZJNSc5eeOP", + "Ur/ZewwWOnAwzEkVuO1A8rvTmpGchB5tuTrcMw9zRB+tm4u/uYeeLns32fVL7aaxvS5EwRLdN+Uyy+Yz", + "bCJdPLGT+ucG3VmH+Kat85077cq+sEBNsXdudhAp/q0Xd8rp6BXuYzy2/spi7o2jTp+p6t/Pvkrbj5n6", + "bgJ29lJg+4kdAdydJ2qbdB1wIcSPnNVhfJYQnn1jyFf+vNF+d1ivzoT6ehUr+3I6yUMA7Cz1cCaWL1HS", + "NN4vPwNeqnLZJafTLZe7Es46nF4QGl1FoPmg2eg5clZkLvVIuijooWLsdaDuZlxlHVTtgJtrULcpvsLu", + "9GvvfGtWFsw/ECEXtYenPzvOfI/znx8TKlkiqKQ7ECAxAcm04tLvHs13yU3/wIy+BNCiUJVE9xJgOHym", + "ZcxIiKu+q/8KAAD//yYnwMOkFgAA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/apps/product-query-svc/adapters/inbound/http/request_mappers.go b/apps/product-query-svc/adapters/inbound/http/request_mappers.go new file mode 100644 index 0000000..637d3ae --- /dev/null +++ b/apps/product-query-svc/adapters/inbound/http/request_mappers.go @@ -0,0 +1,52 @@ +package httpadapter + +import "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" + +const ( + defaultPage = 1 + defaultPageSize = 20 +) + +type searchFilters struct { + query string + page int + pageSize int +} + +func newSearchFilters(params SearchProductsParams) searchFilters { + filters := searchFilters{ + page: defaultPage, + pageSize: defaultPageSize, + } + + if params.Q != nil { + filters.query = *params.Q + } + if params.Page != nil { + filters.page = *params.Page + } + if params.PageSize != nil { + filters.pageSize = *params.PageSize + } + + return filters +} + +func newProductFromCreateBody(body *CreateProductJSONRequestBody) (*domain.Product, error) { + if body == nil { + return nil, domain.ValidationError("invalid request body") + } + return domain.NewProduct(body.Name, amountToCents(body.Price), nil) +} + +func newProductFromUpdateBody(id int64, body *UpdateProductJSONRequestBody) (*domain.Product, error) { + if body == nil { + return nil, domain.ValidationError("invalid request body") + } + product, err := domain.NewProduct(body.Name, amountToCents(body.Price), nil) + if err != nil { + return nil, err + } + product.ID = id + return product, nil +} diff --git a/apps/product-query-svc/adapters/inbound/http/response_helpers.go b/apps/product-query-svc/adapters/inbound/http/response_helpers.go index 5822a22..d58bac0 100644 --- a/apps/product-query-svc/adapters/inbound/http/response_helpers.go +++ b/apps/product-query-svc/adapters/inbound/http/response_helpers.go @@ -15,6 +15,10 @@ func writeJSON(w http.ResponseWriter, status int, v any) { _ = json.NewEncoder(w).Encode(v) } +func writeError(w http.ResponseWriter, status int, code, message string) { + writeJSON(w, status, errorBody{Code: code, Message: message}) +} + type errorBody struct { Code string `json:"code"` Message string `json:"message"` @@ -24,15 +28,6 @@ type errorBody struct { } `json:"details,omitempty"` } -func writeError(w http.ResponseWriter, status int, code, message string) { - writeJSON(w, status, errorBody{Code: code, Message: message}) -} - -func writeDomainError(w http.ResponseWriter, err error) { - status, code := classifyDomainError(err) - writeError(w, status, code, domainErrorMessage(status, err)) -} - func classifyDomainError(err error) (int, string) { switch { case errors.Is(err, domain.ErrValidation): @@ -55,3 +50,143 @@ func domainErrorMessage(status int, err error) string { } return err.Error() } + +func errorPayloadFromDomain(err error) (int, errorBody) { + status, code := classifyDomainError(err) + return status, errorBody{Code: code, Message: domainErrorMessage(status, err)} +} + +func createProductError(err error) (CreateProductResponseObject, bool) { + status, payload := errorPayloadFromDomain(err) + switch status { + case http.StatusBadRequest: + return CreateProduct400JSONResponse{ + Code: payload.Code, + Message: payload.Message, + Details: payload.Details, + }, true + default: + return nil, false + } +} + +func updateProductError(err error) (UpdateProductResponseObject, bool) { + status, payload := errorPayloadFromDomain(err) + switch status { + case http.StatusBadRequest: + return UpdateProduct400JSONResponse{ + Code: payload.Code, + Message: payload.Message, + Details: payload.Details, + }, true + case http.StatusNotFound: + return UpdateProduct404JSONResponse{ + Code: payload.Code, + Message: payload.Message, + Details: payload.Details, + }, true + default: + return nil, false + } +} + +func deleteProductError(err error) (DeleteProductByIDResponseObject, bool) { + status, payload := errorPayloadFromDomain(err) + switch status { + case http.StatusBadRequest: + return DeleteProductByID400JSONResponse{ + Code: payload.Code, + Message: payload.Message, + Details: payload.Details, + }, true + case http.StatusNotFound: + return DeleteProductByID404JSONResponse{ + Code: payload.Code, + Message: payload.Message, + Details: payload.Details, + }, true + default: + return nil, false + } +} + +func getProductError(err error) (GetProductByIDResponseObject, bool) { + status, payload := errorPayloadFromDomain(err) + switch status { + case http.StatusBadRequest: + return GetProductByID400JSONResponse{ + Code: payload.Code, + Message: payload.Message, + Details: payload.Details, + }, true + case http.StatusNotFound: + return GetProductByID404JSONResponse{ + Code: payload.Code, + Message: payload.Message, + Details: payload.Details, + }, true + default: + return nil, false + } +} + +func searchProductsError(err error) (SearchProductsResponseObject, bool) { + status, payload := errorPayloadFromDomain(err) + if status == http.StatusBadRequest { + return SearchProducts400JSONResponse{ + Code: payload.Code, + Message: payload.Message, + Details: payload.Details, + }, true + } + return nil, false +} + +func getUserError(err error) (GetUserByIDResponseObject, bool) { + status, payload := errorPayloadFromDomain(err) + switch status { + case http.StatusBadRequest: + return GetUserByID400JSONResponse{ + Code: payload.Code, + Message: payload.Message, + Details: payload.Details, + }, true + case http.StatusNotFound: + return GetUserByID404JSONResponse{ + Code: payload.Code, + Message: payload.Message, + Details: payload.Details, + }, true + default: + return nil, false + } +} + +func okCreateProduct(product *domain.Product) CreateProductResponseObject { + return CreateProduct201JSONResponse(presentProduct(product)) +} + +func okUpdateProduct(product *domain.Product) UpdateProductResponseObject { + return UpdateProduct200JSONResponse(presentProduct(product)) +} + +func okDeleteProduct() DeleteProductByIDResponseObject { + return DeleteProductByID204Response{} +} + +func okGetProduct(product *domain.Product) GetProductByIDResponseObject { + return GetProductByID200JSONResponse(presentProduct(product)) +} + +func okSearchProducts(items []domain.Product, page, pageSize, total int) SearchProductsResponseObject { + return SearchProducts200JSONResponse(ProductList{ + Items: presentProducts(items), + Page: page, + PageSize: pageSize, + Total: total, + }) +} + +func okGetUser(user *domain.User) GetUserByIDResponseObject { + return GetUserByID200JSONResponse(presentUser(user)) +} diff --git a/apps/product-query-svc/adapters/inbound/http/router.go b/apps/product-query-svc/adapters/inbound/http/router.go index 74ea4a1..e3f82fc 100644 --- a/apps/product-query-svc/adapters/inbound/http/router.go +++ b/apps/product-query-svc/adapters/inbound/http/router.go @@ -1,19 +1,27 @@ package httpadapter import ( + "fmt" "net/http" "github.com/go-chi/chi/v5" + nethttpmiddleware "github.com/oapi-codegen/nethttp-middleware" ) -func NewRouter(si ServerInterface, validator func(http.Handler) http.Handler) http.Handler { +// NewAPIHandler returns a chi-backed handler wired with the strict server and +// OpenAPI request validator. +func NewAPIHandler(server *Server, strictMiddlewares []StrictMiddlewareFunc, middlewares ...func(http.Handler) http.Handler) (http.Handler, error) { + swagger, err := GetSwagger() + if err != nil { + return nil, fmt.Errorf("load swagger spec: %w", err) + } + r := chi.NewRouter() - // 你可以在这儿挂日志、中间件、recover等 - opts := ChiServerOptions{} - if validator != nil { - opts.Middlewares = []MiddlewareFunc{validator} + for _, mw := range middlewares { + r.Use(mw) } - // 使用生成器的 HandlerWithOptions 挂载路由 - r.Mount("/", HandlerWithOptions(si, opts)) - return r + r.Use(nethttpmiddleware.OapiRequestValidator(swagger)) + + strict := NewStrictHTTPHandler(server, strictMiddlewares) + return HandlerFromMux(strict, r), nil } diff --git a/apps/product-query-svc/adapters/inbound/http/server.go b/apps/product-query-svc/adapters/inbound/http/server.go index 7e93ae8..38ed056 100644 --- a/apps/product-query-svc/adapters/inbound/http/server.go +++ b/apps/product-query-svc/adapters/inbound/http/server.go @@ -16,6 +16,22 @@ func NewServer(products inbound.ProductUseCases, users inbound.UserQueries) *Ser return &Server{products: products, users: users} } +var _ StrictServerInterface = (*Server)(nil) + +// NewStrictHTTPHandler wraps the server with oapi-codegen strict adapter using +// JSON error envelopes for request/response failures. +func NewStrictHTTPHandler(server *Server, middlewares []StrictMiddlewareFunc) ServerInterface { + options := StrictHTTPServerOptions{ + RequestErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + writeError(w, http.StatusBadRequest, "INVALID_REQUEST", err.Error()) + }, + ResponseErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + writeError(w, http.StatusInternalServerError, "INTERNAL", err.Error()) + }, + } + return NewStrictHandlerWithOptions(server, middlewares, options) +} + // Health 健康检查 func (s *Server) Health(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/backend/cmd/marketplace/product-query-svc/main.go b/backend/cmd/marketplace/product-query-svc/main.go index 770079b..f5d6440 100644 --- a/backend/cmd/marketplace/product-query-svc/main.go +++ b/backend/cmd/marketplace/product-query-svc/main.go @@ -70,15 +70,20 @@ func main() { server := appshttp.NewServer(productSvc, userSvc) + apiHandler, err := appshttp.NewAPIHandler(server, nil) + if err != nil { + log.Fatalf("build api handler: %v", err) + } + r := chi.NewRouter() // 注册健康检查 r.HandleFunc("/healthz", server.Health) - // 注册 OpenAPI 生成的 handler 到 chi Router - h := appshttp.HandlerFromMux(server, r) + // 注册 OpenAPI 严格处理器到 chi Router,并前置请求校验 + r.Mount("/", apiHandler) srv := &http.Server{ Addr: *addr, - Handler: h, + Handler: r, } // 启动服务器 diff --git a/go.mod b/go.mod index 3a6cc33..0f27978 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( github.com/go-openapi/swag/jsonname v0.24.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect github.com/jackc/pgconn v1.14.0 // indirect github.com/jackc/pgio v1.0.0 // indirect @@ -60,6 +61,7 @@ require ( github.com/moby/term v0.5.0 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/morikuni/aec v1.0.0 // indirect + github.com/oapi-codegen/nethttp-middleware v1.1.2 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect diff --git a/go.sum b/go.sum index 3ae8219..49e705c 100644 --- a/go.sum +++ b/go.sum @@ -64,6 +64,8 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69 github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 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/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0= github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= @@ -159,6 +161,8 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/oapi-codegen/nethttp-middleware v1.1.2 h1:TQwEU3WM6ifc7ObBEtiJgbRPaCe513tvJpiMJjypVPA= +github.com/oapi-codegen/nethttp-middleware v1.1.2/go.mod h1:5qzjxMSiI8HjLljiOEjvs4RdrWyMPKnExeFS2kr8om4= github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= diff --git a/internal/testutil/httpserver.go b/internal/testutil/httpserver.go index 2e0d21c..aad902f 100644 --- a/internal/testutil/httpserver.go +++ b/internal/testutil/httpserver.go @@ -8,7 +8,6 @@ import ( productapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/product" userapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/user" "github.com/fightingBald/GoTuto/apps/product-query-svc/ports/outbound" - "github.com/go-chi/chi/v5" ) // NewHTTPHandler wires repos -> services -> HTTP handler. @@ -16,8 +15,11 @@ func NewHTTPHandler(productRepo outbound.ProductRepository, userRepo outbound.Us productSvc := productapp.NewService(productRepo) userSvc := userapp.NewService(userRepo) server := httpadapter.NewServer(productSvc, userSvc) - r := chi.NewRouter() - return httpadapter.HandlerFromMux(server, r) + h, err := httpadapter.NewAPIHandler(server, nil) + if err != nil { + panic(err) + } + return h } // NewHTTPServer starts an httptest.Server for convenience. diff --git a/readme.md b/readme.md index df1adb0..5bd3020 100644 --- a/readme.md +++ b/readme.md @@ -25,7 +25,7 @@ │ ├── ports/ # 端口(接口),抽象仓储与服务 │ ├── app/ # 应用服务实现(业务编排) │ └── adapters/ -│ ├── inbound/http/ # 生成的 HTTP 接口 + 路由/处理器 +│ ├── inbound/http/ # OpenAPI 严格服务 + 路由装配 + 轻量 handler │ └── outbound/ │ ├── inmem/ # 内存仓储实现(开发/测试) │ └── postgres/ # Postgres 仓储与迁移文件 @@ -50,6 +50,17 @@ --- +## HTTP 适配器设计(Strict Server) + +- **代码生成统一使用 `oapi-codegen strict-server`**:`api/oapi-config.yaml` 只保留严格服务输出,避免手写 handler 接口。每次变更 OpenAPI 需执行 `go generate ./api` 重新生成 `marketplaceapi.gen.go`。 +- **请求校验前移到 OpenAPI**:所有参数/请求体验证(`minimum`/`maxLength`/`enum` 等)写在 `api` 目录的 schema/parameter 中,由 `github.com/oapi-codegen/nethttp-middleware` 提供的 `OapiRequestValidator` 中间件统一拦截。 +- **Handler 职责“三件套”**(`apps/product-query-svc/adapters/inbound/http/handler_*.go`): + 1. 从生成的强类型 `RequestObject` 中取出入参(无需重复校验); + 2. 调用对应的应用服务(`application/*`); + 3. 利用 `response_helpers.go` 中的 `ok*/xxxError` 辅助函数返回严格的响应类型(仅 2xx/4xx)。 +- **跨操作共享错误映射**:`response_helpers.go` 负责把领域错误映射成具体的 OpenAPI 响应类型,并封装标准错误载荷;新增业务错误时只需在此扩展。 +- **统一路由出口**:`NewAPIHandler` 会加载内嵌的 Swagger、挂载必需的中间件(含请求校验)并包装 strict server;在 `main.go`、集成测试与 `internal/testutil` 中均通过该函数装配,保持行为一致。 + ## 验证服务是否可用(Tilt 本地) - 端口转发就绪 @@ -336,6 +347,10 @@ go generate ./api # 或者根据 generate.go 的 //go:generate 指定路径 ``` +- 生成后的 `adapters/inbound/http/marketplaceapi.gen.go` **禁止手动修改**;需要调整校验或字段时改 OpenAPI 资源并重新生成。 +- HTTP handler 只能依赖生成的 `StrictServerInterface`,其实现位于 `handler_*.go`,必须配合 `response_helpers.go` 和 `request_mappers.go` 使用。 +- `NewAPIHandler` 会自动加载最新的 Swagger 并注册 `OapiRequestValidator` 中间件,生产/测试入口都应通过该函数获取路由。 + - 建议:将生成步骤写入 Makefile 或 CI,团队协同时要约定是否把生成产物纳入版本控制(两种策略均可)。 diff --git a/test/http_pg/create_test.go b/test/http_pg/create_test.go index 9bc3c93..e296dbb 100644 --- a/test/http_pg/create_test.go +++ b/test/http_pg/create_test.go @@ -15,7 +15,6 @@ import ( productapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/product" userapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/user" "github.com/fightingBald/GoTuto/internal/testutil" - "github.com/go-chi/chi/v5" ) // TestCreateProduct_Postgres validates POST /products on a real Postgres. @@ -35,8 +34,10 @@ func TestCreateProduct_Postgres(t *testing.T) { userSvc := userapp.NewService(userRepo) server := appshttp.NewServer(productSvc, userSvc) - r := chi.NewRouter() - h := appshttp.HandlerFromMux(server, r) + h, err := appshttp.NewAPIHandler(server, nil) + if err != nil { + t.Fatalf("new api handler: %v", err) + } ts := httptest.NewServer(h) defer ts.Close() diff --git a/test/http_pg/search_test.go b/test/http_pg/search_test.go index 7a2a7cc..37af9fd 100644 --- a/test/http_pg/search_test.go +++ b/test/http_pg/search_test.go @@ -13,7 +13,6 @@ import ( productapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/product" userapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/user" "github.com/fightingBald/GoTuto/internal/testutil" - "github.com/go-chi/chi/v5" ) // TestSearchProducts_Postgres seeds are applied via migrations in dev/CI. @@ -34,8 +33,10 @@ func TestSearchProducts_Postgres(t *testing.T) { userSvc := userapp.NewService(userRepo) server := appshttp.NewServer(productSvc, userSvc) - r := chi.NewRouter() - h := appshttp.HandlerFromMux(server, r) + h, err := appshttp.NewAPIHandler(server, nil) + if err != nil { + t.Fatalf("new api handler: %v", err) + } ts := httptest.NewServer(h) defer ts.Close() diff --git a/test/http_pg/user_test.go b/test/http_pg/user_test.go index 210a355..af69cca 100644 --- a/test/http_pg/user_test.go +++ b/test/http_pg/user_test.go @@ -16,7 +16,6 @@ import ( productapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/product" userapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/user" "github.com/fightingBald/GoTuto/internal/testutil" - "github.com/go-chi/chi/v5" "github.com/jackc/pgconn" ) @@ -57,8 +56,10 @@ func TestGetUserByID_Postgres(t *testing.T) { userSvc := userapp.NewService(userRepo) server := appshttp.NewServer(productSvc, userSvc) - r := chi.NewRouter() - h := appshttp.HandlerFromMux(server, r) + h, err := appshttp.NewAPIHandler(server, nil) + if err != nil { + t.Fatalf("new api handler: %v", err) + } ts := httptest.NewServer(h) defer ts.Close()