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/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/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 := ` + +
+ + +