Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
42 changes: 42 additions & 0 deletions libChi/params.go
Original file line number Diff line number Diff line change
@@ -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)))
})
}
86 changes: 86 additions & 0 deletions libChi/params_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
76 changes: 76 additions & 0 deletions libChi/sqlc_stdlib_integration_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 8 additions & 0 deletions libContext/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions libNetHttp/params_context.go
Original file line number Diff line number Diff line change
@@ -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
}
6 changes: 5 additions & 1 deletion libNetHttp/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading