From 796a95feffb837d95135ad97e143695019553924 Mon Sep 17 00:00:00 2001 From: Ahmad Faiz Kamaludin Date: Sun, 5 Apr 2026 09:27:03 +0700 Subject: [PATCH 1/3] feat: enable tree-shaking by moving provider dispatch to factory pattern Replace the switch statement in handler.go (which forced all 5 UI providers into every binary) with a DocsHandlerFactory field set by each With option. The Go linker now eliminates any provider whose option is never called, reducing binary size to only the provider(s) in use. - Remove default StoplightElements provider; callers must now explicitly specify a provider via WithSwaggerUI, WithStoplightElements, etc. - Cache the docs handler with sync.Once so template parsing happens once - Simplify DocsFunc to resolve the handler at registration time --- CLAUDE.md | 51 ++++++++++++++++++++++++++++++++++++++++++++++++ config/config.go | 6 ++++++ handler.go | 34 ++++++++++++-------------------- option.go | 32 +++++++++++++++++++++++------- 4 files changed, 94 insertions(+), 29 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a3a753e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,51 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Run all tests +go test ./... + +# Run a single test +go test -run TestHandler/Docs/SwaggerUI ./... + +# Run tests with coverage +go test -coverprofile=coverage.out ./... + +# Lint (requires golangci-lint) +golangci-lint run + +# Tidy dependencies +go mod tidy +``` + +## Architecture + +`spec-ui` is a Go library that serves OpenAPI documentation UIs as HTTP handlers. It supports five UI providers: SwaggerUI, StoplightElements, ReDoc, Scalar, and RapiDoc. + +### Data flow + +1. The user calls `specui.NewHandler(opts...)` with functional options from `option.go` +2. Options populate a `config.SpecUI` struct (defined in `config/config.go`) +3. `Handler.Docs()` dispatches to the appropriate provider package under `internal/` based on `cfg.Provider` +4. `Handler.Spec()` always dispatches to `internal/spec`, which serves the raw OpenAPI file + +### Key structural patterns + +- **Provider packages** (`internal/swaggerui`, `internal/stoplightelements`, `internal/redoc`, `internal/scalar`, `internal/rapidoc`): each has a `handler.go` that renders an HTML template, and an `index.tpl.go` that holds the template string. All UI assets are loaded from CDN (URLs pinned in `internal/constant/constant.go`). + +- **Spec serving** (`internal/spec/spec.go`): uses `sync.Once` to read the spec file once and cache it in memory. Supports four source modes: `SpecGenerator` interface, `embed.FS`, `fs.FS`, or plain OS file path. + +- **Config** (`config/config.go`): a single `SpecUI` struct holds all configuration. Each UI provider has its own nested config struct with typed constants for enum-like fields (e.g., `ElementLayout`, `RapiDocTheme`). + +- **Default provider** is `ProviderStoplightElements`; default paths are `/docs` (docs) and `/docs/openapi.json` (spec). + +### Adding a new UI provider + +1. Add a new `Provider` constant to `config/config.go` +2. Add the provider config struct to `config/config.go` +3. Create `internal//handler.go` and `internal//index.tpl.go` +4. Add CDN asset URLs to `internal/constant/constant.go` +5. In `option.go`: import the new internal package, add a `WithXxx` option that sets `c.Provider`, `c.DocsHandlerFactory` (closure wrapping `.NewHandler(c)`), and any provider-specific config defaults. `handler.go:Docs()` needs no changes — it dispatches via `DocsHandlerFactory`. diff --git a/config/config.go b/config/config.go index b20f06c..9ef3a9f 100644 --- a/config/config.go +++ b/config/config.go @@ -3,6 +3,7 @@ package config import ( "embed" "io/fs" + "net/http" ) type Provider uint8 @@ -38,6 +39,11 @@ type SpecUI struct { ReDoc *ReDoc // ReDoc configuration Scalar *Scalar // Scalar configuration RapiDoc *RapiDoc // RapiDoc configuration + + // DocsHandlerFactory is set by With options and controls which + // provider's handler is created at request time. Only the referenced + // provider's code is linked into the binary, enabling tree-shaking. + DocsHandlerFactory func(*SpecUI) http.Handler } type SwaggerLayout string diff --git a/handler.go b/handler.go index 2dc14a6..0dce5f6 100644 --- a/handler.go +++ b/handler.go @@ -3,14 +3,10 @@ package specui import ( "errors" "net/http" + "sync" "github.com/oaswrap/spec-ui/config" - "github.com/oaswrap/spec-ui/internal/rapidoc" - "github.com/oaswrap/spec-ui/internal/redoc" - "github.com/oaswrap/spec-ui/internal/scalar" "github.com/oaswrap/spec-ui/internal/spec" - "github.com/oaswrap/spec-ui/internal/stoplightelements" - "github.com/oaswrap/spec-ui/internal/swaggerui" ) // NewHandler creates a new HTTP handler for the OpenAPI UI. @@ -24,7 +20,9 @@ func NewHandler(opts ...Option) *Handler { // Handler handles HTTP requests for the OpenAPI UI. type Handler struct { - cfg *config.SpecUI + cfg *config.SpecUI + docsOnce sync.Once + docsHandler http.Handler } // DocsPath returns the path to the API documentation. @@ -38,28 +36,20 @@ func (h *Handler) SpecPath() string { } // Docs returns the HTTP handler for the API documentation. +// The handler is created once and cached for subsequent calls. func (h *Handler) Docs() http.Handler { - switch h.cfg.Provider { - case config.ProviderSwaggerUI: - return swaggerui.NewHandler(h.cfg) - case config.ProviderStoplightElements: - return stoplightelements.NewHandler(h.cfg) - case config.ProviderReDoc: - return redoc.NewHandler(h.cfg) - case config.ProviderScalar: - return scalar.NewHandler(h.cfg) - case config.ProviderRapiDoc: - return rapidoc.NewHandler(h.cfg) - default: - panic(errors.New("unsupported provider")) + if h.cfg.DocsHandlerFactory == nil { + panic(errors.New("no UI provider configured: use WithSwaggerUI, WithStoplightElements, WithReDoc, WithScalar, or WithRapiDoc")) } + h.docsOnce.Do(func() { + h.docsHandler = h.cfg.DocsHandlerFactory(h.cfg) + }) + return h.docsHandler } // DocsFunc returns the HTTP handler function for the API documentation. func (h *Handler) DocsFunc() http.HandlerFunc { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.Docs().ServeHTTP(w, r) - }) + return h.Docs().ServeHTTP } // Spec returns the HTTP handler for the OpenAPI specification. diff --git a/option.go b/option.go index 4e38495..10b1de6 100644 --- a/option.go +++ b/option.go @@ -3,18 +3,22 @@ package specui import ( "embed" "io/fs" + "net/http" "github.com/oaswrap/spec-ui/config" + "github.com/oaswrap/spec-ui/internal/rapidoc" + "github.com/oaswrap/spec-ui/internal/redoc" + "github.com/oaswrap/spec-ui/internal/scalar" + "github.com/oaswrap/spec-ui/internal/stoplightelements" + "github.com/oaswrap/spec-ui/internal/swaggerui" ) func newConfig(opts ...Option) *config.SpecUI { cfg := &config.SpecUI{ - Title: "OpenAPI Documentation", - CacheAge: 3600, // Default cache age is 3600 seconds (1 hour) - DocsPath: "/docs", - SpecPath: "/docs/openapi.json", - StoplightElements: &config.StoplightElements{}, - Provider: config.ProviderStoplightElements, + Title: "OpenAPI Documentation", + CacheAge: 3600, // Default cache age is 3600 seconds (1 hour) + DocsPath: "/docs", + SpecPath: "/docs/openapi.json", } for _, opt := range opts { @@ -92,6 +96,9 @@ func WithSpecGenerator(cfg config.SpecGenerator) Option { func WithSwaggerUI(cfg ...config.SwaggerUI) Option { return func(c *config.SpecUI) { c.Provider = config.ProviderSwaggerUI + c.DocsHandlerFactory = func(c *config.SpecUI) http.Handler { + return swaggerui.NewHandler(c) + } if len(cfg) > 0 { c.SwaggerUI = &cfg[0] } @@ -109,10 +116,12 @@ func WithSwaggerUI(cfg ...config.SwaggerUI) Option { // WithStoplightElements set ui documentation to use Stoplight Elements. // It can be used to override the default Stoplight Elements configuration. -// It sets the default router to "hash" and layout to "sidebar" if not specified. func WithStoplightElements(cfg ...config.StoplightElements) Option { return func(c *config.SpecUI) { c.Provider = config.ProviderStoplightElements + c.DocsHandlerFactory = func(c *config.SpecUI) http.Handler { + return stoplightelements.NewHandler(c) + } if len(cfg) > 0 { c.StoplightElements = &cfg[0] } @@ -127,6 +136,9 @@ func WithStoplightElements(cfg ...config.StoplightElements) Option { func WithReDoc(cfg ...config.ReDoc) Option { return func(c *config.SpecUI) { c.Provider = config.ProviderReDoc + c.DocsHandlerFactory = func(c *config.SpecUI) http.Handler { + return redoc.NewHandler(c) + } if len(cfg) > 0 { c.ReDoc = &cfg[0] } @@ -141,6 +153,9 @@ func WithReDoc(cfg ...config.ReDoc) Option { func WithScalar(cfg ...config.Scalar) Option { return func(c *config.SpecUI) { c.Provider = config.ProviderScalar + c.DocsHandlerFactory = func(c *config.SpecUI) http.Handler { + return scalar.NewHandler(c) + } if len(cfg) > 0 { c.Scalar = &cfg[0] } @@ -155,6 +170,9 @@ func WithScalar(cfg ...config.Scalar) Option { func WithRapiDoc(cfg ...config.RapiDoc) Option { return func(c *config.SpecUI) { c.Provider = config.ProviderRapiDoc + c.DocsHandlerFactory = func(c *config.SpecUI) http.Handler { + return rapidoc.NewHandler(c) + } if len(cfg) > 0 { c.RapiDoc = &cfg[0] } From 9b89644b565eedccfb6c85f9988b2f98af0c834d Mon Sep 17 00:00:00 2001 From: Ahmad Faiz Kamaludin Date: Sun, 5 Apr 2026 09:37:40 +0700 Subject: [PATCH 2/3] chore: update ci action version --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c43014..413d852 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,8 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version: '1.23' - name: golangci-lint @@ -30,13 +30,13 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - go-version: ['1.23', '1.24'] + go-version: ['1.23', '1.24', '1.25'] fail-fast: false steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} - name: Cache Go modules From 6b98dc3fcaad3dc6c9143b37a9212bfaa45109ec Mon Sep 17 00:00:00 2001 From: Ahmad Faiz Kamaludin Date: Sun, 5 Apr 2026 10:06:11 +0700 Subject: [PATCH 3/3] feat: upgrade provider version, and use cdn from jsdelivr --- README.md | 2 ++ config/config.go | 1 + internal/constant/constant.go | 10 +++++----- internal/scalar/index.tpl.go | 3 +++ internal/swaggerui/index.tpl.go | 2 +- 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 49b14d5..3a1a8a6 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,7 @@ specui.WithReDoc(config.ReDoc{ | `HideModels` | `bool` | `false` | Hide models in the sidebar | | `DocumentDownloadType` | `string` | `"both"` | Document download type: "json", "yaml", "both", or "none" | | `HideTestRequestButton` | `bool` | `false` | Hide the "Test Request" button | +| `HideDeveloperTools` | `bool` | `false` | Hide developer tools | | `HideSearch` | `bool` | `false` | Hide search bar | | `DarkMode` | `bool` | `false` | Enable dark mode | | `Layout` | `string` | `"modern"` | Layout type: "modern" or "classic" | @@ -266,6 +267,7 @@ specui.WithScalar(config.Scalar{ DocumentDownloadType: "both", HideTestRequestButton: false, HideSearch: false, + HideDeveloperTools: false, DarkMode: true, Layout: "modern", Theme: "moon", diff --git a/config/config.go b/config/config.go index 9ef3a9f..87b71b8 100644 --- a/config/config.go +++ b/config/config.go @@ -115,6 +115,7 @@ type Scalar struct { DocumentDownloadType string // Document download type e.g. "json", "yaml", "both", or "none" HideTestRequestButton bool // Hide the "Test Request" button HideSearch bool // Hide search bar + HideDeveloperTools bool // Hide developer tools DarkMode bool // Enable dark mode Layout ScalarLayout // Layout type e.g. "modern" or "classic" Theme string // Theme name, see https://guides.scalar.com/scalar/scalar-api-references/themes for available themes diff --git a/internal/constant/constant.go b/internal/constant/constant.go index 05124ce..24d67d6 100644 --- a/internal/constant/constant.go +++ b/internal/constant/constant.go @@ -1,17 +1,17 @@ package constant const ( - RapiDocAssetBase = "https://cdnjs.cloudflare.com/ajax/libs/rapidoc/9.3.8" + RapiDocAssetBase = "https://cdn.jsdelivr.net/npm/rapidoc@9.3.8/dist" RapiDocFaviconBase = "https://rapidocweb.com" - RedocAssetsBase = "https://cdn.jsdelivr.net/npm/redoc@2.5.0/bundles" + RedocAssetsBase = "https://cdn.jsdelivr.net/npm/redoc@2.5.2/bundles" - ScalarAssetBase = "https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.34.2/dist" + ScalarAssetBase = "https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.51.0/dist" ScalarFaviconBase = "https://scalar.com" - StoplightElementsAssetsBase = "https://cdn.jsdelivr.net/npm/@stoplight/elements@9.0.6" + StoplightElementsAssetsBase = "https://cdn.jsdelivr.net/npm/@stoplight/elements@9.0.16" StoplightElementFaviconBase = "https://docs.stoplight.io" - SwaggerUIAssetsBase = "https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.27.1" + SwaggerUIAssetsBase = "https://cdn.jsdelivr.net/npm/swagger-ui@5.32.1/dist" SwaggerUIFaviconBase = "https://petstore.swagger.io" ) diff --git a/internal/scalar/index.tpl.go b/internal/scalar/index.tpl.go index f27b8d0..49eebc8 100644 --- a/internal/scalar/index.tpl.go +++ b/internal/scalar/index.tpl.go @@ -27,6 +27,9 @@ func IndexTpl(assetBase, faviconBase string, cfg *config.Scalar) string { addSetting("layout", string(cfg.Layout)) addSetting("documentDownloadType", cfg.DocumentDownloadType) addSetting("theme", cfg.Theme) + if cfg.HideDeveloperTools { + settings["showDeveloperTools"] = "'never'" + } settingsStr := make([]string, 0, len(settings)) for k, v := range settings { diff --git a/internal/swaggerui/index.tpl.go b/internal/swaggerui/index.tpl.go index be81aa7..ea548c9 100644 --- a/internal/swaggerui/index.tpl.go +++ b/internal/swaggerui/index.tpl.go @@ -47,7 +47,7 @@ func IndexTpl(assetsBase, faviconBase string, cfg *config.SwaggerUI) string { {{ .Title }} - Swagger UI - +