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
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
51 changes: 51 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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/<provider>/handler.go` and `internal/<provider>/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 `<pkg>.NewHandler(c)`), and any provider-specific config defaults. `handler.go:Docs()` needs no changes — it dispatches via `DocsHandlerFactory`.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" |
Expand All @@ -266,6 +267,7 @@ specui.WithScalar(config.Scalar{
DocumentDownloadType: "both",
HideTestRequestButton: false,
HideSearch: false,
HideDeveloperTools: false,
DarkMode: true,
Layout: "modern",
Theme: "moon",
Expand Down
7 changes: 7 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"embed"
"io/fs"
"net/http"
)

type Provider uint8
Expand Down Expand Up @@ -38,6 +39,11 @@ type SpecUI struct {
ReDoc *ReDoc // ReDoc configuration
Scalar *Scalar // Scalar configuration
RapiDoc *RapiDoc // RapiDoc configuration

// DocsHandlerFactory is set by With<Provider> 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
Expand Down Expand Up @@ -109,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
Expand Down
34 changes: 12 additions & 22 deletions handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand Down
10 changes: 5 additions & 5 deletions internal/constant/constant.go
Original file line number Diff line number Diff line change
@@ -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"
)
3 changes: 3 additions & 0 deletions internal/scalar/index.tpl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/swaggerui/index.tpl.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func IndexTpl(assetsBase, faviconBase string, cfg *config.SwaggerUI) string {
<head>
<meta charset="UTF-8">
<title>{{ .Title }} - Swagger UI</title>
<link rel="stylesheet" type="text/css" href="` + assetsBase + `/swagger-ui.css">
<link rel="stylesheet" type="text/css" href="` + assetsBase + `/swagger-ui.min.css">
<link rel="icon" type="image/png" href="` + faviconBase + `/favicon-32x32.png" sizes="32x32"/>
<link rel="icon" type="image/png" href="` + faviconBase + `/favicon-16x16.png" sizes="16x16"/>
<style>
Expand Down
32 changes: 25 additions & 7 deletions option.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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]
}
Expand All @@ -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]
}
Expand All @@ -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]
}
Expand All @@ -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]
}
Expand All @@ -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]
}
Expand Down
Loading