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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,6 @@ coverage/
.vscode/

# AI assistant data
.claude/
.claude/

references/*
90 changes: 90 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
# Run core tests only
go test ./...

# Run all tests (core + all adapters)
make test

# Run adapter tests only
make test-adapter

# Run a single test
go test -run TestGolden ./...

# Update golden YAML fixtures after intentional output changes
make test-update

# Lint (golangci-lint v2.12.2)
make lint
make lint-fix # auto-fix

# Tidy go.mod for core + all adapters
make tidy

# Full local check: sync + tidy + lint + test
make check

# Install dev tools (golangci-lint)
make install-tools
```

## Architecture

`oaswrap/spec` is a framework-agnostic OpenAPI 3.x document builder for Go. It generates spec documents from route registrations and Go struct reflection rather than parsing code annotations.

### Core package (`github.com/oaswrap/spec`)

- `types.go` — Public interfaces: `Generator` (embeds `Router`), `Router`, `Route`. Also re-exports common OpenAPI types.
- `router.go` — Concrete `generator` struct implementing `Generator`. `NewGenerator`/`NewRouter` are entry points. Routes accumulate in a tree; `build()` is called on every `GenerateSchema`, `MarshalYAML`, `MarshalJSON`, or `Validate` call (not cached — rebuilds each time).
- `errors.go` — `ValidationErrors` aggregating `validate.Error` with severity. Only `SeverityError` items cause `Validate()` / serialization to fail; `SeverityWarning` and `SeverityInfo` are informational.

### Sub-packages

| Package | Role |
|---|---|
| `openapi/` | Data model structs for OpenAPI documents (`Document`, `Schema`, `Operation`, `Config`, etc.). `Config` drives generator behavior. |
| `option/` | Functional options (`OpenAPIOption`, `OperationOption`, `GroupOption`, `ContentOption`) for configuring the generator and individual operations. |
| `internal/builder/` | Converts accumulated route + option data into `openapi.Document` operations via `Builder.AddOperation` and `Builder.AddWebhookOperation`. |
| `internal/reflect/` | Reflects Go types to `openapi.Schema` objects, managing `$components/schemas` de-duplication. |
| `internal/validate/` | Document validation. Issues carry a `Severity` (Error / Warning / Info). `ValidateDocument` is called at the end of every `build()`. |
| `pkg/parser/` | `ColonParamParser` converts `:param` style paths (used by some frameworks) to `{param}` OpenAPI path template format. |
| `internal/testutil/` | Golden-file test helpers used in `_test.go` files. |

### Adapters (`adapter/`)

Eight framework adapters wrap a spec `Generator` alongside a real HTTP router:

```
chiopenapi echoopenapi echov5openapi fiberopenapi
fiberv3openapi ginopenapi httpopenapi muxopenapi
```

Each adapter is a separate Go module (its own `go.mod`). All are included in the root `go.work` workspace. The pattern is:
- `NewRouter(frameworkRouter, ...option.OpenAPIOption)` → returns adapter's `Generator`
- Route registrations go to both the HTTP router and the spec generator simultaneously
- `gen spec.Generator` field must be propagated into every sub-router and group created by the adapter

### Golden tests

Test fixtures live in `testdata/` as `{case}.{version}.yaml` files (e.g. `petstore.v31.yaml`). Tests cover all three version families: `v30`, `v31`, `v32`. Run `make test-update` to regenerate them after intentional output changes.

### Supported OpenAPI versions

- 3.0.x (`openapi.Version304` is the default)
- 3.1.x (`openapi.Version312` recommended)
- 3.2.0 (`openapi.Version320`) — adds `QUERY` method and `$self`

Webhooks require 3.1.x or 3.2.0.

## Key constraints

- Commits must follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, etc. (enforced by lefthook).
- Max line length is 120 characters (golines).
- Import groups: stdlib → third-party → `github.com/oaswrap/spec` (enforced by goimports).
- Release is a two-stage process: `make release-prepare VERSION=x.y.z` (tags core, syncs adapter deps) then `make release-publish VERSION=x.y.z` (tags all adapters).
12 changes: 6 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -56,24 +56,24 @@ test: ## Run all tests (core + adapters)
@echo "$(GREEN)✅ Core tests passed$(NC)"
@for a in $(ADAPTERS); do \
echo "$(BLUE)🔍 Testing adapter $$a...$(NC)"; \
(cd "adapter/$$a" && go test ./...) || (echo "$(RED)❌ Adapter $$a tests failed$(NC)" && exit 1); \
(cd "adapter/$$a" && go test ./...) || { echo "$(RED)❌ Adapter $$a tests failed$(NC)"; exit 1; }; \
done
@echo "$(GREEN)🎉 All tests passed!$(NC)"

test-adapter: ## Run tests for all adapters
@echo "$(BLUE)🔍 Running tests for all adapters...$(NC)"
@for a in $(ADAPTERS); do \
echo "$(BLUE)🔍 Testing adapter $$a...$(NC)"; \
(cd "adapter/$$a" && go test ./...) || (echo "$(RED)❌ Adapter $$a tests failed$(NC)" && exit 1); \
(cd "adapter/$$a" && go test ./...) || { echo "$(RED)❌ Adapter $$a tests failed$(NC)"; exit 1; }; \
done
@echo "$(GREEN)🎉 All adapter tests passed!$(NC)"

test-update: ## Update golden files for tests
@echo "$(YELLOW)🔍 Running core tests (updating golden files)...$(NC)"
@go test $(PKG) -args -update || (echo "$(RED)❌ Core test update failed$(NC)" && exit 1)
@go test . -args -update || (echo "$(RED)❌ Core test update failed$(NC)" && exit 1)
@for a in $(ADAPTERS); do \
echo "$(YELLOW)🔍 Updating adapter $$a golden files...$(NC)"; \
(cd "adapter/$$a" && go test ./... -args -update) || (echo "$(RED)❌ Adapter $$a update failed$(NC)" && exit 1); \
(cd "adapter/$$a" && go test . -args -update) || { echo "$(RED)❌ Adapter $$a update failed$(NC)"; exit 1; }; \
done
@echo "$(GREEN)✅ All golden files updated!$(NC)"

Expand Down Expand Up @@ -137,7 +137,7 @@ lint: ## Run linting
@for a in $(ADAPTERS); do \
echo "$(BLUE)🔍 Linting adapter/$$a...$(NC)"; \
(cd "adapter/$$a" && golangci-lint run ./...) || \
(echo "$(RED)❌ Adapter $$a linting failed$(NC)" && exit 1); \
{ echo "$(RED)❌ Adapter $$a linting failed$(NC)"; exit 1; }; \
done
@echo "$(GREEN)🎉 All linting passed!$(NC)"

Expand All @@ -147,7 +147,7 @@ lint-fix: ## Run linting with auto-fix
@for a in $(ADAPTERS); do \
echo "$(BLUE)🔧 Auto-fixing adapter/$$a...$(NC)"; \
(cd "adapter/$$a" && golangci-lint run --fix ./...) || \
(echo "$(RED)❌ Adapter $$a lint-fix failed$(NC)" && exit 1); \
{ echo "$(RED)❌ Adapter $$a lint-fix failed$(NC)"; exit 1; }; \
done
@echo "$(GREEN)✅ Lint fixes applied!$(NC)"

Expand Down
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
[![Go Version](https://img.shields.io/github/go-mod/go-version/oaswrap/spec)](https://github.com/oaswrap/spec/blob/main/go.mod)
[![License](https://img.shields.io/github/license/oaswrap/spec)](LICENSE)

`spec` is a Go library for generating OpenAPI `3.0.x`, `3.1.x`, and `3.2.0` documents. It uses a router and functional options API, and owns its OpenAPI model and schema reflection — no external OpenAPI or JSON Schema generators needed. YAML serialization uses `github.com/goccy/go-yaml`.
Code-first, framework-agnostic OpenAPI 3.x spec builder for Go. Generate docs from route registrations and Go structs — no annotations, no vendor lock-in.

---

Expand All @@ -16,7 +16,7 @@
- **Native OpenAPI builder** — paths, operations, components, validation, and schema reflection are all implemented in this repository without third-party OpenAPI dependencies.
- **Framework-agnostic core** — use `spec.NewRouter` for static generation, or drop in adapters for Chi, Echo, Gin, Fiber, net/http, and Mux.
- **Code-first route documentation** — register routes and their documentation together using Go functions and typed options.
- **Version-aware output** — defaults to OpenAPI `3.0.4`, with full support for `3.1.2` and `3.2.0` features when selected.
- **Version-aware output** — defaults to OpenAPI `3.1.2`, with full support for `3.0.x` and `3.2.0` features when selected.
- **Direct model escape hatches** — use typed `openapi` structs, `Extensions` for `x-*` fields, and `Extra` for official or future fields not yet wrapped by a helper option.
- **Deterministic output** — generated documents are stable enough for golden-file snapshot tests and CI documentation checks.

Expand Down Expand Up @@ -124,7 +124,8 @@ type User struct {
| `MarshalJSON()` | Validates and serializes pretty-printed JSON. |
| `WriteSchemaTo("openapi.yaml")` | Infers format from file extension (`.yaml`, `.yml`, `.json`). |
| `Document()` | Returns the built `*openapi.Document`. |
| `Validate()` | Builds the document and checks OpenAPI invariants. |
| `Validate()` | Builds the document and checks OpenAPI invariants. Returns only `SeverityError` findings. |
| `ValidateReport()` | Builds and validates, returning all findings including warnings and info as `ValidationErrors`. |
| `Config()` | Returns the effective OpenAPI configuration. |

---
Expand Down Expand Up @@ -181,7 +182,7 @@ r := spec.NewRouter(
| Option | Purpose |
| --- | --- |
| `WithOpenAPIConfig(opts...)` | Build an `*openapi.Config` with defaults and apply options. |
| `WithOpenAPIVersion(version)` | Set `openapi`; default is `openapi.Version304`. Constants are available for `3.0.0`–`3.0.4`, `3.1.0`–`3.1.2`, and `3.2.0`. |
| `WithOpenAPIVersion(version)` | Set `openapi`; default is `openapi.Version312`. Constants are available for `3.0.0`–`3.0.4`, `3.1.0`–`3.1.2`, and `3.2.0`. |
| `WithSelf(uri)` | Set OpenAPI `3.2.0` `$self`. |
| `WithJSONSchemaDialect(uri)` | Set root `jsonSchemaDialect`. |
| `WithTitle(title)` | Set `info.title`. |
Expand Down Expand Up @@ -227,7 +228,7 @@ r := spec.NewRouter(

**Tag options:** `TagSummary`, `TagDescription`, `TagExternalDocs`, `TagParent` (3.2.0), `TagKind` (3.2.0).

**Server options:** `ServerDescription`, `ServerVariables`.
**Server options:** `ServerDescription`, `ServerVariables`, `ServerName` (3.2.0).

---

Expand Down Expand Up @@ -283,6 +284,7 @@ api.Get("/users/{id}",
| --- | --- |
| `ContentType(contentType)` | Set media type; default is `application/json`. |
| `ContentDescription(description)` | Set request/response description. |
| `ContentSummary(summary)` | Set request/response summary (OpenAPI `3.2.0`). |
| `ContentDefault(isDefault...)` | Mark response as `default`. |
| `ContentEncoding(prop, enc)` | Add media type encoding metadata for a property. |
| `ContentExample(value)` | Set media type `example`. |
Expand Down Expand Up @@ -366,6 +368,7 @@ type SearchRequest struct {
| `header:"name"` | Header parameter. |
| `cookie:"name"` | Cookie parameter. |
| `querystring:"name"` | OpenAPI `3.2.0` whole-query-string parameter. |
| `mediaType:"..."` | Media type for `querystring` parameter content; defaults to `application/x-www-form-urlencoded`. OpenAPI `3.2.0` only. |
| `form:"name"` | Form body property name for form content types. |

**Schema tags:**
Expand Down Expand Up @@ -483,10 +486,13 @@ Selecting `openapi.Version320` enables the following additional features:
- Custom HTTP methods via `Add`, emitted as `additionalOperations`.
- `querystring` parameter tags.
- Root `$self` field.
- Server `name` field.
- Response `summary` field.
- Tag `parent` and `kind` fields.
- Security scheme metadata and deprecation fields.
- `components.mediaTypes`.
- Media type and encoding fields: `itemSchema`, `prefixEncoding`, `itemEncoding`.
- Discriminator `defaultMapping`.
- Example `dataValue` and `serializedValue` fields.
- XML `nodeType`.

Expand Down
7 changes: 0 additions & 7 deletions adapter/chiopenapi/internal/constant/constant.go

This file was deleted.

22 changes: 11 additions & 11 deletions adapter/chiopenapi/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,9 @@ import (

"github.com/oaswrap/spec"
specui "github.com/oaswrap/spec-ui"
"github.com/oaswrap/spec/openapi"
"github.com/oaswrap/spec/internal/mapper"
"github.com/oaswrap/spec/internal/validate"
"github.com/oaswrap/spec/option"
"github.com/oaswrap/spec/pkg/mapper"

"github.com/oaswrap/spec/adapter/chiopenapi/internal/constant"
)

type router struct {
Expand All @@ -34,9 +32,9 @@ func NewRouter(r chi.Router, opts ...option.OpenAPIOption) Generator {
// It initializes the OpenAPI configuration and sets up the necessary handlers for OpenAPI documentation.
func NewGenerator(r chi.Router, opts ...option.OpenAPIOption) Generator {
defaultOpts := []option.OpenAPIOption{
option.WithTitle(constant.DefaultTitle),
option.WithDescription(constant.DefaultDescription),
option.WithVersion(constant.DefaultVersion),
option.WithTitle("Chi OpenAPI"),
option.WithDescription("OpenAPI documentation for Chi applications"),
option.WithVersion("1.0.0"),
option.WithStoplightElements(),
option.WithCacheAge(0),
}
Expand Down Expand Up @@ -124,8 +122,7 @@ func (r *router) Mount(pattern string, h http.Handler) {

func (r *router) Method(method, pattern string, h http.Handler) Route {
r.chiRouter.Method(method, pattern, h)
if method == http.MethodConnect && r.gen.Config().OpenAPIVersion != openapi.Version320 {
// CONNECT requires OpenAPI 3.2, so older specs skip it
if !validate.AllowsOperationMethod(r.gen.Config().OpenAPIVersion, method) {
return &route{}
}
sr := r.specRouter.Add(method, pattern)
Expand All @@ -135,8 +132,7 @@ func (r *router) Method(method, pattern string, h http.Handler) Route {

func (r *router) MethodFunc(method, pattern string, h http.HandlerFunc) Route {
r.chiRouter.MethodFunc(method, pattern, h)
if method == http.MethodConnect && r.gen.Config().OpenAPIVersion != openapi.Version320 {
// CONNECT requires OpenAPI 3.2, so older specs skip it
if !validate.AllowsOperationMethod(r.gen.Config().OpenAPIVersion, method) {
return &route{}
}
sr := r.specRouter.Add(method, pattern)
Expand Down Expand Up @@ -197,6 +193,10 @@ func (r *router) Validate() error {
return r.gen.Validate()
}

func (r *router) ValidateReport() error {
return r.gen.ValidateReport()
}

func (r *router) GenerateSchema(formats ...string) ([]byte, error) {
return r.gen.GenerateSchema(formats...)
}
Expand Down
20 changes: 15 additions & 5 deletions adapter/chiopenapi/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,6 @@ func TestRouter_Spec(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
app := chi.NewRouter()
opts := []option.OpenAPIOption{
option.WithOpenAPIVersion("3.0.3"),
option.WithTitle("Test API " + tt.name),
option.WithVersion("1.0.0"),
option.WithDescription("This is a test API for " + tt.name),
Expand Down Expand Up @@ -575,7 +574,7 @@ func TestGenerator_Docs(t *testing.T) {
rr := httptest.NewRecorder()
c.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code, "expected status OK for /docs/openapi.yaml route")
assert.Contains(t, rr.Body.String(), "openapi: 3.0.4", "expected response body to contain 'openapi: 3.0.4'")
assert.Contains(t, rr.Body.String(), "openapi: 3.1.2", "expected response body to contain 'openapi: 3.1.2'")
})
}

Expand Down Expand Up @@ -631,7 +630,7 @@ func TestGenerator_MarshalJSON(t *testing.T) {
schema, err := r.MarshalJSON()
require.NoError(t, err, "failed to marshal OpenAPI schema to JSON")
assert.NotEmpty(t, schema, "expected non-empty OpenAPI schema JSON")
assert.Contains(t, string(schema), `"openapi": "3.0.4"`, "expected OpenAPI version in schema JSON")
assert.Contains(t, string(schema), `"openapi": "3.1.2"`, "expected OpenAPI version in schema JSON")
assert.Contains(t, string(schema), `"title": "Chi OpenAPI"`, "expected title in schema JSON")
}

Expand All @@ -650,7 +649,7 @@ func TestGenerator_MarshalYAML(t *testing.T) {
schema, err := r.MarshalYAML()
require.NoError(t, err, "failed to marshal OpenAPI schema to YAML")
assert.NotEmpty(t, schema, "expected non-empty OpenAPI schema YAML")
assert.Contains(t, string(schema), "openapi: 3.0.4", "expected OpenAPI version in schema YAML")
assert.Contains(t, string(schema), "openapi: 3.1.2", "expected OpenAPI version in schema YAML")
assert.Contains(t, string(schema), "title: Chi OpenAPI", "expected title in schema YAML")
}

Expand All @@ -674,6 +673,17 @@ func TestGenerator_WriteSchemaTo(t *testing.T) {
schema, err := os.ReadFile(goldenPath)
require.NoError(t, err, "failed to read OpenAPI schema file")
assert.NotEmpty(t, schema, "expected non-empty OpenAPI schema file")
assert.Contains(t, string(schema), "openapi: 3.0.4", "expected OpenAPI version in schema file")
assert.Contains(t, string(schema), "openapi: 3.1.2", "expected OpenAPI version in schema file")
assert.Contains(t, string(schema), "title: Chi OpenAPI", "expected title in schema file")
}

func TestGenerator_ValidateReport(t *testing.T) {
c := chi.NewRouter()
r := chiopenapi.NewRouter(c,
option.WithContact(openapi.Contact{Name: "Support"}),
option.WithLicense(openapi.License{Name: "MIT"}),
option.WithServer("https://example.com"),
)
err := r.ValidateReport()
assert.NoError(t, err)
}
Loading
Loading