From 84d673ce147d4263f8afcd9afc3b51cd4b0f4954 Mon Sep 17 00:00:00 2001 From: Hein Date: Fri, 19 Dec 2025 16:32:14 +0200 Subject: [PATCH 1/5] Added OpenAPI UI Routes Co-authored-by: IvanX006 Co-authored-by: Warkanum Co-authored-by: Hein --- pkg/openapi/README.md | 152 ++++++++++++++-- pkg/openapi/example.go | 63 +++++++ pkg/openapi/ui_handler.go | 294 +++++++++++++++++++++++++++++++ pkg/openapi/ui_handler_test.go | 308 +++++++++++++++++++++++++++++++++ 4 files changed, 804 insertions(+), 13 deletions(-) create mode 100644 pkg/openapi/ui_handler.go create mode 100644 pkg/openapi/ui_handler_test.go diff --git a/pkg/openapi/README.md b/pkg/openapi/README.md index 2c95dcd..a9141bc 100644 --- a/pkg/openapi/README.md +++ b/pkg/openapi/README.md @@ -273,25 +273,151 @@ handler.SetOpenAPIGenerator(func() (string, error) { }) ``` -## Using with Swagger UI +## Using the Built-in UI Handler -You can serve the generated OpenAPI spec with Swagger UI: +The package includes a built-in UI handler that serves popular OpenAPI visualization tools. No need to download or manage static files - everything is served from CDN. + +### Quick Start + +```go +import ( + "github.com/bitechdev/ResolveSpec/pkg/openapi" + "github.com/gorilla/mux" +) + +func main() { + router := mux.NewRouter() + + // Setup your API routes and OpenAPI generator... + // (see examples above) + + // Add the UI handler - defaults to Swagger UI + openapi.SetupUIRoute(router, "/docs", openapi.UIConfig{ + UIType: openapi.SwaggerUI, + SpecURL: "/openapi", + Title: "My API Documentation", + }) + + // Now visit http://localhost:8080/docs + http.ListenAndServe(":8080", router) +} +``` + +### Supported UI Frameworks + +The handler supports four popular OpenAPI UI frameworks: + +#### 1. Swagger UI (Default) +The most widely used OpenAPI UI with excellent compatibility and features. + +```go +openapi.SetupUIRoute(router, "/docs", openapi.UIConfig{ + UIType: openapi.SwaggerUI, + Theme: "dark", // optional: "light" or "dark" +}) +``` + +#### 2. RapiDoc +Modern, customizable, and feature-rich OpenAPI UI. + +```go +openapi.SetupUIRoute(router, "/docs", openapi.UIConfig{ + UIType: openapi.RapiDoc, + Theme: "dark", +}) +``` + +#### 3. Redoc +Clean, responsive documentation with great UX. + +```go +openapi.SetupUIRoute(router, "/docs", openapi.UIConfig{ + UIType: openapi.Redoc, +}) +``` + +#### 4. Scalar +Modern and sleek OpenAPI documentation. + +```go +openapi.SetupUIRoute(router, "/docs", openapi.UIConfig{ + UIType: openapi.Scalar, + Theme: "dark", +}) +``` + +### Configuration Options + +```go +type UIConfig struct { + UIType UIType // SwaggerUI, RapiDoc, Redoc, or Scalar + SpecURL string // URL to OpenAPI spec (default: "/openapi") + Title string // Page title (default: "API Documentation") + FaviconURL string // Custom favicon URL (optional) + CustomCSS string // Custom CSS to inject (optional) + Theme string // "light" or "dark" (support varies by UI) +} +``` + +### Custom Styling Example + +```go +openapi.SetupUIRoute(router, "/docs", openapi.UIConfig{ + UIType: openapi.SwaggerUI, + Title: "Acme Corp API", + CustomCSS: ` + .swagger-ui .topbar { + background-color: #1976d2; + } + .swagger-ui .info .title { + color: #1976d2; + } + `, +}) +``` + +### Using Multiple UIs + +You can serve different UIs at different paths: + +```go +// Swagger UI at /docs +openapi.SetupUIRoute(router, "/docs", openapi.UIConfig{ + UIType: openapi.SwaggerUI, +}) + +// Redoc at /redoc +openapi.SetupUIRoute(router, "/redoc", openapi.UIConfig{ + UIType: openapi.Redoc, +}) + +// RapiDoc at /api-docs +openapi.SetupUIRoute(router, "/api-docs", openapi.UIConfig{ + UIType: openapi.RapiDoc, +}) +``` + +### Manual Handler Usage + +If you need more control, use the handler directly: + +```go +handler := openapi.UIHandler(openapi.UIConfig{ + UIType: openapi.SwaggerUI, + SpecURL: "/api/openapi.json", +}) + +router.Handle("/documentation", handler) +``` + +## Using with External Swagger UI + +Alternatively, you can use an external Swagger UI instance: 1. Get the spec from `/openapi` 2. Load it in Swagger UI at `https://petstore.swagger.io/` 3. Or self-host Swagger UI and point it to your `/openapi` endpoint -Example with self-hosted Swagger UI: - -```go -// Serve Swagger UI static files -router.PathPrefix("/swagger/").Handler( - http.StripPrefix("/swagger/", http.FileServer(http.Dir("./swagger-ui"))), -) - -// Configure Swagger UI to use /openapi -``` - ## Testing You can test the OpenAPI endpoint: diff --git a/pkg/openapi/example.go b/pkg/openapi/example.go index 15022f0..29624bf 100644 --- a/pkg/openapi/example.go +++ b/pkg/openapi/example.go @@ -183,6 +183,69 @@ func ExampleWithFuncSpec() { _ = generatorFunc } +// ExampleWithUIHandler shows how to serve OpenAPI documentation with a web UI +func ExampleWithUIHandler(db *gorm.DB) { + // Create handler and configure OpenAPI generator + handler := restheadspec.NewHandlerWithGORM(db) + registry := modelregistry.NewModelRegistry() + + handler.SetOpenAPIGenerator(func() (string, error) { + generator := NewGenerator(GeneratorConfig{ + Title: "My API", + Description: "API documentation with interactive UI", + Version: "1.0.0", + BaseURL: "http://localhost:8080", + Registry: registry, + IncludeRestheadSpec: true, + }) + return generator.GenerateJSON() + }) + + // Setup routes + router := mux.NewRouter() + restheadspec.SetupMuxRoutes(router, handler, nil) + + // Add UI handlers for different frameworks + // Swagger UI at /docs (most popular) + SetupUIRoute(router, "/docs", UIConfig{ + UIType: SwaggerUI, + SpecURL: "/openapi", + Title: "My API - Swagger UI", + Theme: "light", + }) + + // RapiDoc at /rapidoc (modern alternative) + SetupUIRoute(router, "/rapidoc", UIConfig{ + UIType: RapiDoc, + SpecURL: "/openapi", + Title: "My API - RapiDoc", + }) + + // Redoc at /redoc (clean and responsive) + SetupUIRoute(router, "/redoc", UIConfig{ + UIType: Redoc, + SpecURL: "/openapi", + Title: "My API - Redoc", + }) + + // Scalar at /scalar (modern and sleek) + SetupUIRoute(router, "/scalar", UIConfig{ + UIType: Scalar, + SpecURL: "/openapi", + Title: "My API - Scalar", + Theme: "dark", + }) + + // Now you can access: + // http://localhost:8080/docs - Swagger UI + // http://localhost:8080/rapidoc - RapiDoc + // http://localhost:8080/redoc - Redoc + // http://localhost:8080/scalar - Scalar + // http://localhost:8080/openapi - Raw OpenAPI JSON + + _ = router +} + // ExampleCustomization shows advanced customization options func ExampleCustomization() { // Create registry and register models with descriptions using struct tags diff --git a/pkg/openapi/ui_handler.go b/pkg/openapi/ui_handler.go new file mode 100644 index 0000000..9d5cfe2 --- /dev/null +++ b/pkg/openapi/ui_handler.go @@ -0,0 +1,294 @@ +package openapi + +import ( + "fmt" + "html/template" + "net/http" + "strings" + + "github.com/gorilla/mux" +) + +// UIType represents the type of OpenAPI UI to serve +type UIType string + +const ( + // SwaggerUI is the most popular OpenAPI UI + SwaggerUI UIType = "swagger-ui" + // RapiDoc is a modern, customizable OpenAPI UI + RapiDoc UIType = "rapidoc" + // Redoc is a clean, responsive OpenAPI UI + Redoc UIType = "redoc" + // Scalar is a modern and sleek OpenAPI UI + Scalar UIType = "scalar" +) + +// UIConfig holds configuration for the OpenAPI UI handler +type UIConfig struct { + // UIType specifies which UI framework to use (default: SwaggerUI) + UIType UIType + // SpecURL is the URL to the OpenAPI spec JSON (default: "/openapi") + SpecURL string + // Title is the page title (default: "API Documentation") + Title string + // FaviconURL is the URL to the favicon (optional) + FaviconURL string + // CustomCSS allows injecting custom CSS (optional) + CustomCSS string + // Theme for the UI (light/dark, depends on UI type) + Theme string +} + +// UIHandler creates an HTTP handler that serves an OpenAPI UI +func UIHandler(config UIConfig) http.HandlerFunc { + // Set defaults + if config.UIType == "" { + config.UIType = SwaggerUI + } + if config.SpecURL == "" { + config.SpecURL = "/openapi" + } + if config.Title == "" { + config.Title = "API Documentation" + } + if config.Theme == "" { + config.Theme = "light" + } + + return func(w http.ResponseWriter, r *http.Request) { + var htmlContent string + var err error + + switch config.UIType { + case SwaggerUI: + htmlContent, err = generateSwaggerUI(config) + case RapiDoc: + htmlContent, err = generateRapiDoc(config) + case Redoc: + htmlContent, err = generateRedoc(config) + case Scalar: + htmlContent, err = generateScalar(config) + default: + http.Error(w, "Unsupported UI type", http.StatusBadRequest) + return + } + + if err != nil { + http.Error(w, fmt.Sprintf("Failed to generate UI: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, err = w.Write([]byte(htmlContent)) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to write response: %v", err), http.StatusInternalServerError) + return + } + } +} + +// templateData wraps UIConfig to properly handle CSS in templates +type templateData struct { + UIConfig + SafeCustomCSS template.CSS +} + +// generateSwaggerUI generates the HTML for Swagger UI +func generateSwaggerUI(config UIConfig) (string, error) { + tmpl := ` + + + + + {{.Title}} + {{if .FaviconURL}}{{end}} + + {{if .SafeCustomCSS}}{{end}} + + + +
+ + + + +` + + t, err := template.New("swagger").Parse(tmpl) + if err != nil { + return "", err + } + + data := templateData{ + UIConfig: config, + SafeCustomCSS: template.CSS(config.CustomCSS), + } + + var buf strings.Builder + if err := t.Execute(&buf, data); err != nil { + return "", err + } + + return buf.String(), nil +} + +// generateRapiDoc generates the HTML for RapiDoc +func generateRapiDoc(config UIConfig) (string, error) { + theme := "light" + if config.Theme == "dark" { + theme = "dark" + } + + tmpl := ` + + + + + {{.Title}} + {{if .FaviconURL}}{{end}} + + {{if .SafeCustomCSS}}{{end}} + + + + +` + + t, err := template.New("rapidoc").Parse(tmpl) + if err != nil { + return "", err + } + + data := templateData{ + UIConfig: config, + SafeCustomCSS: template.CSS(config.CustomCSS), + } + + var buf strings.Builder + if err := t.Execute(&buf, data); err != nil { + return "", err + } + + return buf.String(), nil +} + +// generateRedoc generates the HTML for Redoc +func generateRedoc(config UIConfig) (string, error) { + tmpl := ` + + + + + {{.Title}} + {{if .FaviconURL}}{{end}} + {{if .SafeCustomCSS}}{{end}} + + + + + + +` + + t, err := template.New("redoc").Parse(tmpl) + if err != nil { + return "", err + } + + data := templateData{ + UIConfig: config, + SafeCustomCSS: template.CSS(config.CustomCSS), + } + + var buf strings.Builder + if err := t.Execute(&buf, data); err != nil { + return "", err + } + + return buf.String(), nil +} + +// generateScalar generates the HTML for Scalar +func generateScalar(config UIConfig) (string, error) { + tmpl := ` + + + + + {{.Title}} + {{if .FaviconURL}}{{end}} + {{if .SafeCustomCSS}}{{end}} + + + + + + +` + + t, err := template.New("scalar").Parse(tmpl) + if err != nil { + return "", err + } + + data := templateData{ + UIConfig: config, + SafeCustomCSS: template.CSS(config.CustomCSS), + } + + var buf strings.Builder + if err := t.Execute(&buf, data); err != nil { + return "", err + } + + return buf.String(), nil +} + +// SetupUIRoute adds the OpenAPI UI route to a mux router +// This is a convenience function for the most common use case +func SetupUIRoute(router *mux.Router, path string, config UIConfig) { + router.Handle(path, UIHandler(config)) +} diff --git a/pkg/openapi/ui_handler_test.go b/pkg/openapi/ui_handler_test.go new file mode 100644 index 0000000..f5dc594 --- /dev/null +++ b/pkg/openapi/ui_handler_test.go @@ -0,0 +1,308 @@ +package openapi + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gorilla/mux" +) + +func TestUIHandler_SwaggerUI(t *testing.T) { + config := UIConfig{ + UIType: SwaggerUI, + SpecURL: "/openapi", + Title: "Test API Docs", + } + + handler := UIHandler(config) + req := httptest.NewRequest("GET", "/docs", nil) + w := httptest.NewRecorder() + + handler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } + + body := w.Body.String() + + // Check for Swagger UI specific content + if !strings.Contains(body, "swagger-ui") { + t.Error("Expected Swagger UI content") + } + if !strings.Contains(body, "SwaggerUIBundle") { + t.Error("Expected SwaggerUIBundle script") + } + if !strings.Contains(body, config.Title) { + t.Errorf("Expected title '%s' in HTML", config.Title) + } + if !strings.Contains(body, config.SpecURL) { + t.Errorf("Expected spec URL '%s' in HTML", config.SpecURL) + } + if !strings.Contains(body, "swagger-ui-dist") { + t.Error("Expected Swagger UI CDN link") + } +} + +func TestUIHandler_RapiDoc(t *testing.T) { + config := UIConfig{ + UIType: RapiDoc, + SpecURL: "/api/spec", + Title: "RapiDoc Test", + } + + handler := UIHandler(config) + req := httptest.NewRequest("GET", "/docs", nil) + w := httptest.NewRecorder() + + handler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } + + body := w.Body.String() + + // Check for RapiDoc specific content + if !strings.Contains(body, "rapi-doc") { + t.Error("Expected rapi-doc element") + } + if !strings.Contains(body, "rapidoc-min.js") { + t.Error("Expected RapiDoc script") + } + if !strings.Contains(body, config.Title) { + t.Errorf("Expected title '%s' in HTML", config.Title) + } + if !strings.Contains(body, config.SpecURL) { + t.Errorf("Expected spec URL '%s' in HTML", config.SpecURL) + } +} + +func TestUIHandler_Redoc(t *testing.T) { + config := UIConfig{ + UIType: Redoc, + SpecURL: "/spec.json", + Title: "Redoc Test", + } + + handler := UIHandler(config) + req := httptest.NewRequest("GET", "/docs", nil) + w := httptest.NewRecorder() + + handler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } + + body := w.Body.String() + + // Check for Redoc specific content + if !strings.Contains(body, " Date: Fri, 19 Dec 2025 16:42:01 +0200 Subject: [PATCH 2/5] Fixed Attempt to Fix Docker / Podman Co-authored-by: IvanX006 Co-authored-by: Warkanum Co-authored-by: Hein --- Makefile | 6 +++--- pkg/tracing/README.md | 4 ++-- scripts/run-integration-tests.sh | 24 ++++++++++++------------ tests/INTEGRATION_TESTS.md | 16 ++++++++-------- tests/README_TESTS.md | 12 ++++++------ 5 files changed, 31 insertions(+), 31 deletions(-) diff --git a/Makefile b/Makefile index bba4a99..de7dd55 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ test: test-unit test-integration # Start PostgreSQL for integration tests docker-up: @echo "Starting PostgreSQL container..." - @docker-compose up -d postgres-test + @podman compose up -d postgres-test @echo "Waiting for PostgreSQL to be ready..." @sleep 5 @echo "PostgreSQL is ready!" @@ -24,12 +24,12 @@ docker-up: # Stop PostgreSQL container docker-down: @echo "Stopping PostgreSQL container..." - @docker-compose down + @podman compose down # Clean up Docker volumes and test data clean: @echo "Cleaning up..." - @docker-compose down -v + @podman compose down -v @echo "Cleanup complete!" # Run integration tests with Docker (full workflow) diff --git a/pkg/tracing/README.md b/pkg/tracing/README.md index 2dea2e7..080f0c3 100644 --- a/pkg/tracing/README.md +++ b/pkg/tracing/README.md @@ -465,7 +465,7 @@ func processRequest(ctx context.Context) { 1. **Check collector is running:** ```bash - docker-compose ps + podman compose ps ``` 2. **Verify endpoint:** @@ -476,7 +476,7 @@ func processRequest(ctx context.Context) { 3. **Check logs:** ```bash - docker-compose logs otel-collector + podman compose logs otel-collector ``` ### Disable Tracing diff --git a/scripts/run-integration-tests.sh b/scripts/run-integration-tests.sh index f35a337..67e20f2 100755 --- a/scripts/run-integration-tests.sh +++ b/scripts/run-integration-tests.sh @@ -14,33 +14,33 @@ NC='\033[0m' # No Color echo -e "${GREEN}=== ResolveSpec Integration Tests ===${NC}\n" -# Check if docker-compose is available -if ! command -v docker-compose &> /dev/null; then - echo -e "${RED}Error: docker-compose is not installed${NC}" - echo "Please install docker-compose or run PostgreSQL manually" +# Check if podman compose is available +if ! command -v podman &> /dev/null; then + echo -e "${RED}Error: podman is not installed${NC}" + echo "Please install podman or run PostgreSQL manually" echo "See INTEGRATION_TESTS.md for details" exit 1 fi # Clean up any existing containers and networks from previous runs echo -e "${YELLOW}Cleaning up existing containers and networks...${NC}" -docker-compose down -v 2>/dev/null || true +podman compose down -v 2>/dev/null || true # Start PostgreSQL echo -e "${YELLOW}Starting PostgreSQL...${NC}" -docker-compose up -d postgres-test +podman compose up -d postgres-test # Wait for PostgreSQL to be ready echo -e "${YELLOW}Waiting for PostgreSQL to be ready...${NC}" max_attempts=30 attempt=0 -while ! docker-compose exec -T postgres-test pg_isready -U postgres > /dev/null 2>&1; do +while ! podman compose exec -T postgres-test pg_isready -U postgres > /dev/null 2>&1; do attempt=$((attempt + 1)) if [ $attempt -ge $max_attempts ]; then echo -e "${RED}Error: PostgreSQL failed to start after ${max_attempts} seconds${NC}" - docker-compose logs postgres-test - docker-compose down + podman compose logs postgres-test + podman compose down exit 1 fi sleep 1 @@ -51,8 +51,8 @@ echo -e "\n${GREEN}PostgreSQL is ready!${NC}\n" # Create test databases echo -e "${YELLOW}Creating test databases...${NC}" -docker-compose exec -T postgres-test psql -U postgres -c "CREATE DATABASE resolvespec_test;" 2>/dev/null || echo " resolvespec_test already exists" -docker-compose exec -T postgres-test psql -U postgres -c "CREATE DATABASE restheadspec_test;" 2>/dev/null || echo " restheadspec_test already exists" +podman compose exec -T postgres-test psql -U postgres -c "CREATE DATABASE resolvespec_test;" 2>/dev/null || echo " resolvespec_test already exists" +podman compose exec -T postgres-test psql -U postgres -c "CREATE DATABASE restheadspec_test;" 2>/dev/null || echo " restheadspec_test already exists" echo -e "${GREEN}Test databases ready!${NC}\n" # Determine which tests to run @@ -79,6 +79,6 @@ fi # Cleanup echo -e "\n${YELLOW}Stopping PostgreSQL...${NC}" -docker-compose down +podman compose down exit $EXIT_CODE diff --git a/tests/INTEGRATION_TESTS.md b/tests/INTEGRATION_TESTS.md index 664ff24..0198d54 100644 --- a/tests/INTEGRATION_TESTS.md +++ b/tests/INTEGRATION_TESTS.md @@ -19,14 +19,14 @@ Integration tests validate the full functionality of both `pkg/resolvespec` and - Go 1.19 or later - PostgreSQL 12 or later -- Docker and Docker Compose (optional, for easy setup) +- Podman and Podman Compose (optional, for easy setup) -## Quick Start with Docker +## Quick Start with Podman -### 1. Start PostgreSQL with Docker Compose +### 1. Start PostgreSQL with Podman Compose ```bash -docker-compose up -d postgres-test +podman compose up -d postgres-test ``` This starts a PostgreSQL container with the following default settings: @@ -52,7 +52,7 @@ go test -tags=integration ./pkg/restheadspec -v ### 3. Stop PostgreSQL ```bash -docker-compose down +podman compose down ``` ## Manual PostgreSQL Setup @@ -161,7 +161,7 @@ If you see "connection refused" errors: 1. Check that PostgreSQL is running: ```bash - docker-compose ps + podman compose ps ``` 2. Verify connection parameters: @@ -194,10 +194,10 @@ Each test automatically cleans up its data using `TRUNCATE`. If you need a fresh ```bash # Stop and remove containers (removes data) -docker-compose down -v +podman compose down -v # Restart -docker-compose up -d postgres-test +podman compose up -d postgres-test ``` ## CI/CD Integration diff --git a/tests/README_TESTS.md b/tests/README_TESTS.md index ae88fa2..c8813b3 100644 --- a/tests/README_TESTS.md +++ b/tests/README_TESTS.md @@ -119,13 +119,13 @@ Integration tests require a PostgreSQL database and use the `// +build integrati - PostgreSQL 12+ installed and running - Create test databases manually (see below) -### Setup with Docker +### Setup with Podman 1. **Start PostgreSQL**: ```bash make docker-up # or - docker-compose up -d postgres-test + podman compose up -d postgres-test ``` 2. **Run Tests**: @@ -141,10 +141,10 @@ Integration tests require a PostgreSQL database and use the `// +build integrati ```bash make docker-down # or - docker-compose down + podman compose down ``` -### Setup without Docker +### Setup without Podman 1. **Create Databases**: ```sql @@ -289,8 +289,8 @@ go test -tags=integration ./pkg/resolvespec -v **Problem**: "connection refused" or "database does not exist" **Solutions**: -1. Check PostgreSQL is running: `docker-compose ps` -2. Verify databases exist: `docker-compose exec postgres-test psql -U postgres -l` +1. Check PostgreSQL is running: `podman compose ps` +2. Verify databases exist: `podman compose exec postgres-test psql -U postgres -l` 3. Check environment variable: `echo $TEST_DATABASE_URL` 4. Recreate databases: `make clean && make docker-up` From 0525323a473d72c6b34de1c9ed74b8813e24629c Mon Sep 17 00:00:00 2001 From: Hein Date: Fri, 19 Dec 2025 16:50:16 +0200 Subject: [PATCH 3/5] Fixed tests failing due to reponse header status Co-authored-by: IvanX006 Co-authored-by: Warkanum Co-authored-by: Hein --- pkg/restheadspec/integration_test.go | 33 ++++++++++++++-------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/pkg/restheadspec/integration_test.go b/pkg/restheadspec/integration_test.go index 8bb7c67..4326e1f 100644 --- a/pkg/restheadspec/integration_test.go +++ b/pkg/restheadspec/integration_test.go @@ -1,3 +1,4 @@ +//go:build integration // +build integration package restheadspec @@ -21,12 +22,12 @@ import ( // Test models type TestUser struct { - ID uint `gorm:"primaryKey" json:"id"` - Name string `gorm:"not null" json:"name"` - Email string `gorm:"uniqueIndex;not null" json:"email"` - Age int `json:"age"` - Active bool `gorm:"default:true" json:"active"` - CreatedAt time.Time `json:"created_at"` + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"not null" json:"name"` + Email string `gorm:"uniqueIndex;not null" json:"email"` + Age int `json:"age"` + Active bool `gorm:"default:true" json:"active"` + CreatedAt time.Time `json:"created_at"` Posts []TestPost `gorm:"foreignKey:UserID" json:"posts,omitempty"` } @@ -35,13 +36,13 @@ func (TestUser) TableName() string { } type TestPost struct { - ID uint `gorm:"primaryKey" json:"id"` - UserID uint `gorm:"not null" json:"user_id"` - Title string `gorm:"not null" json:"title"` - Content string `json:"content"` - Published bool `gorm:"default:false" json:"published"` - CreatedAt time.Time `json:"created_at"` - User *TestUser `gorm:"foreignKey:UserID" json:"user,omitempty"` + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null" json:"user_id"` + Title string `gorm:"not null" json:"title"` + Content string `json:"content"` + Published bool `gorm:"default:false" json:"published"` + CreatedAt time.Time `json:"created_at"` + User *TestUser `gorm:"foreignKey:UserID" json:"user,omitempty"` Comments []TestComment `gorm:"foreignKey:PostID" json:"comments,omitempty"` } @@ -54,7 +55,7 @@ type TestComment struct { PostID uint `gorm:"not null" json:"post_id"` Content string `gorm:"not null" json:"content"` CreatedAt time.Time `json:"created_at"` - Post *TestPost `gorm:"foreignKey:PostID" json:"post,omitempty"` + Post *TestPost `gorm:"foreignKey:PostID" json:"post,omitempty"` } func (TestComment) TableName() string { @@ -401,7 +402,7 @@ func TestIntegration_GetMetadata(t *testing.T) { muxRouter.ServeHTTP(w, req) - if w.Code != http.StatusOK { + if !(w.Code == http.StatusOK && w.Code == http.StatusPartialContent) { t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) } @@ -492,7 +493,7 @@ func TestIntegration_QueryParamsOverHeaders(t *testing.T) { muxRouter.ServeHTTP(w, req) - if w.Code != http.StatusOK { + if !(w.Code == http.StatusOK && w.Code == http.StatusPartialContent) { t.Errorf("Expected status 200, got %d", w.Code) } From 63ed62a9a36a0a42c15117df35d283e1366910c6 Mon Sep 17 00:00:00 2001 From: Hein Date: Fri, 19 Dec 2025 16:52:34 +0200 Subject: [PATCH 4/5] fix: Stupid logic error. Co-authored-by: IvanX006 Co-authored-by: Warkanum Co-authored-by: Hein --- pkg/restheadspec/integration_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/restheadspec/integration_test.go b/pkg/restheadspec/integration_test.go index 4326e1f..4cdbf96 100644 --- a/pkg/restheadspec/integration_test.go +++ b/pkg/restheadspec/integration_test.go @@ -402,7 +402,7 @@ func TestIntegration_GetMetadata(t *testing.T) { muxRouter.ServeHTTP(w, req) - if !(w.Code == http.StatusOK && w.Code == http.StatusPartialContent) { + if !(w.Code == http.StatusOK || w.Code == http.StatusPartialContent) { t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) } @@ -493,7 +493,7 @@ func TestIntegration_QueryParamsOverHeaders(t *testing.T) { muxRouter.ServeHTTP(w, req) - if !(w.Code == http.StatusOK && w.Code == http.StatusPartialContent) { + if !(w.Code == http.StatusOK || w.Code == http.StatusPartialContent) { t.Errorf("Expected status 200, got %d", w.Code) } From ed67caf055dad94c5412f786a36d0c2354beab14 Mon Sep 17 00:00:00 2001 From: Hein Date: Tue, 23 Dec 2025 14:17:02 +0200 Subject: [PATCH 5/5] fix: reasheadspec customsql calls AddTablePrefixToColumns --- pkg/common/sql_helpers.go | 158 ++++++++++++++++++++++++++++++++---- pkg/restheadspec/handler.go | 9 +- 2 files changed, 150 insertions(+), 17 deletions(-) diff --git a/pkg/common/sql_helpers.go b/pkg/common/sql_helpers.go index 5b14ce5..2036dfb 100644 --- a/pkg/common/sql_helpers.go +++ b/pkg/common/sql_helpers.go @@ -208,21 +208,9 @@ func SanitizeWhereClause(where string, tableName string, options ...*RequestOpti } } } - } else if tableName != "" && !hasTablePrefix(condToCheck) { - // If tableName is provided and the condition DOESN'T have a table prefix, - // qualify unambiguous column references to prevent "ambiguous column" errors - // when there are multiple joins on the same table (e.g., recursive preloads) - columnName := extractUnqualifiedColumnName(condToCheck) - if columnName != "" && (validColumns == nil || isValidColumn(columnName, validColumns)) { - // Qualify the column with the table name - // Be careful to only replace the column name, not other occurrences of the string - oldRef := columnName - newRef := tableName + "." + columnName - // Use word boundary matching to avoid replacing partial matches - cond = qualifyColumnInCondition(cond, oldRef, newRef) - logger.Debug("Qualified unqualified column in condition: '%s' added table prefix '%s'", oldRef, tableName) - } } + // Note: We no longer add prefixes to unqualified columns here. + // Use AddTablePrefixToColumns() separately if you need to add prefixes. validConditions = append(validConditions, cond) } @@ -633,3 +621,145 @@ func isValidColumn(columnName string, validColumns map[string]bool) bool { } return validColumns[strings.ToLower(columnName)] } + +// AddTablePrefixToColumns adds table prefix to unqualified column references in a WHERE clause. +// This function only prefixes simple column references and skips: +// - Columns already having a table prefix (containing a dot) +// - Columns inside function calls or expressions (inside parentheses) +// - Columns inside subqueries +// - Columns that don't exist in the table (validation via model registry) +// +// Examples: +// - "status = 'active'" -> "users.status = 'active'" (if status exists in users table) +// - "COALESCE(status, 'default') = 'active'" -> unchanged (status inside function) +// - "users.status = 'active'" -> unchanged (already has prefix) +// - "(status = 'active')" -> "(users.status = 'active')" (grouping parens are OK) +// - "invalid_col = 'value'" -> unchanged (if invalid_col doesn't exist in table) +// +// Parameters: +// - where: The WHERE clause to process +// - tableName: The table name to use as prefix +// +// Returns: +// - The WHERE clause with table prefixes added to appropriate and valid columns +func AddTablePrefixToColumns(where string, tableName string) string { + if where == "" || tableName == "" { + return where + } + + where = strings.TrimSpace(where) + + // Get valid columns from the model registry for validation + validColumns := getValidColumnsForTable(tableName) + + // Split by AND to handle multiple conditions (parenthesis-aware) + conditions := splitByAND(where) + prefixedConditions := make([]string, 0, len(conditions)) + + for _, cond := range conditions { + cond = strings.TrimSpace(cond) + if cond == "" { + continue + } + + // Process this condition to add table prefix if appropriate + processedCond := addPrefixToSingleCondition(cond, tableName, validColumns) + prefixedConditions = append(prefixedConditions, processedCond) + } + + if len(prefixedConditions) == 0 { + return "" + } + + return strings.Join(prefixedConditions, " AND ") +} + +// addPrefixToSingleCondition adds table prefix to a single condition if appropriate +// Returns the condition unchanged if: +// - The condition is a SQL literal/expression (true, false, null, 1=1, etc.) +// - The column reference is inside a function call +// - The column already has a table prefix +// - No valid column reference is found +// - The column doesn't exist in the table (when validColumns is provided) +func addPrefixToSingleCondition(cond string, tableName string, validColumns map[string]bool) string { + // Strip outer grouping parentheses to get to the actual condition + strippedCond := stripOuterParentheses(cond) + + // Skip SQL literals and trivial conditions (true, false, null, 1=1, etc.) + if IsSQLExpression(strippedCond) || IsTrivialCondition(strippedCond) { + logger.Debug("Skipping SQL literal/trivial condition: '%s'", strippedCond) + return cond + } + + // Extract the left side of the comparison (before the operator) + columnRef := extractLeftSideOfComparison(strippedCond) + if columnRef == "" { + return cond + } + + // Skip if it already has a prefix (contains a dot) + if strings.Contains(columnRef, ".") { + logger.Debug("Skipping column '%s' - already has table prefix", columnRef) + return cond + } + + // Skip if it's a function call or expression (contains parentheses) + if strings.Contains(columnRef, "(") { + logger.Debug("Skipping column reference '%s' - inside function or expression", columnRef) + return cond + } + + // Validate that the column exists in the table (if we have column info) + if !isValidColumn(columnRef, validColumns) { + logger.Debug("Skipping column '%s' - not found in table '%s'", columnRef, tableName) + return cond + } + + // It's a simple unqualified column reference that exists in the table - add the table prefix + newRef := tableName + "." + columnRef + result := qualifyColumnInCondition(cond, columnRef, newRef) + logger.Debug("Added table prefix to column: '%s' -> '%s'", columnRef, newRef) + + return result +} + +// extractLeftSideOfComparison extracts the left side of a comparison operator from a condition. +// This is used to identify the column reference that may need a table prefix. +// +// Examples: +// - "status = 'active'" returns "status" +// - "COALESCE(status, 'default') = 'active'" returns "COALESCE(status, 'default')" +// - "priority > 5" returns "priority" +// +// Returns empty string if no operator is found. +func extractLeftSideOfComparison(cond string) string { + operators := []string{" = ", " != ", " <> ", " > ", " >= ", " < ", " <= ", " LIKE ", " like ", " IN ", " in ", " IS ", " is ", " NOT ", " not "} + + // Find the first operator outside of parentheses and quotes + minIdx := -1 + for _, op := range operators { + idx := findOperatorOutsideParentheses(cond, op) + if idx > 0 && (minIdx == -1 || idx < minIdx) { + minIdx = idx + } + } + + if minIdx > 0 { + leftSide := strings.TrimSpace(cond[:minIdx]) + // Remove any surrounding quotes + leftSide = strings.Trim(leftSide, "`\"'") + return leftSide + } + + // No operator found - might be a boolean column + parts := strings.Fields(cond) + if len(parts) > 0 { + columnRef := strings.Trim(parts[0], "`\"'") + // Make sure it's not a SQL keyword + if !IsSQLKeyword(strings.ToLower(columnRef)) { + return columnRef + } + } + + return "" +} diff --git a/pkg/restheadspec/handler.go b/pkg/restheadspec/handler.go index 2cd2e5c..f109fd5 100644 --- a/pkg/restheadspec/handler.go +++ b/pkg/restheadspec/handler.go @@ -482,8 +482,10 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st // Apply custom SQL WHERE clause (AND condition) if options.CustomSQLWhere != "" { logger.Debug("Applying custom SQL WHERE: %s", options.CustomSQLWhere) - // Sanitize and allow preload table prefixes since custom SQL may reference multiple tables - sanitizedWhere := common.SanitizeWhereClause(options.CustomSQLWhere, reflection.ExtractTableNameOnly(tableName), &options.RequestOptions) + // First add table prefixes to unqualified columns (but skip columns inside function calls) + prefixedWhere := common.AddTablePrefixToColumns(options.CustomSQLWhere, reflection.ExtractTableNameOnly(tableName)) + // Then sanitize and allow preload table prefixes since custom SQL may reference multiple tables + sanitizedWhere := common.SanitizeWhereClause(prefixedWhere, reflection.ExtractTableNameOnly(tableName), &options.RequestOptions) if sanitizedWhere != "" { query = query.Where(sanitizedWhere) } @@ -492,8 +494,9 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st // Apply custom SQL WHERE clause (OR condition) if options.CustomSQLOr != "" { logger.Debug("Applying custom SQL OR: %s", options.CustomSQLOr) + customOr := common.AddTablePrefixToColumns(options.CustomSQLOr, reflection.ExtractTableNameOnly(tableName)) // Sanitize and allow preload table prefixes since custom SQL may reference multiple tables - sanitizedOr := common.SanitizeWhereClause(options.CustomSQLOr, reflection.ExtractTableNameOnly(tableName), &options.RequestOptions) + sanitizedOr := common.SanitizeWhereClause(customOr, reflection.ExtractTableNameOnly(tableName), &options.RequestOptions) if sanitizedOr != "" { query = query.WhereOr(sanitizedOr) }