From 02b2350c1eafcf0267f9ce55348f22b535876cb6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 17 Jun 2026 09:14:24 +0000 Subject: [PATCH] Add chi+nethttp bridge and sqlc stdlib rollout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: حمید ملک محمدی --- README.md | 76 +++++++++++++++++++++++ go.mod | 1 + go.sum | 2 + libChi/params.go | 42 +++++++++++++ libChi/params_test.go | 86 ++++++++++++++++++++++++++ libChi/sqlc_stdlib_integration_test.go | 76 +++++++++++++++++++++++ libContext/init.go | 8 +++ libNetHttp/params_context.go | 37 +++++++++++ libNetHttp/parser.go | 6 +- libNetHttp/response.go | 24 ++++++- libNetHttp/response_bridge_test.go | 66 ++++++++++++++++++++ 11 files changed, 422 insertions(+), 2 deletions(-) create mode 100644 libChi/params.go create mode 100644 libChi/params_test.go create mode 100644 libChi/sqlc_stdlib_integration_test.go create mode 100644 libNetHttp/params_context.go create mode 100644 libNetHttp/response_bridge_test.go diff --git a/README.md b/README.md index 2398f9c..5477473 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,56 @@ This makes the library suitable for heterogeneous environments and for testing w --- +## Canonical setup: chi + net/http + sqlc + pgx/stdlib + +For low-risk adoption, use `sqlc` in `database/sql` mode and connect PostgreSQL with pgx stdlib. + +### 1) sqlc configuration + +```yaml +version: "2" +sql: + - schema: "db/schema.sql" + queries: "db/query.sql" + engine: "postgresql" + gen: + go: + package: "db" + out: "internal/db" + sql_package: "database/sql" +``` + +### 2) Open DB with pgx stdlib + +```go +import ( + "database/sql" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +db, err := sql.Open("pgx", "postgres://user:pass@localhost:5432/appdb?sslmode=disable") +if err != nil { + panic(err) +} +defer db.Close() +``` + +### 3) Use chi route params with requestCore net/http parser + +```go +router := chi.NewRouter() +router.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) { + parser := libChi.InitParser(r, w) + id := parser.GetUrlParam("id") + _ = parser.SendJSONRespBody(http.StatusOK, map[string]string{"id": id}) +}) +``` + +This path keeps compatibility with the current `database/sql`-oriented query layer and enables incremental adoption. + +--- + ## Testing The repository includes a strong testing story: @@ -298,6 +348,32 @@ This allows request handling, query execution, and framework adapters to be test --- +## Optional advanced path: pgx-native sqlc mode + +If you need `sqlc` generated code for `pgx/v5` native interfaces (instead of `database/sql`), treat it as a separate compatibility track: + +- keep current `QueryRunnerInterface` (`database/sql`) for backward compatibility +- add a parallel pgx-native runner contract and adapter implementation +- maintain parity tests for both backends: + - query behavior and error mapping + - DML behavior + - tracing/logging hooks + +Suggested parity matrix: + +| Capability | database/sql backend | pgx-native backend | +|---|---|---| +| Single-row query mapping | required | required | +| Multi-row query mapping | required | required | +| DML affected rows handling | required | required | +| Duplicate / no-data error mapping | required | required | +| Request-scoped tracing attributes | required | required | +| Existing handlers compatibility | required | required | + +This minimizes risk for existing users while allowing pgx-native optimization where needed. + +--- + ## Design principles `requestCore` appears to follow these principles: diff --git a/go.mod b/go.mod index 20b5798..6be1773 100644 --- a/go.mod +++ b/go.mod @@ -58,6 +58,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-chi/chi/v5 v5.3.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect diff --git a/go.sum b/go.sum index 16ec708..255d58d 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= diff --git a/libChi/params.go b/libChi/params.go new file mode 100644 index 0000000..dbc80f4 --- /dev/null +++ b/libChi/params.go @@ -0,0 +1,42 @@ +package libChi + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/hmmftg/requestCore/libNetHttp" +) + +func ExtractURLParams(r *http.Request) map[string]string { + routeCtx := chi.RouteContext(r.Context()) + if routeCtx == nil || len(routeCtx.URLParams.Keys) == 0 { + return map[string]string{} + } + + params := make(map[string]string, len(routeCtx.URLParams.Keys)) + for i := range routeCtx.URLParams.Keys { + params[routeCtx.URLParams.Keys[i]] = routeCtx.URLParams.Values[i] + } + return params +} + +func BindURLParams(r *http.Request, parser *libNetHttp.NetHttpParser) { + if parser == nil { + return + } + for key, value := range ExtractURLParams(r) { + parser.AddParam(key, value) + } +} + +func InitParser(r *http.Request, w http.ResponseWriter) libNetHttp.NetHttpParser { + parser := libNetHttp.InitContext(r, w) + BindURLParams(r, &parser) + return parser +} + +func ParamsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, libNetHttp.WithURLParams(r, ExtractURLParams(r))) + }) +} diff --git a/libChi/params_test.go b/libChi/params_test.go new file mode 100644 index 0000000..c80b331 --- /dev/null +++ b/libChi/params_test.go @@ -0,0 +1,86 @@ +package libChi_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/hmmftg/requestCore/libChi" + "github.com/hmmftg/requestCore/libNetHttp" +) + +func TestParamsMiddlewareInjectsSingleParam(t *testing.T) { + router := chi.NewRouter() + + router.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) { + parser := libChi.InitParser(r, w) + if got := parser.GetUrlParam("id"); got != "42" { + t.Fatalf("expected id=42, got %q", got) + } + }) + + req := httptest.NewRequest(http.MethodGet, "/users/42", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) +} + +func TestParamsMiddlewareInjectsMultipleParamsAndGetUri(t *testing.T) { + type uriParams struct { + ID string `json:"id"` + AccountID string `json:"accountId"` + } + + router := chi.NewRouter() + + router.Get("/users/{id}/accounts/{accountId}", func(w http.ResponseWriter, r *http.Request) { + parser := libChi.InitParser(r, w) + + if got := parser.GetUrlParam("id"); got != "42" { + t.Fatalf("expected id=42, got %q", got) + } + if got := parser.GetUrlParam("accountId"); got != "A1" { + t.Fatalf("expected accountId=A1, got %q", got) + } + if _, ok := parser.CheckUrlParam("missing"); ok { + t.Fatal("missing param should not exist") + } + + var uri uriParams + if err := parser.GetUri(&uri); err != nil { + t.Fatalf("GetUri failed: %v", err) + } + if uri.ID != "42" || uri.AccountID != "A1" { + t.Fatalf("unexpected uri parse result: %+v", uri) + } + }) + + req := httptest.NewRequest(http.MethodGet, "/users/42/accounts/A1", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) +} + +func TestExtractURLParamsMissingRouteContext(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/health", nil) + params := libChi.ExtractURLParams(req) + if len(params) != 0 { + t.Fatalf("expected empty params, got %+v", params) + } +} + +func TestParamsMiddlewareNoopWithoutRouteParams(t *testing.T) { + handler := libChi.ParamsMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + parser := libNetHttp.InitContext(r, w) + if len(parser.GetUrlParams()) != 0 { + t.Fatalf("expected empty params map, got %+v", parser.GetUrlParams()) + } + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("expected status %d, got %d", http.StatusNoContent, rec.Code) + } +} diff --git a/libChi/sqlc_stdlib_integration_test.go b/libChi/sqlc_stdlib_integration_test.go new file mode 100644 index 0000000..ad3e35e --- /dev/null +++ b/libChi/sqlc_stdlib_integration_test.go @@ -0,0 +1,76 @@ +package libChi_test + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-chi/chi/v5" + "github.com/hmmftg/requestCore/libChi" +) + +type sqlcStyleQueries struct { + db *sql.DB +} + +func (q sqlcStyleQueries) GetUserName(ctx context.Context, id string) (string, error) { + row := q.db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = $1", id) + var name string + if err := row.Scan(&name); err != nil { + return "", err + } + return name, nil +} + +func TestChiStdlibSqlcStyleWithSQLDB(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed creating sqlmock: %v", err) + } + defer db.Close() + + queries := sqlcStyleQueries{db: db} + + mock.ExpectQuery(regexp.QuoteMeta("SELECT name FROM users WHERE id = $1")). + WithArgs("42"). + WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("alice")) + + router := chi.NewRouter() + router.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) { + parser := libChi.InitParser(r, w) + id := parser.GetUrlParam("id") + name, err := queries.GetUserName(r.Context(), id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := parser.SendJSONRespBody(http.StatusOK, map[string]string{"id": id, "name": name}); err != nil { + t.Fatalf("failed writing json response: %v", err) + } + }) + + req := httptest.NewRequest(http.MethodGet, "/users/42", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + var resp map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed parsing body: %v", err) + } + if resp["id"] != "42" || resp["name"] != "alice" { + t.Fatalf("unexpected body: %+v", resp) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet db expectations: %v", err) + } +} diff --git a/libContext/init.go b/libContext/init.go index e341f2d..6555e55 100644 --- a/libContext/init.go +++ b/libContext/init.go @@ -44,6 +44,14 @@ func initContext(c any, unknownUser bool) webFramework.WebFramework { var span trace.Span switch ctx := c.(type) { + case context.Context: + req, okReq := libNetHttp.RequestFromContext(ctx) + writer, okWriter := libNetHttp.ResponseWriterFromContext(ctx) + if okReq && okWriter { + return InitNetHttpContext(req, writer, unknownUser) + } + stack := response.GetStack(1, "libContext/init.go") + log.Fatalf("error in InitContext: context missing net/http request/response data %T, Stack: %s", ctx, stack) case *gin.Context: if unknownUser { ctx.Set(UserIdLocal, UnknownUser) diff --git a/libNetHttp/params_context.go b/libNetHttp/params_context.go new file mode 100644 index 0000000..8bd18a5 --- /dev/null +++ b/libNetHttp/params_context.go @@ -0,0 +1,37 @@ +package libNetHttp + +import ( + "context" + "net/http" +) + +type paramsContextKey string + +const urlParamsKey paramsContextKey = "nethttp.urlparams" + +func WithURLParams(r *http.Request, params map[string]string) *http.Request { + if len(params) == 0 { + return r + } + ctx := r.Context() + return r.WithContext(contextWithURLParams(ctx, params)) +} + +func URLParamsFromRequest(r *http.Request) map[string]string { + if r == nil { + return nil + } + return urlParamsFromContext(r.Context()) +} + +func contextWithURLParams(ctx context.Context, params map[string]string) context.Context { + return context.WithValue(ctx, urlParamsKey, params) +} + +func urlParamsFromContext(ctx context.Context) map[string]string { + params, ok := ctx.Value(urlParamsKey).(map[string]string) + if !ok { + return nil + } + return params +} diff --git a/libNetHttp/parser.go b/libNetHttp/parser.go index 616d55e..41ec87d 100644 --- a/libNetHttp/parser.go +++ b/libNetHttp/parser.go @@ -17,12 +17,16 @@ import ( ) func InitContext(r *http.Request, w http.ResponseWriter) NetHttpParser { - return NetHttpParser{ + parser := NetHttpParser{ Request: r, Response: w, Locals: make(map[string]any), Params: make(map[string]string), } + for key, value := range URLParamsFromRequest(r) { + parser.Params[key] = value + } + return parser } func (c NetHttpParser) GetMethod() string { diff --git a/libNetHttp/response.go b/libNetHttp/response.go index fa6a5fa..a2fa6ea 100644 --- a/libNetHttp/response.go +++ b/libNetHttp/response.go @@ -13,6 +13,28 @@ type ContextInitiator interface { Respond(int, int, string, any, bool, webFramework.WebFramework) } +type contextKey string + +const ( + requestKey contextKey = "nethttp.request" + writerKey contextKey = "nethttp.writer" +) + +func WithRequestResponse(ctx context.Context, r *http.Request, w http.ResponseWriter) context.Context { + ctx = context.WithValue(ctx, requestKey, r) + return context.WithValue(ctx, writerKey, w) +} + +func RequestFromContext(ctx context.Context) (*http.Request, bool) { + req, ok := ctx.Value(requestKey).(*http.Request) + return req, ok +} + +func ResponseWriterFromContext(ctx context.Context) (http.ResponseWriter, bool) { + writer, ok := ctx.Value(writerKey).(http.ResponseWriter) + return writer, ok +} + func NetHttpErrorHandler(path, title string, handler ContextInitiator) http.HandlerFunc { log.Println("ErrorHandler: ", path, title) return func(w http.ResponseWriter, r *http.Request) { @@ -49,7 +71,7 @@ func NetHttpHandler(handler any) http.HandlerFunc { // Convert the handler to work with net/http context // The handler expects a context.Context, so we pass the request context if handlerFunc, ok := handler.(func(context.Context)); ok { - handlerFunc(r.Context()) + handlerFunc(WithRequestResponse(r.Context(), r, w)) } else { // If it's not the expected type, log an error log.Printf("Invalid handler type: %T", handler) diff --git a/libNetHttp/response_bridge_test.go b/libNetHttp/response_bridge_test.go new file mode 100644 index 0000000..3b2b868 --- /dev/null +++ b/libNetHttp/response_bridge_test.go @@ -0,0 +1,66 @@ +package libNetHttp_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/hmmftg/requestCore/libContext" + "github.com/hmmftg/requestCore/libNetHttp" +) + +func TestNetHttpHandlerBridgesRequestResponseIntoContext(t *testing.T) { + handler := libNetHttp.NetHttpHandler(func(ctx context.Context) { + wf := libContext.InitContext(ctx) + err := wf.Parser.SendJSONRespBody(http.StatusOK, map[string]string{"status": "ok"}) + if err != nil { + t.Fatalf("unexpected write error: %v", err) + } + }) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + req.Header.Set("User-Id", "bridge-user") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("failed to parse response body: %v", err) + } + + if body["status"] != "ok" { + t.Fatalf("expected status body to be ok, got %q", body["status"]) + } +} + +func TestWithRequestResponseExtractors(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/users/42", nil) + rec := httptest.NewRecorder() + + ctx := libNetHttp.WithRequestResponse(context.Background(), req, rec) + + extractedReq, ok := libNetHttp.RequestFromContext(ctx) + if !ok { + t.Fatal("expected request in context") + } + if extractedReq.URL.Path != "/users/42" { + t.Fatalf("unexpected extracted path: %s", extractedReq.URL.Path) + } + + extractedWriter, ok := libNetHttp.ResponseWriterFromContext(ctx) + if !ok { + t.Fatal("expected response writer in context") + } + + extractedWriter.WriteHeader(http.StatusNoContent) + if rec.Code != http.StatusNoContent { + t.Fatalf("expected status %d, got %d", http.StatusNoContent, rec.Code) + } +}