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, "