From 633bb262c75deee41ce2f88db8eb349d9293071f Mon Sep 17 00:00:00 2001 From: huayitang Date: Mon, 29 Sep 2025 08:54:19 +0200 Subject: [PATCH 1/3] refactor(http): implement strict server handlers --- .../inbound/http/handler_product_read.go | 67 +++++++--- .../inbound/http/handler_product_write.go | 121 +++++++++++------- .../inbound/http/handler_user_read.go | 31 +++-- .../adapters/inbound/http/response_helpers.go | 22 ++-- .../adapters/inbound/http/server.go | 16 +++ 5 files changed, 172 insertions(+), 85 deletions(-) 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..4556391 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,68 @@ package httpadapter -import "net/http" +import ( + "context" + "net/http" +) -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 +func (s *Server) GetProductByID(ctx context.Context, request GetProductByIDRequestObject) (GetProductByIDResponseObject, error) { + if request.Id <= 0 { + payload := newErrorPayload("INVALID_ID", "id must be a positive integer") + return GetProductByID400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil } - p, err := s.products.FetchByID(r.Context(), id) + + product, err := s.products.FetchByID(ctx, request.Id) if err != nil { - writeDomainError(w, err) - return + status, payload := errorPayloadFromDomain(err) + switch status { + case http.StatusBadRequest: + return GetProductByID400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil + case http.StatusNotFound: + return GetProductByID404JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil + default: + return nil, err + } } - writeJSON(w, http.StatusOK, presentProduct(p)) + + return GetProductByID200JSONResponse(presentProduct(product)), nil } -func (s *Server) SearchProducts(w http.ResponseWriter, r *http.Request, params SearchProductsParams) { - q := "" +func (s *Server) SearchProducts(ctx context.Context, request SearchProductsRequestObject) (SearchProductsResponseObject, error) { + params := request.Params + + query := "" if params.Q != nil { - q = *params.Q + query = *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 + if query != "" && len(query) < 3 { + payload := newErrorPayload("INVALID_QUERY", "q must be at least 3 characters if provided") + return SearchProducts400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil } + 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) + + items, total, err := s.products.Search(ctx, query, page, pageSize) if err != nil { - writeDomainError(w, err) - return + status, payload := errorPayloadFromDomain(err) + if status == http.StatusBadRequest { + return SearchProducts400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil + } + return nil, err + } + + resp := ProductList{ + Items: presentProducts(items), + Page: page, + PageSize: pageSize, + Total: total, } - resp := ProductList{Items: presentProducts(items), Page: page, PageSize: pageSize, Total: total} - writeJSON(w, http.StatusOK, resp) + return SearchProducts200JSONResponse(resp), 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..99a6bc8 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,99 @@ package httpadapter import ( - "encoding/json" + "context" "net/http" "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) { + if request.Body == nil { + payload := newErrorPayload("INVALID_JSON", "invalid request body") + return CreateProduct400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil } - if err := s.products.Remove(r.Context(), id); err != nil { - writeDomainError(w, err) - return - } - w.WriteHeader(http.StatusNoContent) -} -// 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) + body := request.Body + cents := amountToCents(body.Price) + product, err := domain.NewProduct(body.Name, cents, nil) if err != nil { - writeDomainError(w, err) - return + status, payload := errorPayloadFromDomain(err) + if status == http.StatusBadRequest { + return CreateProduct400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil + } + return nil, err } - id, err := s.products.Create(r.Context(), p) + + id, err := s.products.Create(ctx, product) if err != nil { - writeDomainError(w, err) - return + status, payload := errorPayloadFromDomain(err) + if status == http.StatusBadRequest { + return CreateProduct400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil + } + return nil, err } - created := *p + + created := *product created.ID = id - writeJSON(w, http.StatusCreated, presentProduct(&created)) + + return CreateProduct201JSONResponse(presentProduct(&created)), 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 +func (s *Server) UpdateProduct(ctx context.Context, request UpdateProductRequestObject) (UpdateProductResponseObject, error) { + if request.Id <= 0 { + payload := newErrorPayload("INVALID_ID", "id must be a positive integer") + return UpdateProduct400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil } - var in UpdateProductJSONBody - if err := json.NewDecoder(r.Body).Decode(&in); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body") - return + if request.Body == nil { + payload := newErrorPayload("INVALID_JSON", "invalid request body") + return UpdateProduct400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil } - cents := amountToCents(in.Price) - p, err := domain.NewProduct(in.Name, cents, nil) + + body := request.Body + cents := amountToCents(body.Price) + product, err := domain.NewProduct(body.Name, cents, nil) if err != nil { - writeDomainError(w, err) - return + status, payload := errorPayloadFromDomain(err) + if status == http.StatusBadRequest { + return UpdateProduct400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil + } + return nil, err } - p.ID = id - updated, err := s.products.Update(r.Context(), p) + + product.ID = request.Id + updated, err := s.products.Update(ctx, product) if err != nil { - writeDomainError(w, err) - return + status, payload := errorPayloadFromDomain(err) + switch status { + case http.StatusBadRequest: + return UpdateProduct400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil + case http.StatusNotFound: + return UpdateProduct404JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil + default: + return nil, err + } } - writeJSON(w, http.StatusOK, presentProduct(updated)) + + return UpdateProduct200JSONResponse(presentProduct(updated)), nil +} + +func (s *Server) DeleteProductByID(ctx context.Context, request DeleteProductByIDRequestObject) (DeleteProductByIDResponseObject, error) { + if request.Id <= 0 { + payload := newErrorPayload("INVALID_ID", "id must be a positive integer") + return DeleteProductByID400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil + } + + if err := s.products.Remove(ctx, request.Id); err != nil { + status, payload := errorPayloadFromDomain(err) + switch status { + case http.StatusBadRequest: + return DeleteProductByID400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil + case http.StatusNotFound: + return DeleteProductByID404JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil + default: + return nil, err + } + } + + return DeleteProductByID204Response{}, 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..334ba71 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,29 @@ package httpadapter -import "net/http" +import ( + "context" + "net/http" +) -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 +func (s *Server) GetUserByID(ctx context.Context, request GetUserByIDRequestObject) (GetUserByIDResponseObject, error) { + if request.Id <= 0 { + payload := newErrorPayload("INVALID_ID", "id must be a positive integer") + return GetUserByID400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil } - u, err := s.users.FetchByID(r.Context(), id) + + user, err := s.users.FetchByID(ctx, request.Id) if err != nil { - writeDomainError(w, err) - return + status, payload := errorPayloadFromDomain(err) + switch status { + case http.StatusBadRequest: + return GetUserByID400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil + case http.StatusNotFound: + return GetUserByID404JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil + default: + return nil, err + } } - writeJSON(w, http.StatusOK, presentUser(u)) + + presented := presentUser(user) + return GetUserByID200JSONResponse(presented), 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..d6c04a9 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,12 @@ 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 newErrorPayload(code, message string) errorBody { + return errorBody{Code: code, Message: message} +} 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) From 36da5cfa6ebd1a717a633dab11d3fda8106ebd78 Mon Sep 17 00:00:00 2001 From: huayitang Date: Mon, 29 Sep 2025 08:54:33 +0200 Subject: [PATCH 2/3] chore: use strict handler in wiring and tests --- backend/cmd/marketplace/product-query-svc/main.go | 5 +++-- internal/testutil/httpserver.go | 3 ++- test/http_pg/create_test.go | 3 ++- test/http_pg/search_test.go | 3 ++- test/http_pg/user_test.go | 3 ++- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/backend/cmd/marketplace/product-query-svc/main.go b/backend/cmd/marketplace/product-query-svc/main.go index 770079b..f2beaed 100644 --- a/backend/cmd/marketplace/product-query-svc/main.go +++ b/backend/cmd/marketplace/product-query-svc/main.go @@ -73,8 +73,9 @@ func main() { r := chi.NewRouter() // 注册健康检查 r.HandleFunc("/healthz", server.Health) - // 注册 OpenAPI 生成的 handler 到 chi Router - h := appshttp.HandlerFromMux(server, r) + // 注册 OpenAPI 生成的 strict handler 到 chi Router + strict := appshttp.NewStrictHTTPHandler(server, nil) + h := appshttp.HandlerFromMux(strict, r) srv := &http.Server{ Addr: *addr, diff --git a/internal/testutil/httpserver.go b/internal/testutil/httpserver.go index 2e0d21c..6d8455e 100644 --- a/internal/testutil/httpserver.go +++ b/internal/testutil/httpserver.go @@ -17,7 +17,8 @@ func NewHTTPHandler(productRepo outbound.ProductRepository, userRepo outbound.Us userSvc := userapp.NewService(userRepo) server := httpadapter.NewServer(productSvc, userSvc) r := chi.NewRouter() - return httpadapter.HandlerFromMux(server, r) + strict := httpadapter.NewStrictHTTPHandler(server, nil) + return httpadapter.HandlerFromMux(strict, r) } // NewHTTPServer starts an httptest.Server for convenience. diff --git a/test/http_pg/create_test.go b/test/http_pg/create_test.go index 9bc3c93..93f128d 100644 --- a/test/http_pg/create_test.go +++ b/test/http_pg/create_test.go @@ -36,7 +36,8 @@ func TestCreateProduct_Postgres(t *testing.T) { server := appshttp.NewServer(productSvc, userSvc) r := chi.NewRouter() - h := appshttp.HandlerFromMux(server, r) + strict := appshttp.NewStrictHTTPHandler(server, nil) + h := appshttp.HandlerFromMux(strict, r) 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..fb9cda3 100644 --- a/test/http_pg/search_test.go +++ b/test/http_pg/search_test.go @@ -35,7 +35,8 @@ func TestSearchProducts_Postgres(t *testing.T) { server := appshttp.NewServer(productSvc, userSvc) r := chi.NewRouter() - h := appshttp.HandlerFromMux(server, r) + strict := appshttp.NewStrictHTTPHandler(server, nil) + h := appshttp.HandlerFromMux(strict, r) 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..6a33418 100644 --- a/test/http_pg/user_test.go +++ b/test/http_pg/user_test.go @@ -58,7 +58,8 @@ func TestGetUserByID_Postgres(t *testing.T) { server := appshttp.NewServer(productSvc, userSvc) r := chi.NewRouter() - h := appshttp.HandlerFromMux(server, r) + strict := appshttp.NewStrictHTTPHandler(server, nil) + h := appshttp.HandlerFromMux(strict, r) ts := httptest.NewServer(h) defer ts.Close() From f28b7217638a21c7c4b48db060afa51d39e64bf3 Mon Sep 17 00:00:00 2001 From: huayitang Date: Mon, 29 Sep 2025 12:21:32 +0200 Subject: [PATCH 3/3] add --- api/components/parameters/ID.yaml | 2 +- .../inbound/http/handler_product_read.go | 59 ++------ .../inbound/http/handler_product_write.go | 81 +++-------- .../inbound/http/handler_user_read.go | 24 +--- .../inbound/http/marketplaceapi.gen.go | 34 ++--- .../adapters/inbound/http/request_mappers.go | 52 +++++++ .../adapters/inbound/http/response_helpers.go | 135 +++++++++++++++++- .../adapters/inbound/http/router.go | 24 ++-- .../cmd/marketplace/product-query-svc/main.go | 12 +- go.mod | 2 + go.sum | 4 + internal/testutil/httpserver.go | 9 +- readme.md | 17 ++- test/http_pg/create_test.go | 8 +- test/http_pg/search_test.go | 8 +- test/http_pg/user_test.go | 8 +- 16 files changed, 300 insertions(+), 179 deletions(-) create mode 100644 apps/product-query-svc/adapters/inbound/http/request_mappers.go 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 4556391..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,68 +1,29 @@ package httpadapter -import ( - "context" - "net/http" -) +import "context" func (s *Server) GetProductByID(ctx context.Context, request GetProductByIDRequestObject) (GetProductByIDResponseObject, error) { - if request.Id <= 0 { - payload := newErrorPayload("INVALID_ID", "id must be a positive integer") - return GetProductByID400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil - } - product, err := s.products.FetchByID(ctx, request.Id) if err != nil { - status, payload := errorPayloadFromDomain(err) - switch status { - case http.StatusBadRequest: - return GetProductByID400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil - case http.StatusNotFound: - return GetProductByID404JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil - default: - return nil, err + if resp, handled := getProductError(err); handled { + return resp, nil } + return nil, err } - return GetProductByID200JSONResponse(presentProduct(product)), nil + return okGetProduct(product), nil } func (s *Server) SearchProducts(ctx context.Context, request SearchProductsRequestObject) (SearchProductsResponseObject, error) { - params := request.Params - - query := "" - if params.Q != nil { - query = *params.Q - } - if query != "" && len(query) < 3 { - payload := newErrorPayload("INVALID_QUERY", "q must be at least 3 characters if provided") - return SearchProducts400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil - } + filters := newSearchFilters(request.Params) - page := 1 - if params.Page != nil { - page = *params.Page - } - - pageSize := 20 - if params.PageSize != nil { - pageSize = *params.PageSize - } - - items, total, err := s.products.Search(ctx, query, page, pageSize) + items, total, err := s.products.Search(ctx, filters.query, filters.page, filters.pageSize) if err != nil { - status, payload := errorPayloadFromDomain(err) - if status == http.StatusBadRequest { - return SearchProducts400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil + if resp, handled := searchProductsError(err); handled { + return resp, nil } return nil, err } - resp := ProductList{ - Items: presentProducts(items), - Page: page, - PageSize: pageSize, - Total: total, - } - return SearchProducts200JSONResponse(resp), nil + 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 99a6bc8..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,99 +1,56 @@ package httpadapter -import ( - "context" - "net/http" - - "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" -) +import "context" func (s *Server) CreateProduct(ctx context.Context, request CreateProductRequestObject) (CreateProductResponseObject, error) { - if request.Body == nil { - payload := newErrorPayload("INVALID_JSON", "invalid request body") - return CreateProduct400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil - } - - body := request.Body - cents := amountToCents(body.Price) - product, err := domain.NewProduct(body.Name, cents, nil) + product, err := newProductFromCreateBody(request.Body) if err != nil { - status, payload := errorPayloadFromDomain(err) - if status == http.StatusBadRequest { - return CreateProduct400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil + if resp, handled := createProductError(err); handled { + return resp, nil } return nil, err } id, err := s.products.Create(ctx, product) if err != nil { - status, payload := errorPayloadFromDomain(err) - if status == http.StatusBadRequest { - return CreateProduct400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil + if resp, handled := createProductError(err); handled { + return resp, nil } return nil, err } - created := *product - created.ID = id + product.ID = id - return CreateProduct201JSONResponse(presentProduct(&created)), nil + return okCreateProduct(product), nil } func (s *Server) UpdateProduct(ctx context.Context, request UpdateProductRequestObject) (UpdateProductResponseObject, error) { - if request.Id <= 0 { - payload := newErrorPayload("INVALID_ID", "id must be a positive integer") - return UpdateProduct400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil - } - if request.Body == nil { - payload := newErrorPayload("INVALID_JSON", "invalid request body") - return UpdateProduct400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil - } - - body := request.Body - cents := amountToCents(body.Price) - product, err := domain.NewProduct(body.Name, cents, nil) + product, err := newProductFromUpdateBody(request.Id, request.Body) if err != nil { - status, payload := errorPayloadFromDomain(err) - if status == http.StatusBadRequest { - return UpdateProduct400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil + if resp, handled := updateProductError(err); handled { + return resp, nil } return nil, err } - product.ID = request.Id updated, err := s.products.Update(ctx, product) if err != nil { - status, payload := errorPayloadFromDomain(err) - switch status { - case http.StatusBadRequest: - return UpdateProduct400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil - case http.StatusNotFound: - return UpdateProduct404JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil - default: - return nil, err + if resp, handled := updateProductError(err); handled { + return resp, nil } + return nil, err } - return UpdateProduct200JSONResponse(presentProduct(updated)), nil + return okUpdateProduct(updated), nil } func (s *Server) DeleteProductByID(ctx context.Context, request DeleteProductByIDRequestObject) (DeleteProductByIDResponseObject, error) { - if request.Id <= 0 { - payload := newErrorPayload("INVALID_ID", "id must be a positive integer") - return DeleteProductByID400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil - } - if err := s.products.Remove(ctx, request.Id); err != nil { - status, payload := errorPayloadFromDomain(err) - switch status { - case http.StatusBadRequest: - return DeleteProductByID400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil - case http.StatusNotFound: - return DeleteProductByID404JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil - default: - return nil, err + if resp, handled := deleteProductError(err); handled { + return resp, nil } + return nil, err } - return DeleteProductByID204Response{}, nil + 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 334ba71..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,29 +1,15 @@ package httpadapter -import ( - "context" - "net/http" -) +import "context" func (s *Server) GetUserByID(ctx context.Context, request GetUserByIDRequestObject) (GetUserByIDResponseObject, error) { - if request.Id <= 0 { - payload := newErrorPayload("INVALID_ID", "id must be a positive integer") - return GetUserByID400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil - } - user, err := s.users.FetchByID(ctx, request.Id) if err != nil { - status, payload := errorPayloadFromDomain(err) - switch status { - case http.StatusBadRequest: - return GetUserByID400JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil - case http.StatusNotFound: - return GetUserByID404JSONResponse{Code: payload.Code, Message: payload.Message, Details: payload.Details}, nil - default: - return nil, err + if resp, handled := getUserError(err); handled { + return resp, nil } + return nil, err } - presented := presentUser(user) - return GetUserByID200JSONResponse(presented), nil + 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 d6c04a9..d58bac0 100644 --- a/apps/product-query-svc/adapters/inbound/http/response_helpers.go +++ b/apps/product-query-svc/adapters/inbound/http/response_helpers.go @@ -56,6 +56,137 @@ func errorPayloadFromDomain(err error) (int, errorBody) { return status, errorBody{Code: code, Message: domainErrorMessage(status, err)} } -func newErrorPayload(code, message string) errorBody { - return errorBody{Code: code, Message: message} +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/backend/cmd/marketplace/product-query-svc/main.go b/backend/cmd/marketplace/product-query-svc/main.go index f2beaed..f5d6440 100644 --- a/backend/cmd/marketplace/product-query-svc/main.go +++ b/backend/cmd/marketplace/product-query-svc/main.go @@ -70,16 +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 生成的 strict handler 到 chi Router - strict := appshttp.NewStrictHTTPHandler(server, nil) - h := appshttp.HandlerFromMux(strict, 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 6d8455e..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,9 +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() - strict := httpadapter.NewStrictHTTPHandler(server, nil) - return httpadapter.HandlerFromMux(strict, 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 93f128d..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,9 +34,10 @@ func TestCreateProduct_Postgres(t *testing.T) { userSvc := userapp.NewService(userRepo) server := appshttp.NewServer(productSvc, userSvc) - r := chi.NewRouter() - strict := appshttp.NewStrictHTTPHandler(server, nil) - h := appshttp.HandlerFromMux(strict, 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 fb9cda3..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,9 +33,10 @@ func TestSearchProducts_Postgres(t *testing.T) { userSvc := userapp.NewService(userRepo) server := appshttp.NewServer(productSvc, userSvc) - r := chi.NewRouter() - strict := appshttp.NewStrictHTTPHandler(server, nil) - h := appshttp.HandlerFromMux(strict, 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 6a33418..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,9 +56,10 @@ func TestGetUserByID_Postgres(t *testing.T) { userSvc := userapp.NewService(userRepo) server := appshttp.NewServer(productSvc, userSvc) - r := chi.NewRouter() - strict := appshttp.NewStrictHTTPHandler(server, nil) - h := appshttp.HandlerFromMux(strict, r) + h, err := appshttp.NewAPIHandler(server, nil) + if err != nil { + t.Fatalf("new api handler: %v", err) + } ts := httptest.NewServer(h) defer ts.Close()