diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f2c7d2..6c66a87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: run: make install-tools - name: โœ… Run Quality Checks - run: make check + run: make sync tidy lint # ======================================== # Test Matrix @@ -77,18 +77,16 @@ jobs: check-latest: true cache: true # Handles module/build cache - - name: ๐Ÿ”ง Install Tools - run: make install-tools - - name: ๐Ÿงช Run Tests + if: matrix.go-version != '1.25' run: make test - name: ๐Ÿ“Š Generate Coverage Report - if: matrix.go-version == '1.23' + if: matrix.go-version == '1.25' run: make testcov - name: ๐Ÿ“ˆ Upload Coverage Report - if: matrix.go-version == '1.23' + if: matrix.go-version == '1.25' uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -116,4 +114,4 @@ jobs: echo "Tests failed" exit 1 fi - echo "All CI checks passed! ๐ŸŽ‰" \ No newline at end of file + echo "All CI checks passed! ๐ŸŽ‰" diff --git a/.golangci.yml b/.golangci.yml index 1785b9b..b441dc7 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -24,12 +24,14 @@ formatters: # with the given prefixes are grouped after 3rd-party packages. # Default: [] local-prefixes: - - github.com/my/project + - github.com/oaswrap/spec golines: # Target maximum line length. # Default: 100 max-len: 120 + # Keep struct tag literals readable; tag padding creates very wide OpenAPI reflection fixtures. + reformat-tags: false linters: enable: @@ -38,8 +40,9 @@ linters: - bidichk # checks for dangerous unicode character sequences - bodyclose # checks whether HTTP response body is closed successfully - canonicalheader # checks whether net/http.Header uses canonical header + - clickhouselint # detects common mistakes with the ClickHouse native Go driver API - copyloopvar # detects places where loop variables are copied (Go 1.22+) - - cyclop # checks function and package cyclomatic complexity + #- cyclop # overlaps with gocognit and gocyclo; too noisy for validation-heavy code - depguard # checks if package imports are in a list of acceptable packages - dupl # tool for code clone detection - durationcheck # checks for two durations multiplied together @@ -51,16 +54,17 @@ linters: - exptostd # detects functions from golang.org/x/exp/ that can be replaced by std functions - fatcontext # detects nested contexts in loops - forbidigo # forbids identifiers - - funcorder # checks the order of functions, methods, and constructors + #- funcorder # style-only and fights natural organization in small packages - funlen # tool for detection of long functions - gocheckcompilerdirectives # validates go compiler directive comments (//go:) - - gochecknoglobals # checks that no global variables exist + #- gochecknoglobals # too strict for regex vars, sentinel errors, and test flags - gochecknoinits # checks that no init functions are present in Go code - gochecksumtype # checks exhaustiveness on Go "sum types" - gocognit # computes and checks the cognitive complexity of functions - - goconst # finds repeated strings that could be replaced by a constant + #- goconst # finds repeated strings that could be replaced by a constant - gocritic # provides diagnostics that check for bugs, performance and style issues - - gocyclo # computes and checks the cyclomatic complexity of functions + #- gocyclo # overlaps with gocognit; gocognit gives better signal here + - godoclint # checks Golang's documentation practice - godot # checks if comments end in a period - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod - goprintffuncname # checks that printf-like functions are named with f at the end @@ -69,10 +73,12 @@ linters: - iface # checks the incorrect use of interfaces, helping developers avoid interface pollution - ineffassign # detects when assignments to existing variables are not used - intrange # finds places where for loops could make use of an integer range + - iotamixing # checks if iotas are being used in const blocks with other non-iota declarations - loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap) - makezero # finds slice declarations with non-zero initial length - mirror # reports wrong mirror patterns of bytes/strings usage - - mnd # detects magic numbers + #- mnd # too noisy for OpenAPI versions, status codes, and schema/domain literals + - modernize # suggests simplifications to Go code, using modern language and library features - musttag # enforces field tags in (un)marshaled structs - nakedret # finds naked returns in functions greater than a specified function length - nestif # reports deeply nested if statements @@ -83,7 +89,8 @@ linters: - nolintlint # reports ill-formed or insufficient nolint directives - nonamedreturns # reports all named returns - nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL - - perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative + #- paralleltest # detects missing usage of t.Parallel() method in your Go test + #- perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative - predeclared # finds code that shadows one of Go's predeclared identifiers - promlinter # checks Prometheus metrics naming via promlint - protogetter # reports direct reads from proto message fields when getters should be used @@ -97,10 +104,11 @@ linters: - staticcheck # is a go vet on steroids, applying a ton of static analysis checks - testableexamples # checks if examples are testable (have an expected output) - testifylint # checks usage of github.com/stretchr/testify - - testpackage # makes you use a separate _test package + #- testpackage # makes you use a separate _test package - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes - unconvert # removes unnecessary type conversions - unparam # reports unused function parameters + - unqueryvet # detects SELECT * in SQL queries and SQL builders, encouraging explicit column selection - unused # checks for unused constants, variables, functions and types - usestdlibvars # detects the possibility to use variables/constants from the Go standard library - usetesting # reports uses of functions with replacement inside the testing package @@ -132,7 +140,7 @@ linters: #- err113 # [too strict] checks the errors handling expressions #- errchkjson # [don't see profit + I'm against of omitting errors like in the first example https://github.com/breml/errchkjson] checks types passed to the json encoding functions. Reports unsupported types and optionally reports occasions, where the check for the returned error can be omitted #- forcetypeassert # [replaced by errcheck] finds forced type assertions - #- gomodguard # [use more powerful depguard] allow and block lists linter for direct Go module dependencies + #- gomodguard_v2: [use more powerful depguard] allow and blocklist linter for direct Go module dependencies #- gosmopolitan # reports certain i18n/l10n anti-patterns in your Go codebase #- grouper # analyzes expression groups #- importas # enforces consistent import aliases @@ -140,10 +148,8 @@ linters: #- maintidx # measures the maintainability index of each function #- misspell # [useless] finds commonly misspelled English words in comments #- nlreturn # [too strict and mostly code is not more readable] checks for a new line before return and branch statements to increase code clarity - #- paralleltest # [too many false positives] detects missing usage of t.Parallel() method in your Go test #- tagliatelle # checks the struct tags #- thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers - #- wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines #- wsl_v5 # [too strict and mostly code is not more readable] add or remove empty lines # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml @@ -282,7 +288,7 @@ linters: gocognit: # Minimal code complexity to report. # Default: 30 (but we recommend 10-20) - min-complexity: 20 + min-complexity: 30 gocritic: # Settings passed to gocritic. @@ -298,6 +304,17 @@ linters: # Default: true skipRecvDeref: false + godoclint: + # List of rules to enable in addition to the default set. + # Default: empty + enable: + # Assert no unused link in godocs. + # https://github.com/godoc-lint/godoc-lint?tab=readme-ov-file#no-unused-link + - no-unused-link + # Require proper doc links to standard library declarations where applicable. + # https://github.com/godoc-lint/godoc-lint?tab=readme-ov-file#require-stdlib-doclink + - require-stdlib-doclink + govet: # Enable all analyzers. # Default: false @@ -312,7 +329,7 @@ linters: shadow: # Whether to be strict about shadowing; can be noisy. # Default: false - strict: true + strict: false inamedparam: # Skips check for interface methods with only a single parameter. @@ -374,21 +391,15 @@ linters: - github.com/jmoiron/sqlx sloglint: - # Enforce not using global loggers. - # Values: - # - "": disabled - # - "all": report all global loggers - # - "default": report only the default slog logger - # https://github.com/go-simpler/sloglint?tab=readme-ov-file#no-global - # Default: "" + # Report the use of global loggers. + # https://github.com/go-simpler/sloglint#no-global-logger + # Values: "all", "default" + # Default: "" (disabled) no-global: all - # Enforce using methods that accept a context. - # Values: - # - "": disabled - # - "all": report all contextless calls - # - "scope": report only if a context exists in the scope of the outermost function - # https://github.com/go-simpler/sloglint?tab=readme-ov-file#context-only - # Default: "" + # Report the use of functions without a context.Context. + # https://github.com/go-simpler/sloglint#context-only + # Values: "all", "scope" + # Default: "" (disabled) context: scope staticcheck: @@ -415,7 +426,7 @@ linters: exclusions: # Log a warning if an exclusion rule is unused. # Default: false - warn-unused: true + warn-unused: false # Predefined exclusion rules. # Default: [] presets: @@ -423,8 +434,6 @@ linters: - common-false-positives # Excluding configuration per-path, per-linter, per-text and per-source. rules: - - text: 'local replacement are not allowed' - linters: [ gomoddirectives ] - source: 'TODO' linters: [ godot ] - text: 'should have a package comment' @@ -437,6 +446,11 @@ linters: - text: 'comment on exported \S+ \S+ should be of the form ".+"' source: '// ?(nolint|TODO)' linters: [ revive, staticcheck ] + - path: 'internal/validate/.*\.go' + text: 'stutters; consider calling this' + linters: [ revive ] + - text: 'local replacement are not allowed' + linters: [ gomoddirectives ] - path: '_test\.go' linters: - bodyclose @@ -446,4 +460,4 @@ linters: - goconst - gosec - noctx - - wrapcheck \ No newline at end of file + - wrapcheck diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c7f5fa6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +This repository is a Go workspace for `github.com/oaswrap/spec`. Core package files live at the repository root and in `openapi/`, `option/`, `pkg/`, and `internal/`. Router adapters are separate modules under `adapter/openapi/`, each with its own examples, tests, and `go.mod`. Runnable examples live in `examples/` and adapter `example/` directories. Golden YAML fixtures and expected OpenAPI output live in `testdata/` and adapter `testdata/` directories. + +## Build, Test, and Development Commands + +- `make install-tools`: installs `golangci-lint`. +- `make test`: runs core and adapter tests with `go test`. +- `make test-adapter`: runs only adapter module tests. +- `make test-update`: regenerates golden files when output changes intentionally. +- `make testcov` / `make testcov-html`: produces coverage reports in `coverage/`. +- `make lint`: runs `golangci-lint` for core and adapters. +- `make tidy`: runs `go mod tidy` across core, adapters, and examples. +- `make sync`: updates the Go workspace. +- `make check`: runs sync, tidy, lint, and all tests. + +Use `go test ./... -run TestName` for focused core tests, or run the same command inside an adapter module for adapter-specific work. + +## Coding Style & Naming Conventions + +Use standard Go formatting (`gofmt`) and idiomatic Go naming. Package names are short lowercase names such as `openapi`, `option`, or `parser`. Test files must use the `_test.go` suffix. Adapter directories follow the `openapi` pattern, for example `fiberopenapi` and `ginopenapi`. Keep public APIs documented when exported names are not self-explanatory. + +## Testing Guidelines + +Tests use Go's standard `testing` package, with golden-file coverage for generated OpenAPI YAML. Add or update tests for behavior changes, especially changes to route registration, schema reflection, options, or adapter output. When running `make test-update`, review all generated fixture diffs before committing. + +## Commit & Pull Request Guidelines + +Use Conventional Commits: + +```text +feat: add response header option +fix: handle empty path group correctly +docs: clarify WithSecurity usage +``` + +Accepted types include `feat`, `fix`, `docs`, `style`, `refactor`, `test`, and `chore`. Keep commits focused. Pull requests should describe what changed, why it changed, affected modules or adapters, related issues, and tests run. Include fixture updates when generated output changes. + +## Agent-Specific Instructions + +Follow existing patterns before adding abstractions. Changes to core generation may require adapter and golden-file updates. Do not modify release tags, generated coverage reports, or unrelated module files unless the task explicitly requires it. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 9cbb06c..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,93 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Commands - -### Development Tools (install once) -```bash -make install-tools # installs gotestsum and golangci-lint -``` - -### Testing -```bash -make test # run core + all adapter tests -make test-adapter # run adapter tests only -make test-update # update golden files (testdata/*.yaml) -make testcov # run tests with coverage -make testcov-html # generate + open HTML coverage report - -# Run a single test (core) -go test ./... -run TestName - -# Run a single adapter test -cd adapter/fiberopenapi && go test ./... -run TestName -``` - -### Code Quality -```bash -make lint # lint core and all adapters -make tidy # go mod tidy for core + all adapters -make sync # go work sync -make check # sync + tidy + lint + test (full local CI) -``` - -### Release (two-stage flow) -```bash -make release-prepare VERSION=x.y.z # Stage 1: tag core + sync adapter go.mod -# then commit and push the adapter go.mod changes -make release-publish VERSION=x.y.z # Stage 2: tag and push all adapters -make release-dry-run VERSION=x.y.z # preview without making changes -``` - -## Architecture - -This is a **Go workspace monorepo** (`go.work`) containing: -- **Core module** (`github.com/oaswrap/spec`) โ€” the OpenAPI spec builder -- **Adapter modules** (`adapter//`) โ€” framework-specific wrappers, each a separate Go module with its own `go.mod` - -### Core module - -| File/Package | Purpose | -|---|---| -| `types.go` | Public interfaces: `Generator`, `Router`, `Route`, `reflector`, `spec` | -| `router.go` | `generator` struct โ€” implements route registration, grouping, and spec serialization | -| `reflector.go` | Dispatches to OAS 3.0 or 3.1 reflector based on version string | -| `reflector3.go` / `reflector31.go` | Version-specific reflectors wrapping `swaggest/openapi-go` | -| `operation.go` | Builds operation context from options | -| `jsonschema.go` | JSON Schema helpers | -| `option/` | All `option.OpenAPIOption`, `option.OperationOption`, `option.GroupOption` constructors | -| `openapi/` | Internal config structs and OpenAPI entity types | -| `pkg/` | Shared utilities: `mapper`, `parser`, `dto`, `testutil`, `util` | -| `internal/` | Private helpers: `debuglog`, `errs`, `mapper`, `parser` | - -### How spec generation works - -1. `NewRouter`/`NewGenerator` creates a `generator` with a `reflector` (OAS 3.0 or 3.1). -2. Route methods (`Get`, `Post`, etc.) register `route` objects (lazy โ€” no reflector call yet). -3. `Group` creates child `generator`s sharing the same `reflector`; group options are propagated at build time. -4. On first `MarshalYAML`/`MarshalJSON`/`Validate`, `buildOnce()` fires: flattens the route tree and calls `reflector.Add()` for each route, applying group options. -5. `reflector.Add()` translates `option.OperationOption` functions into the `swaggest/openapi-go` operation model. - -### Adapters - -Each adapter (e.g. `adapter/fiberopenapi`) wraps both the framework router and `spec.Generator`: -- `NewRouter(frameworkRouter, opts...)` โ†’ returns the adapter's `Generator` interface -- Route methods register handlers on the framework router **and** call `spec.Router.Add()` for documentation -- Docs UI (`/docs`) and YAML spec (`/docs/openapi.yaml`) routes are added automatically unless `option.DisableDocs()` is set -- Each adapter uses a `parser.ColonParamParser` (or similar) to convert framework-specific path params (e.g. `:id`) to OpenAPI style (`{id}`) - -### Golden-file testing - -Tests in `router_test.go` and adapter `router_test.go` compare generated YAML output against files in `testdata/`. Run `make test-update` to regenerate them after intentional changes. - -### Versioning & tagging - -- Core tag: `v1.2.3` -- Adapter tags: `adapter//v1.2.3` -- After tagging core, run `make sync-adapter-deps VERSION=x.y.z` to pin adapter `go.mod` to the new core version, then commit before publishing adapter tags. - -### Git hooks (lefthook) - -Pre-commit: `gofmt`, `go vet`, `golangci-lint`, `go mod tidy`. -Commit messages must follow Conventional Commits: `feat|fix|docs|style|refactor|test|chore`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 35062d5..b0592da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,7 +41,7 @@ Please search existing issues before opening a new one. git clone https://github.com/oaswrap/spec.git cd spec -# Install development tools (gotestsum, golangci-lint) +# Install development tools (golangci-lint) make install-tools ``` @@ -65,7 +65,6 @@ adapter/ fiberopenapi/ # Fiber adapter ginopenapi/ # Gin adapter httpopenapi/ # net/http adapter - httprouteropenapi/ # HttpRouter adapter muxopenapi/ # Gorilla Mux adapter ... ``` @@ -77,7 +76,7 @@ Changes to the core module may require corresponding updates to affected adapter ## Testing ```bash -# Run all tests (core + adapters) +# Run all tests (core + adapters with go test) make test # Run adapter tests only @@ -142,7 +141,7 @@ To add support for a new Go web framework: 2. Add the new module to `go.work`. 3. Implement the `spec.Generator` interface by wrapping both the framework router and the core `spec.Router`. 4. Register routes on the framework router **and** call `spec.Router.Add()` for documentation. -5. Automatically mount `/docs` (UI) and `/docs/openapi.yaml` (spec) unless `option.DisableDocs()` is set. +5. Automatically mount `/docs` (UI) and `/docs/openapi.yaml` (spec) unless `option.WithDisableDocs()` is set. 6. Use a `parser.ColonParamParser` (or equivalent) to translate framework path params (e.g. `:id`) to OpenAPI style (`{id}`). 7. Add a `testdata/` directory and golden-file tests following the pattern in existing adapters. 8. Add a `README.md` describing the adapter. diff --git a/LICENSE b/LICENSE index ae6204e..feb272d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Ahmad Faiz Kamaludin +Copyright (c) 2025 Oaswrap Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index 9b23984..f795998 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,11 @@ PKG := ./... COVERAGE_DIR := coverage COVERAGE_FILE := coverage.out -ADAPTERS := chiopenapi echoopenapi fiberopenapi ginopenapi httpopenapi muxopenapi httprouteropenapi echov5openapi fiberv3openapi +CORE_COV_DIR := $(COVERAGE_DIR)/core +ADAPTER_COV_ROOT := $(COVERAGE_DIR)/adapters +MERGED_COV_DIR := $(COVERAGE_DIR)/merged +CORE_COV_ABS := $(abspath $(CORE_COV_DIR)) +ADAPTERS := chiopenapi echoopenapi fiberopenapi ginopenapi httpopenapi muxopenapi echov5openapi fiberv3openapi # Platform detection for sed compatibility # Using an immediately expanded variable for this is good practice. @@ -24,8 +28,7 @@ BLUE := \033[0;34m NC := \033[0m # No Color # Tool versions -GOLANGCI_LINT_VERSION := v2.11.4 -GOTESTSUM_VERSION := v1.12.3 +GOLANGCI_LINT_VERSION := v2.12.2 # Normalize VERSION input so targets accept both 1.2.3 and v1.2.3. # Pre-release versions are also supported, e.g. 0.4.0-rc.1 or v0.4.0-rc.1. @@ -49,11 +52,11 @@ help: ## Show this help message test: ## Run all tests (core + adapters) @echo "$(BLUE)๐Ÿ” Running core tests...$(NC)" - @gotestsum --format standard-quiet -- $(PKG) || (echo "$(RED)โŒ Core tests failed$(NC)" && exit 1) + @go test $(PKG) || (echo "$(RED)โŒ Core tests failed$(NC)" && exit 1) @echo "$(GREEN)โœ… Core tests passed$(NC)" @for a in $(ADAPTERS); do \ echo "$(BLUE)๐Ÿ” Testing adapter $$a...$(NC)"; \ - (cd "adapter/$$a" && gotestsum --format standard-quiet -- ./...) || (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)" @@ -61,40 +64,48 @@ 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" && gotestsum --format standard-quiet -- ./...) || (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)" - @gotestsum --format standard-quiet -- -update $(PKG) || (echo "$(RED)โŒ Core test update failed$(NC)" && exit 1) + @go test $(PKG) -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" && gotestsum --format standard-quiet -- -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)" -testcov: ## Run tests with coverage and generate reports - @echo "$(BLUE)๐Ÿ“Š Generating coverage report...$(NC)" - @mkdir -p $(COVERAGE_DIR) - @gotestsum --format standard-quiet -- -covermode=atomic -coverprofile="$(COVERAGE_DIR)/$(COVERAGE_FILE)" $(PKG) - +testcov: ## Run coverage using GOCOVERDIR + covdata (core + adapters) + @echo "$(BLUE)๐Ÿ“Š Generating unit coverage with GOCOVERDIR...$(NC)" + @mkdir -p "$(CORE_COV_DIR)" "$(ADAPTER_COV_ROOT)" "$(MERGED_COV_DIR)" + @find "$(CORE_COV_DIR)" -type f -delete + @find "$(ADAPTER_COV_ROOT)" -type f -delete + @find "$(MERGED_COV_DIR)" -type f -delete + @rm -f "$(COVERAGE_DIR)/$(COVERAGE_FILE)" + @go test -cover ./... -args -test.gocoverdir="$(CORE_COV_ABS)" @for a in $(ADAPTERS); do \ - echo "$(BLUE)๐Ÿ“ˆ Adapter $$a coverage:$(NC)"; \ - (cd "adapter/$$a" && gotestsum --format standard-quiet -- -covermode=atomic -coverprofile="../../$(COVERAGE_DIR)/$$a-$(COVERAGE_FILE)" ./...); \ - if [ -f $(COVERAGE_DIR)/$$a-$(COVERAGE_FILE) ]; then \ - tail -n +2 $(COVERAGE_DIR)/$$a-$(COVERAGE_FILE) >> $(COVERAGE_DIR)/coverage.out; \ - fi; \ + echo "$(BLUE)๐Ÿ“ˆ Adapter $$a coverage...$(NC)"; \ + adapter_cov_dir="$(abspath $(ADAPTER_COV_ROOT))/$$a"; \ + mkdir -p "$$adapter_cov_dir"; \ + (cd "adapter/$$a" && go test -cover ./... -args -test.gocoverdir="$$adapter_cov_dir"); \ done - - @echo "$(BLUE)๐Ÿ“Š Combined coverage report saved to $(COVERAGE_DIR)/$(COVERAGE_FILE)$(NC)" + @merge_inputs="$(CORE_COV_DIR)"; \ + for a in $(ADAPTERS); do \ + merge_inputs="$$merge_inputs,$(ADAPTER_COV_ROOT)/$$a"; \ + done; \ + go tool covdata merge -i="$$merge_inputs" -o="$(MERGED_COV_DIR)" + @go tool covdata percent -i="$(MERGED_COV_DIR)" + @go tool covdata textfmt -i="$(MERGED_COV_DIR)" -o="$(COVERAGE_DIR)/$(COVERAGE_FILE)" + @echo "$(GREEN)โœ… Coverage profile written to $(COVERAGE_DIR)/$(COVERAGE_FILE)$(NC)" @go tool cover -func="$(COVERAGE_DIR)/$(COVERAGE_FILE)" testcov-html: testcov ## Generate HTML coverage reports @echo "$(BLUE)๐ŸŒ Generating HTML coverage reports...$(NC)" - @go tool cover -html="coverage/$(COVERAGE_FILE)" -o "coverage/coverage.html" + @go tool cover -html="$(COVERAGE_DIR)/$(COVERAGE_FILE)" -o "$(COVERAGE_DIR)/coverage.html" @echo "$(GREEN)โœ… HTML coverage reports generated!$(NC)" - @open coverage/coverage.html + @open "$(COVERAGE_DIR)/coverage.html" tidy: ## Tidy up Go modules for core and adapters @echo "$(BLUE)๐Ÿงน Tidying core...$(NC)" @@ -125,17 +136,26 @@ lint: ## Run linting @echo "$(GREEN)โœ… Core linting passed$(NC)" @for a in $(ADAPTERS); do \ echo "$(BLUE)๐Ÿ” Linting adapter/$$a...$(NC)"; \ - golangci-lint run ./adapter/$$a/... || \ + (cd "adapter/$$a" && golangci-lint run ./...) || \ (echo "$(RED)โŒ Adapter $$a linting failed$(NC)" && exit 1); \ done @echo "$(GREEN)๐ŸŽ‰ All linting passed!$(NC)" +lint-fix: ## Run linting with auto-fix + @echo "$(BLUE)๐Ÿ”ง Running golangci-lint with auto-fix...$(NC)" + @golangci-lint run --fix || (echo "$(RED)โŒ Lint fix failed$(NC)" && exit 1) + @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); \ + done + @echo "$(GREEN)โœ… Lint fixes applied!$(NC)" + check: sync tidy lint test ## Run all local development checks @echo "$(GREEN)๐ŸŽ‰ All local development checks passed!$(NC)" install-tools: ## Install development tools @echo "$(BLUE)๐Ÿ“ฆ Installing development tools...$(NC)" - @go install gotest.tools/gotestsum@$(GOTESTSUM_VERSION) @go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) @echo "$(GREEN)โœ… Tools installed successfully!$(NC)" @@ -320,4 +340,4 @@ clean-replaces: ## Clean up replace directives in go.mod adapters @find adapter -mindepth 2 -maxdepth 2 -type f -name go.mod \ -exec sed -i.bak '/^replace github\.com\/oaswrap\/spec =>/d' {} \; \ -exec rm {}.bak \; \ - -execdir go mod tidy \; \ No newline at end of file + -execdir go mod tidy \; diff --git a/README.md b/README.md index a8119fd..ecd7227 100644 --- a/README.md +++ b/README.md @@ -7,27 +7,74 @@ [![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) -A lightweight, framework-agnostic OpenAPI 3.x specification builder for Go that gives you complete control over your API documentation without vendor lock-in. +`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`. + +--- + +## Table of Contents + +- [Why oaswrap/spec?](#why-oaswrapspec) +- [Features](#features) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Output](#output) +- [Framework Adapters](#framework-adapters) +- [OpenAPI Options](#openapi-options) +- [Routes and Groups](#routes-and-groups) +- [Security](#security) +- [Reflection Tags](#reflection-tags) +- [Reflected Go Types](#reflected-go-types) +- [Reflector Configuration](#reflector-configuration) +- [OpenAPI 3.2](#openapi-32) +- [Low-Level OpenAPI Control](#low-level-openapi-control) +- [Examples](#examples) +- [API Reference](#api-reference) +- [Contributing](#contributing) +- [License](#license) + +--- ## Why oaswrap/spec? -- **๐ŸŽฏ Framework Agnostic** โ€” Works with any Go web framework or as a standalone tool -- **โšก Zero Dependencies** โ€” Powered by [`swaggest/openapi-go`](https://github.com/swaggest/openapi-go) with minimal overhead -- **๐Ÿ”ง Programmatic Control** โ€” Build specs in pure Go code with full type safety -- **๐Ÿš€ Adapter Ecosystem** โ€” Seamless integration with popular frameworks via dedicated adapters -- **๐Ÿ“ CI/CD Ready** โ€” Generate specs at build time for documentation pipelines +- **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. +- **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. + +--- + +## Features + +- JSON and YAML generation. +- Framework-agnostic route registration with `NewRouter`, groups, route builders, and HTTP method helpers. +- Webhook registration helpers for OpenAPI `3.1.x` and `3.2.0`. +- OpenAPI `3.2.0` `QUERY` method and additional operation support. +- Request and response schema reflection from Go structs. +- Parameter reflection from `path`, `query`, `header`, `cookie`, and OpenAPI `3.2.0` `querystring` tags. +- JSON Schema/OpenAPI schema tags for examples, enums, constraints, nullability, read/write flags, content metadata, and XML metadata. +- Security helpers for API key, HTTP bearer/basic-style schemes, OAuth2, OpenID Connect, and mutual TLS. +- Duplicate response merging into `oneOf`. +- Low-level typed OpenAPI model for direct field control. +- `spec.OneOf` for explicit one-of schemas. +- `SchemaExposer` and `StaticSchemaExposer` hooks for custom reflected schemas. + +--- ## Installation +Requirements: + +- Go `1.22+` + ```bash go get github.com/oaswrap/spec ``` -## Quick Start - -### Static Spec Generation +--- -For CI/CD pipelines and build-time spec generation: +## Quick Start ```go package main @@ -36,297 +83,501 @@ import ( "log" "github.com/oaswrap/spec" + "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" ) func main() { - // Create a new OpenAPI router r := spec.NewRouter( - option.WithTitle("My API"), + option.WithOpenAPIVersion(openapi.Version320), + option.WithTitle("Users API"), option.WithVersion("1.0.0"), option.WithServer("https://api.example.com"), - option.WithSecurity("bearerAuth", option.SecurityHTTPBearer("Bearer")), + option.WithSecurity("bearerAuth", option.SecurityHTTPBearer("bearer")), ) - // Add routes - v1 := r.Group("/api/v1") - + v1 := r.Group("/api/v1", option.GroupTags("users")) v1.Post("/login", - option.Summary("User login"), + option.Summary("Login"), option.Request(new(LoginRequest)), option.Response(200, new(LoginResponse)), ) - - auth := v1.Group("/", option.GroupSecurity("bearerAuth")) - - auth.Get("/users/{id}", - option.Summary("Get user by ID"), + v1.Get("/users/{id}", + option.Summary("Get user"), option.Request(new(GetUserRequest)), option.Response(200, new(User)), ) - // Generate OpenAPI spec if err := r.WriteSchemaTo("openapi.yaml"); err != nil { log.Fatal(err) } - - log.Println("โœ… OpenAPI spec generated at openapi.yaml") } type LoginRequest struct { Username string `json:"username" required:"true"` - Password string `json:"password" required:"true"` + Password string `json:"password" required:"true" writeOnly:"true"` } type LoginResponse struct { - Token string `json:"token"` + Token string `json:"token" required:"true"` } type GetUserRequest struct { - ID string `path:"id" required:"true"` + ID string `path:"id" required:"true" description:"User identifier"` } type User struct { - ID string `json:"id"` + ID string `json:"id" required:"true"` Name string `json:"name"` } ``` -๐Ÿ“– **[View the generated spec](https://rest.wiki/?https://raw.githubusercontent.com/oaswrap/spec/main/examples/basic/openapi.yaml)** on Rest.Wiki +--- + +## Output + +`spec.NewRouter` and `spec.NewGenerator` expose the following output methods: -### Framework Integration +| Method | Description | +| --- | --- | +| `GenerateSchema()` | Defaults to YAML. | +| `GenerateSchema("yaml")` / `GenerateSchema("yml")` | Returns YAML. | +| `GenerateSchema("json")` | Returns pretty-printed JSON. | +| `MarshalYAML()` | Validates and serializes YAML. | +| `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. | +| `Config()` | Returns the effective OpenAPI configuration. | -For seamless HTTP server integration, use one of our framework adapters: +--- + +## Framework Adapters + +Use the core `spec` package for static generation and CI workflows. Use an adapter when you want route registration, spec generation, and docs UI wiring in a single framework-specific router. | Framework | Package | -|-----------|---------| -| **Chi** | [`chiopenapi`](/adapter/chiopenapi) | -| **Echo** | [`echoopenapi`](/adapter/echoopenapi) | -| **Gin** | [`ginopenapi`](/adapter/ginopenapi) | -| **Fiber** | [`fiberopenapi`](/adapter/fiberopenapi) | -| **HTTP** | [`httpopenapi`](/adapter/httpopenapi) | -| **Mux** | [`muxopenapi`](/adapter/muxopenapi) | -| **HTTPRouter** | [`httprouteropenapi`](/adapter/httprouteropenapi) | - -Each adapter provides: -- โœ… Automatic spec generation from your routes -- ๐Ÿ“š Built-in documentation UI at `/docs` -- ๐Ÿ“„ YAML spec endpoints at `/docs/openapi.yaml` -- ๐Ÿ”ง Inline OpenAPI options with route definitions - -Visit the individual adapter repositories for framework-specific examples and detailed integration guides. - -## When to Use What? - -### โœ… Use `spec` for static generation when you: -- Generate OpenAPI files at **build time** -- Integrate with **CI/CD pipelines** -- Build **custom documentation tools** -- Need **static spec generation** - -### โœ… Use framework adapters when you: -- Want **automatic spec generation** from routes -- Need **zero-configuration setup** -- Prefer **inline OpenAPI configuration** -- Want **route registration + documentation** in one step - -## Configuration Options - -The `option` package provides comprehensive OpenAPI configuration: - -### Basic Information +| --- | --- | +| Chi | [`chiopenapi`](/adapter/chiopenapi) | +| Echo v4 | [`echoopenapi`](/adapter/echoopenapi) | +| Echo v5 | [`echov5openapi`](/adapter/echov5openapi) | +| Fiber v2 | [`fiberopenapi`](/adapter/fiberopenapi) | +| Fiber v3 | [`fiberv3openapi`](/adapter/fiberv3openapi) | +| Gin | [`ginopenapi`](/adapter/ginopenapi) | +| net/http | [`httpopenapi`](/adapter/httpopenapi) | +| Mux | [`muxopenapi`](/adapter/muxopenapi) | + +Adapter module import paths: + +- `github.com/oaswrap/spec/adapter/chiopenapi` +- `github.com/oaswrap/spec/adapter/echoopenapi` +- `github.com/oaswrap/spec/adapter/echov5openapi` +- `github.com/oaswrap/spec/adapter/fiberopenapi` +- `github.com/oaswrap/spec/adapter/fiberv3openapi` +- `github.com/oaswrap/spec/adapter/ginopenapi` +- `github.com/oaswrap/spec/adapter/httpopenapi` +- `github.com/oaswrap/spec/adapter/muxopenapi` + +--- + +## OpenAPI Options + +Root options are passed to `spec.NewRouter` or `spec.NewGenerator`. + ```go -option.WithOpenAPIVersion("3.0.3") // Default: "3.0.3" -option.WithTitle("My API") -option.WithDescription("API description") -option.WithVersion("1.2.3") -option.WithContact(openapi.Contact{ - Name: "Support Team", - URL: "https://support.example.com", - Email: "support@example.com", -}) -option.WithLicense(openapi.License{ - Name: "MIT License", - URL: "https://opensource.org/licenses/MIT", -}) -option.WithExternalDocs("https://docs.example.com", "API Documentation") -option.WithTags( - openapi.Tag{ - Name: "User Management", - Description: "Operations related to user management", - }, - openapi.Tag{ - Name: "Authentication", - Description: "Authentication related operations", - }, +r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version312), + option.WithTitle("Payments API"), + option.WithInfoSummary("Payments API"), + option.WithVersion("1.4.0"), + option.WithDescription("Payment operations."), + option.WithTermsOfService("https://example.com/terms"), + option.WithContact(openapi.Contact{Name: "API Team", Email: "api@example.com"}), + option.WithLicense(openapi.License{Name: "Apache 2.0", URL: "https://www.apache.org/licenses/LICENSE-2.0.html"}), + option.WithExternalDocs("https://docs.example.com", "Full documentation"), + option.WithServer("https://api.example.com", option.ServerDescription("Production")), + option.WithTag("payments", option.TagDescription("Payment operations")), + option.WithStripTrailingSlash(), ) ``` -### Servers +| 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`. | +| `WithSelf(uri)` | Set OpenAPI `3.2.0` `$self`. | +| `WithJSONSchemaDialect(uri)` | Set root `jsonSchemaDialect`. | +| `WithTitle(title)` | Set `info.title`. | +| `WithInfoSummary(summary)` | Set `info.summary`. | +| `WithVersion(version)` | Set `info.version`. | +| `WithDescription(description)` | Set `info.description`. | +| `WithContact(contact)` | Set `info.contact`. | +| `WithLicense(license)` | Set `info.license`. | +| `WithTermsOfService(url)` | Set `info.termsOfService`. | +| `WithTags(tags...)` | Add root `tags` from `openapi.Tag` values. | +| `WithTag(name, tagOpts...)` | Add one root tag without constructing `openapi.Tag` manually. | +| `WithServer(url, opts...)` | Add a root server. | +| `WithExternalDocs(url, description...)` | Set root `externalDocs`. | +| `WithSecurity(name, opts...)` | Add a reusable security scheme. | +| `WithGlobalSecurity(name, scopes...)` | Add a root security requirement. | +| `WithReflectorConfig(opts...)` | Configure schema reflection. | +| `WithStripTrailingSlash(strip...)` | Trim trailing slashes from operation paths except `/`. | +| `WithPathParser(parser)` | Convert framework-style paths, e.g. `:id` โ†’ `{id}`. | +| `WithDocument(fn)` | Mutate the final low-level document before validation and serialization. | +| `WithComponentSchema(name, schema)` | Register a reusable schema component. | +| `WithComponentResponse(name, response)` | Register a reusable response component. | +| `WithComponentParameter(name, parameter)` | Register a reusable parameter component. | +| `WithComponentExample(name, example)` | Register a reusable example component. | +| `WithComponentRequestBody(name, requestBody)` | Register a reusable request body component. | +| `WithComponentHeader(name, header)` | Register a reusable header component. | +| `WithComponentSecurityScheme(name, scheme)` | Register a reusable security scheme component. | +| `WithComponentLink(name, link)` | Register a reusable link component. | +| `WithComponentCallback(name, callback)` | Register a reusable callback component. | +| `WithComponentPathItem(name, pathItem)` | Register a reusable path item component. | +| `WithComponentMediaType(name, mediaType)` | Register a reusable media type component (OpenAPI `3.2.0`). | +| `WithDisableDocs(disable...)` | Disable adapter docs endpoints. | +| `WithDocsPath(path)` | Set adapter docs UI path. | +| `WithSpecPath(path)` | Set adapter OpenAPI spec path. | +| `WithCacheAge(cacheAge)` | Set docs/spec cache age. | +| `WithUIOption(opt)` | Set a low-level `spec-ui` option. | +| `WithSwaggerUI(cfg...)` | Use Swagger UI. | +| `WithStoplightElements(cfg...)` | Use Stoplight Elements. | +| `WithReDoc(cfg...)` | Use ReDoc. | +| `WithScalar(cfg...)` | Use Scalar. | +| `WithRapiDoc(cfg...)` | Use RapiDoc. | + +> Adapter-only options: `WithDisableDocs`, `WithDocsPath`, `WithSpecPath`, `WithCacheAge`, `WithUIOption`, `WithSwaggerUI`, `WithStoplightElements`, `WithReDoc`, `WithScalar`, and `WithRapiDoc` affect adapter docs/spec endpoints, not core static generation output. + +**Tag options:** `TagSummary`, `TagDescription`, `TagExternalDocs`, `TagParent` (3.2.0), `TagKind` (3.2.0). + +**Server options:** `ServerDescription`, `ServerVariables`. + +--- + +## Routes and Groups + ```go -option.WithServer("https://api.example.com") -option.WithServer("https://api-example.com/{version}", - option.ServerDescription("Production Server"), - option.ServerVariables(map[string]openapi.ServerVariable{ - "version": { - Default: "v1", - Enum: []string{"v1", "v2"}, - Description: "API version", - }, - }), +api := r.Group("/api/v1", option.GroupTags("v1")) + +api.Get("/users/{id}", + option.OperationID("getUser"), + option.Summary("Get user"), + option.Description("Returns one user."), + option.Tags("users"), + option.Security("bearerAuth"), + option.Request(new(GetUserRequest)), + option.Response(200, new(User), option.ContentDescription("OK")), + option.Response(404, nil, option.ContentDescription("Not Found")), ) ``` -### Security Schemes -```go -// Bearer token -option.WithSecurity("bearerAuth", option.SecurityHTTPBearer("Bearer")) - -// API Key -option.WithSecurity("apiKey", option.SecurityAPIKey("X-API-Key", "header")) - -// OAuth2 -option.WithSecurity("oauth2", option.SecurityOAuth2( - openapi.OAuthFlows{ - Implicit: &openapi.OAuthFlowsImplicit{ - AuthorizationURL: "https://auth.example.com/authorize", - Scopes: map[string]string{ - "read": "Read access", - "write": "Write access", - }, - }, - }, -)) -``` +**Router methods:** + +`Get`, `Post`, `Put`, `Delete`, `Patch`, `Options`, `Head`, `Trace`, `Query` (3.2.0), `Add`, `Webhook` (3.1.x+), `AddWebhook` (3.1.x+), `NewRoute(...).Method(...).Path(...).With(...)`, `Group`, `Route`, `With`. + +**Group options:** + +| Option | Purpose | +| --- | --- | +| `GroupTags(tags...)` | Apply tags to all routes in the group. | +| `GroupSecurity(name, scopes...)` | Apply an operation security requirement to all routes in the group. | +| `GroupDeprecated(deprecated...)` | Mark all routes in the group as deprecated. | +| `GroupHidden(hide...)` | Exclude all routes in the group from the generated document. | + +**Operation options:** + +| Option | Purpose | +| --- | --- | +| `OperationID(id)` | Set `operationId`. | +| `Summary(summary)` | Set `summary`; also sets `description` when description is empty. | +| `Description(description)` | Set `description`. | +| `ExternalDocs(url, description...)` | Set operation `externalDocs`. | +| `Tags(tags...)` | Add operation tags. | +| `Security(name, scopes...)` | Add an operation security requirement. | +| `Deprecated(deprecated...)` | Mark the operation deprecated. | +| `Hidden(hide...)` | Exclude the operation from the generated document. | +| `Request(structure, contentOpts...)` | Add parameters and/or a request body from a Go value. | +| `Response(status, structure, contentOpts...)` | Add a response. Duplicate status/content pairs are merged into `oneOf`. | +| `CustomizeOperation(fn)` | Mutate the low-level `openapi.Operation` directly. | + +**Content options:** + +| Option | Purpose | +| --- | --- | +| `ContentType(contentType)` | Set media type; default is `application/json`. | +| `ContentDescription(description)` | Set request/response description. | +| `ContentDefault(isDefault...)` | Mark response as `default`. | +| `ContentEncoding(prop, enc)` | Add media type encoding metadata for a property. | +| `ContentExample(value)` | Set media type `example`. | +| `ContentNamedExample(name, value, opts...)` | Add one named media type example. | +| `ContentExamples(examples)` | Set media type `examples`. | +| `ContentRequired(required...)` | Mark request body as required. | +| `ContentFormat(format)` | Override the reflected content schema format. | + +**Example options:** `ExampleSummary`, `ExampleDescription`, `ExampleExternalValue`, `ExampleDataValue` (3.2.0), `ExampleSerializedValue` (3.2.0). + +--- + +## Security -### Route Documentation ```go -option.OperationID("getUserByID") // Unique operation ID -option.Summary("Short description") // Brief summary -option.Description("Detailed description") // Full description -option.Tags("User Management", "Authentication") // Group by tags -option.Request(new(RequestModel)) // Request body model -option.Response(200, new(ResponseModel), // Response model - option.ContentDescription("Successful response"), - option.ContentType("application/json"), - option.ContentDefault(true), +r := spec.NewRouter( + option.WithSecurity("apiKey", option.SecurityAPIKey("X-API-Key", openapi.SecuritySchemeAPIKeyInHeader)), + option.WithSecurity("bearerAuth", option.SecurityHTTPBearer("bearer")), + option.WithSecurity("mtls", option.SecurityMutualTLS()), + option.WithSecurity("oauth2", option.SecurityOAuth2AuthorizationCode( + "https://auth.example.com/oauth/authorize", + "https://auth.example.com/oauth/token", + map[string]string{ + "read": "Read access", + "write": "Write access", + }, + )), + option.WithSecurity("oidc", option.SecurityOpenIDConnect("https://auth.example.com/.well-known/openid-configuration")), ) -option.Security("bearerAuth") // Apply security scheme -option.Deprecated() // Mark as deprecated -option.Hidden() // Hide from spec ``` -### Parameter Definition -Define parameters using struct tags in your request models: +**Security helpers:** + +- `SecurityAPIKey(name, in)` +- `SecurityHTTPBearer(scheme, bearerFormat...)` +- `SecurityOAuth2(flows)` +- `SecurityOAuth2Implicit(authorizationURL, scopes, flowOpts...)` +- `SecurityOAuth2Password(tokenURL, scopes, flowOpts...)` +- `SecurityOAuth2ClientCredentials(tokenURL, scopes, flowOpts...)` +- `SecurityOAuth2AuthorizationCode(authorizationURL, tokenURL, scopes, flowOpts...)` +- `SecurityOAuth2DeviceAuthorization(deviceAuthorizationURL, tokenURL, scopes, flowOpts...)` โ€” OpenAPI `3.2.0` +- `OAuthRefreshURL(url)` +- `SecurityOpenIDConnect(url)` +- `SecurityMutualTLS()` +- `SecurityDescription(description)` +- `SecurityOAuth2MetadataURL(url)` โ€” OpenAPI `3.2.0` +- `SecurityDeprecated(deprecated...)` โ€” OpenAPI `3.2.0` + +--- + +## Reflection Tags + +Request structs are split into parameters and request body: + +- Fields with `path`, `query`, `header`, `cookie`, or `querystring` tags become OpenAPI parameters. +- `querystring` is only valid for OpenAPI `3.2.0`. +- Body fields use `json` names by default; for `application/x-www-form-urlencoded` and `multipart/form-data` they use `form` names, falling back to `json`. +- `json:"-"` skips a field. +- Path parameters are always marked required. +- Adapter packages can add framework-specific parameter tags with `ParameterTagMapping` (e.g. Gin uses `uri`, Fiber uses `params`, Echo uses `param`). ```go -type GetUserRequest struct { - ID string `path:"id" required:"true" description:"User identifier"` - Limit int `query:"limit" description:"Maximum number of results"` - APIKey string `header:"X-API-Key" description:"API authentication key"` +type SearchRequest struct { + ID string `path:"id" required:"true" description:"Resource ID"` + Status string `query:"status" enum:"active,inactive"` + TraceID string `header:"X-Trace-ID"` + Session string `cookie:"session"` + Name string `json:"name" minLength:"2" maxLength:"80"` + File []byte `form:"file" format:"binary" description:"Upload file"` + Internal string `json:"-"` } ``` -### Group-Level Configuration -Apply settings to all routes within a group: +**Naming and location tags:** + +| Tag | Purpose | +| --- | --- | +| `json:"name"` | Body/schema property name. | +| `path:"name"` | Path parameter. | +| `query:"name"` | Query parameter. | +| `header:"name"` | Header parameter. | +| `cookie:"name"` | Cookie parameter. | +| `querystring:"name"` | OpenAPI `3.2.0` whole-query-string parameter. | +| `form:"name"` | Form body property name for form content types. | + +**Schema tags:** + +| Tag | Output | +| --- | --- | +| `required:"true"` | Adds property to parent `required`; path parameters are required automatically. | +| `type:"string,null"` | Overrides `type`; comma-separated unions emitted only for OpenAPI `3.1.x`/`3.2.0`. | +| `title:"..."` | `title`. | +| `description:"..."` | `description`. | +| `format:"email"` | `format`. | +| `pattern:"..."` | `pattern`. | +| `default:"..."` | `default`; JSON values are decoded when valid. | +| `example:"..."` | `example`; JSON values are decoded when valid. | +| `examples:"[...]"` | `examples` for OpenAPI `3.1.x`/`3.2.0`; JSON array preferred, comma-separated fallback. | +| `enum:"a,b,c"` | `enum`; JSON array or comma-separated values. | +| `const:"..."` | `const` for OpenAPI `3.1.x`/`3.2.0`. | +| `multipleOf:"2"` | `multipleOf`. | +| `maximum:"100"` | `maximum`. | +| `minimum:"1"` | `minimum`. | +| `exclusiveMaximum:"true"` | OpenAPI `3.0.x`: boolean; OpenAPI `3.1.x`/`3.2.0`: numeric value. | +| `exclusiveMinimum:"true"` | OpenAPI `3.0.x`: boolean; OpenAPI `3.1.x`/`3.2.0`: numeric value. | +| `maxLength:"80"` | `maxLength`. | +| `minLength:"2"` | `minLength`. | +| `maxItems:"10"` | `maxItems`. | +| `minItems:"1"` | `minItems`. | +| `maxProperties:"10"` | `maxProperties`. | +| `minProperties:"1"` | `minProperties`. | +| `uniqueItems:"true"` | `uniqueItems`. | +| `nullable:"true"` | `nullable` for OpenAPI `3.0.x`; `type: [T, null]` for OpenAPI `3.1.x`/`3.2.0` when possible. | +| `deprecated:"true"` | `deprecated`. | +| `readOnly:"true"` | `readOnly`. | +| `writeOnly:"true"` | `writeOnly`. | +| `contentEncoding:"base64"` | `contentEncoding` for OpenAPI `3.1.x`/`3.2.0`. | +| `contentMediaType:"image/png"` | `contentMediaType` for OpenAPI `3.1.x`/`3.2.0`. | +| `xmlName:"name"` | XML object `name`. | +| `xmlNamespace:"uri"` | XML object `namespace`. | +| `xmlPrefix:"p"` | XML object `prefix`. | +| `xmlAttribute:"true"` | XML object `attribute`; skipped when OpenAPI `3.2.0` `xmlNodeType` is set. | +| `xmlWrapped:"true"` | XML object `wrapped`; skipped when OpenAPI `3.2.0` `xmlNodeType` is set. | +| `xmlNodeType:"element"` | OpenAPI `3.2.0` XML object `nodeType`. | + +> Reflection intentionally omits keywords that are invalid for the selected OpenAPI version. For example, `const`, `examples`, `contentEncoding`, and `contentMediaType` are not emitted for OpenAPI `3.0.x`. + +--- + +## Reflected Go Types + +| Go type | Schema | +| --- | --- | +| `bool` | `type: boolean` | +| Signed integers (except `int64`) | `type: integer`, `format: int32` | +| `int64` | `type: integer`, `format: int64` | +| Unsigned integers (except `uint64`/`uintptr`) | `type: integer`, `format: int32`, `minimum: 0` | +| `uint64`, `uintptr` | `type: integer`, `format: int64`, `minimum: 0` | +| `float32` | `type: number`, `format: float` | +| `float64` | `type: number`, `format: double` | +| `string` | `type: string` | +| `time.Time` | `type: string`, `format: date-time` | +| `[]T`, `[N]T` | `type: array`, `items: T` | +| `[]byte` | `3.0.x`: `type: string`, `format: byte`; `3.1.x`/`3.2.0`: `type: string`, `contentEncoding: base64` | +| `map[string]T` | `type: object`, `additionalProperties: T` | +| Structs | `type: object`, `properties` | +| Named structs (component mode) | `#/components/schemas/{TypeName}` reference | +| Pointers | Nullable schema behavior | + +Custom types can expose their own schema when tags are not expressive enough: ```go -// Apply to all routes in the group -adminGroup := r.Group("/admin", - option.GroupTags("Administration"), - option.GroupSecurity("bearerAuth"), - option.GroupDeprecated(), -) +type Slug string -// Hide internal routes from documentation -internalGroup := r.Group("/internal", - option.GroupHidden(), -) +func (*Slug) OpenAPISchema(version string) *openapi.Schema { + if version == openapi.Version304 { + return &openapi.Schema{Type: "string", Format: "slug"} + } + return &openapi.Schema{Type: []string{"string", "null"}, Format: "slug"} +} ``` -### Path Handling -```go -// Remove trailing slashes from all operation paths in the spec. -// "/pet/" becomes "/pet", "/" is left unchanged. -option.WithStripTrailingSlash() -``` +For static schemas, implement `OpenAPISchema() *openapi.Schema` instead. Field tags are still applied on top of custom schemas. + +--- -## Advanced Features +## Reflector Configuration -### Rich Schema Documentation ```go -type CreateUserRequest struct { - Name string `json:"name" required:"true" minLength:"2" maxLength:"50"` - Email string `json:"email" required:"true" format:"email"` - Age int `json:"age" minimum:"18" maximum:"120"` - Tags []string `json:"tags" maxItems:"10"` -} +r := spec.NewRouter( + option.WithReflectorConfig( + option.InlineRefs(), + option.StripDefNamePrefix("DTO"), + option.TypeMapping(NullString{}, new(string)), + option.ParameterTagMapping(openapi.ParameterInPath, "params"), + option.InterceptDefName(func(t reflect.Type, defaultName string) string { + return defaultName + }), + ), +) ``` -For comprehensive struct tag documentation, see [swaggest/openapi-go](https://github.com/swaggest/openapi-go?tab=readme-ov-file#features) and [swaggest/jsonschema-go](https://github.com/swaggest/jsonschema-go?tab=readme-ov-file#field-tags). +| Option | Purpose | +| --- | --- | +| `InlineRefs(inline...)` | Inline schemas instead of using component references for named structs. | +| `StripDefNamePrefix(prefixes...)` | Strip prefixes from generated component names. | +| `TypeMapping(src, dst)` | Reflect `src` as if it were `dst`. | +| `ParameterTagMapping(in, sourceTag)` | Add a custom tag for a parameter location while keeping the default tag. | +| `InterceptDefName(fn)` | Customize schema component names. | -### Generic Response Types -```go -type APIResponse[T any] struct { - Success bool `json:"success"` - Data T `json:"data,omitempty"` - Error string `json:"error,omitempty"` - Timestamp string `json:"timestamp"` -} +--- -// Usage -option.Response(200, new(APIResponse[User])) -option.Response(200, new(APIResponse[[]Product])) -``` +## OpenAPI 3.2 -## Examples +Selecting `openapi.Version320` enables the following additional features: -Explore complete working examples in the [`examples/`](examples/) directory: +- `Router.Query(path, opts...)` โ€” new `QUERY` HTTP method. +- Custom HTTP methods via `Add`, emitted as `additionalOperations`. +- `querystring` parameter tags. +- Root `$self` field. +- Tag `parent` and `kind` fields. +- Security scheme metadata and deprecation fields. +- `components.mediaTypes`. +- Media type and encoding fields: `itemSchema`, `prefixEncoding`, `itemEncoding`. +- Example `dataValue` and `serializedValue` fields. +- XML `nodeType`. -- **[Basic](examples/basic/)** โ€” Standalone spec generation -- **[Basic-HTTP](examples/basic-http/)** โ€” Built-in HTTP server with OpenAPI docs -- **[Petstore](examples/petstore/)** โ€” Full Petstore API with routes and models +--- -## API Reference +## Low-Level OpenAPI Control + +The `openapi` package exposes typed low-level OpenAPI structs. Use them when a feature does not require reflection or doesn't yet have a convenience option. + +```go +r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version320), + option.WithTitle("API"), + option.WithVersion("1.0.0"), + option.WithDocument(func(doc *openapi.Document) { + doc.Webhooks = map[string]*openapi.PathItem{ + "user.created": { + Post: &openapi.Operation{ + Responses: map[string]*openapi.Response{ + "202": {Description: "Accepted"}, + }, + }, + }, + } + doc.Extensions = map[string]any{"x-company": "oaswrap"} + doc.Extra = map[string]any{"futureOfficialField": true} + }), +) +``` -Complete documentation at [pkg.go.dev/github.com/oaswrap/spec](https://pkg.go.dev/github.com/oaswrap/spec). +- Use `Extensions` for `x-*` specification extensions. +- Use `Extra` to emit official or future fields not yet typed. +- Use `CustomizeOperation` to mutate an operation directly. +- Use `WithDocument` to mutate the final document before output. -Key packages: -- [`spec`](https://pkg.go.dev/github.com/oaswrap/spec) โ€” Core router and spec builder -- [`option`](https://pkg.go.dev/github.com/oaswrap/spec/option) โ€” Configuration options +> The library validates many OpenAPI invariants but does not attempt exhaustive semantic validation for every rule in the specification. -## FAQ +--- -**Q: Can I use this with my existing API?** -A: Absolutely! Use the standalone version to document existing APIs, or gradually migrate to framework adapters. +## Examples -**Q: How does this compare to swag/swaggo?** -A: While swag uses code comments, oaswrap uses pure Go code for type safety and better IDE support. Both have their merits - swag is annotation-based while oaswrap is code-first. +Complete working examples are in the [`examples/`](examples/) directory: -**Q: How does this compare to Huma?** -A: Both are excellent choices with different philosophies: -- **Huma** is a complete HTTP framework with built-in OpenAPI generation, validation, and middleware -- **oaswrap/spec** is a lightweight, framework-agnostic documentation builder that works with your existing setup -- Use **Huma** if you're building a new API and want an all-in-one solution with automatic validation -- Use **oaswrap** if you have existing code, prefer framework flexibility, or need standalone spec generation +- **[Basic](examples/basic/)** โ€” standalone spec generation. +- **[Petstore](examples/petstore/)** โ€” full Petstore API with routes and models. +- **Adapter examples** โ€” framework-specific examples in [`adapter/*/example`](adapter/). -**Q: Is this production ready?** -A: The library is in active development. While core functionality is solid, consider it beta software. Thorough testing is recommended before production use. +--- -**Q: How do I handle authentication in the generated docs?** -A: Define security schemes using `option.WithSecurity()` and apply them to routes with `option.Security()`. The generated docs will include authentication UI. +## API Reference -## Contributing +Full documentation is available at [pkg.go.dev/github.com/oaswrap/spec](https://pkg.go.dev/github.com/oaswrap/spec). + +| Package | Purpose | +| --- | --- | +| [`spec`](https://pkg.go.dev/github.com/oaswrap/spec) | Core router and spec builder. | +| [`openapi`](https://pkg.go.dev/github.com/oaswrap/spec/openapi) | Owned OpenAPI model. | +| [`option`](https://pkg.go.dev/github.com/oaswrap/spec/option) | Configuration options. | +| [`pkg/parser`](https://pkg.go.dev/github.com/oaswrap/spec/pkg/parser) | Path parsers such as `NewColonParamParser`. | -We welcome contributions! Here's how you can help: +--- + +## Contributing -1. **๐Ÿ› Report bugs** โ€” Open an issue with reproduction steps -2. **๐Ÿ’ก Suggest features** โ€” Share your ideas for improvements -3. **๐Ÿ“ Improve docs** โ€” Help make our documentation clearer -4. **๐Ÿ”ง Submit PRs** โ€” Fix bugs or add features +Issues and pull requests are welcome. Please check existing issues and discussions before starting work on new features. -Please check existing issues and discussions before starting work on new features. +--- ## License -[MIT](LICENSE) \ No newline at end of file +[MIT](LICENSE) diff --git a/RELEASE.md b/RELEASE.md index 35543b4..827b99a 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -13,12 +13,12 @@ The project consists of: - **Core module**: `github.com/oaswrap/spec` - The main OpenAPI specification builder - **Adapter modules**: Framework-specific integrations - `github.com/oaswrap/spec/adapter/chiopenapi` - Chi framework adapter - - `github.com/oaswrap/spec/adapter/echoopenapi` - Echo framework adapter + - `github.com/oaswrap/spec/adapter/echoopenapi` - Echo v4 framework adapter - `github.com/oaswrap/spec/adapter/echov5openapi` - Echo v5 framework adapter - - `github.com/oaswrap/spec/adapter/fiberopenapi` - Fiber framework adapter + - `github.com/oaswrap/spec/adapter/fiberopenapi` - Fiber v2 framework adapter + - `github.com/oaswrap/spec/adapter/fiberv3openapi` - Fiber v3 framework adapter - `github.com/oaswrap/spec/adapter/ginopenapi` - Gin framework adapter - `github.com/oaswrap/spec/adapter/httpopenapi` - net/http adapter - - `github.com/oaswrap/spec/adapter/httprouteropenapi` - HttpRouter adapter - `github.com/oaswrap/spec/adapter/muxopenapi` - Gorilla Mux adapter ## Prerequisites @@ -319,4 +319,4 @@ For questions about the release process, please: - [Semantic Versioning](https://semver.org/) - [Go Modules Reference](https://golang.org/ref/mod) -- [GitHub Releases Documentation](https://docs.github.com/en/repositories/releasing-projects-on-github) \ No newline at end of file +- [GitHub Releases Documentation](https://docs.github.com/en/repositories/releasing-projects-on-github) diff --git a/adapter/README.md b/adapter/README.md index 5e58551..7e76b06 100644 --- a/adapter/README.md +++ b/adapter/README.md @@ -15,5 +15,4 @@ This directory contains framework-specific adapters for `oaswrap/spec` that prov | [Fiber v3](https://github.com/gofiber/fiber) | [`fiberv3openapi`](./fiberv3openapi) | `github.com/oaswrap/spec/adapter/fiberv3openapi` | Fiber v3 with updated Ctx interface and binding API | | [Gin](https://github.com/gin-gonic/gin) | [`ginopenapi`](./ginopenapi) | `github.com/oaswrap/spec/adapter/ginopenapi` | Fast HTTP web framework with zero allocation | | [net/http](https://pkg.go.dev/net/http) | [`httpopenapi`](./httpopenapi) | `github.com/oaswrap/spec/adapter/httpopenapi` | Standard library HTTP package | -| [HttpRouter](https://github.com/julienschmidt/httprouter) | [`httprouteropenapi`](./httprouteropenapi) | `github.com/oaswrap/spec/adapter/httprouteropenapi` | High performance HTTP request router | | [Gorilla Mux](https://github.com/gorilla/mux) | [`muxopenapi`](./muxopenapi) | `github.com/oaswrap/spec/adapter/muxopenapi` | Powerful HTTP router and URL matcher | \ No newline at end of file diff --git a/adapter/chiopenapi/README.md b/adapter/chiopenapi/README.md index a7bf76f..db38005 100644 --- a/adapter/chiopenapi/README.md +++ b/adapter/chiopenapi/README.md @@ -12,7 +12,7 @@ A lightweight adapter for the [Chi](https://github.com/go-chi/chi) web framework - **๐ŸŽฏ Type Safety** โ€” Full Go type safety for OpenAPI configuration - **๐Ÿ”ง Multiple UI Options** โ€” Swagger UI, Stoplight Elements, ReDoc, Scalar or RapiDoc served automatically at `/docs` - **๐Ÿ“„ YAML Export** โ€” OpenAPI spec available at `/docs/openapi.yaml` -- **๐Ÿš€ Zero Overhead** โ€” Minimal performance impact on your API +- **๐Ÿš€ Low Overhead** โ€” Minimal runtime work beyond route registration and docs serving ## Installation @@ -191,7 +191,7 @@ type CreateProductRequest struct { } ``` -For more struct tag options, see the [swaggest/openapi-go](https://github.com/swaggest/openapi-go?tab=readme-ov-file#features). +Supported tags are implemented by oaswrap/spec directly. Common request tags include `json`, `form`, `path`, `query`, `header`, and `cookie`; common schema tags include `description`, `format`, `default`, `example`, `enum`, `minimum`, `maximum`, `minLength`, `maxLength`, `minItems`, `maxItems`, `nullable`, `deprecated`, `readOnly`, and `writeOnly`. See the root [Reflection Tags](../../README.md#reflection-tags) section for the complete list. ## Example @@ -202,7 +202,7 @@ Check out the [examples directory](/adapter/chiopenapi/example) for more complet 1. **Organize with Tags** โ€” Group related operations using `option.Tags()` 2. **Document Everything** โ€” Use `option.Summary()` and `option.Description()` for all routes 3. **Define Error Responses** โ€” Include common error responses (400, 401, 404, 500) -4. **Use Validation Tags** โ€” Leverage struct tags for request validation documentation +4. **Document Schema Constraints** โ€” Use reflection tags to describe OpenAPI schema constraints; keep runtime validation in handlers or middleware 5. **Security First** โ€” Define and apply appropriate security schemes 6. **Version Your API** โ€” Use route groups for API versioning (`/api/v1`, `/api/v2`) diff --git a/adapter/chiopenapi/example/go.mod b/adapter/chiopenapi/example/go.mod index 2793418..f6a0146 100644 --- a/adapter/chiopenapi/example/go.mod +++ b/adapter/chiopenapi/example/go.mod @@ -1,19 +1,18 @@ module github.com/oaswrap/spec/adapter/chiopenapi/example -go 1.21 +go 1.22 require ( - github.com/go-chi/chi/v5 v5.2.2 + github.com/go-chi/chi/v5 v5.2.5 github.com/oaswrap/spec v0.4.2 github.com/oaswrap/spec/adapter/chiopenapi v0.0.0 ) require ( + github.com/goccy/go-yaml v1.19.2 // indirect github.com/oaswrap/spec-ui v0.2.0 // indirect - github.com/swaggest/jsonschema-go v0.3.79 // indirect - github.com/swaggest/openapi-go v0.2.61 // indirect - github.com/swaggest/refl v1.4.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect ) +replace github.com/oaswrap/spec => ../../.. + replace github.com/oaswrap/spec/adapter/chiopenapi => .. diff --git a/adapter/chiopenapi/example/go.sum b/adapter/chiopenapi/example/go.sum index 0d0f607..971ed25 100644 --- a/adapter/chiopenapi/example/go.sum +++ b/adapter/chiopenapi/example/go.sum @@ -1,45 +1,16 @@ -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= -github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/oaswrap/spec v0.4.2 h1:jOfQrWUsJhP6lz3Zj2XEHWtF3DoOH/lTk8vil5usSwA= -github.com/oaswrap/spec v0.4.2/go.mod h1:ebAiVZ8SUr5B70s/gKk9J/Ccbk/vQzFWAe4f4vk+1zQ= github.com/oaswrap/spec-ui v0.2.0 h1:NVuAqjOUf3acSEmwy5y4q0ii15xD+KGuoJYvNmtLd4k= github.com/oaswrap/spec-ui v0.2.0/go.mod h1:D8EnD6zbYJ3q65wdltw6QHXfw+nut5XwSSA1xtlSEQQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= -github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= -github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= -github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= -github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= -github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/adapter/chiopenapi/example/main.go b/adapter/chiopenapi/example/main.go index aa5acfa..3c86b52 100644 --- a/adapter/chiopenapi/example/main.go +++ b/adapter/chiopenapi/example/main.go @@ -7,8 +7,10 @@ import ( "time" "github.com/go-chi/chi/v5" - "github.com/oaswrap/spec/adapter/chiopenapi" + "github.com/oaswrap/spec/option" + + "github.com/oaswrap/spec/adapter/chiopenapi" ) func main() { diff --git a/adapter/chiopenapi/go.mod b/adapter/chiopenapi/go.mod index 37aca86..3f50bc1 100644 --- a/adapter/chiopenapi/go.mod +++ b/adapter/chiopenapi/go.mod @@ -1,9 +1,9 @@ module github.com/oaswrap/spec/adapter/chiopenapi -go 1.21 +go 1.22 require ( - github.com/go-chi/chi/v5 v5.2.2 + github.com/go-chi/chi/v5 v5.2.5 github.com/oaswrap/spec v0.4.2 github.com/oaswrap/spec-ui v0.2.0 github.com/stretchr/testify v1.11.1 @@ -11,12 +11,10 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/swaggest/jsonschema-go v0.3.79 // indirect - github.com/swaggest/openapi-go v0.2.61 // indirect - github.com/swaggest/refl v1.4.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/adapter/chiopenapi/go.sum b/adapter/chiopenapi/go.sum index 3dae126..ddd37c5 100644 --- a/adapter/chiopenapi/go.sum +++ b/adapter/chiopenapi/go.sum @@ -1,43 +1,26 @@ -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= -github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/oaswrap/spec-ui v0.2.0 h1:NVuAqjOUf3acSEmwy5y4q0ii15xD+KGuoJYvNmtLd4k= github.com/oaswrap/spec-ui v0.2.0/go.mod h1:D8EnD6zbYJ3q65wdltw6QHXfw+nut5XwSSA1xtlSEQQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= -github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= -github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= -github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= -github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= -github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/adapter/chiopenapi/router.go b/adapter/chiopenapi/router.go index e912c39..8733b27 100644 --- a/adapter/chiopenapi/router.go +++ b/adapter/chiopenapi/router.go @@ -4,11 +4,14 @@ import ( "net/http" "github.com/go-chi/chi/v5" + "github.com/oaswrap/spec" specui "github.com/oaswrap/spec-ui" - "github.com/oaswrap/spec/adapter/chiopenapi/internal/constant" + "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" "github.com/oaswrap/spec/pkg/mapper" + + "github.com/oaswrap/spec/adapter/chiopenapi/internal/constant" ) type router struct { @@ -121,8 +124,8 @@ 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 { - // CONNECT method is not supported by OpenAPI, so we skip it + if method == http.MethodConnect && r.gen.Config().OpenAPIVersion != openapi.Version320 { + // CONNECT requires OpenAPI 3.2, so older specs skip it return &route{} } sr := r.specRouter.Add(method, pattern) @@ -132,8 +135,8 @@ 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 { - // CONNECT method is not supported by OpenAPI, so we skip it + if method == http.MethodConnect && r.gen.Config().OpenAPIVersion != openapi.Version320 { + // CONNECT requires OpenAPI 3.2, so older specs skip it return &route{} } sr := r.specRouter.Add(method, pattern) diff --git a/adapter/chiopenapi/router_test.go b/adapter/chiopenapi/router_test.go index 6c96fb2..ccb110f 100644 --- a/adapter/chiopenapi/router_test.go +++ b/adapter/chiopenapi/router_test.go @@ -2,7 +2,6 @@ package chiopenapi_test import ( "encoding/json" - "flag" "net/http" "net/http/httptest" "os" @@ -10,18 +9,17 @@ import ( "testing" "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + stoplightemb "github.com/oaswrap/spec-ui/stoplightemb" - "github.com/oaswrap/spec/adapter/chiopenapi" + "github.com/oaswrap/spec/internal/testutil" + "github.com/oaswrap/spec/internal/testutil/dto" "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" - "github.com/oaswrap/spec/pkg/dto" - "github.com/oaswrap/spec/pkg/testutil" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) -//nolint:gochecknoglobals // test flag for golden file updates -var update = flag.Bool("update", false, "update golden files") + "github.com/oaswrap/spec/adapter/chiopenapi" +) func TestRouter_Spec(t *testing.T) { tests := []struct { @@ -71,7 +69,7 @@ func TestRouter_Spec(t *testing.T) { ), option.WithSecurity("petstore_auth", option.SecurityOAuth2( openapi.OAuthFlows{ - Implicit: &openapi.OAuthFlowsImplicit{ + Implicit: &openapi.OAuthFlow{ AuthorizationURL: "https://petstore3.swagger.io/oauth/authorize", Scopes: map[string]string{ "write:pets": "modify pets in your account", @@ -204,6 +202,23 @@ func TestRouter_Spec(t *testing.T) { option.Response(200, new(dto.PetUser)), option.Response(404, nil), ) + r.Get("/login", nil).With( + option.OperationID("loginUser"), + option.Summary("Logs user into the system"), + option.Description("Logs user into the system."), + option.Request(new(struct { + Username string `query:"username"` + Password string `query:"password"` + })), + option.Response(200, new(string)), + option.Response(400, nil), + ) + r.Get("/logout", nil).With( + option.OperationID("logoutUser"), + option.Summary("Logs out current logged in user session"), + option.Description("Logs out current logged in user session."), + option.Response(200, nil), + ) r.Put("/{username}", nil).With( option.OperationID("updateUser"), option.Summary("Update an existing user"), @@ -239,7 +254,6 @@ func TestRouter_Spec(t *testing.T) { option.WithVersion("1.0.0"), option.WithDescription("This is a test API for " + tt.name), option.WithReflectorConfig( - option.RequiredPropByValidateTag(), option.StripDefNamePrefix("GinopenapiTest"), ), } @@ -264,18 +278,7 @@ func TestRouter_Spec(t *testing.T) { schema, err := r.GenerateSchema() require.NoError(t, err, "failed to generate OpenAPI schema") - golden := filepath.Join("testdata", tt.golden+".yaml") - - if *update { - err = r.WriteSchemaTo(golden) - require.NoError(t, err, "failed to write golden file") - t.Logf("Updated golden file: %s", golden) - } - - want, err := os.ReadFile(golden) - require.NoError(t, err, "failed to read golden file %s", golden) - - testutil.EqualYAML(t, want, schema) + testutil.AssertGolden(t, schema, filepath.Join("testdata", tt.golden+".yaml")) }) } } @@ -572,7 +575,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.3", "expected response body to contain 'openapi: 3.0.3'") + assert.Contains(t, rr.Body.String(), "openapi: 3.0.4", "expected response body to contain 'openapi: 3.0.4'") }) } @@ -628,7 +631,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.3"`, "expected OpenAPI version in schema JSON") + assert.Contains(t, string(schema), `"openapi": "3.0.4"`, "expected OpenAPI version in schema JSON") assert.Contains(t, string(schema), `"title": "Chi OpenAPI"`, "expected title in schema JSON") } @@ -647,7 +650,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.3", "expected OpenAPI version in schema YAML") + assert.Contains(t, string(schema), "openapi: 3.0.4", "expected OpenAPI version in schema YAML") assert.Contains(t, string(schema), "title: Chi OpenAPI", "expected title in schema YAML") } @@ -671,6 +674,6 @@ 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.3", "expected OpenAPI version in schema file") + assert.Contains(t, string(schema), "openapi: 3.0.4", "expected OpenAPI version in schema file") assert.Contains(t, string(schema), "title: Chi OpenAPI", "expected title in schema file") } diff --git a/adapter/chiopenapi/testdata/petstore.yaml b/adapter/chiopenapi/testdata/petstore.yaml index 925dabc..a6b7e16 100644 --- a/adapter/chiopenapi/testdata/petstore.yaml +++ b/adapter/chiopenapi/testdata/petstore.yaml @@ -1,366 +1,400 @@ openapi: 3.0.3 info: + title: Test API Pet Store API + description: This is a sample Petstore server. + termsOfService: https://swagger.io/terms/ contact: email: apiteam@swagger.io - description: This is a sample Petstore server. license: name: Apache 2.0 url: https://www.apache.org/licenses/LICENSE-2.0.html - termsOfService: https://swagger.io/terms/ - title: Test API Pet Store API version: 1.0.0 +servers: + - url: https://petstore3.swagger.io/api/v3 externalDocs: description: Find more info here about swagger url: https://swagger.io -servers: -- url: https://petstore3.swagger.io/api/v3 tags: -- description: Everything about your Pets - externalDocs: - description: Find out more about our Pets - url: https://swagger.io - name: pet -- description: Access to Petstore orders - externalDocs: - description: Find out more about our Store - url: https://swagger.io - name: store -- description: Operations about user - name: user + - name: pet + description: Everything about your Pets + externalDocs: + description: Find out more about our Pets + url: https://swagger.io + - name: store + description: Access to Petstore orders + externalDocs: + description: Find out more about our Store + url: https://swagger.io + - name: user + description: Operations about user paths: - /pet/: - post: - description: Add a new pet to the store. - operationId: addPet + /pet: + put: + tags: + - pet + summary: Update an existing pet + description: Update the details of an existing pet in the store. + operationId: updatePet requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPet' + $ref: "#/components/schemas/DtoPet" responses: - "201": + "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: Created + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Add a new pet + - petstore_auth: + - write:pets + - read:pets + post: tags: - - pet - put: - description: Update the details of an existing pet in the store. - operationId: updatePet + - pet + summary: Add a new pet + description: Add a new pet to the store. + operationId: addPet requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPet' + $ref: "#/components/schemas/DtoPet" responses: - "200": + "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: OK + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Update an existing pet - tags: - - pet - /pet/{petId}: - delete: - description: Delete a pet from the store by its ID. - operationId: deletePet - parameters: - - in: path - name: petId - required: true - schema: - type: integer - - in: header - name: api_key - schema: - type: string - responses: - "204": - description: No Content - security: - - petstore_auth: - - write:pets - - read:pets - summary: Delete a pet - tags: - - pet + - petstore_auth: + - write:pets + - read:pets + /pet/findByStatus: get: - description: Retrieve a pet by its ID. - operationId: getPetById + tags: + - pet + summary: Find pets by status + description: Finds Pets by status. Multiple status values can be provided with comma separated strings. + operationId: findPetsByStatus parameters: - - in: path - name: petId - required: true - schema: - type: integer + - name: status + in: query + schema: + type: string + enum: + - available + - pending + - sold responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: OK + type: array + items: + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Get pet by ID + - petstore_auth: + - write:pets + - read:pets + /pet/findByTags: + get: tags: - - pet - post: - description: Updates a pet in the store with form data. - operationId: updatePetWithForm + - pet + summary: Find pets by tags + description: Finds Pets by tags. Multiple tags can be provided with comma separated strings. + operationId: findPetsByTags parameters: - - in: path - name: petId - required: true - schema: - type: integer - requestBody: - content: - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/FormDataDtoUpdatePetWithFormRequest' + - name: tags + in: query + schema: + type: array + items: + type: string responses: "200": description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Update pet with form + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}: + get: tags: - - pet - /pet/{petId}/uploadImage: - post: - description: Uploads an image for a pet. - operationId: uploadFile + - pet + summary: Get pet by ID + description: Retrieve a pet by its ID. + operationId: getPetById parameters: - - in: query - name: additionalMetadata - schema: - type: string - - in: path - name: petId - required: true - schema: - format: int64 - type: integer - requestBody: - content: - application/octet-stream: - schema: - type: string + - name: petId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoAPIResponse' - description: OK + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Upload an image for a pet + - petstore_auth: + - write:pets + - read:pets + post: tags: - - pet - /pet/findByStatus: - get: - description: Finds Pets by status. Multiple status values can be provided with - comma separated strings. - operationId: findPetsByStatus + - pet + summary: Update pet with form + description: Updates a pet in the store with form data. + operationId: updatePetWithForm parameters: - - in: query - name: status - schema: - enum: - - available - - pending - - sold - type: string + - name: petId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPet' - type: array description: OK security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by status + - petstore_auth: + - write:pets + - read:pets + delete: tags: - - pet - /pet/findByTags: - get: - description: Finds Pets by tags. Multiple tags can be provided with comma separated - strings. - operationId: findPetsByTags + - pet + summary: Delete a pet + description: Delete a pet from the store by its ID. + operationId: deletePet parameters: - - in: query - name: tags - schema: - items: + - name: petId + in: path + required: true + schema: + type: integer + format: int32 + - name: api_key + in: header + schema: + type: string + responses: + "204": + description: No Content + security: + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}/uploadImage: + post: + tags: + - pet + summary: Upload an image for a pet + description: Uploads an image for a pet. + operationId: uploadFile + parameters: + - name: petId + in: path + required: true + schema: + type: integer + format: int64 + - name: additionalMetadata + in: query + schema: type: string - type: array responses: "200": + description: OK content: application/json: schema: - items: - $ref: '#/components/schemas/DtoPet' - type: array - description: OK + $ref: "#/components/schemas/DtoAPIResponse" security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by tags - tags: - - pet + - petstore_auth: + - write:pets + - read:pets /store/order: post: + tags: + - store + summary: Place an order description: Place a new order for a pet. operationId: placeOrder requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' + $ref: "#/components/schemas/DtoOrder" responses: "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' - description: Created - summary: Place an order - tags: - - store + $ref: "#/components/schemas/DtoOrder" /store/order/{orderId}: - delete: - description: Delete an order by its ID. - operationId: deleteOrder - parameters: - - in: path - name: orderId - required: true - schema: - type: integer - responses: - "204": - description: No Content - summary: Delete an order - tags: - - store get: + tags: + - store + summary: Get order by ID description: Retrieve an order by its ID. operationId: getOrderById parameters: - - in: path - name: orderId - required: true - schema: - type: integer + - name: orderId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' - description: OK + $ref: "#/components/schemas/DtoOrder" "404": description: Not Found - summary: Get order by ID + delete: tags: - - store - /user/: + - store + summary: Delete an order + description: Delete an order by its ID. + operationId: deleteOrder + parameters: + - name: orderId + in: path + required: true + schema: + type: integer + format: int32 + responses: + "204": + description: No Content + /user: post: + tags: + - user + summary: Create a new user description: Create a new user in the store. operationId: createUser requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' + $ref: "#/components/schemas/DtoPetUser" responses: "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' + $ref: "#/components/schemas/DtoPetUser" + /user/createWithList: + post: + tags: + - user + summary: Create users with list + description: Create multiple users in the store with a list. + operationId: createUsersWithList + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DtoPetUser" + responses: + "201": description: Created - summary: Create a new user + /user/login: + get: tags: - - user - /user/{username}: - delete: - description: Delete a user from the store by their username. - operationId: deleteUser + - user + summary: Logs user into the system + description: Logs user into the system. + operationId: loginUser parameters: - - in: path - name: username - required: true - schema: - type: string + - name: username + in: query + schema: + type: string + - name: password + in: query + schema: + type: string responses: - "204": - description: No Content - summary: Delete a user + "200": + description: OK + content: + application/json: + schema: + type: string + "400": + description: Bad Request + /user/logout: + get: tags: - - user + - user + summary: Logs out current logged in user session + description: Logs out current logged in user session. + operationId: logoutUser + responses: + "200": + description: OK + /user/{username}: get: + tags: + - user + summary: Get user by username description: Retrieve a user by their username. operationId: getUserByName parameters: - - in: path - name: username - required: true - schema: - type: string + - name: username + in: path + required: true + schema: + type: string responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' - description: OK + $ref: "#/components/schemas/DtoPetUser" "404": description: Not Found - summary: Get user by username - tags: - - user put: + tags: + - user + summary: Update an existing user description: Update the details of an existing user. operationId: updateUser parameters: - - in: path - name: username - required: true - schema: - type: string + - name: username + in: path + required: true + schema: + type: string requestBody: content: application/json: schema: + type: object properties: email: type: string @@ -368,6 +402,7 @@ paths: type: string id: type: integer + format: int32 lastName: type: string password: @@ -375,110 +410,107 @@ paths: phone: type: string userStatus: - enum: - - 0 - - 1 - - 2 type: integer + format: int32 + enum: + - 0 + - 1 + - 2 username: type: string - type: object responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' - description: OK + $ref: "#/components/schemas/DtoPetUser" "404": description: Not Found - summary: Update an existing user + delete: tags: - - user - /user/createWithList: - post: - description: Create multiple users in the store with a list. - operationId: createUsersWithList - requestBody: - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPetUser' - nullable: true - type: array + - user + summary: Delete a user + description: Delete a user from the store by their username. + operationId: deleteUser + parameters: + - name: username + in: path + required: true + schema: + type: string responses: - "201": - description: Created - summary: Create users with list - tags: - - user + "204": + description: No Content components: schemas: DtoAPIResponse: + type: object properties: code: type: integer + format: int32 message: type: string type: type: string - type: object - DtoCategory: - properties: - id: - type: integer - name: - type: string - type: object DtoOrder: + type: object properties: complete: type: boolean id: type: integer + format: int32 petId: type: integer + format: int32 quantity: type: integer + format: int32 shipDate: - format: date-time type: string + format: date-time status: - enum: - - placed - - approved - - delivered type: string - type: object + enum: + - placed + - approved + - delivered DtoPet: + type: object properties: category: - $ref: '#/components/schemas/DtoCategory' + type: object + properties: + id: + type: integer + format: int32 + name: + type: string id: type: integer + format: int32 name: type: string photoUrls: + type: array items: type: string - nullable: true - type: array status: - enum: - - available - - pending - - sold type: string + enum: + - available + - pending + - sold tags: - items: - $ref: '#/components/schemas/DtoTag' - nullable: true type: array + items: + $ref: "#/components/schemas/DtoTag" type: type: string - type: object DtoPetUser: + type: object properties: email: type: string @@ -486,6 +518,7 @@ components: type: string id: type: integer + format: int32 lastName: type: string password: @@ -493,44 +526,32 @@ components: phone: type: string userStatus: - enum: - - 0 - - 1 - - 2 type: integer + format: int32 + enum: + - 0 + - 1 + - 2 username: type: string - type: object DtoTag: + type: object properties: id: type: integer + format: int32 name: type: string - type: object - FormDataDtoUpdatePetWithFormRequest: - properties: - name: - type: string - status: - enum: - - available - - pending - - sold - type: string - required: - - name - type: object securitySchemes: apiKey: - in: header - name: api_key type: apiKey + name: api_key + in: header petstore_auth: + type: oauth2 flows: implicit: authorizationUrl: https://petstore3.swagger.io/oauth/authorize scopes: read:pets: read your pets write:pets: modify pets in your account - type: oauth2 diff --git a/adapter/echoopenapi/README.MD b/adapter/echoopenapi/README.MD index f5b2d16..b76f198 100644 --- a/adapter/echoopenapi/README.MD +++ b/adapter/echoopenapi/README.MD @@ -14,7 +14,7 @@ A lightweight adapter for the [Echo](https://github.com/labstack/echo) web frame - **๐ŸŽฏ Type Safety** โ€” Full Go type safety for OpenAPI configuration - **๐Ÿ”ง Multiple UI Options** โ€” Swagger UI, Stoplight Elements, ReDoc, Scalar or RapiDoc served automatically at `/docs` - **๐Ÿ“„ YAML Export** โ€” OpenAPI spec available at `/docs/openapi.yaml` -- **๐Ÿš€ Zero Overhead** โ€” Minimal performance impact on your API +- **๐Ÿš€ Low Overhead** โ€” Minimal runtime work beyond route registration and docs serving ## Installation @@ -129,7 +129,7 @@ When you create a echoopenapi router, the following endpoints are automatically If you want to disable the built-in UI, you can do so by passing `option.WithDisableDocs()` when creating the router: ```go -r := echoopenapi.NewRouter(c, +r := echoopenapi.NewRouter(e, option.WithTitle("My API"), option.WithVersion("1.0.0"), option.WithDisableDocs(), @@ -146,7 +146,7 @@ Choose from multiple UI options, powered by [`oaswrap/spec-ui`](https://github.c - **RapiDoc** โ€” Highly customizable ```go -r := echoopenapi.NewRouter(c, +r := echoopenapi.NewRouter(e, option.WithTitle("My API"), option.WithVersion("1.0.0"), option.WithScalar(), // Use Scalar as the documentation UI @@ -181,7 +181,7 @@ type CreateProductRequest struct { } ``` -For more struct tag options, see the [swaggest/openapi-go](https://github.com/swaggest/openapi-go?tab=readme-ov-file#features). +Supported tags are implemented by oaswrap/spec directly. Common request tags include `json`, `form`, `path`, `query`, `header`, and `cookie`; common schema tags include `description`, `format`, `default`, `example`, `enum`, `minimum`, `maximum`, `minLength`, `maxLength`, `minItems`, `maxItems`, `nullable`, `deprecated`, `readOnly`, and `writeOnly`. See the root [Reflection Tags](../../README.md#reflection-tags) section for the complete list. ## Example @@ -192,7 +192,7 @@ Check out the [examples directory](/adapter/echoopenapi/example) for more comple 1. **Organize with Tags** โ€” Group related operations using `option.Tags()` 2. **Document Everything** โ€” Use `option.Summary()` and `option.Description()` for all routes 3. **Define Error Responses** โ€” Include common error responses (400, 401, 404, 500) -4. **Use Validation Tags** โ€” Leverage struct tags for request validation documentation +4. **Document Schema Constraints** โ€” Use reflection tags to describe OpenAPI schema constraints; keep runtime validation in handlers or middleware 5. **Security First** โ€” Define and apply appropriate security schemes 6. **Version Your API** โ€” Use route groups for API versioning (`/api/v1`, `/api/v2`) diff --git a/adapter/echoopenapi/example/go.mod b/adapter/echoopenapi/example/go.mod index da6029e..7f9864d 100644 --- a/adapter/echoopenapi/example/go.mod +++ b/adapter/echoopenapi/example/go.mod @@ -1,28 +1,27 @@ module github.com/oaswrap/spec/adapter/echoopenapi/example -go 1.24.0 +go 1.25.0 require ( - github.com/labstack/echo/v4 v4.13.4 + github.com/labstack/echo/v4 v4.15.2 github.com/oaswrap/spec v0.4.2 github.com/oaswrap/spec/adapter/echoopenapi v0.0.0 ) require ( - github.com/labstack/gommon v0.4.2 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/labstack/gommon v0.5.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/oaswrap/spec-ui v0.2.0 // indirect - github.com/swaggest/jsonschema-go v0.3.79 // indirect - github.com/swaggest/openapi-go v0.2.61 // indirect - github.com/swaggest/refl v1.4.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.50.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect ) +replace github.com/oaswrap/spec => ../../.. + replace github.com/oaswrap/spec/adapter/echoopenapi => .. diff --git a/adapter/echoopenapi/example/go.sum b/adapter/echoopenapi/example/go.sum index 0004b76..3260e0b 100644 --- a/adapter/echoopenapi/example/go.sum +++ b/adapter/echoopenapi/example/go.sum @@ -1,64 +1,34 @@ -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= -github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= -github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= -github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4= +github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI= +github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c= +github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/oaswrap/spec v0.4.2 h1:jOfQrWUsJhP6lz3Zj2XEHWtF3DoOH/lTk8vil5usSwA= -github.com/oaswrap/spec v0.4.2/go.mod h1:ebAiVZ8SUr5B70s/gKk9J/Ccbk/vQzFWAe4f4vk+1zQ= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/oaswrap/spec-ui v0.2.0 h1:NVuAqjOUf3acSEmwy5y4q0ii15xD+KGuoJYvNmtLd4k= github.com/oaswrap/spec-ui v0.2.0/go.mod h1:D8EnD6zbYJ3q65wdltw6QHXfw+nut5XwSSA1xtlSEQQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= -github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= -github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= -github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= -github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= -github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/adapter/echoopenapi/example/main.go b/adapter/echoopenapi/example/main.go index 8205bad..8115c71 100644 --- a/adapter/echoopenapi/example/main.go +++ b/adapter/echoopenapi/example/main.go @@ -4,8 +4,10 @@ import ( "log" "github.com/labstack/echo/v4" - "github.com/oaswrap/spec/adapter/echoopenapi" + "github.com/oaswrap/spec/option" + + "github.com/oaswrap/spec/adapter/echoopenapi" ) func main() { diff --git a/adapter/echoopenapi/go.mod b/adapter/echoopenapi/go.mod index ef911d7..72804c6 100644 --- a/adapter/echoopenapi/go.mod +++ b/adapter/echoopenapi/go.mod @@ -1,9 +1,9 @@ module github.com/oaswrap/spec/adapter/echoopenapi -go 1.24.0 +go 1.25.0 require ( - github.com/labstack/echo/v4 v4.13.4 + github.com/labstack/echo/v4 v4.15.2 github.com/oaswrap/spec v0.4.2 github.com/oaswrap/spec-ui v0.2.0 github.com/stretchr/testify v1.11.1 @@ -11,21 +11,19 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/labstack/gommon v0.4.2 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/labstack/gommon v0.5.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/swaggest/jsonschema-go v0.3.79 // indirect - github.com/swaggest/openapi-go v0.2.61 // indirect - github.com/swaggest/refl v1.4.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.50.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/adapter/echoopenapi/go.sum b/adapter/echoopenapi/go.sum index a635696..0862338 100644 --- a/adapter/echoopenapi/go.sum +++ b/adapter/echoopenapi/go.sum @@ -1,62 +1,44 @@ -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= -github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= -github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= -github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4= +github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI= +github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c= +github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/oaswrap/spec-ui v0.2.0 h1:NVuAqjOUf3acSEmwy5y4q0ii15xD+KGuoJYvNmtLd4k= github.com/oaswrap/spec-ui v0.2.0/go.mod h1:D8EnD6zbYJ3q65wdltw6QHXfw+nut5XwSSA1xtlSEQQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= -github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= -github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= -github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= -github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= -github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/adapter/echoopenapi/route.go b/adapter/echoopenapi/route.go index d0ff17b..4ce2323 100644 --- a/adapter/echoopenapi/route.go +++ b/adapter/echoopenapi/route.go @@ -2,6 +2,7 @@ package echoopenapi import ( "github.com/labstack/echo/v4" + "github.com/oaswrap/spec" "github.com/oaswrap/spec/option" ) diff --git a/adapter/echoopenapi/router.go b/adapter/echoopenapi/router.go index 68a4056..75ae287 100644 --- a/adapter/echoopenapi/router.go +++ b/adapter/echoopenapi/router.go @@ -4,13 +4,15 @@ import ( "io/fs" "github.com/labstack/echo/v4" + "github.com/oaswrap/spec" specui "github.com/oaswrap/spec-ui" - "github.com/oaswrap/spec/adapter/echoopenapi/internal/constant" "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" "github.com/oaswrap/spec/pkg/mapper" "github.com/oaswrap/spec/pkg/parser" + + "github.com/oaswrap/spec/adapter/echoopenapi/internal/constant" ) type router struct { @@ -71,8 +73,8 @@ func (r *router) Add(method, path string, handler echo.HandlerFunc, m ...echo.Mi echoRoute := r.echoGroup.Add(method, path, handler, m...) route := &route{echoRoute: echoRoute} - if method == echo.CONNECT { - // CONNECT method is not supported by OpenAPI, so we skip it + if method == echo.CONNECT && r.gen.Config().OpenAPIVersion != openapi.Version320 { + // CONNECT requires OpenAPI 3.2, so older specs skip it return route } route.specRoute = r.specRouter.Add(method, path) diff --git a/adapter/echoopenapi/router_test.go b/adapter/echoopenapi/router_test.go index c15a4d4..734b3c6 100644 --- a/adapter/echoopenapi/router_test.go +++ b/adapter/echoopenapi/router_test.go @@ -1,7 +1,6 @@ package echoopenapi_test import ( - "flag" "fmt" "net/http/httptest" "os" @@ -10,18 +9,17 @@ import ( "time" "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + stoplightemb "github.com/oaswrap/spec-ui/stoplightemb" - "github.com/oaswrap/spec/adapter/echoopenapi" + "github.com/oaswrap/spec/internal/testutil" + "github.com/oaswrap/spec/internal/testutil/dto" "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" - "github.com/oaswrap/spec/pkg/dto" - "github.com/oaswrap/spec/pkg/testutil" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) -//nolint:gochecknoglobals // test flag for golden file updates -var update = flag.Bool("update", false, "update golden files") + "github.com/oaswrap/spec/adapter/echoopenapi" +) type HelloRequest struct { Name string `json:"name" query:"name"` @@ -134,7 +132,7 @@ func TestRouter_Spec(t *testing.T) { ), option.WithSecurity("petstore_auth", option.SecurityOAuth2( openapi.OAuthFlows{ - Implicit: &openapi.OAuthFlowsImplicit{ + Implicit: &openapi.OAuthFlow{ AuthorizationURL: "https://petstore3.swagger.io/oauth/authorize", Scopes: map[string]string{ "write:pets": "modify pets in your account", @@ -182,14 +180,14 @@ func TestRouter_Spec(t *testing.T) { })), option.Response(200, new([]dto.Pet)), ) - pet.POST("/{petId}/uploadImage", nil).With( + pet.POST("/:petId/uploadImage", nil).With( option.OperationID("uploadFile"), option.Summary("Upload an image for a pet"), option.Description("Uploads an image for a pet."), option.Request(new(dto.UploadImageRequest)), option.Response(200, new(dto.APIResponse)), ) - pet.GET("/{petId}", nil).With( + pet.GET("/:petId", nil).With( option.OperationID("getPetById"), option.Summary("Get pet by ID"), option.Description("Retrieve a pet by its ID."), @@ -198,14 +196,14 @@ func TestRouter_Spec(t *testing.T) { })), option.Response(200, new(dto.Pet)), ) - pet.POST("/{petId}", nil).With( + pet.POST("/:petId", nil).With( option.OperationID("updatePetWithForm"), option.Summary("Update pet with form"), option.Description("Updates a pet in the store with form data."), option.Request(new(dto.UpdatePetWithFormRequest)), option.Response(200, nil), ) - pet.DELETE("/{petId}", nil).With( + pet.DELETE("/:petId", nil).With( option.OperationID("deletePet"), option.Summary("Delete a pet"), option.Description("Delete a pet from the store by its ID."), @@ -222,7 +220,7 @@ func TestRouter_Spec(t *testing.T) { option.Request(new(dto.Order)), option.Response(201, new(dto.Order)), ) - store.GET("/order/{orderId}", nil).With( + store.GET("/order/:orderId", nil).With( option.OperationID("getOrderById"), option.Summary("Get order by ID"), option.Description("Retrieve an order by its ID."), @@ -232,7 +230,7 @@ func TestRouter_Spec(t *testing.T) { option.Response(200, new(dto.Order)), option.Response(404, nil), ) - store.DELETE("/order/{orderId}", nil).With( + store.DELETE("/order/:orderId", nil).With( option.OperationID("deleteOrder"), option.Summary("Delete an order"), option.Description("Delete an order by its ID."), @@ -259,7 +257,7 @@ func TestRouter_Spec(t *testing.T) { option.Request(new(dto.PetUser)), option.Response(201, new(dto.PetUser)), ) - user.GET("/{username}", nil).With( + user.GET("/:username", nil).With( option.OperationID("getUserByName"), option.Summary("Get user by username"), option.Description("Retrieve a user by their username."), @@ -269,7 +267,7 @@ func TestRouter_Spec(t *testing.T) { option.Response(200, new(dto.PetUser)), option.Response(404, nil), ) - user.PUT("/{username}", nil).With( + user.PUT("/:username", nil).With( option.OperationID("updateUser"), option.Summary("Update an existing user"), option.Description("Update the details of an existing user."), @@ -281,7 +279,7 @@ func TestRouter_Spec(t *testing.T) { option.Response(200, new(dto.PetUser)), option.Response(404, nil), ) - user.DELETE("/{username}", nil).With( + user.DELETE("/:username", nil).With( option.OperationID("deleteUser"), option.Summary("Delete a user"), option.Description("Delete a user from the store by their username."), @@ -297,7 +295,10 @@ func TestRouter_Spec(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { e := echo.New() - r := echoopenapi.NewRouter(e, tt.opts...) + opts := append([]option.OpenAPIOption{ + option.WithOpenAPIVersion("3.0.3"), + }, tt.opts...) + r := echoopenapi.NewRouter(e, opts...) tt.setup(r) err := r.Validate() @@ -309,19 +310,9 @@ func TestRouter_Spec(t *testing.T) { // Test the OpenAPI schema generation schema, err := r.GenerateSchema() - require.NoError(t, err, "failed to generate schema") - - golden := filepath.Join("testdata", tt.golden+".yaml") - if *update { - err = r.WriteSchemaTo(golden) - require.NoError(t, err, "failed to write golden file") - t.Logf("Updated golden file: %s", golden) - } - - want, err := os.ReadFile(golden) - require.NoError(t, err, "failed to read golden file %s", golden) - testutil.EqualYAML(t, want, schema) + require.NoError(t, err, "failed to generate OpenAPI schema") + testutil.AssertGolden(t, schema, filepath.Join("testdata", tt.golden+".yaml")) }) } } diff --git a/adapter/echoopenapi/testdata/petstore.yaml b/adapter/echoopenapi/testdata/petstore.yaml index 454f621..82b5d6e 100644 --- a/adapter/echoopenapi/testdata/petstore.yaml +++ b/adapter/echoopenapi/testdata/petstore.yaml @@ -1,366 +1,365 @@ openapi: 3.0.3 info: + title: Echo OpenAPI + description: This is a sample Petstore server. + termsOfService: https://swagger.io/terms/ contact: email: apiteam@swagger.io - description: This is a sample Petstore server. license: name: Apache 2.0 url: https://www.apache.org/licenses/LICENSE-2.0.html - termsOfService: https://swagger.io/terms/ - title: Echo OpenAPI version: 1.0.0 +servers: + - url: https://petstore3.swagger.io/api/v3 externalDocs: description: Find more info here about swagger url: https://swagger.io -servers: -- url: https://petstore3.swagger.io/api/v3 tags: -- description: Everything about your Pets - externalDocs: - description: Find out more about our Pets - url: https://swagger.io - name: pet -- description: Access to Petstore orders - externalDocs: - description: Find out more about our Store - url: https://swagger.io - name: store -- description: Operations about user - name: user + - name: pet + description: Everything about your Pets + externalDocs: + description: Find out more about our Pets + url: https://swagger.io + - name: store + description: Access to Petstore orders + externalDocs: + description: Find out more about our Store + url: https://swagger.io + - name: user + description: Operations about user paths: - /pet/: - post: - description: Add a new pet to the store. - operationId: addPet + /pet: + put: + tags: + - pet + summary: Update an existing pet + description: Update the details of an existing pet in the store. + operationId: updatePet requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPet' + $ref: "#/components/schemas/DtoPet" responses: - "201": + "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: Created + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Add a new pet + - petstore_auth: + - write:pets + - read:pets + post: tags: - - pet - put: - description: Update the details of an existing pet in the store. - operationId: updatePet + - pet + summary: Add a new pet + description: Add a new pet to the store. + operationId: addPet requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPet' + $ref: "#/components/schemas/DtoPet" responses: - "200": + "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Update an existing pet - tags: - - pet - /pet/{petId}: - delete: - description: Delete a pet from the store by its ID. - operationId: deletePet - parameters: - - in: path - name: petId - required: true - schema: - type: integer - - in: header - name: api_key - schema: - type: string - responses: - "204": - description: No Content + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Delete a pet - tags: - - pet + - petstore_auth: + - write:pets + - read:pets + /pet/findByStatus: get: - description: Retrieve a pet by its ID. - operationId: getPetById + tags: + - pet + summary: Find pets by status + description: Finds Pets by status. Multiple status values can be provided with comma separated strings. + operationId: findPetsByStatus parameters: - - in: path - name: petId - required: true - schema: - type: integer + - name: status + in: query + schema: + type: string + enum: + - available + - pending + - sold responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: OK + type: array + items: + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Get pet by ID + - petstore_auth: + - write:pets + - read:pets + /pet/findByTags: + get: tags: - - pet - post: - description: Updates a pet in the store with form data. - operationId: updatePetWithForm + - pet + summary: Find pets by tags + description: Finds Pets by tags. Multiple tags can be provided with comma separated strings. + operationId: findPetsByTags parameters: - - in: path - name: petId - required: true - schema: - type: integer - requestBody: - content: - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/FormDataDtoUpdatePetWithFormRequest' + - name: tags + in: query + schema: + type: array + items: + type: string responses: "200": description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Update pet with form + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}: + get: tags: - - pet - /pet/{petId}/uploadImage: - post: - description: Uploads an image for a pet. - operationId: uploadFile + - pet + summary: Get pet by ID + description: Retrieve a pet by its ID. + operationId: getPetById parameters: - - in: query - name: additionalMetadata - schema: - type: string - - in: path - name: petId - required: true - schema: - format: int64 - type: integer - requestBody: - content: - application/octet-stream: - schema: - type: string + - name: petId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoAPIResponse' - description: OK + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Upload an image for a pet + - petstore_auth: + - write:pets + - read:pets + post: tags: - - pet - /pet/findByStatus: - get: - description: Finds Pets by status. Multiple status values can be provided with - comma separated strings. - operationId: findPetsByStatus + - pet + summary: Update pet with form + description: Updates a pet in the store with form data. + operationId: updatePetWithForm parameters: - - in: query - name: status - schema: - enum: - - available - - pending - - sold - type: string + - name: petId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPet' - type: array description: OK security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by status + - petstore_auth: + - write:pets + - read:pets + delete: tags: - - pet - /pet/findByTags: - get: - description: Finds Pets by tags. Multiple tags can be provided with comma separated - strings. - operationId: findPetsByTags + - pet + summary: Delete a pet + description: Delete a pet from the store by its ID. + operationId: deletePet parameters: - - in: query - name: tags - schema: - items: + - name: petId + in: path + required: true + schema: + type: integer + format: int32 + - name: api_key + in: header + schema: + type: string + responses: + "204": + description: No Content + security: + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}/uploadImage: + post: + tags: + - pet + summary: Upload an image for a pet + description: Uploads an image for a pet. + operationId: uploadFile + parameters: + - name: petId + in: path + required: true + schema: + type: integer + format: int64 + - name: additionalMetadata + in: query + schema: type: string - type: array responses: "200": + description: OK content: application/json: schema: - items: - $ref: '#/components/schemas/DtoPet' - type: array - description: OK + $ref: "#/components/schemas/DtoAPIResponse" security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by tags - tags: - - pet + - petstore_auth: + - write:pets + - read:pets /store/order: post: + tags: + - store + summary: Place an order description: Place a new order for a pet. operationId: placeOrder requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' + $ref: "#/components/schemas/DtoOrder" responses: "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' - description: Created - summary: Place an order - tags: - - store + $ref: "#/components/schemas/DtoOrder" /store/order/{orderId}: - delete: - description: Delete an order by its ID. - operationId: deleteOrder - parameters: - - in: path - name: orderId - required: true - schema: - type: integer - responses: - "204": - description: No Content - summary: Delete an order - tags: - - store get: + tags: + - store + summary: Get order by ID description: Retrieve an order by its ID. operationId: getOrderById parameters: - - in: path - name: orderId - required: true - schema: - type: integer + - name: orderId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' - description: OK + $ref: "#/components/schemas/DtoOrder" "404": description: Not Found - summary: Get order by ID + delete: tags: - - store - /user/: + - store + summary: Delete an order + description: Delete an order by its ID. + operationId: deleteOrder + parameters: + - name: orderId + in: path + required: true + schema: + type: integer + format: int32 + responses: + "204": + description: No Content + /user: post: + tags: + - user + summary: Create a new user description: Create a new user in the store. operationId: createUser requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' + $ref: "#/components/schemas/DtoPetUser" responses: "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' - description: Created - summary: Create a new user + $ref: "#/components/schemas/DtoPetUser" + /user/createWithList: + post: tags: - - user - /user/{username}: - delete: - description: Delete a user from the store by their username. - operationId: deleteUser - parameters: - - in: path - name: username - required: true - schema: - type: string + - user + summary: Create users with list + description: Create multiple users in the store with a list. + operationId: createUsersWithList + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DtoPetUser" responses: - "204": - description: No Content - summary: Delete a user - tags: - - user + "201": + description: Created + /user/{username}: get: + tags: + - user + summary: Get user by username description: Retrieve a user by their username. operationId: getUserByName parameters: - - in: path - name: username - required: true - schema: - type: string + - name: username + in: path + required: true + schema: + type: string responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' - description: OK + $ref: "#/components/schemas/DtoPetUser" "404": description: Not Found - summary: Get user by username - tags: - - user put: + tags: + - user + summary: Update an existing user description: Update the details of an existing user. operationId: updateUser parameters: - - in: path - name: username - required: true - schema: - type: string + - name: username + in: path + required: true + schema: + type: string requestBody: content: application/json: schema: + type: object properties: email: type: string @@ -368,6 +367,7 @@ paths: type: string id: type: integer + format: int32 lastName: type: string password: @@ -375,110 +375,107 @@ paths: phone: type: string userStatus: - enum: - - 0 - - 1 - - 2 type: integer + format: int32 + enum: + - 0 + - 1 + - 2 username: type: string - type: object responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' - description: OK + $ref: "#/components/schemas/DtoPetUser" "404": description: Not Found - summary: Update an existing user + delete: tags: - - user - /user/createWithList: - post: - description: Create multiple users in the store with a list. - operationId: createUsersWithList - requestBody: - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPetUser' - nullable: true - type: array + - user + summary: Delete a user + description: Delete a user from the store by their username. + operationId: deleteUser + parameters: + - name: username + in: path + required: true + schema: + type: string responses: - "201": - description: Created - summary: Create users with list - tags: - - user + "204": + description: No Content components: schemas: DtoAPIResponse: + type: object properties: code: type: integer + format: int32 message: type: string type: type: string - type: object - DtoCategory: - properties: - id: - type: integer - name: - type: string - type: object DtoOrder: + type: object properties: complete: type: boolean id: type: integer + format: int32 petId: type: integer + format: int32 quantity: type: integer + format: int32 shipDate: - format: date-time type: string + format: date-time status: - enum: - - placed - - approved - - delivered type: string - type: object + enum: + - placed + - approved + - delivered DtoPet: + type: object properties: category: - $ref: '#/components/schemas/DtoCategory' + type: object + properties: + id: + type: integer + format: int32 + name: + type: string id: type: integer + format: int32 name: type: string photoUrls: + type: array items: type: string - nullable: true - type: array status: - enum: - - available - - pending - - sold type: string + enum: + - available + - pending + - sold tags: - items: - $ref: '#/components/schemas/DtoTag' - nullable: true type: array + items: + $ref: "#/components/schemas/DtoTag" type: type: string - type: object DtoPetUser: + type: object properties: email: type: string @@ -486,6 +483,7 @@ components: type: string id: type: integer + format: int32 lastName: type: string password: @@ -493,44 +491,32 @@ components: phone: type: string userStatus: - enum: - - 0 - - 1 - - 2 type: integer + format: int32 + enum: + - 0 + - 1 + - 2 username: type: string - type: object DtoTag: + type: object properties: id: type: integer + format: int32 name: type: string - type: object - FormDataDtoUpdatePetWithFormRequest: - properties: - name: - type: string - status: - enum: - - available - - pending - - sold - type: string - required: - - name - type: object securitySchemes: apiKey: - in: header - name: api_key type: apiKey + name: api_key + in: header petstore_auth: + type: oauth2 flows: implicit: authorizationUrl: https://petstore3.swagger.io/oauth/authorize scopes: read:pets: read your pets write:pets: modify pets in your account - type: oauth2 diff --git a/adapter/echoopenapi/types.go b/adapter/echoopenapi/types.go index 13bb036..9544fb2 100644 --- a/adapter/echoopenapi/types.go +++ b/adapter/echoopenapi/types.go @@ -4,6 +4,7 @@ import ( "io/fs" "github.com/labstack/echo/v4" + "github.com/oaswrap/spec/option" ) @@ -60,6 +61,7 @@ type Router interface { TRACE(path string, handler echo.HandlerFunc, m ...echo.MiddlewareFunc) Route // CONNECT registers a new CONNECT route. + // CONNECT operations are emitted only for OpenAPI 3.2.0. CONNECT(path string, handler echo.HandlerFunc, m ...echo.MiddlewareFunc) Route // Add registers a new route with the given method, path, and handler. diff --git a/adapter/echov5openapi/README.MD b/adapter/echov5openapi/README.MD index 5dcdebb..75cb03f 100644 --- a/adapter/echov5openapi/README.MD +++ b/adapter/echov5openapi/README.MD @@ -23,7 +23,7 @@ Echo v5 includes several breaking changes from v4: - **Type Safety** - Full Go type safety for OpenAPI configuration - **Multiple UI Options** - Swagger UI, Stoplight Elements, ReDoc, Scalar or RapiDoc served automatically at `/docs` - **YAML Export** - OpenAPI spec available at `/docs/openapi.yaml` -- **Zero Overhead** - Minimal performance impact on your API +- **Low Overhead** - Minimal runtime work beyond route registration and docs serving ## Installation @@ -174,7 +174,7 @@ When you create an echov5openapi router, the following endpoints are automatical If you want to disable the built-in UI, you can do so by passing `option.WithDisableDocs()` when creating the router: ```go -r := echov5openapi.NewRouter(c, +r := echov5openapi.NewRouter(e, option.WithTitle("My API"), option.WithVersion("1.0.0"), option.WithDisableDocs(), @@ -191,7 +191,7 @@ Choose from multiple UI options, powered by [`oaswrap/spec-ui`](https://github.c - **RapiDoc** - Highly customizable ```go -r := echov5openapi.NewRouter(c, +r := echov5openapi.NewRouter(e, option.WithTitle("My API"), option.WithVersion("1.0.0"), option.WithScalar(), // Use Scalar as the documentation UI @@ -226,7 +226,7 @@ type CreateProductRequest struct { } ``` -For more struct tag options, see the [swaggest/openapi-go](https://github.com/swaggest/openapi-go?tab=readme-ov-file#features). +Supported tags are implemented by oaswrap/spec directly. Common request tags include `json`, `form`, `path`, `query`, `header`, and `cookie`; common schema tags include `description`, `format`, `default`, `example`, `enum`, `minimum`, `maximum`, `minLength`, `maxLength`, `minItems`, `maxItems`, `nullable`, `deprecated`, `readOnly`, and `writeOnly`. See the root [Reflection Tags](../../README.md#reflection-tags) section for the complete list. ## Example @@ -237,7 +237,7 @@ Check out the [examples directory](/adapter/echov5openapi/example) for more comp 1. **Organize with Tags** - Group related operations using `option.Tags()` 2. **Document Everything** - Use `option.Summary()` and `option.Description()` for all routes 3. **Define Error Responses** - Include common error responses (400, 401, 404, 500) -4. **Use Validation Tags** - Leverage struct tags for request validation documentation +4. **Document Schema Constraints** โ€” Use reflection tags to describe OpenAPI schema constraints; keep runtime validation in handlers or middleware 5. **Security First** - Define and apply appropriate security schemes 6. **Version Your API** - Use route groups for API versioning (`/api/v1`, `/api/v2`) diff --git a/adapter/echov5openapi/example/go.mod b/adapter/echov5openapi/example/go.mod index e31b065..0a7d9b6 100644 --- a/adapter/echov5openapi/example/go.mod +++ b/adapter/echov5openapi/example/go.mod @@ -3,18 +3,17 @@ module github.com/oaswrap/spec/adapter/echov5openapi/example go 1.25.0 require ( - github.com/labstack/echo/v5 v5.1.0 + github.com/labstack/echo/v5 v5.1.1 github.com/oaswrap/spec v0.4.2 github.com/oaswrap/spec/adapter/echov5openapi v0.0.0 ) require ( + github.com/goccy/go-yaml v1.19.2 // indirect github.com/oaswrap/spec-ui v0.2.0 // indirect - github.com/swaggest/jsonschema-go v0.3.79 // indirect - github.com/swaggest/openapi-go v0.2.61 // indirect - github.com/swaggest/refl v1.4.0 // indirect - golang.org/x/text v0.34.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + golang.org/x/text v0.37.0 // indirect ) +replace github.com/oaswrap/spec => ../../.. + replace github.com/oaswrap/spec/adapter/echov5openapi => .. diff --git a/adapter/echov5openapi/example/go.sum b/adapter/echov5openapi/example/go.sum index 30d3da9..605cd43 100644 --- a/adapter/echov5openapi/example/go.sum +++ b/adapter/echov5openapi/example/go.sum @@ -1,49 +1,20 @@ -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/labstack/echo/v5 v5.1.0 h1:MvIRydoN+p9cx/zq8Lff6YXqUW2ZaEsOMISzEGSMrBI= -github.com/labstack/echo/v5 v5.1.0/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo= -github.com/oaswrap/spec v0.4.2 h1:jOfQrWUsJhP6lz3Zj2XEHWtF3DoOH/lTk8vil5usSwA= -github.com/oaswrap/spec v0.4.2/go.mod h1:ebAiVZ8SUr5B70s/gKk9J/Ccbk/vQzFWAe4f4vk+1zQ= +github.com/labstack/echo/v5 v5.1.1 h1:4QkvKoS8ps5ch49t8b72QS9Z581ytgxhTzxuB/CBA2I= +github.com/labstack/echo/v5 v5.1.1/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo= github.com/oaswrap/spec-ui v0.2.0 h1:NVuAqjOUf3acSEmwy5y4q0ii15xD+KGuoJYvNmtLd4k= github.com/oaswrap/spec-ui v0.2.0/go.mod h1:D8EnD6zbYJ3q65wdltw6QHXfw+nut5XwSSA1xtlSEQQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= -github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= -github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= -github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= -github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= -github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/adapter/echov5openapi/example/main.go b/adapter/echov5openapi/example/main.go index a4d5144..c574a73 100644 --- a/adapter/echov5openapi/example/main.go +++ b/adapter/echov5openapi/example/main.go @@ -4,8 +4,10 @@ import ( "log" "github.com/labstack/echo/v5" - "github.com/oaswrap/spec/adapter/echov5openapi" + "github.com/oaswrap/spec/option" + + "github.com/oaswrap/spec/adapter/echov5openapi" ) func main() { diff --git a/adapter/echov5openapi/go.mod b/adapter/echov5openapi/go.mod index 8af1fd2..251842b 100644 --- a/adapter/echov5openapi/go.mod +++ b/adapter/echov5openapi/go.mod @@ -3,7 +3,7 @@ module github.com/oaswrap/spec/adapter/echov5openapi go 1.25.0 require ( - github.com/labstack/echo/v5 v5.1.0 + github.com/labstack/echo/v5 v5.1.1 github.com/oaswrap/spec v0.4.2 github.com/oaswrap/spec-ui v0.2.0 github.com/stretchr/testify v1.11.1 @@ -11,13 +11,11 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/swaggest/jsonschema-go v0.3.79 // indirect - github.com/swaggest/openapi-go v0.2.61 // indirect - github.com/swaggest/refl v1.4.0 // indirect - golang.org/x/net v0.50.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + golang.org/x/net v0.54.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/adapter/echov5openapi/go.sum b/adapter/echov5openapi/go.sum index 4c4be63..3c47b44 100644 --- a/adapter/echov5openapi/go.sum +++ b/adapter/echov5openapi/go.sum @@ -1,47 +1,30 @@ -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/labstack/echo/v5 v5.1.0 h1:MvIRydoN+p9cx/zq8Lff6YXqUW2ZaEsOMISzEGSMrBI= -github.com/labstack/echo/v5 v5.1.0/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo= +github.com/labstack/echo/v5 v5.1.1 h1:4QkvKoS8ps5ch49t8b72QS9Z581ytgxhTzxuB/CBA2I= +github.com/labstack/echo/v5 v5.1.1/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo= github.com/oaswrap/spec-ui v0.2.0 h1:NVuAqjOUf3acSEmwy5y4q0ii15xD+KGuoJYvNmtLd4k= github.com/oaswrap/spec-ui v0.2.0/go.mod h1:D8EnD6zbYJ3q65wdltw6QHXfw+nut5XwSSA1xtlSEQQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= -github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= -github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= -github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= -github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= -github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/adapter/echov5openapi/route.go b/adapter/echov5openapi/route.go index c0ce6a2..665b618 100644 --- a/adapter/echov5openapi/route.go +++ b/adapter/echov5openapi/route.go @@ -2,6 +2,7 @@ package echov5openapi import ( "github.com/labstack/echo/v5" + "github.com/oaswrap/spec" "github.com/oaswrap/spec/option" ) diff --git a/adapter/echov5openapi/router.go b/adapter/echov5openapi/router.go index eeacd54..ccc06f9 100644 --- a/adapter/echov5openapi/router.go +++ b/adapter/echov5openapi/router.go @@ -5,13 +5,15 @@ import ( "net/http" "github.com/labstack/echo/v5" + "github.com/oaswrap/spec" specui "github.com/oaswrap/spec-ui" - "github.com/oaswrap/spec/adapter/echov5openapi/internal/constant" "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" "github.com/oaswrap/spec/pkg/mapper" "github.com/oaswrap/spec/pkg/parser" + + "github.com/oaswrap/spec/adapter/echov5openapi/internal/constant" ) type router struct { @@ -77,8 +79,8 @@ func (r *router) Add(method, path string, handler echo.HandlerFunc, m ...echo.Mi echoRoute := r.echoGroup.Add(method, path, handler, m...) route := &route{echoRoute: echoRoute} - if method == http.MethodConnect { - // CONNECT method is not supported by OpenAPI, so we skip it + if method == http.MethodConnect && r.gen.Config().OpenAPIVersion != openapi.Version320 { + // CONNECT requires OpenAPI 3.2, so older specs skip it return route } route.specRoute = r.specRouter.Add(method, path) diff --git a/adapter/echov5openapi/router_test.go b/adapter/echov5openapi/router_test.go index e47113a..bc8875f 100644 --- a/adapter/echov5openapi/router_test.go +++ b/adapter/echov5openapi/router_test.go @@ -1,7 +1,6 @@ package echov5openapi_test import ( - "flag" "fmt" "net/http" "net/http/httptest" @@ -11,18 +10,17 @@ import ( "time" "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + stoplightemb "github.com/oaswrap/spec-ui/stoplightemb" - "github.com/oaswrap/spec/adapter/echov5openapi" + "github.com/oaswrap/spec/internal/testutil" + "github.com/oaswrap/spec/internal/testutil/dto" "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" - "github.com/oaswrap/spec/pkg/dto" - "github.com/oaswrap/spec/pkg/testutil" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) -//nolint:gochecknoglobals // test flag for golden file updates -var update = flag.Bool("update", false, "update golden files") + "github.com/oaswrap/spec/adapter/echov5openapi" +) type HelloRequest struct { Name string `json:"name" query:"name"` @@ -135,7 +133,7 @@ func TestRouter_Spec(t *testing.T) { ), option.WithSecurity("petstore_auth", option.SecurityOAuth2( openapi.OAuthFlows{ - Implicit: &openapi.OAuthFlowsImplicit{ + Implicit: &openapi.OAuthFlow{ AuthorizationURL: "https://petstore3.swagger.io/oauth/authorize", Scopes: map[string]string{ "write:pets": "modify pets in your account", @@ -312,17 +310,7 @@ func TestRouter_Spec(t *testing.T) { schema, err := r.GenerateSchema() require.NoError(t, err, "failed to generate schema") - golden := filepath.Join("testdata", tt.golden+".yaml") - if *update { - err = r.WriteSchemaTo(golden) - require.NoError(t, err, "failed to write golden file") - t.Logf("Updated golden file: %s", golden) - } - - want, err := os.ReadFile(golden) - require.NoError(t, err, "failed to read golden file %s", golden) - - testutil.EqualYAML(t, want, schema) + testutil.AssertGolden(t, schema, filepath.Join("testdata", tt.golden+".yaml")) }) } } diff --git a/adapter/echov5openapi/testdata/petstore.yaml b/adapter/echov5openapi/testdata/petstore.yaml index 454f621..5cf82fa 100644 --- a/adapter/echov5openapi/testdata/petstore.yaml +++ b/adapter/echov5openapi/testdata/petstore.yaml @@ -1,366 +1,365 @@ -openapi: 3.0.3 +openapi: 3.0.4 info: + title: Echo OpenAPI + description: This is a sample Petstore server. + termsOfService: https://swagger.io/terms/ contact: email: apiteam@swagger.io - description: This is a sample Petstore server. license: name: Apache 2.0 url: https://www.apache.org/licenses/LICENSE-2.0.html - termsOfService: https://swagger.io/terms/ - title: Echo OpenAPI version: 1.0.0 +servers: + - url: https://petstore3.swagger.io/api/v3 externalDocs: description: Find more info here about swagger url: https://swagger.io -servers: -- url: https://petstore3.swagger.io/api/v3 tags: -- description: Everything about your Pets - externalDocs: - description: Find out more about our Pets - url: https://swagger.io - name: pet -- description: Access to Petstore orders - externalDocs: - description: Find out more about our Store - url: https://swagger.io - name: store -- description: Operations about user - name: user + - name: pet + description: Everything about your Pets + externalDocs: + description: Find out more about our Pets + url: https://swagger.io + - name: store + description: Access to Petstore orders + externalDocs: + description: Find out more about our Store + url: https://swagger.io + - name: user + description: Operations about user paths: - /pet/: - post: - description: Add a new pet to the store. - operationId: addPet + /pet: + put: + tags: + - pet + summary: Update an existing pet + description: Update the details of an existing pet in the store. + operationId: updatePet requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPet' + $ref: "#/components/schemas/DtoPet" responses: - "201": + "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: Created + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Add a new pet + - petstore_auth: + - write:pets + - read:pets + post: tags: - - pet - put: - description: Update the details of an existing pet in the store. - operationId: updatePet + - pet + summary: Add a new pet + description: Add a new pet to the store. + operationId: addPet requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPet' + $ref: "#/components/schemas/DtoPet" responses: - "200": + "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Update an existing pet - tags: - - pet - /pet/{petId}: - delete: - description: Delete a pet from the store by its ID. - operationId: deletePet - parameters: - - in: path - name: petId - required: true - schema: - type: integer - - in: header - name: api_key - schema: - type: string - responses: - "204": - description: No Content + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Delete a pet - tags: - - pet + - petstore_auth: + - write:pets + - read:pets + /pet/findByStatus: get: - description: Retrieve a pet by its ID. - operationId: getPetById + tags: + - pet + summary: Find pets by status + description: Finds Pets by status. Multiple status values can be provided with comma separated strings. + operationId: findPetsByStatus parameters: - - in: path - name: petId - required: true - schema: - type: integer + - name: status + in: query + schema: + type: string + enum: + - available + - pending + - sold responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: OK + type: array + items: + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Get pet by ID + - petstore_auth: + - write:pets + - read:pets + /pet/findByTags: + get: tags: - - pet - post: - description: Updates a pet in the store with form data. - operationId: updatePetWithForm + - pet + summary: Find pets by tags + description: Finds Pets by tags. Multiple tags can be provided with comma separated strings. + operationId: findPetsByTags parameters: - - in: path - name: petId - required: true - schema: - type: integer - requestBody: - content: - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/FormDataDtoUpdatePetWithFormRequest' + - name: tags + in: query + schema: + type: array + items: + type: string responses: "200": description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Update pet with form + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}: + get: tags: - - pet - /pet/{petId}/uploadImage: - post: - description: Uploads an image for a pet. - operationId: uploadFile + - pet + summary: Get pet by ID + description: Retrieve a pet by its ID. + operationId: getPetById parameters: - - in: query - name: additionalMetadata - schema: - type: string - - in: path - name: petId - required: true - schema: - format: int64 - type: integer - requestBody: - content: - application/octet-stream: - schema: - type: string + - name: petId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoAPIResponse' - description: OK + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Upload an image for a pet + - petstore_auth: + - write:pets + - read:pets + post: tags: - - pet - /pet/findByStatus: - get: - description: Finds Pets by status. Multiple status values can be provided with - comma separated strings. - operationId: findPetsByStatus + - pet + summary: Update pet with form + description: Updates a pet in the store with form data. + operationId: updatePetWithForm parameters: - - in: query - name: status - schema: - enum: - - available - - pending - - sold - type: string + - name: petId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPet' - type: array description: OK security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by status + - petstore_auth: + - write:pets + - read:pets + delete: tags: - - pet - /pet/findByTags: - get: - description: Finds Pets by tags. Multiple tags can be provided with comma separated - strings. - operationId: findPetsByTags + - pet + summary: Delete a pet + description: Delete a pet from the store by its ID. + operationId: deletePet parameters: - - in: query - name: tags - schema: - items: + - name: petId + in: path + required: true + schema: + type: integer + format: int32 + - name: api_key + in: header + schema: + type: string + responses: + "204": + description: No Content + security: + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}/uploadImage: + post: + tags: + - pet + summary: Upload an image for a pet + description: Uploads an image for a pet. + operationId: uploadFile + parameters: + - name: petId + in: path + required: true + schema: + type: integer + format: int64 + - name: additionalMetadata + in: query + schema: type: string - type: array responses: "200": + description: OK content: application/json: schema: - items: - $ref: '#/components/schemas/DtoPet' - type: array - description: OK + $ref: "#/components/schemas/DtoAPIResponse" security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by tags - tags: - - pet + - petstore_auth: + - write:pets + - read:pets /store/order: post: + tags: + - store + summary: Place an order description: Place a new order for a pet. operationId: placeOrder requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' + $ref: "#/components/schemas/DtoOrder" responses: "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' - description: Created - summary: Place an order - tags: - - store + $ref: "#/components/schemas/DtoOrder" /store/order/{orderId}: - delete: - description: Delete an order by its ID. - operationId: deleteOrder - parameters: - - in: path - name: orderId - required: true - schema: - type: integer - responses: - "204": - description: No Content - summary: Delete an order - tags: - - store get: + tags: + - store + summary: Get order by ID description: Retrieve an order by its ID. operationId: getOrderById parameters: - - in: path - name: orderId - required: true - schema: - type: integer + - name: orderId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' - description: OK + $ref: "#/components/schemas/DtoOrder" "404": description: Not Found - summary: Get order by ID + delete: tags: - - store - /user/: + - store + summary: Delete an order + description: Delete an order by its ID. + operationId: deleteOrder + parameters: + - name: orderId + in: path + required: true + schema: + type: integer + format: int32 + responses: + "204": + description: No Content + /user: post: + tags: + - user + summary: Create a new user description: Create a new user in the store. operationId: createUser requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' + $ref: "#/components/schemas/DtoPetUser" responses: "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' - description: Created - summary: Create a new user + $ref: "#/components/schemas/DtoPetUser" + /user/createWithList: + post: tags: - - user - /user/{username}: - delete: - description: Delete a user from the store by their username. - operationId: deleteUser - parameters: - - in: path - name: username - required: true - schema: - type: string + - user + summary: Create users with list + description: Create multiple users in the store with a list. + operationId: createUsersWithList + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DtoPetUser" responses: - "204": - description: No Content - summary: Delete a user - tags: - - user + "201": + description: Created + /user/{username}: get: + tags: + - user + summary: Get user by username description: Retrieve a user by their username. operationId: getUserByName parameters: - - in: path - name: username - required: true - schema: - type: string + - name: username + in: path + required: true + schema: + type: string responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' - description: OK + $ref: "#/components/schemas/DtoPetUser" "404": description: Not Found - summary: Get user by username - tags: - - user put: + tags: + - user + summary: Update an existing user description: Update the details of an existing user. operationId: updateUser parameters: - - in: path - name: username - required: true - schema: - type: string + - name: username + in: path + required: true + schema: + type: string requestBody: content: application/json: schema: + type: object properties: email: type: string @@ -368,6 +367,7 @@ paths: type: string id: type: integer + format: int32 lastName: type: string password: @@ -375,110 +375,107 @@ paths: phone: type: string userStatus: - enum: - - 0 - - 1 - - 2 type: integer + format: int32 + enum: + - 0 + - 1 + - 2 username: type: string - type: object responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' - description: OK + $ref: "#/components/schemas/DtoPetUser" "404": description: Not Found - summary: Update an existing user + delete: tags: - - user - /user/createWithList: - post: - description: Create multiple users in the store with a list. - operationId: createUsersWithList - requestBody: - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPetUser' - nullable: true - type: array + - user + summary: Delete a user + description: Delete a user from the store by their username. + operationId: deleteUser + parameters: + - name: username + in: path + required: true + schema: + type: string responses: - "201": - description: Created - summary: Create users with list - tags: - - user + "204": + description: No Content components: schemas: DtoAPIResponse: + type: object properties: code: type: integer + format: int32 message: type: string type: type: string - type: object - DtoCategory: - properties: - id: - type: integer - name: - type: string - type: object DtoOrder: + type: object properties: complete: type: boolean id: type: integer + format: int32 petId: type: integer + format: int32 quantity: type: integer + format: int32 shipDate: - format: date-time type: string + format: date-time status: - enum: - - placed - - approved - - delivered type: string - type: object + enum: + - placed + - approved + - delivered DtoPet: + type: object properties: category: - $ref: '#/components/schemas/DtoCategory' + type: object + properties: + id: + type: integer + format: int32 + name: + type: string id: type: integer + format: int32 name: type: string photoUrls: + type: array items: type: string - nullable: true - type: array status: - enum: - - available - - pending - - sold type: string + enum: + - available + - pending + - sold tags: - items: - $ref: '#/components/schemas/DtoTag' - nullable: true type: array + items: + $ref: "#/components/schemas/DtoTag" type: type: string - type: object DtoPetUser: + type: object properties: email: type: string @@ -486,6 +483,7 @@ components: type: string id: type: integer + format: int32 lastName: type: string password: @@ -493,44 +491,32 @@ components: phone: type: string userStatus: - enum: - - 0 - - 1 - - 2 type: integer + format: int32 + enum: + - 0 + - 1 + - 2 username: type: string - type: object DtoTag: + type: object properties: id: type: integer + format: int32 name: type: string - type: object - FormDataDtoUpdatePetWithFormRequest: - properties: - name: - type: string - status: - enum: - - available - - pending - - sold - type: string - required: - - name - type: object securitySchemes: apiKey: - in: header - name: api_key type: apiKey + name: api_key + in: header petstore_auth: + type: oauth2 flows: implicit: authorizationUrl: https://petstore3.swagger.io/oauth/authorize scopes: read:pets: read your pets write:pets: modify pets in your account - type: oauth2 diff --git a/adapter/echov5openapi/types.go b/adapter/echov5openapi/types.go index c8f55f9..057471a 100644 --- a/adapter/echov5openapi/types.go +++ b/adapter/echov5openapi/types.go @@ -4,6 +4,7 @@ import ( "io/fs" "github.com/labstack/echo/v5" + "github.com/oaswrap/spec/option" ) @@ -60,6 +61,7 @@ type Router interface { TRACE(path string, handler echo.HandlerFunc, m ...echo.MiddlewareFunc) Route // CONNECT registers a new CONNECT route. + // CONNECT operations are emitted only for OpenAPI 3.2.0. CONNECT(path string, handler echo.HandlerFunc, m ...echo.MiddlewareFunc) Route // Add registers a new route with the given method, path, and handler. diff --git a/adapter/fiberopenapi/README.md b/adapter/fiberopenapi/README.md index 01bab42..1585901 100644 --- a/adapter/fiberopenapi/README.md +++ b/adapter/fiberopenapi/README.md @@ -14,7 +14,7 @@ A lightweight adapter for the [Fiber](https://github.com/gofiber/fiber) web fram - **๐ŸŽฏ Type Safety** โ€” Full Go type safety for OpenAPI configuration - **๐Ÿ”ง Multiple UI Options** โ€” Swagger UI, Stoplight Elements, ReDoc, Scalar or RapiDoc served automatically at `/docs` - **๐Ÿ“„ YAML Export** โ€” OpenAPI spec available at `/docs/openapi.yaml` -- **๐Ÿš€ Zero Overhead** โ€” Minimal performance impact on your API +- **๐Ÿš€ Low Overhead** โ€” Minimal runtime work beyond route registration and docs serving ## Installation @@ -124,7 +124,7 @@ When you create a fiberopenapi router, the following endpoints are automatically If you want to disable the built-in UI, you can do so by passing `option.WithDisableDocs()` when creating the router: ```go -r := fiberopenapi.NewRouter(c, +r := fiberopenapi.NewRouter(app, option.WithTitle("My API"), option.WithVersion("1.0.0"), option.WithDisableDocs(), @@ -141,7 +141,7 @@ Choose from multiple UI options, powered by [`oaswrap/spec-ui`](https://github.c - **RapiDoc** โ€” Highly customizable ```go -r := fiberopenapi.NewRouter(c, +r := fiberopenapi.NewRouter(app, option.WithTitle("My API"), option.WithVersion("1.0.0"), option.WithScalar(), // Use Scalar as the documentation UI @@ -176,7 +176,7 @@ type CreateProductRequest struct { } ``` -For more struct tag options, see the [swaggest/openapi-go](https://github.com/swaggest/openapi-go?tab=readme-ov-file#features). +Supported tags are implemented by oaswrap/spec directly. Common request tags include `json`, `form`, `path`, `query`, `header`, and `cookie`; common schema tags include `description`, `format`, `default`, `example`, `enum`, `minimum`, `maximum`, `minLength`, `maxLength`, `minItems`, `maxItems`, `nullable`, `deprecated`, `readOnly`, and `writeOnly`. See the root [Reflection Tags](../../README.md#reflection-tags) section for the complete list. ## Example @@ -187,7 +187,7 @@ Check out the [examples directory](/adapter/fiberopenapi/example) for more compl 1. **Organize with Tags** โ€” Group related operations using `option.Tags()` 2. **Document Everything** โ€” Use `option.Summary()` and `option.Description()` for all routes 3. **Define Error Responses** โ€” Include common error responses (400, 401, 404, 500) -4. **Use Validation Tags** โ€” Leverage struct tags for request validation documentation +4. **Document Schema Constraints** โ€” Use reflection tags to describe OpenAPI schema constraints; keep runtime validation in handlers or middleware 5. **Security First** โ€” Define and apply appropriate security schemes 6. **Version Your API** โ€” Use route groups for API versioning (`/api/v1`, `/api/v2`) diff --git a/adapter/fiberopenapi/example/go.mod b/adapter/fiberopenapi/example/go.mod index 06feeb2..d0aada2 100644 --- a/adapter/fiberopenapi/example/go.mod +++ b/adapter/fiberopenapi/example/go.mod @@ -1,29 +1,28 @@ module github.com/oaswrap/spec/adapter/fiberopenapi/example -go 1.24.0 +go 1.25.0 require ( - github.com/gofiber/fiber/v2 v2.52.12 + github.com/gofiber/fiber/v2 v2.52.13 github.com/oaswrap/spec v0.4.2 github.com/oaswrap/spec/adapter/fiberopenapi v0.0.0 ) require ( - github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/klauspost/compress v1.18.4 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/oaswrap/spec-ui v0.2.0 // indirect - github.com/rivo/uniseg v0.2.0 // indirect - github.com/swaggest/jsonschema-go v0.3.79 // indirect - github.com/swaggest/openapi-go v0.2.61 // indirect - github.com/swaggest/refl v1.4.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.69.0 // indirect - golang.org/x/sys v0.41.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + github.com/valyala/fasthttp v1.71.0 // indirect + golang.org/x/sys v0.44.0 // indirect ) +replace github.com/oaswrap/spec => ../../.. + replace github.com/oaswrap/spec/adapter/fiberopenapi => .. diff --git a/adapter/fiberopenapi/example/go.sum b/adapter/fiberopenapi/example/go.sum index 80efddc..19d5787 100644 --- a/adapter/fiberopenapi/example/go.sum +++ b/adapter/fiberopenapi/example/go.sum @@ -1,68 +1,38 @@ -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw= -github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk= +github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/oaswrap/spec v0.4.2 h1:jOfQrWUsJhP6lz3Zj2XEHWtF3DoOH/lTk8vil5usSwA= -github.com/oaswrap/spec v0.4.2/go.mod h1:ebAiVZ8SUr5B70s/gKk9J/Ccbk/vQzFWAe4f4vk+1zQ= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/oaswrap/spec-ui v0.2.0 h1:NVuAqjOUf3acSEmwy5y4q0ii15xD+KGuoJYvNmtLd4k= github.com/oaswrap/spec-ui v0.2.0/go.mod h1:D8EnD6zbYJ3q65wdltw6QHXfw+nut5XwSSA1xtlSEQQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= -github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= -github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= -github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= -github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= -github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= -github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/adapter/fiberopenapi/example/main.go b/adapter/fiberopenapi/example/main.go index 0800439..1fff2bb 100644 --- a/adapter/fiberopenapi/example/main.go +++ b/adapter/fiberopenapi/example/main.go @@ -4,8 +4,10 @@ import ( "log" "github.com/gofiber/fiber/v2" - "github.com/oaswrap/spec/adapter/fiberopenapi" + "github.com/oaswrap/spec/option" + + "github.com/oaswrap/spec/adapter/fiberopenapi" ) func main() { diff --git a/adapter/fiberopenapi/go.mod b/adapter/fiberopenapi/go.mod index 6012e7c..4d226a2 100644 --- a/adapter/fiberopenapi/go.mod +++ b/adapter/fiberopenapi/go.mod @@ -1,32 +1,30 @@ module github.com/oaswrap/spec/adapter/fiberopenapi -go 1.24.0 +go 1.25.0 require ( - github.com/gofiber/fiber/v2 v2.52.12 + github.com/gofiber/fiber/v2 v2.52.13 github.com/oaswrap/spec v0.4.2 github.com/oaswrap/spec-ui v0.2.0 github.com/stretchr/testify v1.11.1 ) require ( - github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/klauspost/compress v1.18.4 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rivo/uniseg v0.2.0 // indirect - github.com/swaggest/jsonschema-go v0.3.79 // indirect - github.com/swaggest/openapi-go v0.2.61 // indirect - github.com/swaggest/refl v1.4.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.69.0 // indirect - golang.org/x/sys v0.41.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + github.com/valyala/fasthttp v1.71.0 // indirect + golang.org/x/sys v0.44.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/adapter/fiberopenapi/go.sum b/adapter/fiberopenapi/go.sum index 34d9d53..81b92a0 100644 --- a/adapter/fiberopenapi/go.sum +++ b/adapter/fiberopenapi/go.sum @@ -1,66 +1,48 @@ -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw= -github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk= +github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/oaswrap/spec-ui v0.2.0 h1:NVuAqjOUf3acSEmwy5y4q0ii15xD+KGuoJYvNmtLd4k= github.com/oaswrap/spec-ui v0.2.0/go.mod h1:D8EnD6zbYJ3q65wdltw6QHXfw+nut5XwSSA1xtlSEQQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= -github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= -github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= -github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= -github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= -github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= -github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/adapter/fiberopenapi/route.go b/adapter/fiberopenapi/route.go index 9ed601f..fba2513 100644 --- a/adapter/fiberopenapi/route.go +++ b/adapter/fiberopenapi/route.go @@ -2,6 +2,7 @@ package fiberopenapi import ( "github.com/gofiber/fiber/v2" + "github.com/oaswrap/spec" "github.com/oaswrap/spec/option" ) diff --git a/adapter/fiberopenapi/router.go b/adapter/fiberopenapi/router.go index 4ef7215..49d15eb 100644 --- a/adapter/fiberopenapi/router.go +++ b/adapter/fiberopenapi/router.go @@ -3,13 +3,15 @@ package fiberopenapi import ( "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/adaptor" + "github.com/oaswrap/spec" specui "github.com/oaswrap/spec-ui" - "github.com/oaswrap/spec/adapter/fiberopenapi/internal/constant" "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" "github.com/oaswrap/spec/pkg/mapper" "github.com/oaswrap/spec/pkg/parser" + + "github.com/oaswrap/spec/adapter/fiberopenapi/internal/constant" ) // NewGenerator creates a new OpenAPI generator with the specified Fiber router and options. @@ -112,8 +114,8 @@ func (r *router) Add(method, path string, handler ...fiber.Handler) Route { fr := r.fiberRouter.Add(method, path, handler...) route := &route{fr: fr} - if method == fiber.MethodConnect { - // CONNECT method is not supported by OpenAPI, so we skip it + if method == fiber.MethodConnect && r.gen.Config().OpenAPIVersion != openapi.Version320 { + // CONNECT requires OpenAPI 3.2, so older specs skip it return route } route.sr = r.specRouter.Add(method, path) diff --git a/adapter/fiberopenapi/router_test.go b/adapter/fiberopenapi/router_test.go index 23a7186..7197b85 100644 --- a/adapter/fiberopenapi/router_test.go +++ b/adapter/fiberopenapi/router_test.go @@ -1,7 +1,6 @@ package fiberopenapi_test import ( - "flag" "io" "net/http" "os" @@ -9,23 +8,32 @@ import ( "testing" "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + stoplightemb "github.com/oaswrap/spec-ui/stoplightemb" - "github.com/oaswrap/spec/adapter/fiberopenapi" + "github.com/oaswrap/spec/internal/testutil" + "github.com/oaswrap/spec/internal/testutil/dto" "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" - "github.com/oaswrap/spec/pkg/dto" - "github.com/oaswrap/spec/pkg/testutil" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) -//nolint:gochecknoglobals // test flag for golden file updates -var update = flag.Bool("update", false, "update golden files") + "github.com/oaswrap/spec/adapter/fiberopenapi" +) func PingHandler(c *fiber.Ctx) error { return c.SendString("pong") } +func newTestRequest(t *testing.T, method, path string) *http.Request { + t.Helper() + + req, err := http.NewRequest(method, path, nil) + require.NoError(t, err, "failed to create request") + req.Host = "example.com" + + return req +} + func TestRouter_Spec(t *testing.T) { tests := []struct { name string @@ -36,7 +44,7 @@ func TestRouter_Spec(t *testing.T) { }{ { name: "Pet Store API", - golden: "petstore.yaml", + golden: "petstore", options: []option.OpenAPIOption{ option.WithDescription("This is a sample Petstore server."), option.WithVersion("1.0.0"), @@ -74,7 +82,7 @@ func TestRouter_Spec(t *testing.T) { ), option.WithSecurity("petstore_auth", option.SecurityOAuth2( openapi.OAuthFlows{ - Implicit: &openapi.OAuthFlowsImplicit{ + Implicit: &openapi.OAuthFlow{ AuthorizationURL: "https://petstore3.swagger.io/oauth/authorize", Scopes: map[string]string{ "write:pets": "modify pets in your account", @@ -247,12 +255,10 @@ func TestRouter_Spec(t *testing.T) { t.Run(tt.name, func(t *testing.T) { app := fiber.New() 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), - option.WithReflectorConfig( - option.RequiredPropByValidateTag(), - ), } if len(tt.options) > 0 { opts = append(opts, tt.options...) @@ -275,18 +281,7 @@ func TestRouter_Spec(t *testing.T) { schema, err := r.GenerateSchema() require.NoError(t, err, "failed to generate OpenAPI schema") - goldenFile := filepath.Join("testdata", tt.golden) - - if *update { - err = r.WriteSchemaTo(goldenFile) - require.NoError(t, err, "failed to write golden file") - t.Logf("Updated golden file: %s", goldenFile) - } - - want, err := os.ReadFile(goldenFile) - require.NoError(t, err, "failed to read golden file %s", goldenFile) - - testutil.EqualYAML(t, want, schema) + testutil.AssertGolden(t, schema, filepath.Join("testdata", tt.golden+".yaml")) }) } } @@ -326,7 +321,7 @@ func TestRouter_Single(t *testing.T) { fr := app.GetRoute("ping") assert.NotEmpty(t, fr.Name, "expected route name to be set for %s %s", tt.method, tt.path) - req, _ := http.NewRequest(tt.method, tt.path, nil) + req := newTestRequest(t, tt.method, tt.path) res, err := app.Test(req, -1) require.NoError(t, err, "failed to test %s request", tt.method) assert.Equal(t, http.StatusOK, res.StatusCode, "expected status OK for %s request", tt.method) @@ -360,7 +355,7 @@ func TestRouter_Single(t *testing.T) { app := fiber.New() r := fiberopenapi.NewRouter(app) r.Static("/static", "./testdata", fiber.Static{}) - req, _ := http.NewRequest(http.MethodGet, "/static/petstore.yaml", nil) + req := newTestRequest(t, http.MethodGet, "/static/petstore.yaml") res, err := app.Test(req, -1) require.NoError(t, err, "failed to test static file request") assert.Equal(t, http.StatusOK, res.StatusCode, "expected status OK for static file request") @@ -383,7 +378,7 @@ func TestRouter_Group(t *testing.T) { option.Summary("Ping Endpoint"), ) - req, _ := http.NewRequest(http.MethodGet, "/api/ping", nil) + req := newTestRequest(t, http.MethodGet, "/api/ping") res, err := app.Test(req, -1) require.NoError(t, err, "failed to test group route") assert.Equal(t, http.StatusOK, res.StatusCode, "expected status OK for group route") @@ -405,7 +400,7 @@ func TestRouter_Group(t *testing.T) { ) }) - req, _ := http.NewRequest(http.MethodGet, "/api/ping", nil) + req := newTestRequest(t, http.MethodGet, "/api/ping") res, err := app.Test(req, -1) require.NoError(t, err, "failed to test route") assert.Equal(t, http.StatusOK, res.StatusCode, "expected status OK for route") @@ -430,7 +425,7 @@ func TestRouter_Middleware(t *testing.T) { return c.SendString("pong") }) - req, _ := http.NewRequest(http.MethodGet, "/ping", nil) + req := newTestRequest(t, http.MethodGet, "/ping") res, err := app.Test(req, -1) require.NoError(t, err, "failed to test middleware route") assert.Equal(t, http.StatusOK, res.StatusCode, "expected status OK for middleware route") @@ -447,7 +442,7 @@ func TestGenerator_Docs(t *testing.T) { ) t.Run("should serve docs", func(t *testing.T) { - req, _ := http.NewRequest(http.MethodGet, "/docs", nil) + req := newTestRequest(t, http.MethodGet, "/docs") res, err := app.Test(req, -1) require.NoError(t, err, "failed to test docs route") assert.Equal(t, http.StatusOK, res.StatusCode, "expected status OK for docs route") @@ -462,7 +457,7 @@ func TestGenerator_Docs(t *testing.T) { ) }) t.Run("should serve OpenAPI YAML", func(t *testing.T) { - req, _ := http.NewRequest(http.MethodGet, "/docs/openapi.yaml", nil) + req := newTestRequest(t, http.MethodGet, "/docs/openapi.yaml") res, err := app.Test(req, -1) require.NoError(t, err, "failed to test OpenAPI YAML route") assert.Equal(t, http.StatusOK, res.StatusCode, "expected status OK for OpenAPI YAML route") @@ -473,7 +468,7 @@ func TestGenerator_Docs(t *testing.T) { assert.Contains( t, string(body), - "openapi: 3.0.3", + "openapi: 3.0.4", "expected OpenAPI version in response body for OpenAPI YAML route", ) }) @@ -486,7 +481,7 @@ func TestGenerator_Assets(t *testing.T) { option.OperationID("pingHandler"), ) - req, _ := http.NewRequest(http.MethodGet, "/docs/_assets/styles.min.css", nil) + req := newTestRequest(t, http.MethodGet, "/docs/_assets/styles.min.css") res, err := app.Test(req, -1) require.NoError(t, err, "failed to test embedded asset route") assert.Equal(t, http.StatusOK, res.StatusCode, "expected status OK for embedded asset route") @@ -505,14 +500,14 @@ func TestGenerator_DisableDocs(t *testing.T) { ) t.Run("should not register docs route", func(t *testing.T) { - reqDocs, _ := http.NewRequest(http.MethodGet, "/docs", nil) + reqDocs := newTestRequest(t, http.MethodGet, "/docs") resDocs, err := app.Test(reqDocs, -1) require.NoError(t, err, "failed to test docs route") assert.Equal(t, http.StatusNotFound, resDocs.StatusCode, "expected status Not Found for docs route") _ = resDocs.Body.Close() }) t.Run("should not register openapi.yaml route", func(t *testing.T) { - reqOpenAPI, _ := http.NewRequest(http.MethodGet, "/docs/openapi.yaml", nil) + reqOpenAPI := newTestRequest(t, http.MethodGet, "/docs/openapi.yaml") resOpenAPI, err := app.Test(reqOpenAPI, -1) require.NoError(t, err, "failed to test OpenAPI YAML route") assert.Equal(t, http.StatusNotFound, resOpenAPI.StatusCode, "expected status Not Found for OpenAPI YAML route") diff --git a/adapter/fiberopenapi/testdata/petstore.yaml b/adapter/fiberopenapi/testdata/petstore.yaml index 925dabc..99b53d7 100644 --- a/adapter/fiberopenapi/testdata/petstore.yaml +++ b/adapter/fiberopenapi/testdata/petstore.yaml @@ -1,366 +1,365 @@ openapi: 3.0.3 info: + title: Test API Pet Store API + description: This is a sample Petstore server. + termsOfService: https://swagger.io/terms/ contact: email: apiteam@swagger.io - description: This is a sample Petstore server. license: name: Apache 2.0 url: https://www.apache.org/licenses/LICENSE-2.0.html - termsOfService: https://swagger.io/terms/ - title: Test API Pet Store API version: 1.0.0 +servers: + - url: https://petstore3.swagger.io/api/v3 externalDocs: description: Find more info here about swagger url: https://swagger.io -servers: -- url: https://petstore3.swagger.io/api/v3 tags: -- description: Everything about your Pets - externalDocs: - description: Find out more about our Pets - url: https://swagger.io - name: pet -- description: Access to Petstore orders - externalDocs: - description: Find out more about our Store - url: https://swagger.io - name: store -- description: Operations about user - name: user + - name: pet + description: Everything about your Pets + externalDocs: + description: Find out more about our Pets + url: https://swagger.io + - name: store + description: Access to Petstore orders + externalDocs: + description: Find out more about our Store + url: https://swagger.io + - name: user + description: Operations about user paths: - /pet/: - post: - description: Add a new pet to the store. - operationId: addPet + /pet: + put: + tags: + - pet + summary: Update an existing pet + description: Update the details of an existing pet in the store. + operationId: updatePet requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPet' + $ref: "#/components/schemas/DtoPet" responses: - "201": + "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: Created + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Add a new pet + - petstore_auth: + - write:pets + - read:pets + post: tags: - - pet - put: - description: Update the details of an existing pet in the store. - operationId: updatePet + - pet + summary: Add a new pet + description: Add a new pet to the store. + operationId: addPet requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPet' + $ref: "#/components/schemas/DtoPet" responses: - "200": + "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Update an existing pet - tags: - - pet - /pet/{petId}: - delete: - description: Delete a pet from the store by its ID. - operationId: deletePet - parameters: - - in: path - name: petId - required: true - schema: - type: integer - - in: header - name: api_key - schema: - type: string - responses: - "204": - description: No Content + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Delete a pet - tags: - - pet + - petstore_auth: + - write:pets + - read:pets + /pet/findByStatus: get: - description: Retrieve a pet by its ID. - operationId: getPetById + tags: + - pet + summary: Find pets by status + description: Finds Pets by status. Multiple status values can be provided with comma separated strings. + operationId: findPetsByStatus parameters: - - in: path - name: petId - required: true - schema: - type: integer + - name: status + in: query + schema: + type: string + enum: + - available + - pending + - sold responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: OK + type: array + items: + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Get pet by ID + - petstore_auth: + - write:pets + - read:pets + /pet/findByTags: + get: tags: - - pet - post: - description: Updates a pet in the store with form data. - operationId: updatePetWithForm + - pet + summary: Find pets by tags + description: Finds Pets by tags. Multiple tags can be provided with comma separated strings. + operationId: findPetsByTags parameters: - - in: path - name: petId - required: true - schema: - type: integer - requestBody: - content: - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/FormDataDtoUpdatePetWithFormRequest' + - name: tags + in: query + schema: + type: array + items: + type: string responses: "200": description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Update pet with form + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}: + get: tags: - - pet - /pet/{petId}/uploadImage: - post: - description: Uploads an image for a pet. - operationId: uploadFile + - pet + summary: Get pet by ID + description: Retrieve a pet by its ID. + operationId: getPetById parameters: - - in: query - name: additionalMetadata - schema: - type: string - - in: path - name: petId - required: true - schema: - format: int64 - type: integer - requestBody: - content: - application/octet-stream: - schema: - type: string + - name: petId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoAPIResponse' - description: OK + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Upload an image for a pet + - petstore_auth: + - write:pets + - read:pets + post: tags: - - pet - /pet/findByStatus: - get: - description: Finds Pets by status. Multiple status values can be provided with - comma separated strings. - operationId: findPetsByStatus + - pet + summary: Update pet with form + description: Updates a pet in the store with form data. + operationId: updatePetWithForm parameters: - - in: query - name: status - schema: - enum: - - available - - pending - - sold - type: string + - name: petId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPet' - type: array description: OK security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by status + - petstore_auth: + - write:pets + - read:pets + delete: tags: - - pet - /pet/findByTags: - get: - description: Finds Pets by tags. Multiple tags can be provided with comma separated - strings. - operationId: findPetsByTags + - pet + summary: Delete a pet + description: Delete a pet from the store by its ID. + operationId: deletePet parameters: - - in: query - name: tags - schema: - items: + - name: petId + in: path + required: true + schema: + type: integer + format: int32 + - name: api_key + in: header + schema: + type: string + responses: + "204": + description: No Content + security: + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}/uploadImage: + post: + tags: + - pet + summary: Upload an image for a pet + description: Uploads an image for a pet. + operationId: uploadFile + parameters: + - name: petId + in: path + required: true + schema: + type: integer + format: int64 + - name: additionalMetadata + in: query + schema: type: string - type: array responses: "200": + description: OK content: application/json: schema: - items: - $ref: '#/components/schemas/DtoPet' - type: array - description: OK + $ref: "#/components/schemas/DtoAPIResponse" security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by tags - tags: - - pet + - petstore_auth: + - write:pets + - read:pets /store/order: post: + tags: + - store + summary: Place an order description: Place a new order for a pet. operationId: placeOrder requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' + $ref: "#/components/schemas/DtoOrder" responses: "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' - description: Created - summary: Place an order - tags: - - store + $ref: "#/components/schemas/DtoOrder" /store/order/{orderId}: - delete: - description: Delete an order by its ID. - operationId: deleteOrder - parameters: - - in: path - name: orderId - required: true - schema: - type: integer - responses: - "204": - description: No Content - summary: Delete an order - tags: - - store get: + tags: + - store + summary: Get order by ID description: Retrieve an order by its ID. operationId: getOrderById parameters: - - in: path - name: orderId - required: true - schema: - type: integer + - name: orderId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' - description: OK + $ref: "#/components/schemas/DtoOrder" "404": description: Not Found - summary: Get order by ID + delete: tags: - - store - /user/: + - store + summary: Delete an order + description: Delete an order by its ID. + operationId: deleteOrder + parameters: + - name: orderId + in: path + required: true + schema: + type: integer + format: int32 + responses: + "204": + description: No Content + /user: post: + tags: + - user + summary: Create a new user description: Create a new user in the store. operationId: createUser requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' + $ref: "#/components/schemas/DtoPetUser" responses: "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' - description: Created - summary: Create a new user + $ref: "#/components/schemas/DtoPetUser" + /user/createWithList: + post: tags: - - user - /user/{username}: - delete: - description: Delete a user from the store by their username. - operationId: deleteUser - parameters: - - in: path - name: username - required: true - schema: - type: string + - user + summary: Create users with list + description: Create multiple users in the store with a list. + operationId: createUsersWithList + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DtoPetUser" responses: - "204": - description: No Content - summary: Delete a user - tags: - - user + "201": + description: Created + /user/{username}: get: + tags: + - user + summary: Get user by username description: Retrieve a user by their username. operationId: getUserByName parameters: - - in: path - name: username - required: true - schema: - type: string + - name: username + in: path + required: true + schema: + type: string responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' - description: OK + $ref: "#/components/schemas/DtoPetUser" "404": description: Not Found - summary: Get user by username - tags: - - user put: + tags: + - user + summary: Update an existing user description: Update the details of an existing user. operationId: updateUser parameters: - - in: path - name: username - required: true - schema: - type: string + - name: username + in: path + required: true + schema: + type: string requestBody: content: application/json: schema: + type: object properties: email: type: string @@ -368,6 +367,7 @@ paths: type: string id: type: integer + format: int32 lastName: type: string password: @@ -375,110 +375,107 @@ paths: phone: type: string userStatus: - enum: - - 0 - - 1 - - 2 type: integer + format: int32 + enum: + - 0 + - 1 + - 2 username: type: string - type: object responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' - description: OK + $ref: "#/components/schemas/DtoPetUser" "404": description: Not Found - summary: Update an existing user + delete: tags: - - user - /user/createWithList: - post: - description: Create multiple users in the store with a list. - operationId: createUsersWithList - requestBody: - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPetUser' - nullable: true - type: array + - user + summary: Delete a user + description: Delete a user from the store by their username. + operationId: deleteUser + parameters: + - name: username + in: path + required: true + schema: + type: string responses: - "201": - description: Created - summary: Create users with list - tags: - - user + "204": + description: No Content components: schemas: DtoAPIResponse: + type: object properties: code: type: integer + format: int32 message: type: string type: type: string - type: object - DtoCategory: - properties: - id: - type: integer - name: - type: string - type: object DtoOrder: + type: object properties: complete: type: boolean id: type: integer + format: int32 petId: type: integer + format: int32 quantity: type: integer + format: int32 shipDate: - format: date-time type: string + format: date-time status: - enum: - - placed - - approved - - delivered type: string - type: object + enum: + - placed + - approved + - delivered DtoPet: + type: object properties: category: - $ref: '#/components/schemas/DtoCategory' + type: object + properties: + id: + type: integer + format: int32 + name: + type: string id: type: integer + format: int32 name: type: string photoUrls: + type: array items: type: string - nullable: true - type: array status: - enum: - - available - - pending - - sold type: string + enum: + - available + - pending + - sold tags: - items: - $ref: '#/components/schemas/DtoTag' - nullable: true type: array + items: + $ref: "#/components/schemas/DtoTag" type: type: string - type: object DtoPetUser: + type: object properties: email: type: string @@ -486,6 +483,7 @@ components: type: string id: type: integer + format: int32 lastName: type: string password: @@ -493,44 +491,32 @@ components: phone: type: string userStatus: - enum: - - 0 - - 1 - - 2 type: integer + format: int32 + enum: + - 0 + - 1 + - 2 username: type: string - type: object DtoTag: + type: object properties: id: type: integer + format: int32 name: type: string - type: object - FormDataDtoUpdatePetWithFormRequest: - properties: - name: - type: string - status: - enum: - - available - - pending - - sold - type: string - required: - - name - type: object securitySchemes: apiKey: - in: header - name: api_key type: apiKey + name: api_key + in: header petstore_auth: + type: oauth2 flows: implicit: authorizationUrl: https://petstore3.swagger.io/oauth/authorize scopes: read:pets: read your pets write:pets: modify pets in your account - type: oauth2 diff --git a/adapter/fiberopenapi/types.go b/adapter/fiberopenapi/types.go index 7eab067..4a54ac6 100644 --- a/adapter/fiberopenapi/types.go +++ b/adapter/fiberopenapi/types.go @@ -2,6 +2,7 @@ package fiberopenapi import ( "github.com/gofiber/fiber/v2" + "github.com/oaswrap/spec/option" ) @@ -41,6 +42,7 @@ type Router interface { // Delete registers a DELETE route. Delete(path string, handler ...fiber.Handler) Route // Connect registers a CONNECT route. + // CONNECT operations are emitted only for OpenAPI 3.2.0. Connect(path string, handler ...fiber.Handler) Route // Options registers an OPTIONS route. Options(path string, handler ...fiber.Handler) Route diff --git a/adapter/fiberv3openapi/README.md b/adapter/fiberv3openapi/README.md index 64f3031..5e7fa2a 100644 --- a/adapter/fiberv3openapi/README.md +++ b/adapter/fiberv3openapi/README.md @@ -14,7 +14,7 @@ A lightweight adapter for [Fiber v3](https://github.com/gofiber/fiber) that auto - **๐ŸŽฏ Type Safety** โ€” Full Go type safety for OpenAPI configuration - **๐Ÿ”ง Multiple UI Options** โ€” Swagger UI, Stoplight Elements, ReDoc, Scalar or RapiDoc served automatically at `/docs` - **๐Ÿ“„ YAML Export** โ€” OpenAPI spec available at `/docs/openapi.yaml` -- **๐Ÿš€ Zero Overhead** โ€” Minimal performance impact on your API +- **๐Ÿš€ Low Overhead** โ€” Minimal runtime work beyond route registration and docs serving ## Installation @@ -186,7 +186,7 @@ type CreateProductRequest struct { } ``` -For more struct tag options, see [swaggest/openapi-go](https://github.com/swaggest/openapi-go?tab=readme-ov-file#features). +Supported tags are implemented by oaswrap/spec directly. Common request tags include `json`, `form`, `path`, `query`, `header`, and `cookie`; common schema tags include `description`, `format`, `default`, `example`, `enum`, `minimum`, `maximum`, `minLength`, `maxLength`, `minItems`, `maxItems`, `nullable`, `deprecated`, `readOnly`, and `writeOnly`. See the root [Reflection Tags](../../README.md#reflection-tags) section for the complete list. ## Example @@ -197,7 +197,7 @@ Check out the [examples directory](/adapter/fiberv3openapi/example) for more com 1. **Organize with Tags** โ€” Group related operations using `option.Tags()` 2. **Document Everything** โ€” Use `option.Summary()` and `option.Description()` for all routes 3. **Define Error Responses** โ€” Include common error responses (400, 401, 404, 500) -4. **Use Validation Tags** โ€” Leverage struct tags for request validation documentation +4. **Document Schema Constraints** โ€” Use reflection tags to describe OpenAPI schema constraints; keep runtime validation in handlers or middleware 5. **Security First** โ€” Define and apply appropriate security schemes 6. **Version Your API** โ€” Use route groups for API versioning (`/api/v1`, `/api/v2`) diff --git a/adapter/fiberv3openapi/example/go.mod b/adapter/fiberv3openapi/example/go.mod index db5d5a0..e4c8fba 100644 --- a/adapter/fiberv3openapi/example/go.mod +++ b/adapter/fiberv3openapi/example/go.mod @@ -3,32 +3,31 @@ module github.com/oaswrap/spec/adapter/fiberv3openapi/example go 1.25.0 require ( - github.com/gofiber/fiber/v3 v3.1.0 + github.com/gofiber/fiber/v3 v3.2.0 github.com/oaswrap/spec v0.4.2 github.com/oaswrap/spec/adapter/fiberv3openapi v0.0.0 ) require ( - github.com/andybalholm/brotli v1.2.0 // indirect - github.com/gofiber/schema v1.7.0 // indirect - github.com/gofiber/utils/v2 v2.0.2 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/gofiber/schema v1.7.1 // indirect + github.com/gofiber/utils/v2 v2.0.4 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/klauspost/compress v1.18.4 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/oaswrap/spec-ui v0.2.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect - github.com/swaggest/jsonschema-go v0.3.79 // indirect - github.com/swaggest/openapi-go v0.2.61 // indirect - github.com/swaggest/refl v1.4.0 // indirect - github.com/tinylib/msgp v1.6.3 // indirect + github.com/tinylib/msgp v1.6.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.69.0 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.50.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + github.com/valyala/fasthttp v1.71.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect ) +replace github.com/oaswrap/spec => ../../.. + replace github.com/oaswrap/spec/adapter/fiberv3openapi => .. diff --git a/adapter/fiberv3openapi/example/go.sum b/adapter/fiberv3openapi/example/go.sum index 29142fb..1fdfba7 100644 --- a/adapter/fiberv3openapi/example/go.sum +++ b/adapter/fiberv3openapi/example/go.sum @@ -1,84 +1,54 @@ -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY= -github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU= -github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg= -github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk= -github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI= -github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gofiber/fiber/v3 v3.2.0 h1:g9+09D320foINPpCnR3ibQ5oBEFHjAWRRfDG1te54u8= +github.com/gofiber/fiber/v3 v3.2.0/go.mod h1:FHOsc2Db7HhHpsE62QAaJlXVV1pNkbZEptZ4jtti7m4= +github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI= +github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk= +github.com/gofiber/utils/v2 v2.0.4 h1:WwAxUA7L4MW2DjdEHF234lfqvBqd2vYYuBtA9TJq2ec= +github.com/gofiber/utils/v2 v2.0.4/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/oaswrap/spec v0.4.2 h1:jOfQrWUsJhP6lz3Zj2XEHWtF3DoOH/lTk8vil5usSwA= -github.com/oaswrap/spec v0.4.2/go.mod h1:ebAiVZ8SUr5B70s/gKk9J/Ccbk/vQzFWAe4f4vk+1zQ= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/oaswrap/spec-ui v0.2.0 h1:NVuAqjOUf3acSEmwy5y4q0ii15xD+KGuoJYvNmtLd4k= github.com/oaswrap/spec-ui v0.2.0/go.mod h1:D8EnD6zbYJ3q65wdltw6QHXfw+nut5XwSSA1xtlSEQQ= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU= github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= -github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= -github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= -github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= -github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= -github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= -github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= -github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= -github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/adapter/fiberv3openapi/example/main.go b/adapter/fiberv3openapi/example/main.go index 92cb78d..fb56659 100644 --- a/adapter/fiberv3openapi/example/main.go +++ b/adapter/fiberv3openapi/example/main.go @@ -4,8 +4,10 @@ import ( "log" "github.com/gofiber/fiber/v3" - "github.com/oaswrap/spec/adapter/fiberv3openapi" + "github.com/oaswrap/spec/option" + + "github.com/oaswrap/spec/adapter/fiberv3openapi" ) func main() { diff --git a/adapter/fiberv3openapi/go.mod b/adapter/fiberv3openapi/go.mod index 293a18c..89f289a 100644 --- a/adapter/fiberv3openapi/go.mod +++ b/adapter/fiberv3openapi/go.mod @@ -3,35 +3,33 @@ module github.com/oaswrap/spec/adapter/fiberv3openapi go 1.25.0 require ( - github.com/gofiber/fiber/v3 v3.1.0 + github.com/gofiber/fiber/v3 v3.2.0 github.com/oaswrap/spec v0.4.2 github.com/oaswrap/spec-ui v0.2.0 github.com/stretchr/testify v1.11.1 ) require ( - github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/gofiber/schema v1.7.0 // indirect - github.com/gofiber/utils/v2 v2.0.2 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/gofiber/schema v1.7.1 // indirect + github.com/gofiber/utils/v2 v2.0.4 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/klauspost/compress v1.18.4 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/swaggest/jsonschema-go v0.3.79 // indirect - github.com/swaggest/openapi-go v0.2.61 // indirect - github.com/swaggest/refl v1.4.0 // indirect - github.com/tinylib/msgp v1.6.3 // indirect + github.com/tinylib/msgp v1.6.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.69.0 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.50.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + github.com/valyala/fasthttp v1.71.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/adapter/fiberv3openapi/go.sum b/adapter/fiberv3openapi/go.sum index cf400fc..fddf57b 100644 --- a/adapter/fiberv3openapi/go.sum +++ b/adapter/fiberv3openapi/go.sum @@ -1,82 +1,64 @@ -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY= -github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU= -github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg= -github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk= -github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI= -github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gofiber/fiber/v3 v3.2.0 h1:g9+09D320foINPpCnR3ibQ5oBEFHjAWRRfDG1te54u8= +github.com/gofiber/fiber/v3 v3.2.0/go.mod h1:FHOsc2Db7HhHpsE62QAaJlXVV1pNkbZEptZ4jtti7m4= +github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI= +github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk= +github.com/gofiber/utils/v2 v2.0.4 h1:WwAxUA7L4MW2DjdEHF234lfqvBqd2vYYuBtA9TJq2ec= +github.com/gofiber/utils/v2 v2.0.4/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/oaswrap/spec-ui v0.2.0 h1:NVuAqjOUf3acSEmwy5y4q0ii15xD+KGuoJYvNmtLd4k= github.com/oaswrap/spec-ui v0.2.0/go.mod h1:D8EnD6zbYJ3q65wdltw6QHXfw+nut5XwSSA1xtlSEQQ= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU= github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= -github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= -github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= -github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= -github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= -github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= -github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= -github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= -github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/adapter/fiberv3openapi/router.go b/adapter/fiberv3openapi/router.go index c8dae78..67c6ea2 100644 --- a/adapter/fiberv3openapi/router.go +++ b/adapter/fiberv3openapi/router.go @@ -3,13 +3,15 @@ package fiberv3openapi import ( "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/adaptor" + "github.com/oaswrap/spec" specui "github.com/oaswrap/spec-ui" - "github.com/oaswrap/spec/adapter/fiberv3openapi/internal/constant" "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" "github.com/oaswrap/spec/pkg/mapper" "github.com/oaswrap/spec/pkg/parser" + + "github.com/oaswrap/spec/adapter/fiberv3openapi/internal/constant" ) // NewGenerator creates a new OpenAPI generator with the specified Fiber v3 router and options. @@ -124,8 +126,8 @@ func (r *router) Add(method, path string, handlers ...fiber.Handler) Route { r.fiberRouter.Add([]string{method}, path, anyHandlers[0], anyHandlers[1:]...) rt := &route{} - if method == fiber.MethodConnect { - // CONNECT method is not supported by OpenAPI, so we skip it + if method == fiber.MethodConnect && r.gen.Config().OpenAPIVersion != openapi.Version320 { + // CONNECT requires OpenAPI 3.2, so older specs skip it return rt } rt.sr = r.specRouter.Add(method, path) diff --git a/adapter/fiberv3openapi/router_test.go b/adapter/fiberv3openapi/router_test.go index fd9748f..fca72ad 100644 --- a/adapter/fiberv3openapi/router_test.go +++ b/adapter/fiberv3openapi/router_test.go @@ -1,26 +1,88 @@ package fiberv3openapi_test import ( - "flag" "io" + "mime/multipart" "net/http" "os" "path/filepath" "testing" + "time" "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + stoplightemb "github.com/oaswrap/spec-ui/stoplightemb" - "github.com/oaswrap/spec/adapter/fiberv3openapi" + "github.com/oaswrap/spec/internal/testutil" "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" - "github.com/oaswrap/spec/pkg/dto" - "github.com/oaswrap/spec/pkg/testutil" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + + "github.com/oaswrap/spec/adapter/fiberv3openapi" ) -//nolint:gochecknoglobals // test flag for golden file updates -var update = flag.Bool("update", false, "update golden files") +type Pet struct { + ID int `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status" enum:"available,pending,sold"` + Category Category `json:"category"` + Tags []Tag `json:"tags"` + PhotoURLs []string `json:"photoUrls"` +} + +type Tag struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type Category struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type UpdatePetWithFormRequest struct { + ID int `path:"petId" required:"true"` + Name string `required:"true" formData:"name"` + Status string `formData:"status" enum:"available,pending,sold"` +} + +type UploadImageRequest struct { + ID int64 `params:"petId" path:"petId"` + AdditionalMetaData string `query:"additionalMetadata"` + _ *multipart.File `contentType:"application/octet-stream"` +} + +type DeletePetRequest struct { + ID int `path:"petId" required:"true"` + APIKey string `header:"api_key"` +} + +type Order struct { + ID int `json:"id"` + PetID int `json:"petId"` + Quantity int `json:"quantity"` + ShipDate time.Time `json:"shipDate"` + Status string `json:"status" enum:"placed,approved,delivered"` + Complete bool `json:"complete"` +} + +type PetUser struct { + ID int `json:"id"` + Username string `json:"username"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Email string `json:"email"` + Password string `json:"password"` + Phone string `json:"phone"` + UserStatus int `json:"userStatus" enum:"0,1,2"` +} + +type APIResponse struct { + Message string `json:"message"` + Type string `json:"type"` + Code int `json:"code"` +} func PingHandler(c fiber.Ctx) error { return c.SendString("pong") @@ -36,7 +98,7 @@ func TestRouter_Spec(t *testing.T) { }{ { name: "Pet Store API", - golden: "petstore.yaml", + golden: "petstore", options: []option.OpenAPIOption{ option.WithDescription("This is a sample Petstore server."), option.WithVersion("1.0.0"), @@ -74,7 +136,7 @@ func TestRouter_Spec(t *testing.T) { ), option.WithSecurity("petstore_auth", option.SecurityOAuth2( openapi.OAuthFlows{ - Implicit: &openapi.OAuthFlowsImplicit{ + Implicit: &openapi.OAuthFlow{ AuthorizationURL: "https://petstore3.swagger.io/oauth/authorize", Scopes: map[string]string{ "write:pets": "modify pets in your account", @@ -94,15 +156,15 @@ func TestRouter_Spec(t *testing.T) { option.OperationID("updatePet"), option.Summary("Update an existing pet"), option.Description("Update the details of an existing pet in the store."), - option.Request(new(dto.Pet)), - option.Response(200, new(dto.Pet)), + option.Request(new(Pet)), + option.Response(200, new(Pet)), ) pet.Post("/", nil).With( option.OperationID("addPet"), option.Summary("Add a new pet"), option.Description("Add a new pet to the store."), - option.Request(new(dto.Pet)), - option.Response(201, new(dto.Pet)), + option.Request(new(Pet)), + option.Response(201, new(Pet)), ) pet.Get("/findByStatus", nil).With( option.OperationID("findPetsByStatus"), @@ -111,7 +173,7 @@ func TestRouter_Spec(t *testing.T) { option.Request(new(struct { Status string `query:"status" enum:"available,pending,sold"` })), - option.Response(200, new([]dto.Pet)), + option.Response(200, new([]Pet)), ) pet.Get("/findByTags", nil).With( option.OperationID("findPetsByTags"), @@ -120,14 +182,14 @@ func TestRouter_Spec(t *testing.T) { option.Request(new(struct { Tags []string `query:"tags"` })), - option.Response(200, new([]dto.Pet)), + option.Response(200, new([]Pet)), ) pet.Post("/:petId/uploadImage", nil).With( option.OperationID("uploadFile"), option.Summary("Upload an image for a pet"), option.Description("Uploads an image for a pet."), - option.Request(new(dto.UploadImageRequest)), - option.Response(200, new(dto.APIResponse)), + option.Request(new(UploadImageRequest)), + option.Response(200, new(APIResponse)), ) pet.Get("/:petId", nil).With( option.OperationID("getPetById"), @@ -136,20 +198,20 @@ func TestRouter_Spec(t *testing.T) { option.Request(new(struct { ID int `uri:"petId" required:"true"` })), - option.Response(200, new(dto.Pet)), + option.Response(200, new(Pet)), ) pet.Post("/:petId", nil).With( option.OperationID("updatePetWithForm"), option.Summary("Update pet with form"), option.Description("Updates a pet in the store with form data."), - option.Request(new(dto.UpdatePetWithFormRequest)), + option.Request(new(UpdatePetWithFormRequest)), option.Response(200, nil), ) pet.Delete("/{petId}", nil).With( option.OperationID("deletePet"), option.Summary("Delete a pet"), option.Description("Delete a pet from the store by its ID."), - option.Request(new(dto.DeletePetRequest)), + option.Request(new(DeletePetRequest)), option.Response(204, nil), ) store := r.Group("/store").With( @@ -159,8 +221,8 @@ func TestRouter_Spec(t *testing.T) { option.OperationID("placeOrder"), option.Summary("Place an order"), option.Description("Place a new order for a pet."), - option.Request(new(dto.Order)), - option.Response(201, new(dto.Order)), + option.Request(new(Order)), + option.Response(201, new(Order)), ) store.Get("/order/:orderId", nil).With( option.OperationID("getOrderById"), @@ -169,7 +231,7 @@ func TestRouter_Spec(t *testing.T) { option.Request(new(struct { ID int `uri:"orderId" required:"true"` })), - option.Response(200, new(dto.Order)), + option.Response(200, new(Order)), option.Response(404, nil), ) store.Delete("/order/:orderId", nil).With( @@ -189,15 +251,15 @@ func TestRouter_Spec(t *testing.T) { option.OperationID("createUsersWithList"), option.Summary("Create users with list"), option.Description("Create multiple users in the store with a list."), - option.Request(new([]dto.PetUser)), + option.Request(new([]PetUser)), option.Response(201, nil), ) user.Post("/", nil).With( option.OperationID("createUser"), option.Summary("Create a new user"), option.Description("Create a new user in the store."), - option.Request(new(dto.PetUser)), - option.Response(201, new(dto.PetUser)), + option.Request(new(PetUser)), + option.Response(201, new(PetUser)), ) user.Get("/:username", nil).With( option.OperationID("getUserByName"), @@ -206,7 +268,7 @@ func TestRouter_Spec(t *testing.T) { option.Request(new(struct { Username string `uri:"username" required:"true"` })), - option.Response(200, new(dto.PetUser)), + option.Response(200, new(PetUser)), option.Response(404, nil), ) user.Put("/:username", nil).With( @@ -214,11 +276,11 @@ func TestRouter_Spec(t *testing.T) { option.Summary("Update an existing user"), option.Description("Update the details of an existing user."), option.Request(new(struct { - dto.PetUser + PetUser Username string `uri:"username" required:"true"` })), - option.Response(200, new(dto.PetUser)), + option.Response(200, new(PetUser)), option.Response(404, nil), ) user.Delete("/:username", nil).With( @@ -250,9 +312,6 @@ func TestRouter_Spec(t *testing.T) { option.WithTitle("Test API " + tt.name), option.WithVersion("1.0.0"), option.WithDescription("This is a test API for " + tt.name), - option.WithReflectorConfig( - option.RequiredPropByValidateTag(), - ), } if len(tt.options) > 0 { opts = append(opts, tt.options...) @@ -275,18 +334,7 @@ func TestRouter_Spec(t *testing.T) { schema, err := r.GenerateSchema() require.NoError(t, err, "failed to generate OpenAPI schema") - goldenFile := filepath.Join("testdata", tt.golden) - - if *update { - err = r.WriteSchemaTo(goldenFile) - require.NoError(t, err, "failed to write golden file") - t.Logf("Updated golden file: %s", goldenFile) - } - - want, err := os.ReadFile(goldenFile) - require.NoError(t, err, "failed to read golden file %s", goldenFile) - - testutil.EqualYAML(t, want, schema) + testutil.AssertGolden(t, schema, filepath.Join("testdata", tt.golden+".yaml")) }) } } @@ -461,7 +509,7 @@ func TestGenerator_Docs(t *testing.T) { assert.Contains( t, string(body), - "openapi: 3.0.3", + "openapi: 3.0.4", "expected OpenAPI version in response body for OpenAPI YAML route", ) }) diff --git a/adapter/fiberv3openapi/testdata/petstore.yaml b/adapter/fiberv3openapi/testdata/petstore.yaml index 925dabc..c03edf6 100644 --- a/adapter/fiberv3openapi/testdata/petstore.yaml +++ b/adapter/fiberv3openapi/testdata/petstore.yaml @@ -1,366 +1,365 @@ -openapi: 3.0.3 +openapi: 3.0.4 info: + title: Test API Pet Store API + description: This is a sample Petstore server. + termsOfService: https://swagger.io/terms/ contact: email: apiteam@swagger.io - description: This is a sample Petstore server. license: name: Apache 2.0 url: https://www.apache.org/licenses/LICENSE-2.0.html - termsOfService: https://swagger.io/terms/ - title: Test API Pet Store API version: 1.0.0 +servers: + - url: https://petstore3.swagger.io/api/v3 externalDocs: description: Find more info here about swagger url: https://swagger.io -servers: -- url: https://petstore3.swagger.io/api/v3 tags: -- description: Everything about your Pets - externalDocs: - description: Find out more about our Pets - url: https://swagger.io - name: pet -- description: Access to Petstore orders - externalDocs: - description: Find out more about our Store - url: https://swagger.io - name: store -- description: Operations about user - name: user + - name: pet + description: Everything about your Pets + externalDocs: + description: Find out more about our Pets + url: https://swagger.io + - name: store + description: Access to Petstore orders + externalDocs: + description: Find out more about our Store + url: https://swagger.io + - name: user + description: Operations about user paths: - /pet/: - post: - description: Add a new pet to the store. - operationId: addPet + /pet: + put: + tags: + - pet + summary: Update an existing pet + description: Update the details of an existing pet in the store. + operationId: updatePet requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPet' + $ref: "#/components/schemas/Fiberv3openapi_testPet" responses: - "201": + "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: Created + $ref: "#/components/schemas/Fiberv3openapi_testPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Add a new pet + - petstore_auth: + - write:pets + - read:pets + post: tags: - - pet - put: - description: Update the details of an existing pet in the store. - operationId: updatePet + - pet + summary: Add a new pet + description: Add a new pet to the store. + operationId: addPet requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPet' + $ref: "#/components/schemas/Fiberv3openapi_testPet" responses: - "200": + "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Update an existing pet - tags: - - pet - /pet/{petId}: - delete: - description: Delete a pet from the store by its ID. - operationId: deletePet - parameters: - - in: path - name: petId - required: true - schema: - type: integer - - in: header - name: api_key - schema: - type: string - responses: - "204": - description: No Content + $ref: "#/components/schemas/Fiberv3openapi_testPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Delete a pet - tags: - - pet + - petstore_auth: + - write:pets + - read:pets + /pet/findByStatus: get: - description: Retrieve a pet by its ID. - operationId: getPetById + tags: + - pet + summary: Find pets by status + description: Finds Pets by status. Multiple status values can be provided with comma separated strings. + operationId: findPetsByStatus parameters: - - in: path - name: petId - required: true - schema: - type: integer + - name: status + in: query + schema: + type: string + enum: + - available + - pending + - sold responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: OK + type: array + items: + $ref: "#/components/schemas/Fiberv3openapi_testPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Get pet by ID + - petstore_auth: + - write:pets + - read:pets + /pet/findByTags: + get: tags: - - pet - post: - description: Updates a pet in the store with form data. - operationId: updatePetWithForm + - pet + summary: Find pets by tags + description: Finds Pets by tags. Multiple tags can be provided with comma separated strings. + operationId: findPetsByTags parameters: - - in: path - name: petId - required: true - schema: - type: integer - requestBody: - content: - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/FormDataDtoUpdatePetWithFormRequest' + - name: tags + in: query + schema: + type: array + items: + type: string responses: "200": description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Fiberv3openapi_testPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Update pet with form + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}: + get: tags: - - pet - /pet/{petId}/uploadImage: - post: - description: Uploads an image for a pet. - operationId: uploadFile + - pet + summary: Get pet by ID + description: Retrieve a pet by its ID. + operationId: getPetById parameters: - - in: query - name: additionalMetadata - schema: - type: string - - in: path - name: petId - required: true - schema: - format: int64 - type: integer - requestBody: - content: - application/octet-stream: - schema: - type: string + - name: petId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoAPIResponse' - description: OK + $ref: "#/components/schemas/Fiberv3openapi_testPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Upload an image for a pet + - petstore_auth: + - write:pets + - read:pets + post: tags: - - pet - /pet/findByStatus: - get: - description: Finds Pets by status. Multiple status values can be provided with - comma separated strings. - operationId: findPetsByStatus + - pet + summary: Update pet with form + description: Updates a pet in the store with form data. + operationId: updatePetWithForm parameters: - - in: query - name: status - schema: - enum: - - available - - pending - - sold - type: string + - name: petId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPet' - type: array description: OK security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by status + - petstore_auth: + - write:pets + - read:pets + delete: tags: - - pet - /pet/findByTags: - get: - description: Finds Pets by tags. Multiple tags can be provided with comma separated - strings. - operationId: findPetsByTags + - pet + summary: Delete a pet + description: Delete a pet from the store by its ID. + operationId: deletePet parameters: - - in: query - name: tags - schema: - items: + - name: petId + in: path + required: true + schema: + type: integer + format: int32 + - name: api_key + in: header + schema: + type: string + responses: + "204": + description: No Content + security: + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}/uploadImage: + post: + tags: + - pet + summary: Upload an image for a pet + description: Uploads an image for a pet. + operationId: uploadFile + parameters: + - name: petId + in: path + required: true + schema: + type: integer + format: int64 + - name: additionalMetadata + in: query + schema: type: string - type: array responses: "200": + description: OK content: application/json: schema: - items: - $ref: '#/components/schemas/DtoPet' - type: array - description: OK + $ref: "#/components/schemas/Fiberv3openapi_testAPIResponse" security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by tags - tags: - - pet + - petstore_auth: + - write:pets + - read:pets /store/order: post: + tags: + - store + summary: Place an order description: Place a new order for a pet. operationId: placeOrder requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' + $ref: "#/components/schemas/Fiberv3openapi_testOrder" responses: "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' - description: Created - summary: Place an order - tags: - - store + $ref: "#/components/schemas/Fiberv3openapi_testOrder" /store/order/{orderId}: - delete: - description: Delete an order by its ID. - operationId: deleteOrder - parameters: - - in: path - name: orderId - required: true - schema: - type: integer - responses: - "204": - description: No Content - summary: Delete an order - tags: - - store get: + tags: + - store + summary: Get order by ID description: Retrieve an order by its ID. operationId: getOrderById parameters: - - in: path - name: orderId - required: true - schema: - type: integer + - name: orderId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' - description: OK + $ref: "#/components/schemas/Fiberv3openapi_testOrder" "404": description: Not Found - summary: Get order by ID + delete: tags: - - store - /user/: + - store + summary: Delete an order + description: Delete an order by its ID. + operationId: deleteOrder + parameters: + - name: orderId + in: path + required: true + schema: + type: integer + format: int32 + responses: + "204": + description: No Content + /user: post: + tags: + - user + summary: Create a new user description: Create a new user in the store. operationId: createUser requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' + $ref: "#/components/schemas/Fiberv3openapi_testPetUser" responses: "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' - description: Created - summary: Create a new user + $ref: "#/components/schemas/Fiberv3openapi_testPetUser" + /user/createWithList: + post: tags: - - user - /user/{username}: - delete: - description: Delete a user from the store by their username. - operationId: deleteUser - parameters: - - in: path - name: username - required: true - schema: - type: string + - user + summary: Create users with list + description: Create multiple users in the store with a list. + operationId: createUsersWithList + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Fiberv3openapi_testPetUser" responses: - "204": - description: No Content - summary: Delete a user - tags: - - user + "201": + description: Created + /user/{username}: get: + tags: + - user + summary: Get user by username description: Retrieve a user by their username. operationId: getUserByName parameters: - - in: path - name: username - required: true - schema: - type: string + - name: username + in: path + required: true + schema: + type: string responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' - description: OK + $ref: "#/components/schemas/Fiberv3openapi_testPetUser" "404": description: Not Found - summary: Get user by username - tags: - - user put: + tags: + - user + summary: Update an existing user description: Update the details of an existing user. operationId: updateUser parameters: - - in: path - name: username - required: true - schema: - type: string + - name: username + in: path + required: true + schema: + type: string requestBody: content: application/json: schema: + type: object properties: email: type: string @@ -368,6 +367,7 @@ paths: type: string id: type: integer + format: int32 lastName: type: string password: @@ -375,110 +375,107 @@ paths: phone: type: string userStatus: - enum: - - 0 - - 1 - - 2 type: integer + format: int32 + enum: + - 0 + - 1 + - 2 username: type: string - type: object responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' - description: OK + $ref: "#/components/schemas/Fiberv3openapi_testPetUser" "404": description: Not Found - summary: Update an existing user + delete: tags: - - user - /user/createWithList: - post: - description: Create multiple users in the store with a list. - operationId: createUsersWithList - requestBody: - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPetUser' - nullable: true - type: array + - user + summary: Delete a user + description: Delete a user from the store by their username. + operationId: deleteUser + parameters: + - name: username + in: path + required: true + schema: + type: string responses: - "201": - description: Created - summary: Create users with list - tags: - - user + "204": + description: No Content components: schemas: - DtoAPIResponse: + Fiberv3openapi_testAPIResponse: + type: object properties: code: type: integer + format: int32 message: type: string type: type: string + Fiberv3openapi_testOrder: type: object - DtoCategory: - properties: - id: - type: integer - name: - type: string - type: object - DtoOrder: properties: complete: type: boolean id: type: integer + format: int32 petId: type: integer + format: int32 quantity: type: integer + format: int32 shipDate: - format: date-time type: string + format: date-time status: - enum: - - placed - - approved - - delivered type: string + enum: + - placed + - approved + - delivered + Fiberv3openapi_testPet: type: object - DtoPet: properties: category: - $ref: '#/components/schemas/DtoCategory' + type: object + properties: + id: + type: integer + format: int32 + name: + type: string id: type: integer + format: int32 name: type: string photoUrls: + type: array items: type: string - nullable: true - type: array status: - enum: - - available - - pending - - sold type: string + enum: + - available + - pending + - sold tags: - items: - $ref: '#/components/schemas/DtoTag' - nullable: true type: array + items: + $ref: "#/components/schemas/Fiberv3openapi_testTag" type: type: string + Fiberv3openapi_testPetUser: type: object - DtoPetUser: properties: email: type: string @@ -486,6 +483,7 @@ components: type: string id: type: integer + format: int32 lastName: type: string password: @@ -493,44 +491,32 @@ components: phone: type: string userStatus: - enum: - - 0 - - 1 - - 2 type: integer + format: int32 + enum: + - 0 + - 1 + - 2 username: type: string + Fiberv3openapi_testTag: type: object - DtoTag: properties: id: type: integer + format: int32 name: type: string - type: object - FormDataDtoUpdatePetWithFormRequest: - properties: - name: - type: string - status: - enum: - - available - - pending - - sold - type: string - required: - - name - type: object securitySchemes: apiKey: - in: header - name: api_key type: apiKey + name: api_key + in: header petstore_auth: + type: oauth2 flows: implicit: authorizationUrl: https://petstore3.swagger.io/oauth/authorize scopes: read:pets: read your pets write:pets: modify pets in your account - type: oauth2 diff --git a/adapter/fiberv3openapi/types.go b/adapter/fiberv3openapi/types.go index 8e34134..579b428 100644 --- a/adapter/fiberv3openapi/types.go +++ b/adapter/fiberv3openapi/types.go @@ -2,6 +2,7 @@ package fiberv3openapi import ( "github.com/gofiber/fiber/v3" + "github.com/oaswrap/spec/option" ) @@ -41,6 +42,7 @@ type Router interface { // Delete registers a DELETE route. Delete(path string, handler ...fiber.Handler) Route // Connect registers a CONNECT route. + // CONNECT operations are emitted only for OpenAPI 3.2.0. Connect(path string, handler ...fiber.Handler) Route // Options registers an OPTIONS route. Options(path string, handler ...fiber.Handler) Route diff --git a/adapter/ginopenapi/README.md b/adapter/ginopenapi/README.md index 1c7c1d9..d519d7f 100644 --- a/adapter/ginopenapi/README.md +++ b/adapter/ginopenapi/README.md @@ -12,7 +12,7 @@ A lightweight adapter for the [Gin](https://github.com/gin-gonic/gin) web framew - **๐ŸŽฏ Type Safety** โ€” Full Go type safety for OpenAPI configuration - **๐Ÿ”ง Multiple UI Options** โ€” Swagger UI, Stoplight Elements, ReDoc, Scalar or RapiDoc served automatically at `/docs` - **๐Ÿ“„ YAML Export** โ€” OpenAPI spec available at `/docs/openapi.yaml` -- **๐Ÿš€ Zero Overhead** โ€” Minimal performance impact on your API +- **๐Ÿš€ Low Overhead** โ€” Minimal runtime work beyond route registration and docs serving ## Installation @@ -126,7 +126,7 @@ When you create a ginopenapi router, the following endpoints are automatically a If you want to disable the built-in UI, you can do so by passing `option.WithDisableDocs()` when creating the router: ```go -r := ginopenapi.NewRouter(c, +r := ginopenapi.NewRouter(e, option.WithTitle("My API"), option.WithVersion("1.0.0"), option.WithDisableDocs(), @@ -143,7 +143,7 @@ Choose from multiple UI options, powered by [`oaswrap/spec-ui`](https://github.c - **RapiDoc** โ€” Highly customizable ```go -r := ginopenapi.NewRouter(c, +r := ginopenapi.NewRouter(e, option.WithTitle("My API"), option.WithVersion("1.0.0"), option.WithScalar(), // Use Scalar as the documentation UI @@ -178,7 +178,7 @@ type CreateProductRequest struct { } ``` -For more struct tag options, see the [swaggest/openapi-go](https://github.com/swaggest/openapi-go?tab=readme-ov-file#features). +Supported tags are implemented by oaswrap/spec directly. Common request tags include `json`, `form`, `path`, `query`, `header`, and `cookie`; common schema tags include `description`, `format`, `default`, `example`, `enum`, `minimum`, `maximum`, `minLength`, `maxLength`, `minItems`, `maxItems`, `nullable`, `deprecated`, `readOnly`, and `writeOnly`. See the root [Reflection Tags](../../README.md#reflection-tags) section for the complete list. ## Example @@ -189,7 +189,7 @@ Check out the [examples directory](/adapter/ginopenapi/example) for more complet 1. **Organize with Tags** โ€” Group related operations using `option.Tags()` 2. **Document Everything** โ€” Use `option.Summary()` and `option.Description()` for all routes 3. **Define Error Responses** โ€” Include common error responses (400, 401, 404, 500) -4. **Use Validation Tags** โ€” Leverage struct tags for request validation documentation +4. **Document Schema Constraints** โ€” Use reflection tags to describe OpenAPI schema constraints; keep runtime validation in handlers or middleware 5. **Security First** โ€” Define and apply appropriate security schemes 6. **Version Your API** โ€” Use route groups for API versioning (`/api/v1`, `/api/v2`) diff --git a/adapter/ginopenapi/example/go.mod b/adapter/ginopenapi/example/go.mod index a9d4322..3b9dbee 100644 --- a/adapter/ginopenapi/example/go.mod +++ b/adapter/ginopenapi/example/go.mod @@ -1,45 +1,46 @@ module github.com/oaswrap/spec/adapter/ginopenapi/example -go 1.24.0 +go 1.25.0 require ( - github.com/gin-gonic/gin v1.10.1 + github.com/gin-gonic/gin v1.12.0 github.com/oaswrap/spec v0.4.2 github.com/oaswrap/spec/adapter/ginopenapi v0.0.0 ) require ( - github.com/bytedance/sonic v1.11.6 // indirect - github.com/bytedance/sonic/loader v0.1.1 // indirect - github.com/cloudwego/base64x v0.1.4 // indirect - github.com/cloudwego/iasm v0.2.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.3 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect + github.com/bytedance/gopkg v0.1.4 // indirect + github.com/bytedance/sonic v1.15.1 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect + github.com/cloudwego/base64x v0.1.7 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gin-contrib/sse v1.1.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.20.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/go-playground/validator/v10 v10.30.2 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/oaswrap/spec-ui v0.2.0 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect - github.com/swaggest/jsonschema-go v0.3.79 // indirect - github.com/swaggest/openapi-go v0.2.61 // indirect - github.com/swaggest/refl v1.4.0 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.2.12 // indirect - golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.50.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect - google.golang.org/protobuf v1.34.1 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect + golang.org/x/arch v0.27.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) +replace github.com/oaswrap/spec => ../../.. + replace github.com/oaswrap/spec/adapter/ginopenapi => .. diff --git a/adapter/ginopenapi/example/go.sum b/adapter/ginopenapi/example/go.sum index b207a51..f863dad 100644 --- a/adapter/ginopenapi/example/go.sum +++ b/adapter/ginopenapi/example/go.sum @@ -1,119 +1,90 @@ -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= -github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= -github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= -github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= -github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= -github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= -github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= -github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= +github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= +github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= -github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= -github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= -github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= +github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= -github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= -github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= -github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/oaswrap/spec v0.4.2 h1:jOfQrWUsJhP6lz3Zj2XEHWtF3DoOH/lTk8vil5usSwA= -github.com/oaswrap/spec v0.4.2/go.mod h1:ebAiVZ8SUr5B70s/gKk9J/Ccbk/vQzFWAe4f4vk+1zQ= github.com/oaswrap/spec-ui v0.2.0 h1:NVuAqjOUf3acSEmwy5y4q0ii15xD+KGuoJYvNmtLd4k= github.com/oaswrap/spec-ui v0.2.0/go.mod h1:D8EnD6zbYJ3q65wdltw6QHXfw+nut5XwSSA1xtlSEQQ= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= -github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= -github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= -github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= -github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= -github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= -github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= -golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8= +go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU= +golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/adapter/ginopenapi/example/main.go b/adapter/ginopenapi/example/main.go index 22e53f1..773374d 100644 --- a/adapter/ginopenapi/example/main.go +++ b/adapter/ginopenapi/example/main.go @@ -4,8 +4,10 @@ import ( "log" "github.com/gin-gonic/gin" - "github.com/oaswrap/spec/adapter/ginopenapi" + "github.com/oaswrap/spec/option" + + "github.com/oaswrap/spec/adapter/ginopenapi" ) func main() { diff --git a/adapter/ginopenapi/go.mod b/adapter/ginopenapi/go.mod index b8cd53f..fbf592e 100644 --- a/adapter/ginopenapi/go.mod +++ b/adapter/ginopenapi/go.mod @@ -1,47 +1,47 @@ module github.com/oaswrap/spec/adapter/ginopenapi -go 1.24.0 +go 1.25.0 require ( - github.com/gin-gonic/gin v1.10.1 + github.com/gin-gonic/gin v1.12.0 github.com/oaswrap/spec v0.4.2 github.com/oaswrap/spec-ui v0.2.0 github.com/stretchr/testify v1.11.1 ) require ( - github.com/bytedance/sonic v1.11.6 // indirect - github.com/bytedance/sonic/loader v0.1.1 // indirect - github.com/cloudwego/base64x v0.1.4 // indirect - github.com/cloudwego/iasm v0.2.0 // indirect + github.com/bytedance/gopkg v0.1.4 // indirect + github.com/bytedance/sonic v1.15.1 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect + github.com/cloudwego/base64x v0.1.7 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.3 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gin-contrib/sse v1.1.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.20.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/go-playground/validator/v10 v10.30.2 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/swaggest/jsonschema-go v0.3.79 // indirect - github.com/swaggest/openapi-go v0.2.61 // indirect - github.com/swaggest/refl v1.4.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.2.12 // indirect - golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.50.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect - google.golang.org/protobuf v1.34.1 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect + golang.org/x/arch v0.27.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/adapter/ginopenapi/go.sum b/adapter/ginopenapi/go.sum index c366d9f..93040c6 100644 --- a/adapter/ginopenapi/go.sum +++ b/adapter/ginopenapi/go.sum @@ -1,53 +1,47 @@ -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= -github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= -github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= -github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= -github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= -github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= -github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= -github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= +github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= +github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= -github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= -github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= -github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= +github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= -github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= -github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= -github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -55,63 +49,50 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/oaswrap/spec-ui v0.2.0 h1:NVuAqjOUf3acSEmwy5y4q0ii15xD+KGuoJYvNmtLd4k= github.com/oaswrap/spec-ui v0.2.0/go.mod h1:D8EnD6zbYJ3q65wdltw6QHXfw+nut5XwSSA1xtlSEQQ= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= -github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= -github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= -github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= -github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= -github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= -github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= -golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8= +go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU= +golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/adapter/ginopenapi/route.go b/adapter/ginopenapi/route.go index 565d052..1cbe857 100644 --- a/adapter/ginopenapi/route.go +++ b/adapter/ginopenapi/route.go @@ -2,6 +2,7 @@ package ginopenapi import ( "github.com/gin-gonic/gin" + "github.com/oaswrap/spec" "github.com/oaswrap/spec/option" ) diff --git a/adapter/ginopenapi/router.go b/adapter/ginopenapi/router.go index 2283432..120d5d5 100644 --- a/adapter/ginopenapi/router.go +++ b/adapter/ginopenapi/router.go @@ -4,13 +4,15 @@ import ( "net/http" "github.com/gin-gonic/gin" + "github.com/oaswrap/spec" specui "github.com/oaswrap/spec-ui" - "github.com/oaswrap/spec/adapter/ginopenapi/internal/constant" "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" "github.com/oaswrap/spec/pkg/mapper" "github.com/oaswrap/spec/pkg/parser" + + "github.com/oaswrap/spec/adapter/ginopenapi/internal/constant" ) // NewGenerator returns a new OpenAPI generator for Gin. @@ -73,8 +75,8 @@ func (r *router) Handle(method string, path string, handlers ...gin.HandlerFunc) gr := r.ginRouter.Handle(method, path, handlers...) route := &route{ginRoute: gr} - if method == http.MethodConnect { - // CONNECT method is not supported by OpenAPI, so we skip it + if method == http.MethodConnect && r.gen.Config().OpenAPIVersion != openapi.Version320 { + // CONNECT requires OpenAPI 3.2, so older specs skip it return route } route.specRoute = r.specRouter.Add(method, path) diff --git a/adapter/ginopenapi/router_test.go b/adapter/ginopenapi/router_test.go index 27e4dd9..ddd5be0 100644 --- a/adapter/ginopenapi/router_test.go +++ b/adapter/ginopenapi/router_test.go @@ -1,7 +1,6 @@ package ginopenapi_test import ( - "flag" "net/http" "net/http/httptest" "os" @@ -9,18 +8,17 @@ import ( "testing" "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + stoplightemb "github.com/oaswrap/spec-ui/stoplightemb" - "github.com/oaswrap/spec/adapter/ginopenapi" + "github.com/oaswrap/spec/internal/testutil" + "github.com/oaswrap/spec/internal/testutil/dto" "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" - "github.com/oaswrap/spec/pkg/dto" - "github.com/oaswrap/spec/pkg/testutil" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) -//nolint:gochecknoglobals // test flag for golden file updates -var update = flag.Bool("update", false, "update golden files") + "github.com/oaswrap/spec/adapter/ginopenapi" +) func TestRouter_Spec(t *testing.T) { gin.SetMode(gin.TestMode) @@ -72,7 +70,7 @@ func TestRouter_Spec(t *testing.T) { ), option.WithSecurity("petstore_auth", option.SecurityOAuth2( openapi.OAuthFlows{ - Implicit: &openapi.OAuthFlowsImplicit{ + Implicit: &openapi.OAuthFlow{ AuthorizationURL: "https://petstore3.swagger.io/oauth/authorize", Scopes: map[string]string{ "write:pets": "modify pets in your account", @@ -241,14 +239,13 @@ func TestRouter_Spec(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - app := gin.Default() + app := gin.New() 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), option.WithReflectorConfig( - option.RequiredPropByValidateTag(), option.StripDefNamePrefix("GinopenapiTest"), ), } @@ -273,18 +270,7 @@ func TestRouter_Spec(t *testing.T) { schema, err := r.GenerateSchema() require.NoError(t, err, "failed to generate OpenAPI schema") - golden := filepath.Join("testdata", tt.golden+".yaml") - - if *update { - err = r.WriteSchemaTo(golden) - require.NoError(t, err, "failed to write golden file") - t.Logf("Updated golden file: %s", golden) - } - - want, err := os.ReadFile(golden) - require.NoError(t, err, "failed to read golden file %s", golden) - - testutil.EqualYAML(t, want, schema) + testutil.AssertGolden(t, schema, filepath.Join("testdata", tt.golden+".yaml")) }) } } @@ -559,7 +545,7 @@ func TestGenerator_Docs(t *testing.T) { "application/x-yaml", "expected Content-Type to be application/x-yaml", ) - assert.Contains(t, rec.Body.String(), "openapi: 3.0.3", "expected OpenAPI version in response body") + assert.Contains(t, rec.Body.String(), "openapi: 3.0.4", "expected OpenAPI version in response body") }) } diff --git a/adapter/ginopenapi/testdata/petstore.yaml b/adapter/ginopenapi/testdata/petstore.yaml index 925dabc..99b53d7 100644 --- a/adapter/ginopenapi/testdata/petstore.yaml +++ b/adapter/ginopenapi/testdata/petstore.yaml @@ -1,366 +1,365 @@ openapi: 3.0.3 info: + title: Test API Pet Store API + description: This is a sample Petstore server. + termsOfService: https://swagger.io/terms/ contact: email: apiteam@swagger.io - description: This is a sample Petstore server. license: name: Apache 2.0 url: https://www.apache.org/licenses/LICENSE-2.0.html - termsOfService: https://swagger.io/terms/ - title: Test API Pet Store API version: 1.0.0 +servers: + - url: https://petstore3.swagger.io/api/v3 externalDocs: description: Find more info here about swagger url: https://swagger.io -servers: -- url: https://petstore3.swagger.io/api/v3 tags: -- description: Everything about your Pets - externalDocs: - description: Find out more about our Pets - url: https://swagger.io - name: pet -- description: Access to Petstore orders - externalDocs: - description: Find out more about our Store - url: https://swagger.io - name: store -- description: Operations about user - name: user + - name: pet + description: Everything about your Pets + externalDocs: + description: Find out more about our Pets + url: https://swagger.io + - name: store + description: Access to Petstore orders + externalDocs: + description: Find out more about our Store + url: https://swagger.io + - name: user + description: Operations about user paths: - /pet/: - post: - description: Add a new pet to the store. - operationId: addPet + /pet: + put: + tags: + - pet + summary: Update an existing pet + description: Update the details of an existing pet in the store. + operationId: updatePet requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPet' + $ref: "#/components/schemas/DtoPet" responses: - "201": + "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: Created + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Add a new pet + - petstore_auth: + - write:pets + - read:pets + post: tags: - - pet - put: - description: Update the details of an existing pet in the store. - operationId: updatePet + - pet + summary: Add a new pet + description: Add a new pet to the store. + operationId: addPet requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPet' + $ref: "#/components/schemas/DtoPet" responses: - "200": + "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Update an existing pet - tags: - - pet - /pet/{petId}: - delete: - description: Delete a pet from the store by its ID. - operationId: deletePet - parameters: - - in: path - name: petId - required: true - schema: - type: integer - - in: header - name: api_key - schema: - type: string - responses: - "204": - description: No Content + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Delete a pet - tags: - - pet + - petstore_auth: + - write:pets + - read:pets + /pet/findByStatus: get: - description: Retrieve a pet by its ID. - operationId: getPetById + tags: + - pet + summary: Find pets by status + description: Finds Pets by status. Multiple status values can be provided with comma separated strings. + operationId: findPetsByStatus parameters: - - in: path - name: petId - required: true - schema: - type: integer + - name: status + in: query + schema: + type: string + enum: + - available + - pending + - sold responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: OK + type: array + items: + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Get pet by ID + - petstore_auth: + - write:pets + - read:pets + /pet/findByTags: + get: tags: - - pet - post: - description: Updates a pet in the store with form data. - operationId: updatePetWithForm + - pet + summary: Find pets by tags + description: Finds Pets by tags. Multiple tags can be provided with comma separated strings. + operationId: findPetsByTags parameters: - - in: path - name: petId - required: true - schema: - type: integer - requestBody: - content: - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/FormDataDtoUpdatePetWithFormRequest' + - name: tags + in: query + schema: + type: array + items: + type: string responses: "200": description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Update pet with form + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}: + get: tags: - - pet - /pet/{petId}/uploadImage: - post: - description: Uploads an image for a pet. - operationId: uploadFile + - pet + summary: Get pet by ID + description: Retrieve a pet by its ID. + operationId: getPetById parameters: - - in: query - name: additionalMetadata - schema: - type: string - - in: path - name: petId - required: true - schema: - format: int64 - type: integer - requestBody: - content: - application/octet-stream: - schema: - type: string + - name: petId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoAPIResponse' - description: OK + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Upload an image for a pet + - petstore_auth: + - write:pets + - read:pets + post: tags: - - pet - /pet/findByStatus: - get: - description: Finds Pets by status. Multiple status values can be provided with - comma separated strings. - operationId: findPetsByStatus + - pet + summary: Update pet with form + description: Updates a pet in the store with form data. + operationId: updatePetWithForm parameters: - - in: query - name: status - schema: - enum: - - available - - pending - - sold - type: string + - name: petId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPet' - type: array description: OK security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by status + - petstore_auth: + - write:pets + - read:pets + delete: tags: - - pet - /pet/findByTags: - get: - description: Finds Pets by tags. Multiple tags can be provided with comma separated - strings. - operationId: findPetsByTags + - pet + summary: Delete a pet + description: Delete a pet from the store by its ID. + operationId: deletePet parameters: - - in: query - name: tags - schema: - items: + - name: petId + in: path + required: true + schema: + type: integer + format: int32 + - name: api_key + in: header + schema: + type: string + responses: + "204": + description: No Content + security: + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}/uploadImage: + post: + tags: + - pet + summary: Upload an image for a pet + description: Uploads an image for a pet. + operationId: uploadFile + parameters: + - name: petId + in: path + required: true + schema: + type: integer + format: int64 + - name: additionalMetadata + in: query + schema: type: string - type: array responses: "200": + description: OK content: application/json: schema: - items: - $ref: '#/components/schemas/DtoPet' - type: array - description: OK + $ref: "#/components/schemas/DtoAPIResponse" security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by tags - tags: - - pet + - petstore_auth: + - write:pets + - read:pets /store/order: post: + tags: + - store + summary: Place an order description: Place a new order for a pet. operationId: placeOrder requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' + $ref: "#/components/schemas/DtoOrder" responses: "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' - description: Created - summary: Place an order - tags: - - store + $ref: "#/components/schemas/DtoOrder" /store/order/{orderId}: - delete: - description: Delete an order by its ID. - operationId: deleteOrder - parameters: - - in: path - name: orderId - required: true - schema: - type: integer - responses: - "204": - description: No Content - summary: Delete an order - tags: - - store get: + tags: + - store + summary: Get order by ID description: Retrieve an order by its ID. operationId: getOrderById parameters: - - in: path - name: orderId - required: true - schema: - type: integer + - name: orderId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' - description: OK + $ref: "#/components/schemas/DtoOrder" "404": description: Not Found - summary: Get order by ID + delete: tags: - - store - /user/: + - store + summary: Delete an order + description: Delete an order by its ID. + operationId: deleteOrder + parameters: + - name: orderId + in: path + required: true + schema: + type: integer + format: int32 + responses: + "204": + description: No Content + /user: post: + tags: + - user + summary: Create a new user description: Create a new user in the store. operationId: createUser requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' + $ref: "#/components/schemas/DtoPetUser" responses: "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' - description: Created - summary: Create a new user + $ref: "#/components/schemas/DtoPetUser" + /user/createWithList: + post: tags: - - user - /user/{username}: - delete: - description: Delete a user from the store by their username. - operationId: deleteUser - parameters: - - in: path - name: username - required: true - schema: - type: string + - user + summary: Create users with list + description: Create multiple users in the store with a list. + operationId: createUsersWithList + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DtoPetUser" responses: - "204": - description: No Content - summary: Delete a user - tags: - - user + "201": + description: Created + /user/{username}: get: + tags: + - user + summary: Get user by username description: Retrieve a user by their username. operationId: getUserByName parameters: - - in: path - name: username - required: true - schema: - type: string + - name: username + in: path + required: true + schema: + type: string responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' - description: OK + $ref: "#/components/schemas/DtoPetUser" "404": description: Not Found - summary: Get user by username - tags: - - user put: + tags: + - user + summary: Update an existing user description: Update the details of an existing user. operationId: updateUser parameters: - - in: path - name: username - required: true - schema: - type: string + - name: username + in: path + required: true + schema: + type: string requestBody: content: application/json: schema: + type: object properties: email: type: string @@ -368,6 +367,7 @@ paths: type: string id: type: integer + format: int32 lastName: type: string password: @@ -375,110 +375,107 @@ paths: phone: type: string userStatus: - enum: - - 0 - - 1 - - 2 type: integer + format: int32 + enum: + - 0 + - 1 + - 2 username: type: string - type: object responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' - description: OK + $ref: "#/components/schemas/DtoPetUser" "404": description: Not Found - summary: Update an existing user + delete: tags: - - user - /user/createWithList: - post: - description: Create multiple users in the store with a list. - operationId: createUsersWithList - requestBody: - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPetUser' - nullable: true - type: array + - user + summary: Delete a user + description: Delete a user from the store by their username. + operationId: deleteUser + parameters: + - name: username + in: path + required: true + schema: + type: string responses: - "201": - description: Created - summary: Create users with list - tags: - - user + "204": + description: No Content components: schemas: DtoAPIResponse: + type: object properties: code: type: integer + format: int32 message: type: string type: type: string - type: object - DtoCategory: - properties: - id: - type: integer - name: - type: string - type: object DtoOrder: + type: object properties: complete: type: boolean id: type: integer + format: int32 petId: type: integer + format: int32 quantity: type: integer + format: int32 shipDate: - format: date-time type: string + format: date-time status: - enum: - - placed - - approved - - delivered type: string - type: object + enum: + - placed + - approved + - delivered DtoPet: + type: object properties: category: - $ref: '#/components/schemas/DtoCategory' + type: object + properties: + id: + type: integer + format: int32 + name: + type: string id: type: integer + format: int32 name: type: string photoUrls: + type: array items: type: string - nullable: true - type: array status: - enum: - - available - - pending - - sold type: string + enum: + - available + - pending + - sold tags: - items: - $ref: '#/components/schemas/DtoTag' - nullable: true type: array + items: + $ref: "#/components/schemas/DtoTag" type: type: string - type: object DtoPetUser: + type: object properties: email: type: string @@ -486,6 +483,7 @@ components: type: string id: type: integer + format: int32 lastName: type: string password: @@ -493,44 +491,32 @@ components: phone: type: string userStatus: - enum: - - 0 - - 1 - - 2 type: integer + format: int32 + enum: + - 0 + - 1 + - 2 username: type: string - type: object DtoTag: + type: object properties: id: type: integer + format: int32 name: type: string - type: object - FormDataDtoUpdatePetWithFormRequest: - properties: - name: - type: string - status: - enum: - - available - - pending - - sold - type: string - required: - - name - type: object securitySchemes: apiKey: - in: header - name: api_key type: apiKey + name: api_key + in: header petstore_auth: + type: oauth2 flows: implicit: authorizationUrl: https://petstore3.swagger.io/oauth/authorize scopes: read:pets: read your pets write:pets: modify pets in your account - type: oauth2 diff --git a/adapter/ginopenapi/types.go b/adapter/ginopenapi/types.go index e5a928b..2c7c87a 100644 --- a/adapter/ginopenapi/types.go +++ b/adapter/ginopenapi/types.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/gin-gonic/gin" + "github.com/oaswrap/spec/option" ) diff --git a/adapter/httpopenapi/README.md b/adapter/httpopenapi/README.md index e289a28..399456d 100644 --- a/adapter/httpopenapi/README.md +++ b/adapter/httpopenapi/README.md @@ -12,7 +12,7 @@ A lightweight adapter for the [net/http](https://pkg.go.dev/net/http) package th - **๐ŸŽฏ Type Safety** โ€” Full Go type safety for OpenAPI configuration - **๐Ÿ”ง Multiple UI Options** โ€” Swagger UI, Stoplight Elements, ReDoc, Scalar or RapiDoc served automatically at `/docs` - **๐Ÿ“„ YAML Export** โ€” OpenAPI spec available at `/docs/openapi.yaml` -- **๐Ÿš€ Zero Overhead** โ€” Minimal performance impact on your API +- **๐Ÿš€ Low Overhead** โ€” Minimal runtime work beyond route registration and docs serving ## Installation @@ -137,7 +137,7 @@ When you create a httpopenapi router, the following endpoints are automatically If you want to disable the built-in UI, you can do so by passing `option.WithDisableDocs()` when creating the router: ```go -r := httpopenapi.NewRouter(c, +r := httpopenapi.NewGenerator(mainMux, option.WithTitle("My API"), option.WithVersion("1.0.0"), option.WithDisableDocs(), @@ -154,7 +154,7 @@ Choose from multiple UI options, powered by [`oaswrap/spec-ui`](https://github.c - **RapiDoc** โ€” Highly customizable ```go -r := httpopenapi.NewRouter(c, +r := httpopenapi.NewGenerator(mainMux, option.WithTitle("My API"), option.WithVersion("1.0.0"), option.WithScalar(), // Use Scalar as the documentation UI @@ -189,7 +189,7 @@ type CreateProductRequest struct { } ``` -For more struct tag options, see the [swaggest/openapi-go](https://github.com/swaggest/openapi-go?tab=readme-ov-file#features). +Supported tags are implemented by oaswrap/spec directly. Common request tags include `json`, `form`, `path`, `query`, `header`, and `cookie`; common schema tags include `description`, `format`, `default`, `example`, `enum`, `minimum`, `maximum`, `minLength`, `maxLength`, `minItems`, `maxItems`, `nullable`, `deprecated`, `readOnly`, and `writeOnly`. See the root [Reflection Tags](../../README.md#reflection-tags) section for the complete list. ## Example @@ -200,7 +200,7 @@ Check out the [examples directory](/adapter/httpopenapi/example) for more comple 1. **Organize with Tags** โ€” Group related operations using `option.Tags()` 2. **Document Everything** โ€” Use `option.Summary()` and `option.Description()` for all routes 3. **Define Error Responses** โ€” Include common error responses (400, 401, 404, 500) -4. **Use Validation Tags** โ€” Leverage struct tags for request validation documentation +4. **Document Schema Constraints** โ€” Use reflection tags to describe OpenAPI schema constraints; keep runtime validation in handlers or middleware 5. **Security First** โ€” Define and apply appropriate security schemes 6. **Version Your API** โ€” Use route groups for API versioning (`/api/v1`, `/api/v2`) @@ -217,4 +217,4 @@ We welcome contributions! Please open issues and PRs at the main [oaswrap/spec]( ## License -[MIT](../../LICENSE) \ No newline at end of file +[MIT](../../LICENSE) diff --git a/adapter/httpopenapi/example/go.mod b/adapter/httpopenapi/example/go.mod index 5d4a769..67f017d 100644 --- a/adapter/httpopenapi/example/go.mod +++ b/adapter/httpopenapi/example/go.mod @@ -8,11 +8,10 @@ require ( ) require ( + github.com/goccy/go-yaml v1.19.2 // indirect github.com/oaswrap/spec-ui v0.2.0 // indirect - github.com/swaggest/jsonschema-go v0.3.79 // indirect - github.com/swaggest/openapi-go v0.2.61 // indirect - github.com/swaggest/refl v1.4.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect ) +replace github.com/oaswrap/spec => ../../.. + replace github.com/oaswrap/spec/adapter/httpopenapi => .. diff --git a/adapter/httpopenapi/example/go.sum b/adapter/httpopenapi/example/go.sum index 985287e..b478a8f 100644 --- a/adapter/httpopenapi/example/go.sum +++ b/adapter/httpopenapi/example/go.sum @@ -1,43 +1,14 @@ -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/oaswrap/spec v0.4.2 h1:jOfQrWUsJhP6lz3Zj2XEHWtF3DoOH/lTk8vil5usSwA= -github.com/oaswrap/spec v0.4.2/go.mod h1:ebAiVZ8SUr5B70s/gKk9J/Ccbk/vQzFWAe4f4vk+1zQ= github.com/oaswrap/spec-ui v0.2.0 h1:NVuAqjOUf3acSEmwy5y4q0ii15xD+KGuoJYvNmtLd4k= github.com/oaswrap/spec-ui v0.2.0/go.mod h1:D8EnD6zbYJ3q65wdltw6QHXfw+nut5XwSSA1xtlSEQQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= -github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= -github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= -github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= -github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= -github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/adapter/httpopenapi/example/main.go b/adapter/httpopenapi/example/main.go index 2172c14..df3a161 100644 --- a/adapter/httpopenapi/example/main.go +++ b/adapter/httpopenapi/example/main.go @@ -6,8 +6,9 @@ import ( "net/http" "time" - "github.com/oaswrap/spec/adapter/httpopenapi" "github.com/oaswrap/spec/option" + + "github.com/oaswrap/spec/adapter/httpopenapi" ) func main() { diff --git a/adapter/httpopenapi/go.mod b/adapter/httpopenapi/go.mod index cf030ca..9c92842 100644 --- a/adapter/httpopenapi/go.mod +++ b/adapter/httpopenapi/go.mod @@ -10,12 +10,10 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/swaggest/jsonschema-go v0.3.79 // indirect - github.com/swaggest/openapi-go v0.2.61 // indirect - github.com/swaggest/refl v1.4.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/adapter/httpopenapi/go.sum b/adapter/httpopenapi/go.sum index 0fbcdcb..7e5e68d 100644 --- a/adapter/httpopenapi/go.sum +++ b/adapter/httpopenapi/go.sum @@ -1,41 +1,24 @@ -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/oaswrap/spec-ui v0.2.0 h1:NVuAqjOUf3acSEmwy5y4q0ii15xD+KGuoJYvNmtLd4k= github.com/oaswrap/spec-ui v0.2.0/go.mod h1:D8EnD6zbYJ3q65wdltw6QHXfw+nut5XwSSA1xtlSEQQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= -github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= -github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= -github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= -github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= -github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/adapter/httpopenapi/internal/parser/parser_test.go b/adapter/httpopenapi/internal/parser/parser_test.go index 1f9ed5d..189d786 100644 --- a/adapter/httpopenapi/internal/parser/parser_test.go +++ b/adapter/httpopenapi/internal/parser/parser_test.go @@ -3,9 +3,10 @@ package parser_test import ( "testing" - "github.com/oaswrap/spec/adapter/httpopenapi/internal/parser" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/oaswrap/spec/adapter/httpopenapi/internal/parser" ) func TestParseRoutePattern(t *testing.T) { diff --git a/adapter/httpopenapi/router.go b/adapter/httpopenapi/router.go index 5adae2f..ceb65e2 100644 --- a/adapter/httpopenapi/router.go +++ b/adapter/httpopenapi/router.go @@ -7,10 +7,12 @@ import ( "github.com/oaswrap/spec" specui "github.com/oaswrap/spec-ui" - "github.com/oaswrap/spec/adapter/httpopenapi/internal/constant" - "github.com/oaswrap/spec/adapter/httpopenapi/internal/parser" + "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" "github.com/oaswrap/spec/pkg/mapper" + + "github.com/oaswrap/spec/adapter/httpopenapi/internal/constant" + "github.com/oaswrap/spec/adapter/httpopenapi/internal/parser" ) type router struct { @@ -76,7 +78,8 @@ func (r *router) HandleFunc(pattern string, handler func(http.ResponseWriter, *h route := &route{} routePattern, err := parser.ParseRoutePattern(pattern) - if err != nil || routePattern.Method == "" || routePattern.Method == http.MethodConnect { + if err != nil || routePattern.Method == "" || + (routePattern.Method == http.MethodConnect && r.gen.Config().OpenAPIVersion != openapi.Version320) { return route } route.specRoute = r.specRouter.Add(routePattern.Method, routePattern.Path) @@ -91,7 +94,8 @@ func (r *router) Handle(pattern string, handler http.Handler) Route { route := &route{} routePattern, err := parser.ParseRoutePattern(pattern) - if err != nil || routePattern.Method == "" || routePattern.Method == http.MethodConnect { + if err != nil || routePattern.Method == "" || + (routePattern.Method == http.MethodConnect && r.gen.Config().OpenAPIVersion != openapi.Version320) { return route } route.specRoute = r.specRouter.Add(routePattern.Method, routePattern.Path) diff --git a/adapter/httpopenapi/router_test.go b/adapter/httpopenapi/router_test.go index 35ae565..b7b5041 100644 --- a/adapter/httpopenapi/router_test.go +++ b/adapter/httpopenapi/router_test.go @@ -2,25 +2,23 @@ package httpopenapi_test import ( "encoding/json" - "flag" "net/http" "net/http/httptest" "os" "path/filepath" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + stoplightemb "github.com/oaswrap/spec-ui/stoplightemb" - "github.com/oaswrap/spec/adapter/httpopenapi" + "github.com/oaswrap/spec/internal/testutil" + "github.com/oaswrap/spec/internal/testutil/dto" "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" - "github.com/oaswrap/spec/pkg/dto" - "github.com/oaswrap/spec/pkg/testutil" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) -//nolint:gochecknoglobals // test flag for golden file updates -var update = flag.Bool("update", false, "update golden files") + "github.com/oaswrap/spec/adapter/httpopenapi" +) func TestRouter_Spec(t *testing.T) { tests := []struct { @@ -70,7 +68,7 @@ func TestRouter_Spec(t *testing.T) { ), option.WithSecurity("petstore_auth", option.SecurityOAuth2( openapi.OAuthFlows{ - Implicit: &openapi.OAuthFlowsImplicit{ + Implicit: &openapi.OAuthFlow{ AuthorizationURL: "https://petstore3.swagger.io/oauth/authorize", Scopes: map[string]string{ "write:pets": "modify pets in your account", @@ -205,6 +203,23 @@ func TestRouter_Spec(t *testing.T) { option.Response(200, new(dto.PetUser)), option.Response(404, nil), ) + user.HandleFunc("GET /login", nil).With( + option.OperationID("loginUser"), + option.Summary("Logs user into the system"), + option.Description("Logs user into the system."), + option.Request(new(struct { + Username string `query:"username"` + Password string `query:"password"` + })), + option.Response(200, new(string)), + option.Response(400, nil), + ) + user.HandleFunc("GET /logout", nil).With( + option.OperationID("logoutUser"), + option.Summary("Logs out current logged in user session"), + option.Description("Logs out current logged in user session."), + option.Response(200, nil), + ) user.HandleFunc("PUT /{username}", nil).With( option.OperationID("updateUser"), option.Summary("Update an existing user"), @@ -239,7 +254,6 @@ func TestRouter_Spec(t *testing.T) { option.WithVersion("1.0.0"), option.WithDescription("This is a test API for " + tt.name), option.WithReflectorConfig( - option.RequiredPropByValidateTag(), option.StripDefNamePrefix("GinopenapiTest"), ), } @@ -264,18 +278,7 @@ func TestRouter_Spec(t *testing.T) { schema, err := r.GenerateSchema() require.NoError(t, err, "failed to generate OpenAPI schema") - golden := filepath.Join("testdata", tt.golden+".yaml") - - if *update { - err = r.WriteSchemaTo(golden) - require.NoError(t, err, "failed to write golden file") - t.Logf("Updated golden file: %s", golden) - } - - want, err := os.ReadFile(golden) - require.NoError(t, err, "failed to read golden file %s", golden) - - testutil.EqualYAML(t, want, schema) + testutil.AssertGolden(t, schema, filepath.Join("testdata", tt.golden+".yaml")) }) } } @@ -491,7 +494,7 @@ func TestGenerator_Docs(t *testing.T) { assert.Equal(t, http.StatusOK, docsFileRec.Code) assert.NotEmpty(t, docsFileRec.Body.String()) assert.Contains(t, docsFileRec.Header().Get("Content-Type"), "application/x-yaml") - assert.Contains(t, docsFileRec.Body.String(), "openapi: 3.0.3") + assert.Contains(t, docsFileRec.Body.String(), "openapi: 3.0.4") }) } @@ -588,3 +591,17 @@ func TestGenerator_WriteSchemaTo(t *testing.T) { assert.NotEmpty(t, schemaData, "expected non-empty schema data") assert.Contains(t, string(schemaData), "operationId: pingHandler", "expected operationId in written schema") } + +func TestRouter_ServeHTTP(t *testing.T) { + mux := http.NewServeMux() + r := httpopenapi.NewRouter(mux) + + r.HandleFunc("GET /ping", pingHandler).With(option.OperationID("pingHandler")) + + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "pong") +} diff --git a/adapter/httpopenapi/testdata/petstore.yaml b/adapter/httpopenapi/testdata/petstore.yaml index 925dabc..a6b7e16 100644 --- a/adapter/httpopenapi/testdata/petstore.yaml +++ b/adapter/httpopenapi/testdata/petstore.yaml @@ -1,366 +1,400 @@ openapi: 3.0.3 info: + title: Test API Pet Store API + description: This is a sample Petstore server. + termsOfService: https://swagger.io/terms/ contact: email: apiteam@swagger.io - description: This is a sample Petstore server. license: name: Apache 2.0 url: https://www.apache.org/licenses/LICENSE-2.0.html - termsOfService: https://swagger.io/terms/ - title: Test API Pet Store API version: 1.0.0 +servers: + - url: https://petstore3.swagger.io/api/v3 externalDocs: description: Find more info here about swagger url: https://swagger.io -servers: -- url: https://petstore3.swagger.io/api/v3 tags: -- description: Everything about your Pets - externalDocs: - description: Find out more about our Pets - url: https://swagger.io - name: pet -- description: Access to Petstore orders - externalDocs: - description: Find out more about our Store - url: https://swagger.io - name: store -- description: Operations about user - name: user + - name: pet + description: Everything about your Pets + externalDocs: + description: Find out more about our Pets + url: https://swagger.io + - name: store + description: Access to Petstore orders + externalDocs: + description: Find out more about our Store + url: https://swagger.io + - name: user + description: Operations about user paths: - /pet/: - post: - description: Add a new pet to the store. - operationId: addPet + /pet: + put: + tags: + - pet + summary: Update an existing pet + description: Update the details of an existing pet in the store. + operationId: updatePet requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPet' + $ref: "#/components/schemas/DtoPet" responses: - "201": + "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: Created + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Add a new pet + - petstore_auth: + - write:pets + - read:pets + post: tags: - - pet - put: - description: Update the details of an existing pet in the store. - operationId: updatePet + - pet + summary: Add a new pet + description: Add a new pet to the store. + operationId: addPet requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPet' + $ref: "#/components/schemas/DtoPet" responses: - "200": + "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: OK + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Update an existing pet - tags: - - pet - /pet/{petId}: - delete: - description: Delete a pet from the store by its ID. - operationId: deletePet - parameters: - - in: path - name: petId - required: true - schema: - type: integer - - in: header - name: api_key - schema: - type: string - responses: - "204": - description: No Content - security: - - petstore_auth: - - write:pets - - read:pets - summary: Delete a pet - tags: - - pet + - petstore_auth: + - write:pets + - read:pets + /pet/findByStatus: get: - description: Retrieve a pet by its ID. - operationId: getPetById + tags: + - pet + summary: Find pets by status + description: Finds Pets by status. Multiple status values can be provided with comma separated strings. + operationId: findPetsByStatus parameters: - - in: path - name: petId - required: true - schema: - type: integer + - name: status + in: query + schema: + type: string + enum: + - available + - pending + - sold responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: OK + type: array + items: + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Get pet by ID + - petstore_auth: + - write:pets + - read:pets + /pet/findByTags: + get: tags: - - pet - post: - description: Updates a pet in the store with form data. - operationId: updatePetWithForm + - pet + summary: Find pets by tags + description: Finds Pets by tags. Multiple tags can be provided with comma separated strings. + operationId: findPetsByTags parameters: - - in: path - name: petId - required: true - schema: - type: integer - requestBody: - content: - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/FormDataDtoUpdatePetWithFormRequest' + - name: tags + in: query + schema: + type: array + items: + type: string responses: "200": description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Update pet with form + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}: + get: tags: - - pet - /pet/{petId}/uploadImage: - post: - description: Uploads an image for a pet. - operationId: uploadFile + - pet + summary: Get pet by ID + description: Retrieve a pet by its ID. + operationId: getPetById parameters: - - in: query - name: additionalMetadata - schema: - type: string - - in: path - name: petId - required: true - schema: - format: int64 - type: integer - requestBody: - content: - application/octet-stream: - schema: - type: string + - name: petId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoAPIResponse' - description: OK + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Upload an image for a pet + - petstore_auth: + - write:pets + - read:pets + post: tags: - - pet - /pet/findByStatus: - get: - description: Finds Pets by status. Multiple status values can be provided with - comma separated strings. - operationId: findPetsByStatus + - pet + summary: Update pet with form + description: Updates a pet in the store with form data. + operationId: updatePetWithForm parameters: - - in: query - name: status - schema: - enum: - - available - - pending - - sold - type: string + - name: petId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPet' - type: array description: OK security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by status + - petstore_auth: + - write:pets + - read:pets + delete: tags: - - pet - /pet/findByTags: - get: - description: Finds Pets by tags. Multiple tags can be provided with comma separated - strings. - operationId: findPetsByTags + - pet + summary: Delete a pet + description: Delete a pet from the store by its ID. + operationId: deletePet parameters: - - in: query - name: tags - schema: - items: + - name: petId + in: path + required: true + schema: + type: integer + format: int32 + - name: api_key + in: header + schema: + type: string + responses: + "204": + description: No Content + security: + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}/uploadImage: + post: + tags: + - pet + summary: Upload an image for a pet + description: Uploads an image for a pet. + operationId: uploadFile + parameters: + - name: petId + in: path + required: true + schema: + type: integer + format: int64 + - name: additionalMetadata + in: query + schema: type: string - type: array responses: "200": + description: OK content: application/json: schema: - items: - $ref: '#/components/schemas/DtoPet' - type: array - description: OK + $ref: "#/components/schemas/DtoAPIResponse" security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by tags - tags: - - pet + - petstore_auth: + - write:pets + - read:pets /store/order: post: + tags: + - store + summary: Place an order description: Place a new order for a pet. operationId: placeOrder requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' + $ref: "#/components/schemas/DtoOrder" responses: "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' - description: Created - summary: Place an order - tags: - - store + $ref: "#/components/schemas/DtoOrder" /store/order/{orderId}: - delete: - description: Delete an order by its ID. - operationId: deleteOrder - parameters: - - in: path - name: orderId - required: true - schema: - type: integer - responses: - "204": - description: No Content - summary: Delete an order - tags: - - store get: + tags: + - store + summary: Get order by ID description: Retrieve an order by its ID. operationId: getOrderById parameters: - - in: path - name: orderId - required: true - schema: - type: integer + - name: orderId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' - description: OK + $ref: "#/components/schemas/DtoOrder" "404": description: Not Found - summary: Get order by ID + delete: tags: - - store - /user/: + - store + summary: Delete an order + description: Delete an order by its ID. + operationId: deleteOrder + parameters: + - name: orderId + in: path + required: true + schema: + type: integer + format: int32 + responses: + "204": + description: No Content + /user: post: + tags: + - user + summary: Create a new user description: Create a new user in the store. operationId: createUser requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' + $ref: "#/components/schemas/DtoPetUser" responses: "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' + $ref: "#/components/schemas/DtoPetUser" + /user/createWithList: + post: + tags: + - user + summary: Create users with list + description: Create multiple users in the store with a list. + operationId: createUsersWithList + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DtoPetUser" + responses: + "201": description: Created - summary: Create a new user + /user/login: + get: tags: - - user - /user/{username}: - delete: - description: Delete a user from the store by their username. - operationId: deleteUser + - user + summary: Logs user into the system + description: Logs user into the system. + operationId: loginUser parameters: - - in: path - name: username - required: true - schema: - type: string + - name: username + in: query + schema: + type: string + - name: password + in: query + schema: + type: string responses: - "204": - description: No Content - summary: Delete a user + "200": + description: OK + content: + application/json: + schema: + type: string + "400": + description: Bad Request + /user/logout: + get: tags: - - user + - user + summary: Logs out current logged in user session + description: Logs out current logged in user session. + operationId: logoutUser + responses: + "200": + description: OK + /user/{username}: get: + tags: + - user + summary: Get user by username description: Retrieve a user by their username. operationId: getUserByName parameters: - - in: path - name: username - required: true - schema: - type: string + - name: username + in: path + required: true + schema: + type: string responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' - description: OK + $ref: "#/components/schemas/DtoPetUser" "404": description: Not Found - summary: Get user by username - tags: - - user put: + tags: + - user + summary: Update an existing user description: Update the details of an existing user. operationId: updateUser parameters: - - in: path - name: username - required: true - schema: - type: string + - name: username + in: path + required: true + schema: + type: string requestBody: content: application/json: schema: + type: object properties: email: type: string @@ -368,6 +402,7 @@ paths: type: string id: type: integer + format: int32 lastName: type: string password: @@ -375,110 +410,107 @@ paths: phone: type: string userStatus: - enum: - - 0 - - 1 - - 2 type: integer + format: int32 + enum: + - 0 + - 1 + - 2 username: type: string - type: object responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' - description: OK + $ref: "#/components/schemas/DtoPetUser" "404": description: Not Found - summary: Update an existing user + delete: tags: - - user - /user/createWithList: - post: - description: Create multiple users in the store with a list. - operationId: createUsersWithList - requestBody: - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPetUser' - nullable: true - type: array + - user + summary: Delete a user + description: Delete a user from the store by their username. + operationId: deleteUser + parameters: + - name: username + in: path + required: true + schema: + type: string responses: - "201": - description: Created - summary: Create users with list - tags: - - user + "204": + description: No Content components: schemas: DtoAPIResponse: + type: object properties: code: type: integer + format: int32 message: type: string type: type: string - type: object - DtoCategory: - properties: - id: - type: integer - name: - type: string - type: object DtoOrder: + type: object properties: complete: type: boolean id: type: integer + format: int32 petId: type: integer + format: int32 quantity: type: integer + format: int32 shipDate: - format: date-time type: string + format: date-time status: - enum: - - placed - - approved - - delivered type: string - type: object + enum: + - placed + - approved + - delivered DtoPet: + type: object properties: category: - $ref: '#/components/schemas/DtoCategory' + type: object + properties: + id: + type: integer + format: int32 + name: + type: string id: type: integer + format: int32 name: type: string photoUrls: + type: array items: type: string - nullable: true - type: array status: - enum: - - available - - pending - - sold type: string + enum: + - available + - pending + - sold tags: - items: - $ref: '#/components/schemas/DtoTag' - nullable: true type: array + items: + $ref: "#/components/schemas/DtoTag" type: type: string - type: object DtoPetUser: + type: object properties: email: type: string @@ -486,6 +518,7 @@ components: type: string id: type: integer + format: int32 lastName: type: string password: @@ -493,44 +526,32 @@ components: phone: type: string userStatus: - enum: - - 0 - - 1 - - 2 type: integer + format: int32 + enum: + - 0 + - 1 + - 2 username: type: string - type: object DtoTag: + type: object properties: id: type: integer + format: int32 name: type: string - type: object - FormDataDtoUpdatePetWithFormRequest: - properties: - name: - type: string - status: - enum: - - available - - pending - - sold - type: string - required: - - name - type: object securitySchemes: apiKey: - in: header - name: api_key type: apiKey + name: api_key + in: header petstore_auth: + type: oauth2 flows: implicit: authorizationUrl: https://petstore3.swagger.io/oauth/authorize scopes: read:pets: read your pets write:pets: modify pets in your account - type: oauth2 diff --git a/adapter/httprouteropenapi/README.md b/adapter/httprouteropenapi/README.md deleted file mode 100644 index 4ac5919..0000000 --- a/adapter/httprouteropenapi/README.md +++ /dev/null @@ -1,213 +0,0 @@ -# httprouteropenapi - -[![Go Reference](https://pkg.go.dev/badge/github.com/oaswrap/spec/adapter/httprouteropenapi.svg)](https://pkg.go.dev/github.com/oaswrap/spec/adapter/httprouteropenapi) -[![Go Report Card](https://goreportcard.com/badge/github.com/oaswrap/spec/adapter/httprouteropenapi)](https://goreportcard.com/report/github.com/oaswrap/spec/adapter/httprouteropenapi) - -A lightweight adapter for the [httprouter](https://github.com/julienschmidt/httprouter) package that automatically generates OpenAPI 3.x specifications from your routes using [`oaswrap/spec`](https://github.com/oaswrap/spec). - -## Features - -- **โšก Seamless Integration** โ€” Works with your existing httprouter routes and handlers -- **๐Ÿ“ Automatic Documentation** โ€” Generate OpenAPI specs from route definitions and struct tags -- **๐ŸŽฏ Type Safety** โ€” Full Go type safety for OpenAPI configuration -- **๐Ÿ”ง Multiple UI Options** โ€” Swagger UI, Stoplight Elements, ReDoc, Scalar or RapiDoc served automatically at `/docs` -- **๐Ÿ“„ YAML Export** โ€” OpenAPI spec available at `/docs/openapi.yaml` -- **๐Ÿš€ Zero Overhead** โ€” Minimal performance impact on your API - -## Installation - -```bash -go get github.com/oaswrap/spec/adapter/httprouteropenapi -``` - -## Quick Start - -```go -package main - -import ( - "encoding/json" - "log" - "net/http" - "time" - - "github.com/julienschmidt/httprouter" - "github.com/oaswrap/spec/adapter/httprouteropenapi" - "github.com/oaswrap/spec/option" -) - -func main() { - httpRouter := httprouter.New() - r := httprouteropenapi.NewRouter(httpRouter, - option.WithTitle("My API"), - option.WithVersion("1.0.0"), - option.WithSecurity("bearerAuth", option.SecurityHTTPBearer("Bearer")), - ) - v1 := r.Group("/api/v1") - v1.POST("/login", LoginHandler).With( - option.Summary("User login"), - option.Request(new(LoginRequest)), - option.Response(200, new(LoginResponse)), - ) - auth := v1.Group("/", AuthMiddleware).With( - option.GroupSecurity("bearerAuth"), - ) - auth.GET("/users/:id", GetUserHandler).With( - option.Summary("Get user by ID"), - option.Request(new(GetUserRequest)), - option.Response(200, new(User)), - ) - - log.Printf("๐Ÿš€ OpenAPI docs available at: %s", "http://localhost:3000/docs") - - server := &http.Server{ - Addr: ":3000", - Handler: httpRouter, - ReadHeaderTimeout: 5 * time.Second, - } - if err := server.ListenAndServe(); err != nil { - log.Fatal(err) - } -} - -type LoginRequest struct { - Username string `json:"username" required:"true"` - Password string `json:"password" required:"true"` -} - -type LoginResponse struct { - Token string `json:"token"` -} - -type GetUserRequest struct { - ID string `path:"id" required:"true"` -} - -type User struct { - ID string `json:"id"` - Name string `json:"name"` -} - -func AuthMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Simulate authentication logic - authHeader := r.Header.Get("Authorization") - if authHeader != "" && authHeader == "Bearer example-token" { - next.ServeHTTP(w, r) - } else { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - } - }) -} - -func LoginHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - var req LoginRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - // Simulate login logic - _ = json.NewEncoder(w).Encode(LoginResponse{Token: "example-token"}) -} - -func GetUserHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - var req GetUserRequest - req.ID = ps.ByName("id") - - // Simulate getting user logic - _ = json.NewEncoder(w).Encode(User{ID: req.ID, Name: "John Doe"}) -} -``` - -## Documentation Features - -### Built-in Endpoints -When you create a httpopenapi router, the following endpoints are automatically available: - -- **`/docs`** โ€” Interactive UI documentation -- **`/docs/openapi.yaml`** โ€” Raw OpenAPI specification in YAML format - -If you want to disable the built-in UI, you can do so by passing `option.WithDisableDocs()` when creating the router: - -```go -r := httprouteropenapi.NewRouter(c, - option.WithTitle("My API"), - option.WithVersion("1.0.0"), - option.WithDisableDocs(), -) -``` - -### Supported Documentation UIs -Choose from multiple UI options, powered by [`oaswrap/spec-ui`](https://github.com/oaswrap/spec-ui): - -- **Stoplight Elements** โ€” Modern, clean design (default) -- **Swagger UI** โ€” Classic interface with try-it functionality -- **ReDoc** โ€” Three-panel responsive layout -- **Scalar** โ€” Beautiful and fast interface -- **RapiDoc** โ€” Highly customizable - -```go -r := httprouteropenapi.NewRouter(c, - option.WithTitle("My API"), - option.WithVersion("1.0.0"), - option.WithScalar(), // Use Scalar as the documentation UI -) -``` - -### Embed Mode (Local Assets) -By default, UI providers use CDN assets to keep binaries small. - -To serve embedded assets (offline mode), use `option.WithUIOption(...)` with the provider emb package directly: - -```go -import ( - stoplightemb "github.com/oaswrap/spec-ui/stoplightemb" -) - -// example: -option.WithUIOption(stoplightemb.WithUI()) -``` - -### Rich Schema Documentation -Use struct tags to generate detailed OpenAPI schemas. **Note: These tags are used only for OpenAPI spec generation and documentation - they do not perform actual request validation.** - -```go -type CreateProductRequest struct { - Name string `json:"name" required:"true" minLength:"1" maxLength:"100"` - Description string `json:"description" maxLength:"500"` - Price float64 `json:"price" required:"true" minimum:"0" maximum:"999999.99"` - Category string `json:"category" required:"true" enum:"electronics,books,clothing"` - Tags []string `json:"tags" maxItems:"10"` - InStock bool `json:"in_stock" default:"true"` -} -``` - -For more struct tag options, see the [swaggest/openapi-go](https://github.com/swaggest/openapi-go?tab=readme-ov-file#features). - -## Example - -Check out the [examples directory](/adapter/httprouteropenapi/example) for more complete implementations and use cases. - -## Best Practices - -1. **Organize with Tags** โ€” Group related operations using `option.Tags()` -2. **Document Everything** โ€” Use `option.Summary()` and `option.Description()` for all routes -3. **Define Error Responses** โ€” Include common error responses (400, 401, 404, 500) -4. **Use Validation Tags** โ€” Leverage struct tags for request validation documentation -5. **Security First** โ€” Define and apply appropriate security schemes -6. **Version Your API** โ€” Use route groups for API versioning (`/api/v1`, `/api/v2`) - -## API Reference - -- **Spec**: [pkg.go.dev/github.com/oaswrap/spec](https://pkg.go.dev/github.com/oaswrap/spec) -- **HTTP Router Adapter**: [pkg.go.dev/github.com/oaswrap/spec/adapter/httprouteropenapi](https://pkg.go.dev/github.com/oaswrap/spec/adapter/httprouteropenapi) -- **Options**: [pkg.go.dev/github.com/oaswrap/spec/option](https://pkg.go.dev/github.com/oaswrap/spec/option) -- **Spec UI**: [pkg.go.dev/github.com/oaswrap/spec-ui](https://pkg.go.dev/github.com/oaswrap/spec-ui) - -## Contributing - -We welcome contributions! Please open issues and PRs at the main [oaswrap/spec](https://github.com/oaswrap/spec) repository. - -## License - -[MIT](../../LICENSE) \ No newline at end of file diff --git a/adapter/httprouteropenapi/example/go.mod b/adapter/httprouteropenapi/example/go.mod deleted file mode 100644 index 375b4a0..0000000 --- a/adapter/httprouteropenapi/example/go.mod +++ /dev/null @@ -1,19 +0,0 @@ -module github.com/oaswrap/spec/adapter/httprouteropenapi/example - -go 1.21 - -require ( - github.com/julienschmidt/httprouter v1.3.0 - github.com/oaswrap/spec v0.4.2 - github.com/oaswrap/spec/adapter/httprouteropenapi v0.0.0 -) - -require ( - github.com/oaswrap/spec-ui v0.2.0 // indirect - github.com/swaggest/jsonschema-go v0.3.79 // indirect - github.com/swaggest/openapi-go v0.2.61 // indirect - github.com/swaggest/refl v1.4.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect -) - -replace github.com/oaswrap/spec/adapter/httprouteropenapi => .. diff --git a/adapter/httprouteropenapi/example/go.sum b/adapter/httprouteropenapi/example/go.sum deleted file mode 100644 index 19720cc..0000000 --- a/adapter/httprouteropenapi/example/go.sum +++ /dev/null @@ -1,45 +0,0 @@ -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= -github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/oaswrap/spec v0.4.2 h1:jOfQrWUsJhP6lz3Zj2XEHWtF3DoOH/lTk8vil5usSwA= -github.com/oaswrap/spec v0.4.2/go.mod h1:ebAiVZ8SUr5B70s/gKk9J/Ccbk/vQzFWAe4f4vk+1zQ= -github.com/oaswrap/spec-ui v0.2.0 h1:NVuAqjOUf3acSEmwy5y4q0ii15xD+KGuoJYvNmtLd4k= -github.com/oaswrap/spec-ui v0.2.0/go.mod h1:D8EnD6zbYJ3q65wdltw6QHXfw+nut5XwSSA1xtlSEQQ= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= -github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= -github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= -github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= -github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= -github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/adapter/httprouteropenapi/example/main.go b/adapter/httprouteropenapi/example/main.go deleted file mode 100644 index 58a26f5..0000000 --- a/adapter/httprouteropenapi/example/main.go +++ /dev/null @@ -1,94 +0,0 @@ -package main - -import ( - "encoding/json" - "log" - "net/http" - "time" - - "github.com/julienschmidt/httprouter" - "github.com/oaswrap/spec/adapter/httprouteropenapi" - "github.com/oaswrap/spec/option" -) - -func main() { - httpRouter := httprouter.New() - r := httprouteropenapi.NewRouter(httpRouter, - option.WithTitle("My API"), - option.WithVersion("1.0.0"), - option.WithSecurity("bearerAuth", option.SecurityHTTPBearer("Bearer")), - ) - v1 := r.Group("/api/v1") - v1.POST("/login", LoginHandler).With( - option.Summary("User login"), - option.Request(new(LoginRequest)), - option.Response(200, new(LoginResponse)), - ) - auth := v1.Group("/", AuthMiddleware).With( - option.GroupSecurity("bearerAuth"), - ) - auth.GET("/users/:id", GetUserHandler).With( - option.Summary("Get user by ID"), - option.Request(new(GetUserRequest)), - option.Response(200, new(User)), - ) - - log.Printf("๐Ÿš€ OpenAPI docs available at: %s", "http://localhost:3000/docs") - - server := &http.Server{ - Addr: ":3000", - Handler: httpRouter, - ReadHeaderTimeout: 5 * time.Second, - } - if err := server.ListenAndServe(); err != nil { - log.Fatal(err) - } -} - -type LoginRequest struct { - Username string `json:"username" required:"true"` - Password string `json:"password" required:"true"` -} - -type LoginResponse struct { - Token string `json:"token"` -} - -type GetUserRequest struct { - ID string `path:"id" required:"true"` -} - -type User struct { - ID string `json:"id"` - Name string `json:"name"` -} - -func AuthMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Simulate authentication logic - authHeader := r.Header.Get("Authorization") - if authHeader != "" && authHeader == "Bearer example-token" { - next.ServeHTTP(w, r) - } else { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - } - }) -} - -func LoginHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - var req LoginRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - // Simulate login logic - _ = json.NewEncoder(w).Encode(LoginResponse{Token: "example-token"}) -} - -func GetUserHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { - var req GetUserRequest - req.ID = ps.ByName("id") - - // Simulate getting user logic - _ = json.NewEncoder(w).Encode(User{ID: req.ID, Name: "John Doe"}) -} diff --git a/adapter/httprouteropenapi/go.mod b/adapter/httprouteropenapi/go.mod deleted file mode 100644 index d320700..0000000 --- a/adapter/httprouteropenapi/go.mod +++ /dev/null @@ -1,23 +0,0 @@ -module github.com/oaswrap/spec/adapter/httprouteropenapi - -go 1.21 - -require ( - github.com/julienschmidt/httprouter v1.3.0 - github.com/oaswrap/spec v0.4.2 - github.com/oaswrap/spec-ui v0.2.0 - github.com/stretchr/testify v1.11.1 -) - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/swaggest/jsonschema-go v0.3.79 // indirect - github.com/swaggest/openapi-go v0.2.61 // indirect - github.com/swaggest/refl v1.4.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) - -replace github.com/oaswrap/spec => ../.. diff --git a/adapter/httprouteropenapi/go.sum b/adapter/httprouteropenapi/go.sum deleted file mode 100644 index d32cbc3..0000000 --- a/adapter/httprouteropenapi/go.sum +++ /dev/null @@ -1,43 +0,0 @@ -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= -github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/oaswrap/spec-ui v0.2.0 h1:NVuAqjOUf3acSEmwy5y4q0ii15xD+KGuoJYvNmtLd4k= -github.com/oaswrap/spec-ui v0.2.0/go.mod h1:D8EnD6zbYJ3q65wdltw6QHXfw+nut5XwSSA1xtlSEQQ= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= -github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= -github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= -github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= -github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= -github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/adapter/httprouteropenapi/internal/constant/constant.go b/adapter/httprouteropenapi/internal/constant/constant.go deleted file mode 100644 index 4923580..0000000 --- a/adapter/httprouteropenapi/internal/constant/constant.go +++ /dev/null @@ -1,7 +0,0 @@ -package constant - -const ( - DefaultTitle = "HTTP Router OpenAPI" - DefaultDescription = "OpenAPI documentation for HTTP Router applications" - DefaultVersion = "1.0.0" -) diff --git a/adapter/httprouteropenapi/route.go b/adapter/httprouteropenapi/route.go deleted file mode 100644 index 4f6a5e7..0000000 --- a/adapter/httprouteropenapi/route.go +++ /dev/null @@ -1,20 +0,0 @@ -package httprouteropenapi - -import ( - "github.com/oaswrap/spec" - "github.com/oaswrap/spec/option" -) - -type route struct { - specRoute spec.Route -} - -var _ Route = (*route)(nil) - -func (r *route) With(opts ...option.OperationOption) Route { - if r.specRoute == nil { - return r - } - r.specRoute.With(opts...) - return r -} diff --git a/adapter/httprouteropenapi/router.go b/adapter/httprouteropenapi/router.go deleted file mode 100644 index 48306f1..0000000 --- a/adapter/httprouteropenapi/router.go +++ /dev/null @@ -1,194 +0,0 @@ -package httprouteropenapi - -import ( - "net/http" - - "github.com/julienschmidt/httprouter" - "github.com/oaswrap/spec" - specui "github.com/oaswrap/spec-ui" - "github.com/oaswrap/spec/adapter/httprouteropenapi/internal/constant" - "github.com/oaswrap/spec/option" - "github.com/oaswrap/spec/pkg/mapper" - "github.com/oaswrap/spec/pkg/parser" - "github.com/oaswrap/spec/pkg/util" -) - -// NewRouter creates a new router with the given HTTP router and options. -func NewRouter(httpRouter *httprouter.Router, opts ...option.OpenAPIOption) Generator { - return NewGenerator(httpRouter, opts...) -} - -func NewGenerator(httpRouter *httprouter.Router, opts ...option.OpenAPIOption) Generator { - defaultOpts := []option.OpenAPIOption{ - option.WithTitle(constant.DefaultTitle), - option.WithDescription(constant.DefaultDescription), - option.WithVersion(constant.DefaultVersion), - option.WithStoplightElements(), - option.WithCacheAge(0), - option.WithPathParser(parser.NewColonParamParser()), - } - opts = append(defaultOpts, opts...) - gen := spec.NewRouter(opts...) - - r := &router{ - router: httpRouter, - specRouter: gen, - gen: gen, - } - - cfg := gen.Config() - if cfg.DisableDocs { - return r - } - - handler := specui.NewHandler(mapper.SpecUIOpts(gen)...) - - httpRouter.Handler(http.MethodGet, cfg.DocsPath, handler.Docs()) - httpRouter.Handler(http.MethodGet, cfg.SpecPath, handler.Spec()) - - if handler.AssetsEnabled() { - httpRouter.Handler(http.MethodGet, handler.AssetsPath()+"/*filepath", handler.Assets()) - } - - return r -} - -type router struct { - router *httprouter.Router - prefix string - middlewares []func(http.Handler) http.Handler - - specRouter spec.Router - gen spec.Generator -} - -func (r *router) wrapHandler(h httprouter.Handle) httprouter.Handle { - return func(w http.ResponseWriter, rr *http.Request, ps httprouter.Params) { - handlerFunc := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h(w, r, ps) - }) - handler := http.Handler(handlerFunc) - for i := len(r.middlewares) - 1; i >= 0; i-- { - m := r.middlewares[i] - handler = m(handler) - } - handler.ServeHTTP(w, rr) - } -} - -func (r *router) pathOf(path string) string { - if r.prefix == "" { - return path - } - return util.JoinPath(r.prefix, path) -} - -func (r *router) Handle(method, path string, handle httprouter.Handle) Route { - if len(r.middlewares) > 0 { - handle = r.wrapHandler(handle) - } - path = r.pathOf(path) - r.router.Handle(method, path, handle) - rr := &route{} - if method != http.MethodConnect { - rr.specRoute = r.specRouter.Add(method, path) - } - - return rr -} - -func (r *router) Handler(method, path string, handler http.Handler) Route { - if len(r.middlewares) > 0 { - for i := len(r.middlewares) - 1; i >= 0; i-- { - handler = r.middlewares[i](handler) - } - } - fullPath := r.pathOf(path) - r.router.Handler(method, fullPath, handler) - rr := &route{} - if method != http.MethodConnect { - rr.specRoute = r.specRouter.Add(method, fullPath) - } - - return rr -} - -func (r *router) HandlerFunc(method, path string, handlerFunc http.HandlerFunc) Route { - return r.Handler(method, path, handlerFunc) -} - -func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) { - r.router.ServeHTTP(w, req) -} - -func (r *router) GET(path string, handle httprouter.Handle) Route { - return r.Handle(http.MethodGet, path, handle) -} - -func (r *router) POST(path string, handle httprouter.Handle) Route { - return r.Handle(http.MethodPost, path, handle) -} - -func (r *router) PUT(path string, handle httprouter.Handle) Route { - return r.Handle(http.MethodPut, path, handle) -} - -func (r *router) DELETE(path string, handle httprouter.Handle) Route { - return r.Handle(http.MethodDelete, path, handle) -} - -func (r *router) PATCH(path string, handle httprouter.Handle) Route { - return r.Handle(http.MethodPatch, path, handle) -} - -func (r *router) HEAD(path string, handle httprouter.Handle) Route { - return r.Handle(http.MethodHead, path, handle) -} - -func (r *router) OPTIONS(path string, handle httprouter.Handle) Route { - return r.Handle(http.MethodOptions, path, handle) -} - -func (r *router) Lookup(method, path string) (httprouter.Handle, httprouter.Params, bool) { - return r.router.Lookup(method, path) -} - -func (r *router) ServeFiles(path string, root http.FileSystem) { - r.router.ServeFiles(path, root) -} - -func (r *router) Group(prefix string, middlewares ...func(http.Handler) http.Handler) Router { - group := &router{ - router: r.router, - middlewares: append(r.middlewares, middlewares...), - specRouter: r.specRouter.Group(""), - prefix: r.pathOf(prefix), - gen: r.gen, - } - return group -} - -func (r *router) With(opts ...option.GroupOption) Router { - r.specRouter.With(opts...) - return r -} - -func (r *router) GenerateSchema(formats ...string) ([]byte, error) { - return r.gen.GenerateSchema(formats...) -} - -func (r *router) MarshalJSON() ([]byte, error) { - return r.gen.MarshalJSON() -} - -func (r *router) MarshalYAML() ([]byte, error) { - return r.gen.MarshalYAML() -} - -func (r *router) Validate() error { - return r.gen.Validate() -} - -func (r *router) WriteSchemaTo(path string) error { - return r.gen.WriteSchemaTo(path) -} diff --git a/adapter/httprouteropenapi/router_test.go b/adapter/httprouteropenapi/router_test.go deleted file mode 100644 index c63ab4e..0000000 --- a/adapter/httprouteropenapi/router_test.go +++ /dev/null @@ -1,584 +0,0 @@ -package httprouteropenapi_test - -import ( - "encoding/json" - "flag" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - - "github.com/julienschmidt/httprouter" - stoplightemb "github.com/oaswrap/spec-ui/stoplightemb" - "github.com/oaswrap/spec/adapter/httprouteropenapi" - "github.com/oaswrap/spec/openapi" - "github.com/oaswrap/spec/option" - "github.com/oaswrap/spec/pkg/dto" - "github.com/oaswrap/spec/pkg/testutil" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -//nolint:gochecknoglobals // test flag for golden file updates -var update = flag.Bool("update", false, "update golden files") - -func DummyHandler(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) { - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]string{"message": "Hello, World!"}) -} - -func TestRouter_Spec(t *testing.T) { - tests := []struct { - name string - golden string - opts []option.OpenAPIOption - setup func(r httprouteropenapi.Router) - shouldErr bool - }{ - { - name: "Pet Store API", - golden: "petstore", - opts: []option.OpenAPIOption{ - option.WithDescription("This is a sample Petstore server."), - option.WithVersion("1.0.0"), - option.WithTermsOfService("https://swagger.io/terms/"), - option.WithContact(openapi.Contact{ - Email: "apiteam@swagger.io", - }), - option.WithLicense(openapi.License{ - Name: "Apache 2.0", - URL: "https://www.apache.org/licenses/LICENSE-2.0.html", - }), - option.WithExternalDocs("https://swagger.io", "Find more info here about swagger"), - option.WithServer("https://petstore3.swagger.io/api/v3"), - option.WithTags( - openapi.Tag{ - Name: "pet", - Description: "Everything about your Pets", - ExternalDocs: &openapi.ExternalDocs{ - Description: "Find out more about our Pets", - URL: "https://swagger.io", - }, - }, - openapi.Tag{ - Name: "store", - Description: "Access to Petstore orders", - ExternalDocs: &openapi.ExternalDocs{ - Description: "Find out more about our Store", - URL: "https://swagger.io", - }, - }, - openapi.Tag{ - Name: "user", - Description: "Operations about user", - }, - ), - option.WithSecurity("petstore_auth", option.SecurityOAuth2( - openapi.OAuthFlows{ - Implicit: &openapi.OAuthFlowsImplicit{ - AuthorizationURL: "https://petstore3.swagger.io/oauth/authorize", - Scopes: map[string]string{ - "write:pets": "modify pets in your account", - "read:pets": "read your pets", - }, - }, - }), - ), - option.WithSecurity("apiKey", option.SecurityAPIKey("api_key", openapi.SecuritySchemeAPIKeyInHeader)), - }, - setup: func(r httprouteropenapi.Router) { - pet := r.Group("/pet").With( - option.GroupTags("pet"), - option.GroupSecurity("petstore_auth", "write:pets", "read:pets"), - ) - pet.PUT("/", DummyHandler).With( - option.OperationID("updatePet"), - option.Summary("Update an existing pet"), - option.Description("Update the details of an existing pet in the store."), - option.Request(new(dto.Pet)), - option.Response(200, new(dto.Pet)), - ) - pet.POST("/", DummyHandler).With( - option.OperationID("addPet"), - option.Summary("Add a new pet"), - option.Description("Add a new pet to the store."), - option.Request(new(dto.Pet)), - option.Response(201, new(dto.Pet)), - ) - pet.GET("/findByStatus", DummyHandler).With( - option.OperationID("findPetsByStatus"), - option.Summary("Find pets by status"), - option.Description("Finds Pets by status. Multiple status values can be provided with comma separated strings."), - option.Request(new(struct { - Status string `query:"status" enum:"available,pending,sold"` - })), - option.Response(200, new([]dto.Pet)), - ) - pet.GET("/findByTags", DummyHandler).With( - option.OperationID("findPetsByTags"), - option.Summary("Find pets by tags"), - option.Description("Finds Pets by tags. Multiple tags can be provided with comma separated strings."), - option.Request(new(struct { - Tags []string `query:"tags"` - })), - option.Response(200, new([]dto.Pet)), - ) - pet.POST("/:petId/uploadImage", DummyHandler).With( - option.OperationID("uploadFile"), - option.Summary("Upload an image for a pet"), - option.Description("Uploads an image for a pet."), - option.Request(new(dto.UploadImageRequest)), - option.Response(200, new(dto.APIResponse)), - ) - pet.GET("/id/:petId", DummyHandler).With( - option.OperationID("getPetById"), - option.Summary("Get pet by ID"), - option.Description("Retrieve a pet by its ID."), - option.Request(new(struct { - ID int `path:"petId" required:"true"` - })), - option.Response(200, new(dto.Pet)), - ) - pet.POST("/:petId", DummyHandler).With( - option.OperationID("updatePetWithForm"), - option.Summary("Update pet with form"), - option.Description("Updates a pet in the store with form data."), - option.Request(new(dto.UpdatePetWithFormRequest)), - option.Response(200, nil), - ) - pet.DELETE("/:petId", DummyHandler).With( - option.OperationID("deletePet"), - option.Summary("Delete a pet"), - option.Description("Delete a pet from the store by its ID."), - option.Request(new(dto.DeletePetRequest)), - option.Response(204, nil), - ) - store := r.Group("/store").With( - option.GroupTags("store"), - ) - store.POST("/order", DummyHandler).With( - option.OperationID("placeOrder"), - option.Summary("Place an order"), - option.Description("Place a new order for a pet."), - option.Request(new(dto.Order)), - option.Response(201, new(dto.Order)), - ) - store.GET("/order/:orderId", DummyHandler).With( - option.OperationID("getOrderById"), - option.Summary("Get order by ID"), - option.Description("Retrieve an order by its ID."), - option.Request(new(struct { - ID int `path:"orderId" required:"true"` - })), - option.Response(200, new(dto.Order)), - option.Response(404, nil), - ) - store.DELETE("/order/:orderId", DummyHandler).With( - option.OperationID("deleteOrder"), - option.Summary("Delete an order"), - option.Description("Delete an order by its ID."), - option.Request(new(struct { - ID int `path:"orderId" required:"true"` - })), - option.Response(204, nil), - ) - - user := r.Group("/user").With( - option.GroupTags("user"), - ) - user.POST("/createWithList", DummyHandler).With( - option.OperationID("createUsersWithList"), - option.Summary("Create users with list"), - option.Description("Create multiple users in the store with a list."), - option.Request(new([]dto.PetUser)), - option.Response(201, nil), - ) - user.POST("/", DummyHandler).With( - option.OperationID("createUser"), - option.Summary("Create a new user"), - option.Description("Create a new user in the store."), - option.Request(new(dto.PetUser)), - option.Response(201, new(dto.PetUser)), - ) - user.GET("/:username", DummyHandler).With( - option.OperationID("getUserByName"), - option.Summary("Get user by username"), - option.Description("Retrieve a user by their username."), - option.Request(new(struct { - Username string `path:"username" required:"true"` - })), - option.Response(200, new(dto.PetUser)), - option.Response(404, nil), - ) - user.PUT("/:username", DummyHandler).With( - option.OperationID("updateUser"), - option.Summary("Update an existing user"), - option.Description("Update the details of an existing user."), - option.Request(new(struct { - dto.PetUser - - Username string `path:"username" required:"true"` - })), - option.Response(200, new(dto.PetUser)), - option.Response(404, nil), - ) - user.DELETE("/:username", DummyHandler).With( - option.OperationID("deleteUser"), - option.Summary("Delete a user"), - option.Description("Delete a user from the store by their username."), - option.Request(new(struct { - Username string `path:"username" required:"true"` - })), - option.Response(204, nil), - ) - }, - }, - { - name: "Invalid Open API Version", - opts: []option.OpenAPIOption{ - option.WithOpenAPIVersion("2.0.0"), // Invalid version for this test - }, - shouldErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - router := httprouter.New() - 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), - option.WithReflectorConfig( - option.RequiredPropByValidateTag(), - option.StripDefNamePrefix("GinopenapiTest"), - ), - } - if len(tt.opts) > 0 { - opts = append(opts, tt.opts...) - } - r := httprouteropenapi.NewRouter(router, opts...) - - if tt.setup != nil { - tt.setup(r) - } - - if tt.shouldErr { - err := r.Validate() - require.Error(t, err, "expected error for invalid OpenAPI configuration") - return - } - err := r.Validate() - require.NoError(t, err, "failed to validate OpenAPI configuration") - - // Test the OpenAPI schema generation - schema, err := r.GenerateSchema() - - require.NoError(t, err, "failed to generate OpenAPI schema") - golden := filepath.Join("testdata", tt.golden+".yaml") - - if *update { - err = r.WriteSchemaTo(golden) - require.NoError(t, err, "failed to write golden file") - t.Logf("Updated golden file: %s", golden) - } - - want, err := os.ReadFile(golden) - require.NoError(t, err, "failed to read golden file %s", golden) - - testutil.EqualYAML(t, want, schema) - }) - } -} - -type SingleRouteFunc func(path string, handle httprouter.Handle) httprouteropenapi.Route - -func PingHandler(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) { - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]string{"message": "pong"}) -} - -func TestRouter_SingleRoute(t *testing.T) { - tests := []struct { - method string - path string - methodFunc func(r httprouteropenapi.Router) SingleRouteFunc - }{ - { - method: http.MethodGet, - path: "/ping", - methodFunc: func(r httprouteropenapi.Router) SingleRouteFunc { - return r.GET - }, - }, - { - method: http.MethodPost, - path: "/ping", - methodFunc: func(r httprouteropenapi.Router) SingleRouteFunc { - return r.POST - }, - }, - { - method: http.MethodPut, - path: "/ping", - methodFunc: func(r httprouteropenapi.Router) SingleRouteFunc { - return r.PUT - }, - }, - { - method: http.MethodDelete, - path: "/ping", - methodFunc: func(r httprouteropenapi.Router) SingleRouteFunc { - return r.DELETE - }, - }, - { - method: http.MethodPatch, - path: "/ping", - methodFunc: func(r httprouteropenapi.Router) SingleRouteFunc { - return r.PATCH - }, - }, - { - method: http.MethodOptions, - path: "/ping", - methodFunc: func(r httprouteropenapi.Router) SingleRouteFunc { - return r.OPTIONS - }, - }, - { - method: http.MethodHead, - path: "/ping", - methodFunc: func(r httprouteropenapi.Router) SingleRouteFunc { - return r.HEAD - }, - }, - } - - for _, tt := range tests { - t.Run(tt.method, func(t *testing.T) { - router := httprouter.New() - r := httprouteropenapi.NewRouter(router) - - tt.methodFunc(r)(tt.path, PingHandler).With( - option.Summary("Ping the server"), - option.Description("Returns a simple pong response"), - option.Response(200, new(struct { - Message string `json:"message" example:"pong"` - })), - ) - - // Test the route - req := httptest.NewRequest(tt.method, tt.path, nil) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusOK, rec.Code) - assert.JSONEq(t, `{"message":"pong"}`, rec.Body.String(), "response body should match") - - schema, err := r.GenerateSchema() - require.NoError(t, err, "failed to generate OpenAPI schema") - assert.NotEmpty(t, schema, "OpenAPI schema should not be empty") - assert.Contains(t, string(schema), "summary: Ping the server", "OpenAPI schema should contain the summary") - }) - } -} - -func TestRouter_Group(t *testing.T) { - logs := []string{} - middleware1 := func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - logs = append(logs, "middleware1") - next.ServeHTTP(w, r) - logs = append(logs, "middleware1") - }) - } - middleware2 := func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - logs = append(logs, "middleware2") - next.ServeHTTP(w, r) - logs = append(logs, "middleware2") - }) - } - router := httprouter.New() - r := httprouteropenapi.NewRouter(router) - api := r.Group("/api/v1", middleware1, middleware2).With( - option.GroupTags("apiv1"), - ) - dummyHandler := func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]string{"message": "pong"}) - } - api.GET("/ping", PingHandler).With( - option.Summary("Ping the server"), - option.Description("Returns a simple pong response"), - option.Response(200, new(struct { - Message string `json:"message" example:"pong"` - })), - ) - api.HandlerFunc(http.MethodGet, "/dummy", dummyHandler).With( - option.Summary("Dummy endpoint"), - option.Description("Returns a simple dummy response"), - option.Response(200, new(struct { - Message string `json:"message" example:"pong"` - })), - ) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/ping", nil) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusOK, rec.Code) - assert.JSONEq(t, `{"message":"pong"}`, rec.Body.String(), "response body should match") - - assert.Equal(t, []string{ - "middleware1", - "middleware2", - "middleware2", - "middleware1", - }, logs, "middleware logs should match") - - schema, err := r.GenerateSchema() - require.NoError(t, err, "failed to generate OpenAPI schema") - assert.NotEmpty(t, schema, "OpenAPI schema should not be empty") - assert.Contains(t, string(schema), "apiv1", "OpenAPI schema should contain the group tags") - - req = httptest.NewRequest(http.MethodGet, "/api/v1/dummy", nil) - rec = httptest.NewRecorder() - router.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusOK, rec.Code) - assert.JSONEq(t, `{"message":"pong"}`, rec.Body.String()) -} - -func TestGenerator_Docs(t *testing.T) { - router := httprouter.New() - r := httprouteropenapi.NewRouter(router) - - r.GET("/ping", PingHandler).With( - option.Summary("Ping the server"), - option.Description("Returns a simple pong response"), - option.Response(200, new(struct { - Message string `json:"message" example:"pong"` - })), - ) - - t.Run("should serve /docs", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/docs", nil) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusOK, rec.Code) - assert.NotEmpty(t, rec.Body.String(), "response body should not be empty") - }) - t.Run("should serve /docs/openapi.yaml", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/docs/openapi.yaml", nil) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusOK, rec.Code) - assert.NotEmpty(t, rec.Body.String(), "response body should not be empty") - }) -} - -func TestGenerator_DisableDocs(t *testing.T) { - router := httprouter.New() - r := httprouteropenapi.NewRouter(router, - option.WithDisableDocs(), - ) - - r.GET("/ping", PingHandler).With( - option.Summary("Ping the server"), - option.Description("Returns a simple pong response"), - option.Response(200, new(struct { - Message string `json:"message" example:"pong"` - })), - ) - - t.Run("should not serve /docs", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/docs", nil) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusNotFound, rec.Code) - }) - t.Run("should not serve /docs/openapi.yaml", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/docs/openapi.yaml", nil) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusNotFound, rec.Code) - }) -} - -func TestGenerator_Assets(t *testing.T) { - router := httprouter.New() - r := httprouteropenapi.NewRouter(router, option.WithUIOption(stoplightemb.WithUI())) - - r.GET("/ping", PingHandler).With( - option.OperationID("pingHandler"), - ) - - req := httptest.NewRequest(http.MethodGet, "/docs/_assets/styles.min.css", nil) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - - assert.Equal(t, http.StatusOK, rec.Code) -} - -func TestGenerator_MarshalYAML(t *testing.T) { - router := httprouter.New() - r := httprouteropenapi.NewRouter(router) - - r.GET("/ping", PingHandler).With( - option.Summary("Ping the server"), - option.Description("Returns a simple pong response"), - option.Response(200, new(struct { - Message string `json:"message" example:"pong"` - })), - ) - - schema, err := r.MarshalYAML() - require.NoError(t, err, "failed to marshal OpenAPI schema to YAML") - assert.NotEmpty(t, schema, "YAML schema should not be empty") - assert.Contains(t, string(schema), "summary: Ping the server", "YAML schema should contain the summary") -} - -func TestGenerator_MarshalJSON(t *testing.T) { - router := httprouter.New() - r := httprouteropenapi.NewRouter(router) - - r.GET("/ping", PingHandler).With( - option.Summary("Ping the server"), - option.Description("Returns a simple pong response"), - option.Response(200, new(struct { - Message string `json:"message" example:"pong"` - })), - ) - - schema, err := r.MarshalJSON() - require.NoError(t, err, "failed to marshal OpenAPI schema to JSON") - assert.NotEmpty(t, schema, "JSON schema should not be empty") - assert.Contains(t, string(schema), `"summary": "Ping the server"`, "JSON schema should contain the summary") -} - -func TestGenerator_WriteSchemaTo(t *testing.T) { - router := httprouter.New() - r := httprouteropenapi.NewRouter(router) - - r.GET("/ping", PingHandler).With( - option.Summary("Ping the server"), - option.Description("Returns a simple pong response"), - option.Response(200, new(struct { - Message string `json:"message" example:"pong"` - })), - ) - - dir := t.TempDir() - path := filepath.Join(dir, "openapi.yaml") - err := r.WriteSchemaTo(path) - require.NoError(t, err, "failed to write OpenAPI schema to directory") - assert.FileExists(t, path, "openapi.yaml should be created") -} diff --git a/adapter/httprouteropenapi/testdata/petstore.yaml b/adapter/httprouteropenapi/testdata/petstore.yaml deleted file mode 100644 index dba1b21..0000000 --- a/adapter/httprouteropenapi/testdata/petstore.yaml +++ /dev/null @@ -1,537 +0,0 @@ -openapi: 3.0.3 -info: - contact: - email: apiteam@swagger.io - description: This is a sample Petstore server. - license: - name: Apache 2.0 - url: https://www.apache.org/licenses/LICENSE-2.0.html - termsOfService: https://swagger.io/terms/ - title: Test API Pet Store API - version: 1.0.0 -externalDocs: - description: Find more info here about swagger - url: https://swagger.io -servers: -- url: https://petstore3.swagger.io/api/v3 -tags: -- description: Everything about your Pets - externalDocs: - description: Find out more about our Pets - url: https://swagger.io - name: pet -- description: Access to Petstore orders - externalDocs: - description: Find out more about our Store - url: https://swagger.io - name: store -- description: Operations about user - name: user -paths: - /pet/: - post: - description: Add a new pet to the store. - operationId: addPet - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPet' - responses: - "201": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPet' - description: Created - security: - - petstore_auth: - - write:pets - - read:pets - summary: Add a new pet - tags: - - pet - put: - description: Update the details of an existing pet in the store. - operationId: updatePet - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPet' - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPet' - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Update an existing pet - tags: - - pet - /pet/{petId}: - delete: - description: Delete a pet from the store by its ID. - operationId: deletePet - parameters: - - in: path - name: petId - required: true - schema: - type: integer - - in: header - name: api_key - schema: - type: string - responses: - "204": - description: No Content - security: - - petstore_auth: - - write:pets - - read:pets - summary: Delete a pet - tags: - - pet - post: - description: Updates a pet in the store with form data. - operationId: updatePetWithForm - parameters: - - in: path - name: petId - required: true - schema: - type: integer - requestBody: - content: - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/FormDataDtoUpdatePetWithFormRequest' - responses: - "200": - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Update pet with form - tags: - - pet - /pet/{petId}/uploadImage: - post: - description: Uploads an image for a pet. - operationId: uploadFile - parameters: - - in: query - name: additionalMetadata - schema: - type: string - - in: path - name: petId - required: true - schema: - format: int64 - type: integer - requestBody: - content: - application/octet-stream: - schema: - type: string - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoAPIResponse' - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Upload an image for a pet - tags: - - pet - /pet/findByStatus: - get: - description: Finds Pets by status. Multiple status values can be provided with - comma separated strings. - operationId: findPetsByStatus - parameters: - - in: query - name: status - schema: - enum: - - available - - pending - - sold - type: string - responses: - "200": - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPet' - type: array - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by status - tags: - - pet - /pet/findByTags: - get: - description: Finds Pets by tags. Multiple tags can be provided with comma separated - strings. - operationId: findPetsByTags - parameters: - - in: query - name: tags - schema: - items: - type: string - type: array - responses: - "200": - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPet' - type: array - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by tags - tags: - - pet - /pet/id/{petId}: - get: - description: Retrieve a pet by its ID. - operationId: getPetById - parameters: - - in: path - name: petId - required: true - schema: - type: integer - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPet' - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Get pet by ID - tags: - - pet - /store/order: - post: - description: Place a new order for a pet. - operationId: placeOrder - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoOrder' - responses: - "201": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoOrder' - description: Created - summary: Place an order - tags: - - store - /store/order/{orderId}: - delete: - description: Delete an order by its ID. - operationId: deleteOrder - parameters: - - in: path - name: orderId - required: true - schema: - type: integer - responses: - "204": - description: No Content - summary: Delete an order - tags: - - store - get: - description: Retrieve an order by its ID. - operationId: getOrderById - parameters: - - in: path - name: orderId - required: true - schema: - type: integer - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoOrder' - description: OK - "404": - description: Not Found - summary: Get order by ID - tags: - - store - /user/: - post: - description: Create a new user in the store. - operationId: createUser - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPetUser' - responses: - "201": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPetUser' - description: Created - summary: Create a new user - tags: - - user - /user/{username}: - delete: - description: Delete a user from the store by their username. - operationId: deleteUser - parameters: - - in: path - name: username - required: true - schema: - type: string - responses: - "204": - description: No Content - summary: Delete a user - tags: - - user - get: - description: Retrieve a user by their username. - operationId: getUserByName - parameters: - - in: path - name: username - required: true - schema: - type: string - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPetUser' - description: OK - "404": - description: Not Found - summary: Get user by username - tags: - - user - put: - description: Update the details of an existing user. - operationId: updateUser - parameters: - - in: path - name: username - required: true - schema: - type: string - requestBody: - content: - application/json: - schema: - properties: - email: - type: string - firstName: - type: string - id: - type: integer - lastName: - type: string - password: - type: string - phone: - type: string - userStatus: - enum: - - 0 - - 1 - - 2 - type: integer - username: - type: string - type: object - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPetUser' - description: OK - "404": - description: Not Found - summary: Update an existing user - tags: - - user - /user/createWithList: - post: - description: Create multiple users in the store with a list. - operationId: createUsersWithList - requestBody: - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPetUser' - nullable: true - type: array - responses: - "201": - description: Created - summary: Create users with list - tags: - - user -components: - schemas: - DtoAPIResponse: - properties: - code: - type: integer - message: - type: string - type: - type: string - type: object - DtoCategory: - properties: - id: - type: integer - name: - type: string - type: object - DtoOrder: - properties: - complete: - type: boolean - id: - type: integer - petId: - type: integer - quantity: - type: integer - shipDate: - format: date-time - type: string - status: - enum: - - placed - - approved - - delivered - type: string - type: object - DtoPet: - properties: - category: - $ref: '#/components/schemas/DtoCategory' - id: - type: integer - name: - type: string - photoUrls: - items: - type: string - nullable: true - type: array - status: - enum: - - available - - pending - - sold - type: string - tags: - items: - $ref: '#/components/schemas/DtoTag' - nullable: true - type: array - type: - type: string - type: object - DtoPetUser: - properties: - email: - type: string - firstName: - type: string - id: - type: integer - lastName: - type: string - password: - type: string - phone: - type: string - userStatus: - enum: - - 0 - - 1 - - 2 - type: integer - username: - type: string - type: object - DtoTag: - properties: - id: - type: integer - name: - type: string - type: object - FormDataDtoUpdatePetWithFormRequest: - properties: - name: - type: string - status: - enum: - - available - - pending - - sold - type: string - required: - - name - type: object - securitySchemes: - apiKey: - in: header - name: api_key - type: apiKey - petstore_auth: - flows: - implicit: - authorizationUrl: https://petstore3.swagger.io/oauth/authorize - scopes: - read:pets: read your pets - write:pets: modify pets in your account - type: oauth2 diff --git a/adapter/httprouteropenapi/types.go b/adapter/httprouteropenapi/types.go deleted file mode 100644 index cbf0d8b..0000000 --- a/adapter/httprouteropenapi/types.go +++ /dev/null @@ -1,72 +0,0 @@ -package httprouteropenapi - -import ( - "net/http" - - "github.com/julienschmidt/httprouter" - "github.com/oaswrap/spec/option" -) - -// Generator is an interface for generating OpenAPI specifications. -type Generator interface { - Router - - // GenerateSchema generates the OpenAPI schema for the router. - GenerateSchema(formats ...string) ([]byte, error) - - // MarshalJSON marshals the schema to JSON. - MarshalJSON() ([]byte, error) - - // MarshalYAML marshals the schema to YAML. - MarshalYAML() ([]byte, error) - - // Validate validates the schema. - Validate() error - - // WriteSchemaTo writes the schema to a file. - WriteSchemaTo(path string) error -} - -// Router is an interface for handling HTTP requests. -type Router interface { - http.Handler - - // Handle registers a new route with the given method, path, and handler. - Handle(method, path string, handle httprouter.Handle) Route - // Handler registers a new route with the given method, path, and handler. - Handler(method, path string, handler http.Handler) Route - // HandlerFunc registers a new route with the given method, path, and handler function. - HandlerFunc(method, path string, handler http.HandlerFunc) Route - - // GET registers a new GET route with the given path and handler. - GET(path string, handle httprouter.Handle) Route - // POST registers a new POST route with the given path and handler. - POST(path string, handle httprouter.Handle) Route - // PUT registers a new PUT route with the given path and handler. - PUT(path string, handle httprouter.Handle) Route - // DELETE registers a new DELETE route with the given path and handler. - DELETE(path string, handle httprouter.Handle) Route - // PATCH registers a new PATCH route with the given path and handler. - PATCH(path string, handle httprouter.Handle) Route - // HEAD registers a new HEAD route with the given path and handler. - HEAD(path string, handle httprouter.Handle) Route - // OPTIONS registers a new OPTIONS route with the given path and handler. - OPTIONS(path string, handle httprouter.Handle) Route - - // Group creates a new route group with the given prefix and middlewares. - Group(prefix string, middlewares ...func(http.Handler) http.Handler) Router - - // Lookup retrieves the route for the given method and path. - Lookup(method, path string) (httprouter.Handle, httprouter.Params, bool) - // ServeFiles serves static files from the given root. - ServeFiles(path string, root http.FileSystem) - - // With adds the given options to the group. - With(opts ...option.GroupOption) Router -} - -// Route is an interface for handling route-specific options. -type Route interface { - // With adds the given options to the route. - With(opts ...option.OperationOption) Route -} diff --git a/adapter/muxopenapi/README.md b/adapter/muxopenapi/README.md index ee22f67..6d9580b 100644 --- a/adapter/muxopenapi/README.md +++ b/adapter/muxopenapi/README.md @@ -12,7 +12,7 @@ A lightweight adapter for the [gorilla/mux](https://pkg.go.dev/github.com/gorill - **๐ŸŽฏ Type Safety** โ€” Full Go type safety for OpenAPI configuration - **๐Ÿ”ง Multiple UI Options** โ€” Swagger UI, Stoplight Elements, ReDoc, Scalar or RapiDoc served automatically at `/docs` - **๐Ÿ“„ YAML Export** โ€” OpenAPI spec available at `/docs/openapi.yaml` -- **๐Ÿš€ Zero Overhead** โ€” Minimal performance impact on your API +- **๐Ÿš€ Low Overhead** โ€” Minimal runtime work beyond route registration and docs serving ## Installation @@ -141,7 +141,7 @@ When you create a muxopenapi router, the following endpoints are automatically a If you want to disable the built-in UI, you can do so by passing `option.WithDisableDocs()` when creating the router: ```go -r := muxopenapi.NewRouter(c, +r := muxopenapi.NewRouter(mux, option.WithTitle("My API"), option.WithVersion("1.0.0"), option.WithDisableDocs(), @@ -158,7 +158,7 @@ Choose from multiple UI options, powered by [`oaswrap/spec-ui`](https://github.c - **RapiDoc** โ€” Highly customizable ```go -r := muxopenapi.NewRouter(c, +r := muxopenapi.NewRouter(mux, option.WithTitle("My API"), option.WithVersion("1.0.0"), option.WithScalar(), // Use Scalar as the documentation UI @@ -193,7 +193,7 @@ type CreateProductRequest struct { } ``` -For more struct tag options, see the [swaggest/openapi-go](https://github.com/swaggest/openapi-go?tab=readme-ov-file#features). +Supported tags are implemented by oaswrap/spec directly. Common request tags include `json`, `form`, `path`, `query`, `header`, and `cookie`; common schema tags include `description`, `format`, `default`, `example`, `enum`, `minimum`, `maximum`, `minLength`, `maxLength`, `minItems`, `maxItems`, `nullable`, `deprecated`, `readOnly`, and `writeOnly`. See the root [Reflection Tags](../../README.md#reflection-tags) section for the complete list. ## Example @@ -204,7 +204,7 @@ Check out the [examples directory](/adapter/muxopenapi/example) for more complet 1. **Organize with Tags** โ€” Group related operations using `option.Tags()` 2. **Document Everything** โ€” Use `option.Summary()` and `option.Description()` for all routes 3. **Define Error Responses** โ€” Include common error responses (400, 401, 404, 500) -4. **Use Validation Tags** โ€” Leverage struct tags for request validation documentation +4. **Document Schema Constraints** โ€” Use reflection tags to describe OpenAPI schema constraints; keep runtime validation in handlers or middleware 5. **Security First** โ€” Define and apply appropriate security schemes 6. **Version Your API** โ€” Use route groups for API versioning (`/api/v1`, `/api/v2`) diff --git a/adapter/muxopenapi/example/go.mod b/adapter/muxopenapi/example/go.mod index a42d5d7..6dee80f 100644 --- a/adapter/muxopenapi/example/go.mod +++ b/adapter/muxopenapi/example/go.mod @@ -1,6 +1,6 @@ module github.com/oaswrap/spec/adapter/muxopenapi/example -go 1.21 +go 1.22 require ( github.com/gorilla/mux v1.8.1 @@ -9,11 +9,10 @@ require ( ) require ( + github.com/goccy/go-yaml v1.19.2 // indirect github.com/oaswrap/spec-ui v0.2.0 // indirect - github.com/swaggest/jsonschema-go v0.3.79 // indirect - github.com/swaggest/openapi-go v0.2.61 // indirect - github.com/swaggest/refl v1.4.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect ) +replace github.com/oaswrap/spec => ../../.. + replace github.com/oaswrap/spec/adapter/muxopenapi => .. diff --git a/adapter/muxopenapi/example/go.sum b/adapter/muxopenapi/example/go.sum index 88b54bc..92cbe21 100644 --- a/adapter/muxopenapi/example/go.sum +++ b/adapter/muxopenapi/example/go.sum @@ -1,45 +1,16 @@ -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/oaswrap/spec v0.4.2 h1:jOfQrWUsJhP6lz3Zj2XEHWtF3DoOH/lTk8vil5usSwA= -github.com/oaswrap/spec v0.4.2/go.mod h1:ebAiVZ8SUr5B70s/gKk9J/Ccbk/vQzFWAe4f4vk+1zQ= github.com/oaswrap/spec-ui v0.2.0 h1:NVuAqjOUf3acSEmwy5y4q0ii15xD+KGuoJYvNmtLd4k= github.com/oaswrap/spec-ui v0.2.0/go.mod h1:D8EnD6zbYJ3q65wdltw6QHXfw+nut5XwSSA1xtlSEQQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= -github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= -github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= -github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= -github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= -github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/adapter/muxopenapi/example/main.go b/adapter/muxopenapi/example/main.go index bc11fd7..905485b 100644 --- a/adapter/muxopenapi/example/main.go +++ b/adapter/muxopenapi/example/main.go @@ -7,8 +7,10 @@ import ( "time" "github.com/gorilla/mux" - "github.com/oaswrap/spec/adapter/muxopenapi" + "github.com/oaswrap/spec/option" + + "github.com/oaswrap/spec/adapter/muxopenapi" ) func main() { diff --git a/adapter/muxopenapi/go.mod b/adapter/muxopenapi/go.mod index 567e2b8..ec8fa33 100644 --- a/adapter/muxopenapi/go.mod +++ b/adapter/muxopenapi/go.mod @@ -1,8 +1,9 @@ module github.com/oaswrap/spec/adapter/muxopenapi -go 1.21 +go 1.22 require ( + github.com/google/go-cmp v0.7.0 github.com/gorilla/mux v1.8.1 github.com/oaswrap/spec v0.4.2 github.com/oaswrap/spec-ui v0.2.0 @@ -11,12 +12,9 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/google/go-cmp v0.7.0 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/swaggest/jsonschema-go v0.3.79 // indirect - github.com/swaggest/openapi-go v0.2.61 // indirect - github.com/swaggest/refl v1.4.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/adapter/muxopenapi/go.sum b/adapter/muxopenapi/go.sum index ea9b7b1..860e6db 100644 --- a/adapter/muxopenapi/go.sum +++ b/adapter/muxopenapi/go.sum @@ -1,43 +1,26 @@ -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/oaswrap/spec-ui v0.2.0 h1:NVuAqjOUf3acSEmwy5y4q0ii15xD+KGuoJYvNmtLd4k= github.com/oaswrap/spec-ui v0.2.0/go.mod h1:D8EnD6zbYJ3q65wdltw6QHXfw+nut5XwSSA1xtlSEQQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= -github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= -github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= -github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= -github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= -github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/adapter/muxopenapi/route.go b/adapter/muxopenapi/route.go index 582b3a5..fcdfe62 100644 --- a/adapter/muxopenapi/route.go +++ b/adapter/muxopenapi/route.go @@ -4,7 +4,9 @@ import ( "net/http" "github.com/gorilla/mux" + "github.com/oaswrap/spec" + "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" ) @@ -53,7 +55,8 @@ func (r *route) Host(tpl string) Route { func (r *route) Methods(methods ...string) Route { r.muxRoute.Methods(methods...) - if len(methods) > 0 && methods[0] != http.MethodConnect { + if len(methods) > 0 && + (methods[0] != http.MethodConnect || r.gen.Config().OpenAPIVersion == openapi.Version320) { r.specRoute.Method(methods[0]) } return r diff --git a/adapter/muxopenapi/router.go b/adapter/muxopenapi/router.go index e03a93a..1049b54 100644 --- a/adapter/muxopenapi/router.go +++ b/adapter/muxopenapi/router.go @@ -4,11 +4,13 @@ import ( "net/http" "github.com/gorilla/mux" + "github.com/oaswrap/spec" specui "github.com/oaswrap/spec-ui" - "github.com/oaswrap/spec/adapter/muxopenapi/internal/constant" "github.com/oaswrap/spec/option" "github.com/oaswrap/spec/pkg/mapper" + + "github.com/oaswrap/spec/adapter/muxopenapi/internal/constant" ) type router struct { diff --git a/adapter/muxopenapi/router_test.go b/adapter/muxopenapi/router_test.go index 705dea1..ba575c0 100644 --- a/adapter/muxopenapi/router_test.go +++ b/adapter/muxopenapi/router_test.go @@ -2,26 +2,25 @@ package muxopenapi_test import ( "encoding/json" - "flag" "net/http" "net/http/httptest" "os" "path/filepath" "testing" + "github.com/google/go-cmp/cmp" "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + stoplightemb "github.com/oaswrap/spec-ui/stoplightemb" - "github.com/oaswrap/spec/adapter/muxopenapi" + "github.com/oaswrap/spec/internal/testutil" + "github.com/oaswrap/spec/internal/testutil/dto" "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" - "github.com/oaswrap/spec/pkg/dto" - "github.com/oaswrap/spec/pkg/testutil" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) -//nolint:gochecknoglobals // test flag for golden file updates -var update = flag.Bool("update", false, "update golden files") + "github.com/oaswrap/spec/adapter/muxopenapi" +) func TestRouter_Spec(t *testing.T) { tests := []struct { @@ -71,7 +70,7 @@ func TestRouter_Spec(t *testing.T) { ), option.WithSecurity("petstore_auth", option.SecurityOAuth2( openapi.OAuthFlows{ - Implicit: &openapi.OAuthFlowsImplicit{ + Implicit: &openapi.OAuthFlow{ AuthorizationURL: "https://petstore3.swagger.io/oauth/authorize", Scopes: map[string]string{ "write:pets": "modify pets in your account", @@ -88,7 +87,7 @@ func TestRouter_Spec(t *testing.T) { option.GroupTags("pet"), option.GroupSecurity("petstore_auth", "write:pets", "read:pets"), ) - pet.HandleFunc("/", nil).Methods("GET").With( + pet.HandleFunc("/", nil).Methods("PUT").With( option.OperationID("updatePet"), option.Summary("Update an existing pet"), option.Description("Update the details of an existing pet in the store."), @@ -208,6 +207,23 @@ func TestRouter_Spec(t *testing.T) { option.Response(200, new(dto.PetUser)), option.Response(404, nil), ) + user.HandleFunc("/login", nil).Methods("GET").With( + option.OperationID("loginUser"), + option.Summary("Logs user into the system"), + option.Description("Logs user into the system."), + option.Request(new(struct { + Username string `query:"username"` + Password string `query:"password"` + })), + option.Response(200, new(string)), + option.Response(400, nil), + ) + user.HandleFunc("/logout", nil).Methods("GET").With( + option.OperationID("logoutUser"), + option.Summary("Logs out current logged in user session"), + option.Description("Logs out current logged in user session."), + option.Response(200, nil), + ) user.HandleFunc("/{username}", nil).Methods("PUT").With( option.OperationID("updateUser"), option.Summary("Update an existing user"), @@ -242,7 +258,6 @@ func TestRouter_Spec(t *testing.T) { option.WithVersion("1.0.0"), option.WithDescription("This is a test API for " + tt.name), option.WithReflectorConfig( - option.RequiredPropByValidateTag(), option.StripDefNamePrefix("GinopenapiTest"), ), } @@ -267,18 +282,7 @@ func TestRouter_Spec(t *testing.T) { schema, err := r.GenerateSchema() require.NoError(t, err, "failed to generate OpenAPI schema") - golden := filepath.Join("testdata", tt.golden+".yaml") - - if *update { - err = r.WriteSchemaTo(golden) - require.NoError(t, err, "failed to write golden file") - t.Logf("Updated golden file: %s", golden) - } - - want, err := os.ReadFile(golden) - require.NoError(t, err, "failed to read golden file %s", golden) - - testutil.EqualYAML(t, want, schema) + testutil.AssertGolden(t, schema, filepath.Join("testdata", tt.golden+".yaml")) }) } } @@ -327,6 +331,29 @@ func TestRouter_HandleFunc(t *testing.T) { } } +func TestRoute_HostSchemesSkipClean(t *testing.T) { + m := mux.NewRouter() + r := muxopenapi.NewRouter(m) + + route := r.NewRoute(). + Host("example.com"). + Schemes("https"). + Path("/ping"). + Methods(http.MethodGet). + HandlerFunc(PingHandler). + Name("ping") + + assert.False(t, route.SkipClean()) + assert.Equal(t, "ping", route.GetName()) + + req := httptest.NewRequest(http.MethodGet, "https://example.com/ping", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.JSONEq(t, `{"message":"pong"}`, rec.Body.String()) +} + func TestRouter_Handle(t *testing.T) { methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE", "CONNECT"} for _, method := range methods { @@ -524,7 +551,9 @@ func TestGenerator_Assets(t *testing.T) { m := mux.NewRouter() r := muxopenapi.NewRouter(m, option.WithUIOption(stoplightemb.WithUI())) - r.HandleFunc("/ping", PingHandler).Methods("GET").With( + r.HandleFunc("/ping", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{"message": "pong"}) + }).Methods("GET").With( option.OperationID("pingHandler"), ) @@ -602,5 +631,8 @@ func TestGenerator_WriteSchemaTo(t *testing.T) { want, err := os.ReadFile(filePath) require.NoError(t, err, "failed to read schema from file") - testutil.EqualYAML(t, want, schema) + diff := cmp.Diff(string(want), string(schema)) + if diff != "" { + t.Errorf("OpenAPI schema mismatch (-want +got):\n%s", diff) + } } diff --git a/adapter/muxopenapi/testdata/petstore.yaml b/adapter/muxopenapi/testdata/petstore.yaml index ac16c66..d4696d5 100644 --- a/adapter/muxopenapi/testdata/petstore.yaml +++ b/adapter/muxopenapi/testdata/petstore.yaml @@ -1,362 +1,401 @@ openapi: 3.0.3 info: + title: Test API Pet Store API + description: This is a sample Petstore server. + termsOfService: https://swagger.io/terms/ contact: email: apiteam@swagger.io - description: This is a sample Petstore server. license: name: Apache 2.0 url: https://www.apache.org/licenses/LICENSE-2.0.html - termsOfService: https://swagger.io/terms/ - title: Test API Pet Store API version: 1.0.0 +servers: + - url: https://petstore3.swagger.io/api/v3 externalDocs: description: Find more info here about swagger url: https://swagger.io -servers: -- url: https://petstore3.swagger.io/api/v3 tags: -- description: Everything about your Pets - externalDocs: - description: Find out more about our Pets - url: https://swagger.io - name: pet -- description: Access to Petstore orders - externalDocs: - description: Find out more about our Store - url: https://swagger.io - name: store -- description: Operations about user - name: user + - name: pet + description: Everything about your Pets + externalDocs: + description: Find out more about our Pets + url: https://swagger.io + - name: store + description: Access to Petstore orders + externalDocs: + description: Find out more about our Store + url: https://swagger.io + - name: user + description: Operations about user paths: /pet: - get: + put: + tags: + - pet + summary: Update an existing pet description: Update the details of an existing pet in the store. operationId: updatePet + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/DtoPet" responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: OK + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Update an existing pet - tags: - - pet + - petstore_auth: + - write:pets + - read:pets post: + tags: + - pet + summary: Add a new pet description: Add a new pet to the store. operationId: addPet requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPet' + $ref: "#/components/schemas/DtoPet" responses: "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoPet' - description: Created + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Add a new pet + - petstore_auth: + - write:pets + - read:pets + /pet/delete/{petId}: + delete: tags: - - pet - /pet/{petId}: - get: - description: Retrieve a pet by its ID. - operationId: getPetById + - pet + summary: Delete a pet + description: Delete a pet from the store by its ID. + operationId: deletePet parameters: - - in: path - name: petId - required: true - schema: - type: integer + - name: petId + in: path + required: true + schema: + type: integer + format: int32 + - name: api_key + in: header + schema: + type: string responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPet' - description: OK + "204": + description: No Content security: - - petstore_auth: - - write:pets - - read:pets - summary: Get pet by ID + - petstore_auth: + - write:pets + - read:pets + /pet/findByStatus: + get: tags: - - pet - post: - description: Updates a pet in the store with form data. - operationId: updatePetWithForm + - pet + summary: Find pets by status + description: Finds Pets by status. Multiple status values can be provided with comma separated strings. + operationId: findPetsByStatus parameters: - - in: path - name: petId - required: true - schema: - type: integer - requestBody: - content: - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/FormDataDtoUpdatePetWithFormRequest' + - name: status + in: query + schema: + type: string + enum: + - available + - pending + - sold responses: "200": description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Update pet with form + - petstore_auth: + - write:pets + - read:pets + /pet/findByTags: + get: tags: - - pet - /pet/{petId}/uploadImage: - post: - description: Uploads an image for a pet. - operationId: uploadFile + - pet + summary: Find pets by tags + description: Finds Pets by tags. Multiple tags can be provided with comma separated strings. + operationId: findPetsByTags parameters: - - in: query - name: additionalMetadata - schema: - type: string - - in: path - name: petId - required: true - schema: - format: int64 - type: integer - requestBody: - content: - application/octet-stream: - schema: + - name: tags + in: query + schema: + type: array + items: type: string responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoAPIResponse' - description: OK + type: array + items: + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Upload an image for a pet + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}: + get: tags: - - pet - /pet/delete/{petId}: - delete: - description: Delete a pet from the store by its ID. - operationId: deletePet + - pet + summary: Get pet by ID + description: Retrieve a pet by its ID. + operationId: getPetById parameters: - - in: path - name: petId - required: true - schema: - type: integer - - in: header - name: api_key - schema: - type: string + - name: petId + in: path + required: true + schema: + type: integer + format: int32 responses: - "204": - description: No Content + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/DtoPet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Delete a pet + - petstore_auth: + - write:pets + - read:pets + post: tags: - - pet - /pet/findByStatus: - get: - description: Finds Pets by status. Multiple status values can be provided with - comma separated strings. - operationId: findPetsByStatus + - pet + summary: Update pet with form + description: Updates a pet in the store with form data. + operationId: updatePetWithForm parameters: - - in: query - name: status - schema: - enum: - - available - - pending - - sold - type: string + - name: petId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPet' - type: array description: OK security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by status + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}/uploadImage: + post: tags: - - pet - /pet/findByTags: - get: - description: Finds Pets by tags. Multiple tags can be provided with comma separated - strings. - operationId: findPetsByTags + - pet + summary: Upload an image for a pet + description: Uploads an image for a pet. + operationId: uploadFile parameters: - - in: query - name: tags - schema: - items: + - name: petId + in: path + required: true + schema: + type: integer + format: int64 + - name: additionalMetadata + in: query + schema: type: string - type: array responses: "200": + description: OK content: application/json: schema: - items: - $ref: '#/components/schemas/DtoPet' - type: array - description: OK + $ref: "#/components/schemas/DtoAPIResponse" security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by tags - tags: - - pet + - petstore_auth: + - write:pets + - read:pets /store/order: post: + tags: + - store + summary: Place an order description: Place a new order for a pet. operationId: placeOrder requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' + $ref: "#/components/schemas/DtoOrder" responses: "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' - description: Created - summary: Place an order - tags: - - store + $ref: "#/components/schemas/DtoOrder" /store/order/{orderId}: - delete: - description: Delete an order by its ID. - operationId: deleteOrder - parameters: - - in: path - name: orderId - required: true - schema: - type: integer - responses: - "204": - description: No Content - summary: Delete an order - tags: - - store get: + tags: + - store + summary: Get order by ID description: Retrieve an order by its ID. operationId: getOrderById parameters: - - in: path - name: orderId - required: true - schema: - type: integer + - name: orderId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoOrder' - description: OK + $ref: "#/components/schemas/DtoOrder" "404": description: Not Found - summary: Get order by ID + delete: tags: - - store + - store + summary: Delete an order + description: Delete an order by its ID. + operationId: deleteOrder + parameters: + - name: orderId + in: path + required: true + schema: + type: integer + format: int32 + responses: + "204": + description: No Content /user: post: + tags: + - user + summary: Create a new user description: Create a new user in the store. operationId: createUser requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' + $ref: "#/components/schemas/DtoPetUser" responses: "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' + $ref: "#/components/schemas/DtoPetUser" + /user/createWithList: + post: + tags: + - user + summary: Create users with list + description: Create multiple users in the store with a list. + operationId: createUsersWithList + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DtoPetUser" + responses: + "201": description: Created - summary: Create a new user + /user/login: + get: tags: - - user - /user/{username}: - delete: - description: Delete a user from the store by their username. - operationId: deleteUser + - user + summary: Logs user into the system + description: Logs user into the system. + operationId: loginUser parameters: - - in: path - name: username - required: true - schema: - type: string + - name: username + in: query + schema: + type: string + - name: password + in: query + schema: + type: string responses: - "204": - description: No Content - summary: Delete a user + "200": + description: OK + content: + application/json: + schema: + type: string + "400": + description: Bad Request + /user/logout: + get: tags: - - user + - user + summary: Logs out current logged in user session + description: Logs out current logged in user session. + operationId: logoutUser + responses: + "200": + description: OK + /user/{username}: get: + tags: + - user + summary: Get user by username description: Retrieve a user by their username. operationId: getUserByName parameters: - - in: path - name: username - required: true - schema: - type: string + - name: username + in: path + required: true + schema: + type: string responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' - description: OK + $ref: "#/components/schemas/DtoPetUser" "404": description: Not Found - summary: Get user by username - tags: - - user put: + tags: + - user + summary: Update an existing user description: Update the details of an existing user. operationId: updateUser parameters: - - in: path - name: username - required: true - schema: - type: string + - name: username + in: path + required: true + schema: + type: string requestBody: content: application/json: schema: + type: object properties: email: type: string @@ -364,6 +403,7 @@ paths: type: string id: type: integer + format: int32 lastName: type: string password: @@ -371,110 +411,107 @@ paths: phone: type: string userStatus: - enum: - - 0 - - 1 - - 2 type: integer + format: int32 + enum: + - 0 + - 1 + - 2 username: type: string - type: object responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/DtoPetUser' - description: OK + $ref: "#/components/schemas/DtoPetUser" "404": description: Not Found - summary: Update an existing user + delete: tags: - - user - /user/createWithList: - post: - description: Create multiple users in the store with a list. - operationId: createUsersWithList - requestBody: - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPetUser' - nullable: true - type: array + - user + summary: Delete a user + description: Delete a user from the store by their username. + operationId: deleteUser + parameters: + - name: username + in: path + required: true + schema: + type: string responses: - "201": - description: Created - summary: Create users with list - tags: - - user + "204": + description: No Content components: schemas: DtoAPIResponse: + type: object properties: code: type: integer + format: int32 message: type: string type: type: string - type: object - DtoCategory: - properties: - id: - type: integer - name: - type: string - type: object DtoOrder: + type: object properties: complete: type: boolean id: type: integer + format: int32 petId: type: integer + format: int32 quantity: type: integer + format: int32 shipDate: - format: date-time type: string + format: date-time status: - enum: - - placed - - approved - - delivered type: string - type: object + enum: + - placed + - approved + - delivered DtoPet: + type: object properties: category: - $ref: '#/components/schemas/DtoCategory' + type: object + properties: + id: + type: integer + format: int32 + name: + type: string id: type: integer + format: int32 name: type: string photoUrls: + type: array items: type: string - nullable: true - type: array status: - enum: - - available - - pending - - sold type: string + enum: + - available + - pending + - sold tags: - items: - $ref: '#/components/schemas/DtoTag' - nullable: true type: array + items: + $ref: "#/components/schemas/DtoTag" type: type: string - type: object DtoPetUser: + type: object properties: email: type: string @@ -482,6 +519,7 @@ components: type: string id: type: integer + format: int32 lastName: type: string password: @@ -489,44 +527,32 @@ components: phone: type: string userStatus: - enum: - - 0 - - 1 - - 2 type: integer + format: int32 + enum: + - 0 + - 1 + - 2 username: type: string - type: object DtoTag: + type: object properties: id: type: integer + format: int32 name: type: string - type: object - FormDataDtoUpdatePetWithFormRequest: - properties: - name: - type: string - status: - enum: - - available - - pending - - sold - type: string - required: - - name - type: object securitySchemes: apiKey: - in: header - name: api_key type: apiKey + name: api_key + in: header petstore_auth: + type: oauth2 flows: implicit: authorizationUrl: https://petstore3.swagger.io/oauth/authorize scopes: read:pets: read your pets write:pets: modify pets in your account - type: oauth2 diff --git a/adapter/muxopenapi/types.go b/adapter/muxopenapi/types.go index 7794b8e..3277f18 100644 --- a/adapter/muxopenapi/types.go +++ b/adapter/muxopenapi/types.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/gorilla/mux" + "github.com/oaswrap/spec/option" ) diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..1bc9a56 --- /dev/null +++ b/errors.go @@ -0,0 +1,35 @@ +package spec + +import "strings" + +// ValidationErrors is a collection of validation errors that can be returned by the Validate method of various structs in the spec package. It implements the error interface and can be used to aggregate multiple validation errors into a single error value. +type ValidationErrors struct { //nolint:errname // ValidationErrors is a better name than ErrorsError or ValidationError here + Errors []error +} + +func (e ValidationErrors) Error() string { + parts := make([]string, 0, len(e.Errors)) + for _, err := range e.Errors { + if err != nil { + parts = append(parts, err.Error()) + } + } + return strings.Join(parts, "; ") +} + +func (e ValidationErrors) Unwrap() []error { + return e.Errors +} + +func joinErrors(errs []error) error { + var filtered []error + for _, err := range errs { + if err != nil { + filtered = append(filtered, err) + } + } + if len(filtered) == 0 { + return nil + } + return ValidationErrors{Errors: filtered} +} diff --git a/examples/basic/go.mod b/examples/basic/go.mod index d86f492..27a3598 100644 --- a/examples/basic/go.mod +++ b/examples/basic/go.mod @@ -1,15 +1,12 @@ module github.com/oaswrap/spec/examples/basic -go 1.21 +go 1.22 require github.com/oaswrap/spec v0.4.2 require ( + github.com/goccy/go-yaml v1.19.2 // indirect github.com/oaswrap/spec-ui v0.2.0 // indirect - github.com/swaggest/jsonschema-go v0.3.79 // indirect - github.com/swaggest/openapi-go v0.2.61 // indirect - github.com/swaggest/refl v1.4.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect ) replace github.com/oaswrap/spec => ../.. diff --git a/examples/basic/go.sum b/examples/basic/go.sum index 0fbcdcb..b478a8f 100644 --- a/examples/basic/go.sum +++ b/examples/basic/go.sum @@ -1,41 +1,14 @@ -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/oaswrap/spec-ui v0.2.0 h1:NVuAqjOUf3acSEmwy5y4q0ii15xD+KGuoJYvNmtLd4k= github.com/oaswrap/spec-ui v0.2.0/go.mod h1:D8EnD6zbYJ3q65wdltw6QHXfw+nut5XwSSA1xtlSEQQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= -github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= -github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= -github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= -github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= -github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/basic/openapi.yaml b/examples/basic/openapi.yaml index ec05dae..ba09c75 100644 --- a/examples/basic/openapi.yaml +++ b/examples/basic/openapi.yaml @@ -1,70 +1,70 @@ -openapi: 3.0.3 +openapi: 3.0.4 info: title: My API version: 1.0.0 servers: -- url: https://api.example.com + - url: https://api.example.com paths: /api/v1/login: post: + summary: User login description: User login requestBody: content: application/json: schema: - $ref: '#/components/schemas/LoginRequest' + $ref: "#/components/schemas/LoginRequest" responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/LoginResponse' - description: OK - summary: User login + $ref: "#/components/schemas/LoginResponse" /api/v1/users/{id}: get: + summary: Get user by ID description: Get user by ID parameters: - - in: path - name: id - required: true - schema: - type: string + - name: id + in: path + required: true + schema: + type: string responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/User' - description: OK + $ref: "#/components/schemas/User" security: - - bearerAuth: [] - summary: Get user by ID + - bearerAuth: [] components: schemas: LoginRequest: + type: object + required: + - username + - password properties: password: type: string username: type: string - required: - - username - - password - type: object LoginResponse: + type: object properties: token: type: string - type: object User: + type: object properties: id: type: string name: type: string - type: object securitySchemes: bearerAuth: - scheme: Bearer type: http + scheme: Bearer diff --git a/examples/petstore/go.mod b/examples/petstore/go.mod index 1f0d463..80ba4a6 100644 --- a/examples/petstore/go.mod +++ b/examples/petstore/go.mod @@ -1,15 +1,12 @@ module github.com/oaswrap/spec/examples/petstore -go 1.21 +go 1.22 require github.com/oaswrap/spec v0.4.2 require ( + github.com/goccy/go-yaml v1.19.2 // indirect github.com/oaswrap/spec-ui v0.2.0 // indirect - github.com/swaggest/jsonschema-go v0.3.79 // indirect - github.com/swaggest/openapi-go v0.2.61 // indirect - github.com/swaggest/refl v1.4.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect ) replace github.com/oaswrap/spec => ../.. diff --git a/examples/petstore/go.sum b/examples/petstore/go.sum index 0fbcdcb..b478a8f 100644 --- a/examples/petstore/go.sum +++ b/examples/petstore/go.sum @@ -1,41 +1,14 @@ -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/oaswrap/spec-ui v0.2.0 h1:NVuAqjOUf3acSEmwy5y4q0ii15xD+KGuoJYvNmtLd4k= github.com/oaswrap/spec-ui v0.2.0/go.mod h1:D8EnD6zbYJ3q65wdltw6QHXfw+nut5XwSSA1xtlSEQQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= -github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= -github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= -github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= -github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= -github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/petstore/openapi.yaml b/examples/petstore/openapi.yaml index 3f16577..867e0e8 100644 --- a/examples/petstore/openapi.yaml +++ b/examples/petstore/openapi.yaml @@ -1,385 +1,385 @@ openapi: 3.0.3 info: + title: Petstore API + description: This is a sample Petstore server. + termsOfService: https://swagger.io/terms/ contact: email: apiteam@swagger.io - description: This is a sample Petstore server. license: name: Apache 2.0 url: https://www.apache.org/licenses/LICENSE-2.0.html - termsOfService: https://swagger.io/terms/ - title: Petstore API version: 1.0.0 +servers: + - url: https://petstore3.swagger.io/api/v3 externalDocs: description: Find more info here about swagger url: https://swagger.io -servers: -- url: https://petstore3.swagger.io/api/v3 tags: -- description: Everything about your Pets - externalDocs: - description: Find out more about our Pets - url: https://swagger.io - name: pet -- description: Access to Petstore orders - externalDocs: - description: Find out more about our Store - url: https://swagger.io - name: store -- description: Operations about user - name: user + - name: pet + description: Everything about your Pets + externalDocs: + description: Find out more about our Pets + url: https://swagger.io + - name: store + description: Access to Petstore orders + externalDocs: + description: Find out more about our Store + url: https://swagger.io + - name: user + description: Operations about user paths: /pet: - post: - description: Add a new pet to the store. - operationId: addPet + put: + tags: + - pet + summary: Update an existing pet + description: Update the details of an existing pet in the store. + operationId: updatePet requestBody: content: application/json: schema: - $ref: '#/components/schemas/Pet' + $ref: "#/components/schemas/Pet" responses: - "201": + "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/Pet' - description: Created + $ref: "#/components/schemas/Pet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Add a new pet + - petstore_auth: + - write:pets + - read:pets + post: tags: - - pet - put: - description: Update the details of an existing pet in the store. - operationId: updatePet + - pet + summary: Add a new pet + description: Add a new pet to the store. + operationId: addPet requestBody: content: application/json: schema: - $ref: '#/components/schemas/Pet' + $ref: "#/components/schemas/Pet" responses: - "200": + "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/Pet' - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Update an existing pet - tags: - - pet - /pet/{petId}: - delete: - description: Delete a pet from the store by its ID. - operationId: deletePet - parameters: - - in: path - name: petId - required: true - schema: - type: integer - - in: header - name: api_key - schema: - type: string - responses: - "204": - description: No Content + $ref: "#/components/schemas/Pet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Delete a pet - tags: - - pet + - petstore_auth: + - write:pets + - read:pets + /pet/findByStatus: get: - description: Retrieve a pet by its ID. - operationId: getPetById + tags: + - pet + summary: Find pets by status + description: Finds Pets by status. Multiple status values can be provided with comma separated strings. + operationId: findPetsByStatus parameters: - - in: path - name: petId - required: true - schema: - type: integer + - name: status + in: query + schema: + type: string + enum: + - available + - pending + - sold responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/Pet' - description: OK + type: array + items: + $ref: "#/components/schemas/Pet" security: - - api_key: [] - - petstore_auth: - - write:pets - - read:pets - summary: Get pet by ID + - petstore_auth: + - write:pets + - read:pets + /pet/findByTags: + get: tags: - - pet - post: - description: Updates a pet in the store with form data. - operationId: updatePetWithForm + - pet + summary: Find pets by tags + description: Finds Pets by tags. Multiple tags can be provided with comma separated strings. + operationId: findPetsByTags parameters: - - in: path - name: petId - required: true - schema: - type: integer - requestBody: - content: - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/FormDataUpdatePetWithFormRequest' + - name: tags + in: query + schema: + type: array + items: + type: string responses: "200": description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Pet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Update pet with form + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}: + get: tags: - - pet - /pet/{petId}/uploadImage: - post: - description: Uploads an image for a pet. - operationId: uploadFile + - pet + summary: Get pet by ID + description: Retrieve a pet by its ID. + operationId: getPetById parameters: - - in: query - name: additionalMetadata - schema: - type: string - - in: path - name: petId - required: true - schema: - format: int64 - type: integer - requestBody: - content: - application/octet-stream: - schema: - type: string + - name: petId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/ApiResponse' - description: OK + $ref: "#/components/schemas/Pet" security: - - petstore_auth: - - write:pets - - read:pets - summary: Upload an image for a pet + - petstore_auth: + - write:pets + - read:pets + - api_key: [] + post: tags: - - pet - /pet/findByStatus: - get: - description: Finds Pets by status. Multiple status values can be provided with - comma separated strings. - operationId: findPetsByStatus + - pet + summary: Update pet with form + description: Updates a pet in the store with form data. + operationId: updatePetWithForm parameters: - - in: query - name: status - schema: - enum: - - available - - pending - - sold - type: string + - name: petId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": - content: - application/json: - schema: - items: - $ref: '#/components/schemas/Pet' - type: array description: OK security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by status + - petstore_auth: + - write:pets + - read:pets + delete: tags: - - pet - /pet/findByTags: - get: - description: Finds Pets by tags. Multiple tags can be provided with comma separated - strings. - operationId: findPetsByTags + - pet + summary: Delete a pet + description: Delete a pet from the store by its ID. + operationId: deletePet parameters: - - in: query - name: tags - schema: - items: + - name: petId + in: path + required: true + schema: + type: integer + format: int32 + - name: api_key + in: header + schema: + type: string + responses: + "204": + description: No Content + security: + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}/uploadImage: + post: + tags: + - pet + summary: Upload an image for a pet + description: Uploads an image for a pet. + operationId: uploadFile + parameters: + - name: petId + in: path + required: true + schema: + type: integer + format: int64 + - name: additionalMetadata + in: query + schema: type: string - type: array responses: "200": + description: OK content: application/json: schema: - items: - $ref: '#/components/schemas/Pet' - type: array - description: OK + $ref: "#/components/schemas/ApiResponse" security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by tags - tags: - - pet + - petstore_auth: + - write:pets + - read:pets /store/inventory: get: + tags: + - store + summary: Returns pet inventories by status. description: Returns a map of status codes to quantities. operationId: getInventory responses: "200": + description: OK content: application/json: schema: + type: object additionalProperties: type: integer - type: object - description: OK + format: int32 security: - - api_key: [] - summary: Returns pet inventories by status. - tags: - - store + - api_key: [] /store/order: post: + tags: + - store + summary: Place an order description: Place a new order for a pet. operationId: placeOrder requestBody: content: application/json: schema: - $ref: '#/components/schemas/Order' + $ref: "#/components/schemas/Order" responses: "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/Order' - description: Created - summary: Place an order - tags: - - store + $ref: "#/components/schemas/Order" /store/order/{orderId}: - delete: - description: Delete an order by its ID. - operationId: deleteOrder - parameters: - - in: path - name: orderId - required: true - schema: - type: integer - responses: - "204": - description: No Content - summary: Delete an order - tags: - - store get: + tags: + - store + summary: Get order by ID description: Retrieve an order by its ID. operationId: getOrderById parameters: - - in: path - name: orderId - required: true - schema: - type: integer + - name: orderId + in: path + required: true + schema: + type: integer + format: int32 responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/Order' - description: OK + $ref: "#/components/schemas/Order" "404": description: Not Found - summary: Get order by ID + delete: tags: - - store + - store + summary: Delete an order + description: Delete an order by its ID. + operationId: deleteOrder + parameters: + - name: orderId + in: path + required: true + schema: + type: integer + format: int32 + responses: + "204": + description: No Content /user: post: + tags: + - user + summary: Create a new user description: Create a new user in the store. operationId: createUser requestBody: content: application/json: schema: - $ref: '#/components/schemas/User' + $ref: "#/components/schemas/User" responses: "201": + description: Created content: application/json: schema: - $ref: '#/components/schemas/User' - description: Created - summary: Create a new user + $ref: "#/components/schemas/User" + /user/createWithList: + post: tags: - - user - /user/{username}: - delete: - description: Delete a user from the store by their username. - operationId: deleteUser - parameters: - - in: path - name: username - required: true - schema: - type: string + - user + summary: Create users with list + description: Create multiple users in the store with a list. + operationId: createUsersWithList + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" responses: - "204": - description: No Content - summary: Delete a user - tags: - - user + "201": + description: Created + /user/{username}: get: + tags: + - user + summary: Get user by username description: Retrieve a user by their username. operationId: getUserByName parameters: - - in: path - name: username - required: true - schema: - type: string + - name: username + in: path + required: true + schema: + type: string responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/User' - description: OK + $ref: "#/components/schemas/User" "404": description: Not Found - summary: Get user by username - tags: - - user put: + tags: + - user + summary: Update an existing user description: Update the details of an existing user. operationId: updateUser parameters: - - in: path - name: username - required: true - schema: - type: string + - name: username + in: path + required: true + schema: + type: string requestBody: content: application/json: schema: + type: object properties: email: type: string @@ -387,6 +387,7 @@ paths: type: string id: type: integer + format: int32 lastName: type: string password: @@ -394,130 +395,115 @@ paths: phone: type: string userStatus: - enum: - - 0 - - 1 - - 2 type: integer + format: int32 + enum: + - 0 + - 1 + - 2 username: type: string - type: object responses: "200": + description: OK content: application/json: schema: - $ref: '#/components/schemas/User' - description: OK + $ref: "#/components/schemas/User" "404": description: Not Found - summary: Update an existing user + delete: tags: - - user - /user/createWithList: - post: - description: Create multiple users in the store with a list. - operationId: createUsersWithList - requestBody: - content: - application/json: - schema: - items: - $ref: '#/components/schemas/User' - nullable: true - type: array + - user + summary: Delete a user + description: Delete a user from the store by their username. + operationId: deleteUser + parameters: + - name: username + in: path + required: true + schema: + type: string responses: - "201": - description: Created - summary: Create users with list - tags: - - user + "204": + description: No Content components: schemas: ApiResponse: + type: object properties: code: type: integer + format: int32 message: type: string type: type: string - type: object - Category: - properties: - id: - type: integer - name: - type: string - type: object - FormDataUpdatePetWithFormRequest: - properties: - name: - type: string - status: - enum: - - available - - pending - - sold - type: string - required: - - name - type: object Order: + type: object properties: complete: type: boolean id: type: integer + format: int32 petId: type: integer + format: int32 quantity: type: integer + format: int32 shipDate: - format: date-time type: string + format: date-time status: - enum: - - placed - - approved - - delivered type: string - type: object + enum: + - placed + - approved + - delivered Pet: + type: object properties: category: - $ref: '#/components/schemas/Category' + type: object + properties: + id: + type: integer + format: int32 + name: + type: string id: type: integer + format: int32 name: type: string photoUrls: + type: array items: type: string - nullable: true - type: array status: - enum: - - available - - pending - - sold type: string + enum: + - available + - pending + - sold tags: - items: - $ref: '#/components/schemas/Tag' - nullable: true type: array + items: + $ref: "#/components/schemas/Tag" type: type: string - type: object Tag: + type: object properties: id: type: integer + format: int32 name: type: string - type: object User: + type: object properties: email: type: string @@ -525,6 +511,7 @@ components: type: string id: type: integer + format: int32 lastName: type: string password: @@ -532,24 +519,24 @@ components: phone: type: string userStatus: - enum: - - 0 - - 1 - - 2 type: integer + format: int32 + enum: + - 0 + - 1 + - 2 username: type: string - type: object securitySchemes: api_key: - in: header - name: api_key type: apiKey + name: api_key + in: header petstore_auth: + type: oauth2 flows: implicit: authorizationUrl: https://petstore3.swagger.io/oauth/authorize scopes: read:pets: read your pets write:pets: modify pets in your account - type: oauth2 diff --git a/examples/petstore/router.go b/examples/petstore/router.go index 7054a65..b23394b 100644 --- a/examples/petstore/router.go +++ b/examples/petstore/router.go @@ -46,7 +46,7 @@ func createRouter() spec.Generator { ), option.WithSecurity("petstore_auth", option.SecurityOAuth2( openapi.OAuthFlows{ - Implicit: &openapi.OAuthFlowsImplicit{ + Implicit: &openapi.OAuthFlow{ AuthorizationURL: "https://petstore3.swagger.io/oauth/authorize", Scopes: map[string]string{ "write:pets": "modify pets in your account", diff --git a/go.mod b/go.mod index c206cbe..b14c989 100644 --- a/go.mod +++ b/go.mod @@ -1,22 +1,19 @@ module github.com/oaswrap/spec -go 1.21 +go 1.22 require ( + github.com/goccy/go-yaml v1.19.2 github.com/google/go-cmp v0.7.0 github.com/oaswrap/spec-ui v0.2.0 github.com/stretchr/testify v1.11.1 - github.com/swaggest/jsonschema-go v0.3.79 - github.com/swaggest/openapi-go v0.2.61 - gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/kr/pretty v0.1.0 // indirect - github.com/kr/text v0.2.0 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/swaggest/refl v1.4.0 // indirect - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 5f3db93..7657024 100644 --- a/go.sum +++ b/go.sum @@ -1,44 +1,29 @@ -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= -github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/oaswrap/spec-ui v0.2.0 h1:NVuAqjOUf3acSEmwy5y4q0ii15xD+KGuoJYvNmtLd4k= github.com/oaswrap/spec-ui v0.2.0/go.mod h1:D8EnD6zbYJ3q65wdltw6QHXfw+nut5XwSSA1xtlSEQQ= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= -github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= -github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= -github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= -github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= -github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= -github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= -github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= -github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= -github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= -github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go.work b/go.work index 836ea7c..471ac8d 100644 --- a/go.work +++ b/go.work @@ -16,8 +16,6 @@ use ( ./adapter/ginopenapi/example ./adapter/httpopenapi ./adapter/httpopenapi/example - ./adapter/httprouteropenapi - ./adapter/httprouteropenapi/example ./adapter/muxopenapi ./adapter/muxopenapi/example ./examples/basic diff --git a/go.work.sum b/go.work.sum index a66e6f6..3c7913a 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,46 +1,133 @@ +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE= github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw= +github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk= +github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/knz/go-libedit v1.10.1 h1:0pHpWtx9vcvC0xGZqEQlQdfSQs7WRlAjuPvk3fOZDCo= github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.15.2/go.mod h1:Dd6YFfwBW84ETqqtL0CPyPXillHgY6XhQH3uuCCTr/o= github.com/onsi/gomega v1.11.0/go.mod h1:azGKhqFUon9Vuj0YmTfLSmx0FUwqXYSTl5re8lQLTUg= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/yosuke-furukawa/json5 v0.1.2-0.20201207051438-cf7bb3f354ff/go.mod h1:sw49aWDqNdRJ6DYUtIQiaA3xyj2IL9tjeNYmX2ixwcU= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= nullprogram.com/x/optparse v1.0.0 h1:xGFgVi5ZaWOnYdac2foDT3vg0ZZC9ErXFV57mr4OHrI= rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/golden_test.go b/golden_test.go new file mode 100644 index 0000000..b868e05 --- /dev/null +++ b/golden_test.go @@ -0,0 +1,514 @@ +package spec_test + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oaswrap/spec" + "github.com/oaswrap/spec/internal/testutil" + "github.com/oaswrap/spec/openapi" + "github.com/oaswrap/spec/option" +) + +type ComplexRequest struct { + String string `json:"string" required:"true" minLength:"1" maxLength:"10" pattern:"^[a-z]+$"` + Int int `json:"int" minimum:"1" maximum:"100"` + Number float64 `json:"number" multipleOf:"0.5"` + Bool bool `json:"bool"` + Enum string `json:"enum" enum:"a,b,c"` + Array []string `json:"array" minItems:"1" maxItems:"5" uniqueItems:"true"` + Object *SimpleObject `json:"object"` + Map map[string]int `json:"map"` + Any any `json:"any"` + Nullable *string `json:"nullable"` +} + +type SimpleObject struct { + Foo string `json:"foo"` +} + +type ComplexResponse struct { + Data ComplexRequest `json:"data"` + Status string `json:"status"` + Message string `json:"message" deprecated:"true"` +} + +type UploadRequest struct { + File []byte `form:"file" format:"binary" description:"The file to upload"` + FileName string `form:"fileName" description:"Optional file name"` +} + +type OneOfRequest struct { + Value any `json:"value" oneOf:"string,int"` +} + +type AnonymousStructRequest struct { + Foo struct { + Bar string `json:"bar"` + } `json:"foo"` +} + +type NestedRequest struct { + Level1 struct { + Level2 struct { + Level3 string `json:"level3"` + } `json:"level2"` + } `json:"level1"` +} + +type mockPathParser struct{} + +func (mockPathParser) Parse(path string) (string, error) { + return strings.ReplaceAll(path, ":", "{") + "}", nil // simplified mock +} + +type BaseResponse[T any] struct { + Data T `json:"data"` + Message string `json:"message"` + Success bool `json:"success"` +} + +type ProfileResponse BaseResponse[User] + +func TestGolden(t *testing.T) { + allVersions := []string{openapi.Version304, openapi.Version312, openapi.Version320} + cases := []struct { + name string + versions []string + opts []option.OpenAPIOption + run func(r spec.Router) + }{ + { + name: "generics", + opts: []option.OpenAPIOption{option.WithTitle("Generics API"), option.WithVersion("1.0.0")}, + run: func(r spec.Router) { + r.Get("/user", option.Response(200, new(BaseResponse[User]))) + r.Post("/users", option.Response(201, new(BaseResponse[[]User]))) + r.Get("/profile", option.Response(200, new(ProfileResponse))) + }, + }, + { + name: "composition", + opts: []option.OpenAPIOption{option.WithTitle("Composition API"), option.WithVersion("1.0.0")}, + run: func(r spec.Router) { + r.Post("/oneof", + option.Request(new(OneOfRequest)), + option.Response(200, spec.OneOf("string", "int")), + ) + }, + }, + { + name: "anonymous_structs", + run: func(r spec.Router) { + r.Post("/anonymous", option.Request(new(AnonymousStructRequest)), option.Response(204, nil)) + }, + }, + { + name: "nested_structures", + run: func(r spec.Router) { + r.Post("/nested", option.Request(new(NestedRequest)), option.Response(204, nil)) + }, + }, + { + name: "custom_path_parser", + opts: []option.OpenAPIOption{option.WithPathParser(mockPathParser{})}, + run: func(r spec.Router) { + r.Get("/users/:id", option.Request(new(GetUserRequest)), option.Response(200, new(User))) + }, + }, + { + name: "trailing_slash", + opts: []option.OpenAPIOption{option.WithStripTrailingSlash(true)}, + run: func(r spec.Router) { + r.Get("/ping/", option.Response(204, nil)) + }, + }, + { + name: "multipart_binary", + opts: []option.OpenAPIOption{option.WithTitle("Binary API"), option.WithVersion("1.0.0")}, + run: func(r spec.Router) { + r.Post("/upload", + option.OperationID("uploadFile"), + option.Request(new(UploadRequest), + option.ContentType("multipart/form-data"), + option.ContentEncoding("file", "image/png"), + ), + option.Response(201, nil, option.ContentDescription("File uploaded")), + ) + r.Get("/download", + option.OperationID("downloadFile"), + option.Response(200, "string", + option.ContentType("image/png"), + option.ContentDescription("The image file"), + option.ContentFormat("binary"), + ), + ) + r.Post("/upload-raw", + option.OperationID("uploadRaw"), + option.Request(nil, + option.ContentType("image/png"), + option.ContentFormat("binary"), + ), + option.Response(204, nil), + ) + }, + }, + { + name: "spec_information", + opts: []option.OpenAPIOption{ + option.WithTitle("Users API"), + option.WithVersion("1.2.3"), + option.WithDescription("User account operations."), + option.WithTermsOfService("https://example.com/terms"), + option.WithContact(openapi.Contact{Name: "API Team", Email: "api@example.com"}), + option.WithLicense( + openapi.License{Name: "Apache 2.0", URL: "https://www.apache.org/licenses/LICENSE-2.0.html"}, + ), + option.WithExternalDocs("https://docs.example.com", "API documentation"), + option.WithServer("https://api.example.com"), + option.WithTags(openapi.Tag{Name: "users", Description: "User operations"}), + }, + run: func(r spec.Router) { + r.Get("/ping", option.Response(204, nil)) + }, + }, + { + name: "request_response", + opts: []option.OpenAPIOption{option.WithTitle("Login API"), option.WithVersion("1.0.0")}, + run: func(r spec.Router) { + r.Post("/login", + option.OperationID("login"), + option.Summary("Login"), + option.Request(new(LoginRequest)), + option.Response(200, new(LoginResponse)), + ) + }, + }, + { + name: "path_parameters", + opts: []option.OpenAPIOption{option.WithTitle("Users API"), option.WithVersion("1.0.0")}, + run: func(r spec.Router) { + r.Get("/users/{id}", + option.OperationID("getUser"), + option.Summary("Get user"), + option.Request(new(GetUserRequest)), + option.Response(200, new(User)), + ) + }, + }, + { + name: "security", + opts: []option.OpenAPIOption{ + option.WithTitle("Secure API"), + option.WithVersion("1.0.0"), + option.WithSecurity("bearerAuth", option.SecurityHTTPBearer("bearer")), + option.WithSecurity("apiKey", option.SecurityAPIKey("X-API-Key", "header")), + }, + run: func(r spec.Router) { + r.Get("/me", + option.Security("bearerAuth"), + option.Response(200, new(User)), + ) + }, + }, + { + name: "complex_types", + opts: []option.OpenAPIOption{option.WithTitle("Complex API"), option.WithVersion("1.0.0")}, + run: func(r spec.Router) { + r.Post("/complex", + option.OperationID("complex"), + option.Request(new(ComplexRequest)), + option.Response(200, new(ComplexResponse)), + ) + }, + }, + { + name: "multiple_content_types", + opts: []option.OpenAPIOption{option.WithTitle("Multi-content API"), option.WithVersion("1.0.0")}, + run: func(r spec.Router) { + r.Post("/multi", + option.OperationID("multi"), + option.Request(new(LoginRequest), option.ContentType("application/json")), + option.Request(new(LoginRequest), option.ContentType("application/x-www-form-urlencoded")), + option.Response(200, new(LoginResponse), option.ContentType("application/json")), + option.Response(200, new(LoginResponse), option.ContentType("application/xml")), + ) + }, + }, + { + name: "server_variables", + opts: []option.OpenAPIOption{ + option.WithTitle("Server Var API"), + option.WithVersion("1.0.0"), + option.WithServer("https://{environment}.example.com/v1", + option.ServerVariables(map[string]openapi.ServerVariable{ + "environment": { + Default: "production", + Description: "API environment", + Enum: []string{"production", "staging", "dev"}, + }, + }), + ), + }, + run: func(r spec.Router) { + r.Get("/ping", option.Response(204, nil)) + }, + }, + { + name: "openapi_320_operations", + versions: []string{openapi.Version320}, + opts: []option.OpenAPIOption{option.WithTitle("Search API"), option.WithVersion("1.0.0")}, + run: func(r spec.Router) { + r.Query("/search", option.Response(200, new([]User))) + r.Add("PURGE", "/cache", option.Response(204, nil)) + }, + }, + { + name: "compatibility_extensions", + versions: []string{openapi.Version304, openapi.Version312}, + opts: []option.OpenAPIOption{ + option.WithTitle("Compatibility API"), + option.WithVersion("1.0.0"), + option.WithSecurity("mtls", option.SecurityMutualTLS()), + option.WithDocument(func(doc *openapi.Document) { + doc.Extensions = map[string]any{"x-root": "ok"} + if doc.OpenAPI != openapi.Version304 { + doc.Webhooks = map[string]*openapi.PathItem{ + "user.created": { + Post: &openapi.Operation{ + Responses: map[string]*openapi.Response{"202": {Description: "Accepted"}}, + }, + }, + } + } + }), + }, + run: func(r spec.Router) { + r.Get("/users/{id}", + option.Request(new(GetUserRequest)), + option.Response(200, new(User)), + option.CustomizeOperation(func(op *openapi.Operation) { + op.Extensions = map[string]any{"x-operation": "ok"} + }), + ) + }, + }, + { + name: "webhook_helpers", + versions: []string{openapi.Version312, openapi.Version320}, + opts: []option.OpenAPIOption{option.WithTitle("Webhook API"), option.WithVersion("1.0.0")}, + run: func(r spec.Router) { + r.Webhook("user.created", option.Response(202, nil)) + r.AddWebhook("POST", "cache.invalidate", option.Response(204, nil)) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + versions := tc.versions + if len(versions) == 0 { + versions = allVersions + } + for _, v := range versions { + t.Run(v, func(t *testing.T) { + r := spec.NewRouter(append(tc.opts, option.WithOpenAPIVersion(v))...) + if tc.run != nil { + tc.run(r) + } + schema, err := r.GenerateSchema("yaml") + require.NoError(t, err) + versionSuffix := strings.ReplaceAll(v[:3], ".", "") + testutil.AssertGolden(t, schema, filepath.Join("testdata", tc.name+".v"+versionSuffix+".yaml")) + }) + } + }) + } +} + +func TestGoldenPetstore(t *testing.T) { + versions := []string{openapi.Version304, openapi.Version312, openapi.Version320} + for _, v := range versions { + t.Run(v, func(t *testing.T) { + r := newPetstoreRouter(option.WithOpenAPIVersion(v)) + schema, err := r.GenerateSchema("yaml") + require.NoError(t, err) + versionSuffix := strings.ReplaceAll(v[:3], ".", "") + testutil.AssertGolden(t, schema, filepath.Join("testdata", "petstore.v"+versionSuffix+".yaml")) + }) + } +} + +func TestGoldenOpenAPI320Features(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version320), + option.WithSelf("https://api.example.com/openapi.yaml"), + option.WithTitle("OpenAPI 3.2 Features API"), + option.WithVersion("1.0.0"), + option.WithTags(openapi.Tag{ + Name: "commerce", + Summary: "Commerce", + Description: "Commerce APIs.", + Kind: "nav", + }), + option.WithTags(openapi.Tag{ + Name: "payments", + Summary: "Payments", + Description: "Payment operations.", + Parent: "commerce", + Kind: "nav", + }), + option.WithSecurity("deviceAuth", + option.SecurityOAuth2(openapi.OAuthFlows{ + DeviceAuthorization: &openapi.OAuthFlow{ + DeviceAuthorizationURL: "https://auth.example.com/device", + TokenURL: "https://auth.example.com/token", + Scopes: map[string]string{ + "payments:read": "Read payments", + }, + }, + }), + option.SecurityOAuth2MetadataURL("https://auth.example.com/.well-known/oauth-authorization-server"), + option.SecurityDeprecated(), + ), + option.WithGlobalSecurity("deviceAuth", "payments:read"), + ) + r.Get("/payments/{id}", + option.Tags("payments"), + option.Request(new(GetUserRequest)), + option.Response(200, new(User)), + option.CustomizeOperation(func(op *openapi.Operation) { + resp := op.Responses["200"] + resp.Summary = "Payment found" + mt := resp.Content["application/json"] + mt.Examples = map[string]*openapi.Example{ + "encoded-id": { + Summary: "Encoded identifier", + DataValue: map[string]any{"id": "pay_123"}, + SerializedValue: `{"id":"pay_123"}`, + }, + } + resp.Content["application/json"] = mt + }), + ) + + schema, err := r.GenerateSchema("yaml") + require.NoError(t, err) + testutil.AssertGolden(t, schema, filepath.Join("testdata", "openapi_320_features.v32.yaml")) +} + +func TestGoldenOpenAPI312ReferenceDescriptions(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version312), + option.WithTitle("Reference Descriptions API"), + option.WithVersion("1.0.0"), + option.WithDocument(func(doc *openapi.Document) { + doc.Components.Schemas = map[string]*openapi.Schema{ + "User": { + Type: "object", + Required: []string{"id"}, + Properties: map[string]*openapi.Schema{ + "id": {Type: "string"}, + "name": {Type: "string"}, + }, + }, + } + doc.Components.Parameters = map[string]*openapi.Parameter{ + "TraceID": { + Name: "traceId", + In: "header", + Required: true, + Schema: &openapi.Schema{Type: "string"}, + }, + } + doc.Components.RequestBodies = map[string]*openapi.RequestBody{ + "UserBody": { + Content: map[string]openapi.MediaType{ + "application/json": {Schema: &openapi.Schema{Ref: "#/components/schemas/User"}}, + }, + }, + } + doc.Components.Responses = map[string]*openapi.Response{ + "UserResponse": { + Description: "User response", + Content: map[string]openapi.MediaType{ + "application/json": {Schema: &openapi.Schema{Ref: "#/components/schemas/User"}}, + }, + }, + } + doc.Components.Examples = map[string]*openapi.Example{ + "UserExample": { + Value: map[string]string{"id": "user-123", "name": "Ada"}, + }, + } + doc.Components.Links = map[string]*openapi.Link{ + "FindUser": {OperationID: "findUser"}, + } + }), + ) + r.Get("/users/{id}", + option.OperationID("findUser"), + option.Request(new(GetUserRequest)), + option.CustomizeOperation(func(op *openapi.Operation) { + op.Parameters = append(op.Parameters, &openapi.Parameter{ + Ref: "#/components/parameters/TraceID", + Description: "Request trace identifier.", + }) + op.RequestBody = &openapi.RequestBody{ + Ref: "#/components/requestBodies/UserBody", + Description: "Reusable user payload.", + } + op.Responses = map[string]*openapi.Response{ + "200": { + Ref: "#/components/responses/UserResponse", + Description: "Reusable user response.", + }, + "204": { + Description: "No content", + Links: map[string]*openapi.Link{ + "findUser": { + Ref: "#/components/links/FindUser", + Description: "Reusable follow-up link.", + }, + }, + }, + } + }), + ) + + schema, err := r.GenerateSchema("yaml") + require.NoError(t, err) + testutil.AssertGolden(t, schema, filepath.Join("testdata", "openapi_312_reference_descriptions.v31.yaml")) +} + +func TestOutputKeepsOpenAPIObjectOrder(t *testing.T) { + r := spec.NewRouter(option.WithTitle("Ordered API"), option.WithVersion("1.0.0")) + r.Get("/users/{id}", option.Request(new(GetUserRequest)), option.Response(200, new(User))) + + yamlRaw, err := r.GenerateSchema("yaml") + require.NoError(t, err) + assertContainsInOrder(t, string(yamlRaw), "\n", "openapi:", "info:", "paths:", "components:") + + jsonRaw, err := r.GenerateSchema("json") + require.NoError(t, err) + assertContainsInOrder(t, string(jsonRaw), "", `"openapi":`, `"info":`, `"paths":`, `"components":`) +} + +func assertContainsInOrder(t *testing.T, value, separator string, parts ...string) { + t.Helper() + offset := 0 + for _, part := range parts { + index := strings.Index(value[offset:], part) + if !assert.GreaterOrEqual(t, index, 0, "expected %q after offset %d in:\n%s", part, offset, value) { + t.FailNow() + } + offset += index + len(part) + if separator != "" { + offset += len(separator) + } + } +} diff --git a/internal/builder/builder.go b/internal/builder/builder.go new file mode 100644 index 0000000..c59ae03 --- /dev/null +++ b/internal/builder/builder.go @@ -0,0 +1,177 @@ +package builder + +import ( + "fmt" + "regexp" + "strings" + + "github.com/oaswrap/spec/internal/reflect" + "github.com/oaswrap/spec/openapi" + "github.com/oaswrap/spec/option" +) + +type Builder struct { + Config *openapi.Config + Doc *openapi.Document + Reflector *reflect.Reflector +} + +var pathParamTemplateRe = regexp.MustCompile(`\{([^{}]+)\}`) + +func NewBuilder(cfg *openapi.Config, doc *openapi.Document) *Builder { + return &Builder{ + Config: cfg, + Doc: doc, + Reflector: reflect.NewReflector(cfg), + } +} + +func (b *Builder) AddOperation(method, path string, opts []option.OperationOption) error { + return b.AddOperationTo(method, path, opts, b.Doc.Paths) +} + +func (b *Builder) AddWebhookOperation(method, name string, opts []option.OperationOption) error { + if reflect.IsOpenAPI30(b.Config.OpenAPIVersion) { + return fmt.Errorf("webhooks require OpenAPI 3.1.x or 3.2.0") + } + if b.Doc.Webhooks == nil { + b.Doc.Webhooks = map[string]*openapi.PathItem{} + } + return b.AddOperationTo(method, name, opts, b.Doc.Webhooks) +} + +func (b *Builder) AddOperationTo( + method, target string, + opts []option.OperationOption, + items map[string]*openapi.PathItem, +) error { + cfg := &option.OperationConfig{} + for _, opt := range opts { + opt(cfg) + } + if cfg.Hide { + return nil + } + + method = strings.ToUpper(method) + if method == "QUERY" && b.Config.OpenAPIVersion != openapi.Version320 { + return fmt.Errorf("method QUERY requires OpenAPI 3.2.0") + } + + op := &openapi.Operation{Responses: map[string]*openapi.Response{}} + op.OperationID = cfg.OperationID + op.Summary = cfg.Summary + op.Description = cfg.Description + op.ExternalDocs = cfg.ExternalDocs + op.Deprecated = cfg.Deprecated + op.Tags = append(op.Tags, cfg.Tags...) + for _, sec := range cfg.Security { + op.Security = append(op.Security, SecurityRequirement(sec.Name, sec.Scopes)) + } + + for _, req := range cfg.Requests { + b.AddRequest(op, req) + } + if len(cfg.Responses) == 0 { + op.Responses["default"] = &openapi.Response{Description: "Default response"} + } + for _, resp := range MergeResponses(cfg.Responses) { + if err := b.AddResponse(op, resp); err != nil { + return fmt.Errorf("%s %s response: %w", method, target, err) + } + } + for _, customize := range cfg.Customizers { + customize(op) + } + b.ensurePathParameters(target, op) + + item := items[target] + if item == nil { + item = &openapi.PathItem{} + items[target] = item + } + return SetOperation(item, method, op, b.Config.OpenAPIVersion) +} + +func (b *Builder) Finish() { + if b.Doc.Components == nil { + b.Doc.Components = &openapi.Components{} + } + if len(b.Reflector.Components) > 0 { + if b.Doc.Components.Schemas == nil { + b.Doc.Components.Schemas = map[string]*openapi.Schema{} + } + for name, schema := range b.Reflector.Components { + b.Doc.Components.Schemas[name] = schema + } + } + if ComponentsEmpty(b.Doc.Components) { + b.Doc.Components = nil + } +} + +func ComponentsEmpty(components *openapi.Components) bool { + if components == nil { + return true + } + return len(components.Schemas) == 0 && + len(components.SecuritySchemes) == 0 && + len(components.Responses) == 0 && + len(components.Parameters) == 0 && + len(components.Examples) == 0 && + len(components.RequestBodies) == 0 && + len(components.Headers) == 0 && + len(components.Links) == 0 && + len(components.Callbacks) == 0 && + len(components.PathItems) == 0 && + len(components.MediaTypes) == 0 +} + +func SecurityRequirement(name string, scopes []string) openapi.SecurityRequirement { + if scopes == nil { + scopes = []string{} + } + return openapi.SecurityRequirement{name: scopes} +} + +func (b *Builder) ensurePathParameters(target string, op *openapi.Operation) { + if !strings.HasPrefix(target, "/") { + return + } + matches := pathParamTemplateRe.FindAllStringSubmatch(target, -1) + if len(matches) == 0 { + return + } + existing := map[string]struct{}{} + hasComponentParamRef := false + for _, p := range op.Parameters { + if p == nil { + continue + } + if p.Ref != "" { + if strings.HasPrefix(p.Ref, "#/components/parameters/") { + hasComponentParamRef = true + } + continue + } + if p.In == string(openapi.ParameterInPath) && p.Name != "" { + existing[p.Name] = struct{}{} + } + } + if hasComponentParamRef { + return + } + for _, m := range matches { + name := m[1] + if _, ok := existing[name]; ok { + continue + } + op.Parameters = append(op.Parameters, &openapi.Parameter{ + Name: name, + In: string(openapi.ParameterInPath), + Required: true, + Schema: &openapi.Schema{Type: "string"}, + }) + existing[name] = struct{}{} + } +} diff --git a/internal/builder/builder_test.go b/internal/builder/builder_test.go new file mode 100644 index 0000000..37a018c --- /dev/null +++ b/internal/builder/builder_test.go @@ -0,0 +1,136 @@ +package builder + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oaswrap/spec/openapi" + "github.com/oaswrap/spec/option" +) + +func TestBuilder_AddOperation(t *testing.T) { + cfg := &openapi.Config{OpenAPIVersion: openapi.Version304} + doc := &openapi.Document{Paths: map[string]*openapi.PathItem{}} + b := NewBuilder(cfg, doc) + + err := b.AddOperation("GET", "/test", []option.OperationOption{ + option.Summary("Test Summary"), + }) + require.NoError(t, err) + + assert.NotNil(t, doc.Paths["/test"]) + assert.NotNil(t, doc.Paths["/test"].Get) + assert.Equal(t, "Test Summary", doc.Paths["/test"].Get.Summary) +} + +func TestBuilder_AddWebhookOperation(t *testing.T) { + t.Run("OpenAPI 3.0.4", func(t *testing.T) { + cfg := &openapi.Config{OpenAPIVersion: openapi.Version304} + doc := &openapi.Document{} + b := NewBuilder(cfg, doc) + + err := b.AddWebhookOperation("POST", "webhook", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "webhooks require OpenAPI 3.1.x or 3.2.0") + }) + + t.Run("OpenAPI 3.1.2", func(t *testing.T) { + cfg := &openapi.Config{OpenAPIVersion: openapi.Version312} + doc := &openapi.Document{} + b := NewBuilder(cfg, doc) + + err := b.AddWebhookOperation("POST", "webhook", nil) + require.NoError(t, err) + assert.NotNil(t, doc.Webhooks["webhook"]) + }) +} + +func TestBuilder_Finish(t *testing.T) { + cfg := &openapi.Config{OpenAPIVersion: openapi.Version304} + doc := &openapi.Document{} + b := NewBuilder(cfg, doc) + + // Simulate reflector having components + b.Reflector.Components["User"] = &openapi.Schema{Type: "object"} + + b.Finish() + + assert.NotNil(t, doc.Components) + assert.NotNil(t, doc.Components.Schemas["User"]) +} + +func TestBuilder_ComponentsEmpty(t *testing.T) { + assert.True(t, ComponentsEmpty(nil)) + assert.True(t, ComponentsEmpty(&openapi.Components{})) + assert.False(t, ComponentsEmpty(&openapi.Components{Schemas: map[string]*openapi.Schema{"S": {}}})) +} + +func TestBuilder_SecurityRequirement(t *testing.T) { + sr := SecurityRequirement("auth", []string{"read"}) + assert.Equal(t, []string{"read"}, sr["auth"]) + + sr = SecurityRequirement("auth", nil) + assert.Equal(t, []string{}, sr["auth"]) +} + +func TestBuilder_AddOperationTo_QueryVersionGuard(t *testing.T) { + cfg := &openapi.Config{OpenAPIVersion: openapi.Version312} + doc := &openapi.Document{Paths: map[string]*openapi.PathItem{}} + b := NewBuilder(cfg, doc) + + err := b.AddOperationTo("QUERY", "/search", nil, doc.Paths) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires OpenAPI 3.2.0") +} + +func TestBuilder_AddOperationTo_HideOptionSkipsOperation(t *testing.T) { + cfg := &openapi.Config{OpenAPIVersion: openapi.Version320} + doc := &openapi.Document{Paths: map[string]*openapi.PathItem{}} + b := NewBuilder(cfg, doc) + + err := b.AddOperation("GET", "/hidden", []option.OperationOption{option.Hidden()}) + require.NoError(t, err) + assert.NotContains(t, doc.Paths, "/hidden") +} + +func TestBuilder_EnsurePathParametersBehavior(t *testing.T) { + cfg := &openapi.Config{OpenAPIVersion: openapi.Version304} + doc := &openapi.Document{Paths: map[string]*openapi.PathItem{}} + b := NewBuilder(cfg, doc) + + t.Run("auto-add missing path params and deduplicate", func(t *testing.T) { + op := &openapi.Operation{ + Parameters: []*openapi.Parameter{ + nil, + { + Name: "id", + In: string(openapi.ParameterInPath), + Required: true, + Schema: &openapi.Schema{Type: "string"}, + }, + }, + } + b.ensurePathParameters("/users/{id}/orders/{orderID}", op) + require.Len(t, op.Parameters, 3) + assert.Equal(t, "orderID", op.Parameters[2].Name) + assert.Equal(t, string(openapi.ParameterInPath), op.Parameters[2].In) + assert.True(t, op.Parameters[2].Required) + }) + + t.Run("skip when path parameter component ref exists", func(t *testing.T) { + op := &openapi.Operation{ + Parameters: []*openapi.Parameter{{Ref: "#/components/parameters/UserID"}}, + } + b.ensurePathParameters("/users/{id}", op) + require.Len(t, op.Parameters, 1) + assert.Equal(t, "#/components/parameters/UserID", op.Parameters[0].Ref) + }) + + t.Run("skip for non-http-style target", func(t *testing.T) { + op := &openapi.Operation{} + b.ensurePathParameters("user.created", op) + assert.Empty(t, op.Parameters) + }) +} diff --git a/internal/builder/operation.go b/internal/builder/operation.go new file mode 100644 index 0000000..e136fd0 --- /dev/null +++ b/internal/builder/operation.go @@ -0,0 +1,91 @@ +package builder + +import ( + "fmt" + "strconv" + + "github.com/oaswrap/spec/internal/reflect" + "github.com/oaswrap/spec/openapi" +) + +func (b *Builder) AddRequest(op *openapi.Operation, cu *openapi.ContentUnit) { + params, body := b.Reflector.RequestParts(cu.Structure, ContentType(cu)) + op.Parameters = append(op.Parameters, params...) + + ct := ContentType(cu) + if body == nil { + isDefaultJSON := ct == "application/json" || cu.ContentType == "" + if isDefaultJSON && cu.Format == "" && cu.Example == nil && len(cu.Examples) == 0 { + return + } + } + + if op.RequestBody == nil { + op.RequestBody = &openapi.RequestBody{Content: map[string]openapi.MediaType{}} + } + if cu.Description != "" { + op.RequestBody.Description = cu.Description + } + if cu.Required { + op.RequestBody.Required = true + } + if body == nil && cu.Structure == nil { + body = &openapi.Schema{Type: "string"} + } + if body != nil && cu.Format != "" { + body.Format = cu.Format + } + mt := openapi.MediaType{Schema: body} + ApplyContentExamples(&mt, cu) + if len(cu.Encoding) > 0 { + mt.Encoding = map[string]*openapi.Encoding{} + for prop, enc := range cu.Encoding { + mt.Encoding[prop] = &openapi.Encoding{ContentType: enc} + } + } + op.RequestBody.Content[ct] = mt +} + +func (b *Builder) AddResponse(op *openapi.Operation, cu *openapi.ContentUnit) error { + key := strconv.Itoa(cu.HTTPStatus) + if cu.IsDefault { + key = "default" + } else if cu.HTTPStatus == 0 { + return fmt.Errorf("HTTP status is required unless ContentDefault is set") + } + + response := op.Responses[key] + if response == nil { + response = &openapi.Response{Description: ResponseDescription(cu)} + op.Responses[key] = response + } + if response.Content == nil { + response.Content = map[string]openapi.MediaType{} + } + + ct := ContentType(cu) + if cu.Structure != nil || cu.ContentType != "" || cu.Example != nil || len(cu.Examples) > 0 { + schema := b.Reflector.SchemaForValue(cu.Structure, reflect.SchemaUseComponent) + if schema == nil && cu.ContentType != "" { + schema = &openapi.Schema{Type: "string"} + } + if schema != nil && cu.Format != "" { + schema.Format = cu.Format + } + mt := openapi.MediaType{ + Schema: schema, + } + ApplyContentExamples(&mt, cu) + response.Content[ct] = mt + } + return nil +} + +func ApplyContentExamples(mediaType *openapi.MediaType, cu *openapi.ContentUnit) { + if cu.Example != nil { + mediaType.Example = cu.Example + } + if len(cu.Examples) > 0 { + mediaType.Examples = cu.Examples + } +} diff --git a/internal/builder/operation_test.go b/internal/builder/operation_test.go new file mode 100644 index 0000000..d1b7bca --- /dev/null +++ b/internal/builder/operation_test.go @@ -0,0 +1,95 @@ +package builder + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oaswrap/spec/openapi" +) + +func TestBuilder_AddRequest(t *testing.T) { + t.Run("Path Parameter", func(t *testing.T) { + cfg := &openapi.Config{OpenAPIVersion: openapi.Version304} + doc := &openapi.Document{} + b := NewBuilder(cfg, doc) + op := &openapi.Operation{} + + type Request struct { + ID string `path:"id"` + } + cu := &openapi.ContentUnit{Structure: Request{}} + b.AddRequest(op, cu) + + assert.Len(t, op.Parameters, 1) + assert.Equal(t, "id", op.Parameters[0].Name) + }) + + t.Run("Body and Description", func(t *testing.T) { + cfg := &openapi.Config{OpenAPIVersion: openapi.Version304} + doc := &openapi.Document{} + b := NewBuilder(cfg, doc) + op := &openapi.Operation{} + + cu := &openapi.ContentUnit{ + Structure: map[string]string{"foo": "bar"}, + Description: "Body description", + Required: true, + Format: "custom", + Encoding: map[string]string{"foo": "text/plain"}, + } + b.AddRequest(op, cu) + + require.NotNil(t, op.RequestBody) + assert.Equal(t, "Body description", op.RequestBody.Description) + assert.True(t, op.RequestBody.Required) + mt := op.RequestBody.Content["application/json"] + assert.Equal(t, "custom", mt.Schema.Format) + assert.Equal(t, "text/plain", mt.Encoding["foo"].ContentType) + }) + + t.Run("Default String Body", func(t *testing.T) { + cfg := &openapi.Config{OpenAPIVersion: openapi.Version304} + doc := &openapi.Document{} + b := NewBuilder(cfg, doc) + op := &openapi.Operation{} + + cu := &openapi.ContentUnit{ContentType: "text/plain"} + b.AddRequest(op, cu) + + require.NotNil(t, op.RequestBody) + assert.Equal(t, "string", op.RequestBody.Content["text/plain"].Schema.Type) + }) +} + +func TestBuilder_AddResponse(t *testing.T) { + cfg := &openapi.Config{OpenAPIVersion: openapi.Version304} + doc := &openapi.Document{} + b := NewBuilder(cfg, doc) + op := &openapi.Operation{Responses: map[string]*openapi.Response{}} + + cu := &openapi.ContentUnit{ + HTTPStatus: 200, + Structure: map[string]string{"foo": "bar"}, + } + + err := b.AddResponse(op, cu) + require.NoError(t, err) + + assert.NotNil(t, op.Responses["200"]) + assert.NotNil(t, op.Responses["200"].Content["application/json"]) +} + +func TestApplyContentExamples(t *testing.T) { + mt := &openapi.MediaType{} + cu := &openapi.ContentUnit{ + Example: "ex", + Examples: map[string]*openapi.Example{"e1": {Value: "v1"}}, + } + + ApplyContentExamples(mt, cu) + + assert.Equal(t, "ex", mt.Example) + assert.Equal(t, "v1", mt.Examples["e1"].Value) +} diff --git a/internal/builder/utils.go b/internal/builder/utils.go new file mode 100644 index 0000000..7a9a9aa --- /dev/null +++ b/internal/builder/utils.go @@ -0,0 +1,142 @@ +package builder + +import ( + "fmt" + "net/http" + + "github.com/oaswrap/spec/openapi" +) + +func SetOperation(item *openapi.PathItem, method string, op *openapi.Operation, version string) error { + switch method { + case http.MethodGet: + if item.Get != nil { + return fmt.Errorf("duplicate GET operation") + } + item.Get = op + case http.MethodPut: + if item.Put != nil { + return fmt.Errorf("duplicate PUT operation") + } + item.Put = op + case http.MethodPost: + if item.Post != nil { + return fmt.Errorf("duplicate POST operation") + } + item.Post = op + case http.MethodDelete: + if item.Delete != nil { + return fmt.Errorf("duplicate DELETE operation") + } + item.Delete = op + case http.MethodOptions: + if item.Options != nil { + return fmt.Errorf("duplicate OPTIONS operation") + } + item.Options = op + case http.MethodHead: + if item.Head != nil { + return fmt.Errorf("duplicate HEAD operation") + } + item.Head = op + case http.MethodPatch: + if item.Patch != nil { + return fmt.Errorf("duplicate PATCH operation") + } + item.Patch = op + case http.MethodTrace: + if item.Trace != nil { + return fmt.Errorf("duplicate TRACE operation") + } + item.Trace = op + case "QUERY": + if item.Query != nil { + return fmt.Errorf("duplicate QUERY operation") + } + item.Query = op + default: + if version != openapi.Version320 { + return fmt.Errorf("unsupported HTTP method %q", method) + } + if item.AdditionalOperations == nil { + item.AdditionalOperations = map[string]*openapi.Operation{} + } + if _, exists := item.AdditionalOperations[method]; exists { + return fmt.Errorf("duplicate %s operation", method) + } + item.AdditionalOperations[method] = op + } + return nil +} + +type ResponseKey struct { + Status int + ContentType string + IsDefault bool +} + +func MergeResponses(responses []*openapi.ContentUnit) []*openapi.ContentUnit { + type group struct { + key ResponseKey + items []*openapi.ContentUnit + } + groups := map[ResponseKey]*group{} + var order []ResponseKey + for _, resp := range responses { + key := ResponseKey{Status: resp.HTTPStatus, ContentType: ContentType(resp), IsDefault: resp.IsDefault} + if _, ok := groups[key]; !ok { + groups[key] = &group{key: key} + order = append(order, key) + } + groups[key].items = append(groups[key].items, resp) + } + + out := make([]*openapi.ContentUnit, 0, len(order)) + for _, key := range order { + items := groups[key].items + if len(items) == 1 { + out = append(out, items[0]) + continue + } + merged := *items[0] + values := make([]any, 0, len(items)) + for _, item := range items { + values = append(values, item.Structure) + } + merged.Structure = OneOf(values...) + out = append(out, &merged) + } + return out +} + +func ContentType(cu *openapi.ContentUnit) string { + if cu != nil && cu.ContentType != "" { + return cu.ContentType + } + return "application/json" +} + +func ResponseDescription(cu *openapi.ContentUnit) string { + if cu.Description != "" { + return cu.Description + } + if cu.IsDefault { + return "Default response" + } + if text := http.StatusText(cu.HTTPStatus); text != "" { + return text + } + return fmt.Sprintf("HTTP %d response", cu.HTTPStatus) +} + +type oneOfValue struct { + values []any +} + +func (ov oneOfValue) GetValues() []any { + return ov.values +} + +func OneOf(values ...any) any { + return oneOfValue{values: values} +} diff --git a/internal/builder/utils_test.go b/internal/builder/utils_test.go new file mode 100644 index 0000000..d7c7b68 --- /dev/null +++ b/internal/builder/utils_test.go @@ -0,0 +1,92 @@ +package builder + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/oaswrap/spec/openapi" +) + +func TestSetOperation(t *testing.T) { + tests := []struct { + name string + method string + version string + wantErr bool + }{ + {"GET", http.MethodGet, openapi.Version304, false}, + {"POST", http.MethodPost, openapi.Version304, false}, + {"PUT", http.MethodPut, openapi.Version304, false}, + {"DELETE", http.MethodDelete, openapi.Version304, false}, + {"PATCH", http.MethodPatch, openapi.Version304, false}, + {"HEAD", http.MethodHead, openapi.Version304, false}, + {"OPTIONS", http.MethodOptions, openapi.Version304, false}, + {"TRACE", http.MethodTrace, openapi.Version304, false}, + {"QUERY 3.2.0", "QUERY", openapi.Version320, false}, + { + "QUERY 3.1.2", + "QUERY", + openapi.Version312, + false, + }, // QUERY is handled as a standard method in SetOperation but it's not in net/http. + {"Custom method 3.2.0", "CUSTOM", openapi.Version320, false}, + {"Custom method 3.0.4", "CUSTOM", openapi.Version304, true}, + {"Duplicate GET", http.MethodGet, openapi.Version304, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + item := &openapi.PathItem{} + op := &openapi.Operation{} + if tt.name == "Duplicate GET" { + item.Get = &openapi.Operation{} + } + + err := SetOperation(item, tt.method, op, tt.version) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestContentType(t *testing.T) { + assert.Equal(t, "application/json", ContentType(nil)) + assert.Equal(t, "application/json", ContentType(&openapi.ContentUnit{})) + assert.Equal(t, "application/xml", ContentType(&openapi.ContentUnit{ContentType: "application/xml"})) +} + +func TestResponseDescription(t *testing.T) { + assert.Equal(t, "OK", ResponseDescription(&openapi.ContentUnit{HTTPStatus: http.StatusOK})) + assert.Equal(t, "Custom", ResponseDescription(&openapi.ContentUnit{Description: "Custom"})) + assert.Equal(t, "Default response", ResponseDescription(&openapi.ContentUnit{IsDefault: true})) + assert.Equal(t, "HTTP 999 response", ResponseDescription(&openapi.ContentUnit{HTTPStatus: 999})) +} + +func TestOneOf(t *testing.T) { + v := OneOf(1, "two") + ov, ok := v.(oneOfValue) + assert.True(t, ok) + assert.Equal(t, []any{1, "two"}, ov.GetValues()) +} + +func TestMergeResponses(t *testing.T) { + responses := []*openapi.ContentUnit{ + {HTTPStatus: 200, ContentType: "application/json", Structure: 1}, + {HTTPStatus: 200, ContentType: "application/json", Structure: "two"}, + {HTTPStatus: 400, ContentType: "application/json", Structure: "err"}, + } + + merged := MergeResponses(responses) + assert.Len(t, merged, 2) + assert.Equal(t, 200, merged[0].HTTPStatus) + assert.Equal(t, 400, merged[1].HTTPStatus) + + ov, ok := merged[0].Structure.(oneOfValue) + assert.True(t, ok) + assert.Equal(t, []any{1, "two"}, ov.GetValues()) +} diff --git a/internal/debuglog/debuglog.go b/internal/debuglog/debuglog.go deleted file mode 100644 index 661f175..0000000 --- a/internal/debuglog/debuglog.go +++ /dev/null @@ -1,122 +0,0 @@ -package debuglog - -import ( - "fmt" - - "github.com/oaswrap/spec/openapi" -) - -type Logger struct { - prefix string - logger openapi.Logger -} - -func NewLogger(prefix string, logger openapi.Logger) *Logger { - return &Logger{logger: logger, prefix: fmt.Sprintf("[%s]", prefix)} -} - -func (l *Logger) Printf(format string, v ...any) { - l.logger.Printf(l.prefix+" "+format, v...) -} - -func (l *Logger) LogOp(method, path, action, value string) { - l.Printf("%s %s โ†’ %s: %s", method, path, action, value) -} - -func (l *Logger) LogAction(action, value string) { - l.Printf("%s: %s", action, value) -} - -func (l *Logger) LogContact(contact *openapi.Contact) { - if contact == nil { - return - } - var contactInfo string - if contact.Name != "" { - contactInfo += "name: " + contact.Name + ", " - } - if contact.Email != "" { - contactInfo += "email: " + contact.Email + ", " - } - if contact.URL != "" { - contactInfo += "url: " + contact.URL - } - if contactInfo != "" { - l.Printf("set contact: %s", contactInfo) - } -} - -func (l *Logger) LogLicense(license *openapi.License) { - var licenseInfo string - if license.Name != "" { - licenseInfo += "name: " + license.Name + ", " - } - if license.URL != "" { - licenseInfo += "url: " + license.URL - } - if licenseInfo != "" { - l.Printf("set license: %s", licenseInfo) - } -} - -func (l *Logger) LogExternalDocs(externalDocs *openapi.ExternalDocs) { - var docsInfo string - if externalDocs.URL != "" { - docsInfo += "url: " + externalDocs.URL - } - if externalDocs.Description != "" { - docsInfo += ", description: " + externalDocs.Description - } - if docsInfo != "" { - l.Printf("set external docs: %s", docsInfo) - } -} - -func (l *Logger) LogServer(server openapi.Server) { - var serverInfo string - serverInfo += "url: " + server.URL - if server.Description != nil { - serverInfo += ", description: " + *server.Description - } - if len(server.Variables) > 0 { - serverInfo += ", variables: " - for name, variable := range server.Variables { - serverInfo += name + ": " + variable.Default + ", " //nolint:perfsprint // simple diagnostic string build - } - serverInfo = serverInfo[:len(serverInfo)-2] // Remove trailing comma and space - } - l.Printf("set server: %s", serverInfo) -} - -func (l *Logger) LogTag(tag openapi.Tag) { - tagInfo := "name: " + tag.Name - if tag.Description != "" { - tagInfo += ", description: " + tag.Description - } - if tag.ExternalDocs != nil { - tagInfo += ", external docs: " + tag.ExternalDocs.URL - if tag.ExternalDocs.Description != "" { - tagInfo += " (" + tag.ExternalDocs.Description + ")" - } - } - l.Printf("add tag: %s", tagInfo) -} - -func (l *Logger) LogSecurityScheme(name string, scheme *openapi.SecurityScheme) { - var typeInfo string - switch { - case scheme.APIKey != nil: - typeInfo = "APIKey" - case scheme.HTTPBearer != nil: - typeInfo = "HTTPBearer" - case scheme.OAuth2 != nil: - typeInfo = "OAuth2" - default: - typeInfo = "Unknown" - } - schemeInfo := "name: " + name + ", type: " + typeInfo - if scheme.Description != nil { - schemeInfo += ", description: " + *scheme.Description - } - l.Printf("add security scheme: %s", schemeInfo) -} diff --git a/internal/debuglog/debuglog_test.go b/internal/debuglog/debuglog_test.go deleted file mode 100644 index 297e957..0000000 --- a/internal/debuglog/debuglog_test.go +++ /dev/null @@ -1,179 +0,0 @@ -package debuglog_test - -import ( - "fmt" - "testing" - - "github.com/oaswrap/spec/internal/debuglog" - "github.com/oaswrap/spec/openapi" - "github.com/stretchr/testify/assert" -) - -type mockLogger struct { - messages []string -} - -func (m *mockLogger) Printf(format string, v ...any) { - m.messages = append(m.messages, fmt.Sprintf(format, v...)) -} - -func TestLogger_Printf(t *testing.T) { - mockLog := &mockLogger{} - logger := debuglog.NewLogger("test", mockLog) - - logger.Printf("Hello %s", "world") - - expected := "[test] Hello world" - assert.Len(t, mockLog.messages, 1) - assert.Equal(t, expected, mockLog.messages[0]) -} - -func TestLogger_LogOp(t *testing.T) { - mockLog := &mockLogger{} - logger := debuglog.NewLogger("api", mockLog) - - logger.LogOp("GET", "/users", "fetch", "all users") - - expected := "[api] GET /users โ†’ fetch: all users" - assert.Len(t, mockLog.messages, 1) - assert.Equal(t, expected, mockLog.messages[0]) -} - -func TestLogger_LogAction(t *testing.T) { - mockLog := &mockLogger{} - logger := debuglog.NewLogger("test", mockLog) - - logger.LogAction("validate", "schema") - - expected := "[test] validate: schema" - assert.Len(t, mockLog.messages, 1) - assert.Equal(t, expected, mockLog.messages[0]) -} - -func TestLogger_LogContact(t *testing.T) { - mockLog := &mockLogger{} - logger := debuglog.NewLogger("test", mockLog) - - // Test with nil contact - logger.LogContact(nil) - assert.Empty(t, mockLog.messages) - - // Test with full contact - contact := &openapi.Contact{ - Name: "John Doe", - Email: "john@example.com", - URL: "https://example.com", - } - logger.LogContact(contact) - - expected := "[test] set contact: name: John Doe, email: john@example.com, url: https://example.com" - assert.Len(t, mockLog.messages, 1) - assert.Equal(t, expected, mockLog.messages[0]) -} - -func TestLogger_LogLicense(t *testing.T) { - mockLog := &mockLogger{} - logger := debuglog.NewLogger("test", mockLog) - - license := &openapi.License{ - Name: "MIT", - URL: "https://opensource.org/licenses/MIT", - } - logger.LogLicense(license) - - expected := "[test] set license: name: MIT, url: https://opensource.org/licenses/MIT" - assert.Len(t, mockLog.messages, 1) - assert.Equal(t, expected, mockLog.messages[0]) -} - -func TestLogger_LogExternalDocs(t *testing.T) { - mockLog := &mockLogger{} - logger := debuglog.NewLogger("test", mockLog) - - docs := &openapi.ExternalDocs{ - URL: "https://docs.example.com", - Description: "API Documentation", - } - logger.LogExternalDocs(docs) - - expected := "[test] set external docs: url: https://docs.example.com, description: API Documentation" - assert.Len(t, mockLog.messages, 1) - assert.Equal(t, expected, mockLog.messages[0]) -} - -func TestLogger_LogServer(t *testing.T) { - mockLog := &mockLogger{} - logger := debuglog.NewLogger("test", mockLog) - - desc := "Production server" - server := openapi.Server{ - URL: "https://api.example.com", - Description: &desc, - Variables: map[string]openapi.ServerVariable{ - "version": {Default: "v1"}, - "env": {Default: "prod"}, - }, - } - logger.LogServer(server) - - assert.Len(t, mockLog.messages, 1) - message := mockLog.messages[0] - assert.Contains(t, message, "[test] set server: url: https://api.example.com") - assert.Contains(t, message, "description: Production server") - assert.Contains(t, message, "variables:") -} - -func TestLogger_LogTag(t *testing.T) { - mockLog := &mockLogger{} - logger := debuglog.NewLogger("test", mockLog) - - tag := openapi.Tag{ - Name: "users", - Description: "User operations", - ExternalDocs: &openapi.ExternalDocs{ - URL: "https://docs.example.com/users", - Description: "User docs", - }, - } - logger.LogTag(tag) - - expected := "[test] add tag: name: users, description: User operations, external docs: https://docs.example.com/users (User docs)" - assert.Len(t, mockLog.messages, 1) - assert.Equal(t, expected, mockLog.messages[0]) -} - -func TestLogger_LogSecurityScheme(t *testing.T) { - mockLog := &mockLogger{} - logger := debuglog.NewLogger("test", mockLog) - - desc := "API Key authentication" - scheme := &openapi.SecurityScheme{ - APIKey: &openapi.SecuritySchemeAPIKey{}, - Description: &desc, - } - logger.LogSecurityScheme("apiKey", scheme) - - expected := "[test] add security scheme: name: apiKey, type: APIKey, description: API Key authentication" - assert.Len(t, mockLog.messages, 1) - assert.Equal(t, expected, mockLog.messages[0]) - - desc = "HTTP Bearer authentication" - scheme = &openapi.SecurityScheme{ - HTTPBearer: &openapi.SecuritySchemeHTTPBearer{}, - Description: &desc, - } - logger.LogSecurityScheme("bearer", scheme) - expected = "[test] add security scheme: name: bearer, type: HTTPBearer, description: HTTP Bearer authentication" - assert.Len(t, mockLog.messages, 2) - assert.Equal(t, expected, mockLog.messages[1]) - - desc = "OAuth 2.0 authentication" - scheme = &openapi.SecurityScheme{ - OAuth2: &openapi.SecuritySchemeOAuth2{}, - Description: &desc, - } - logger.LogSecurityScheme("oauth2", scheme) - expected = "[test] add security scheme: name: oauth2, type: OAuth2, description: OAuth 2.0 authentication" - assert.Len(t, mockLog.messages, 3) - assert.Equal(t, expected, mockLog.messages[2]) -} diff --git a/internal/errs/error.go b/internal/errs/error.go deleted file mode 100644 index fca894f..0000000 --- a/internal/errs/error.go +++ /dev/null @@ -1,49 +0,0 @@ -package errs - -import ( - "strings" - "sync" -) - -// SpecError is a thread-safe error collector for OpenAPI specification errors. -type SpecError struct { - mu sync.Mutex - errors []error -} - -func (se *SpecError) Add(err error) { - se.mu.Lock() - defer se.mu.Unlock() - if err != nil { - se.errors = append(se.errors, err) - } -} - -// Errors returns a slice of collected errors. -func (se *SpecError) Errors() []error { - se.mu.Lock() - defer se.mu.Unlock() - return se.errors -} - -// Error implements the error interface for SpecError. -func (se *SpecError) Error() string { - se.mu.Lock() - defer se.mu.Unlock() - if len(se.errors) == 0 { - return "" - } - var sb strings.Builder - sb.WriteString("Spec errors:\n") - for _, err := range se.errors { - sb.WriteString("- " + err.Error() + "\n") - } - return sb.String() -} - -// HasErrors checks if there are any collected errors. -func (se *SpecError) HasErrors() bool { - se.mu.Lock() - defer se.mu.Unlock() - return len(se.errors) > 0 -} diff --git a/internal/errs/error_test.go b/internal/errs/error_test.go deleted file mode 100644 index 6ca1c0c..0000000 --- a/internal/errs/error_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package errs_test - -import ( - "errors" - "sync" - "testing" - - "github.com/oaswrap/spec/internal/errs" - "github.com/stretchr/testify/assert" -) - -func TestSpecError_Add(t *testing.T) { - se := &errs.SpecError{} - - // Test adding a valid error - err := errors.New("test error") - se.Add(err) - - assert.Len(t, se.Errors(), 1) - - // Test adding nil error (should not be added) - se.Add(nil) - - assert.Len(t, se.Errors(), 1) -} - -func TestSpecError_Errors(t *testing.T) { - se := &errs.SpecError{} - - // Test empty errors - errs := se.Errors() - assert.Empty(t, errs) - - // Test with errors - err1 := errors.New("error 1") - err2 := errors.New("error 2") - se.Add(err1) - se.Add(err2) - - errs = se.Errors() - assert.Len(t, errs, 2) -} - -func TestSpecError_Error(t *testing.T) { - se := &errs.SpecError{} - - // Test empty error message - msg := se.Error() - assert.Empty(t, msg) - - // Test with single error - se.Add(errors.New("test error")) - msg = se.Error() - expected := "Spec errors:\n- test error\n" - assert.Equal(t, expected, msg) - - // Test with multiple errors - se.Add(errors.New("second error")) - msg = se.Error() - expected = "Spec errors:\n- test error\n- second error\n" - assert.Equal(t, expected, msg) -} - -func TestSpecError_HasErrors(t *testing.T) { - se := &errs.SpecError{} - - // Test with no errors - assert.False(t, se.HasErrors()) - - // Test with errors - se.Add(errors.New("test error")) - assert.True(t, se.HasErrors()) -} - -func TestSpecError_ConcurrentAccess(t *testing.T) { - se := &errs.SpecError{} - var wg sync.WaitGroup - - // Test concurrent adds - for i := 0; i < 100; i++ { - wg.Add(1) - go func() { - defer wg.Done() - se.Add(errors.New("error")) - }() - } - - wg.Wait() - - assert.Len(t, se.Errors(), 100) -} diff --git a/internal/mapper/openapi3.go b/internal/mapper/openapi3.go deleted file mode 100644 index d3c0740..0000000 --- a/internal/mapper/openapi3.go +++ /dev/null @@ -1,238 +0,0 @@ -package mapper - -import ( - "github.com/oaswrap/spec/openapi" - "github.com/swaggest/openapi-go/openapi3" -) - -func OAS3Contact(contact *openapi.Contact) *openapi3.Contact { - if contact == nil { - return nil - } - result := &openapi3.Contact{ - MapOfAnything: contact.MapOfAnything, - } - if contact.Name != "" { - result.Name = &contact.Name - } - if contact.URL != "" { - result.URL = &contact.URL - } - if contact.Email != "" { - result.Email = &contact.Email - } - return result -} - -func OAS3License(license *openapi.License) *openapi3.License { - if license == nil { - return nil - } - result := &openapi3.License{ - Name: license.Name, - MapOfAnything: license.MapOfAnything, - } - if license.URL != "" { - result.URL = &license.URL - } - return result -} - -func OAS3ExternalDocs(docs *openapi.ExternalDocs) *openapi3.ExternalDocumentation { - if docs == nil { - return nil - } - result := &openapi3.ExternalDocumentation{ - URL: docs.URL, - MapOfAnything: docs.MapOfAnything, - } - if docs.Description != "" { - result.Description = &docs.Description - } - return result -} - -func OAS3Tags(tags []openapi.Tag) []openapi3.Tag { - if len(tags) == 0 { - return nil - } - result := make([]openapi3.Tag, 0, len(tags)) - for _, tag := range tags { - result = append(result, OAS3Tag(tag)) - } - return result -} - -func OAS3Tag(tag openapi.Tag) openapi3.Tag { - result := openapi3.Tag{ - Name: tag.Name, - MapOfAnything: tag.MapOfAnything, - } - if tag.Description != "" { - result.Description = &tag.Description - } - if tag.ExternalDocs != nil { - result.ExternalDocs = OAS3ExternalDocs(tag.ExternalDocs) - } - return result -} - -func OAS3Servers(servers []openapi.Server) []openapi3.Server { - if len(servers) == 0 { - return nil - } - result := make([]openapi3.Server, 0, len(servers)) - for _, server := range servers { - result = append(result, OAS3Server(server)) - } - return result -} - -func OAS3Server(server openapi.Server) openapi3.Server { - var variables map[string]openapi3.ServerVariable - - if len(server.Variables) > 0 { - variables = make(map[string]openapi3.ServerVariable, len(server.Variables)) - for name, variable := range server.Variables { - oasServerVariable := openapi3.ServerVariable{ - Default: variable.Default, - Enum: variable.Enum, - MapOfAnything: variable.MapOfAnything, - } - if variable.Description != "" { - description := variable.Description - oasServerVariable.Description = &description - } - variables[name] = oasServerVariable - } - } - - return openapi3.Server{ - URL: server.URL, - Description: server.Description, - Variables: variables, - } -} - -func OAS3SecurityScheme(scheme *openapi.SecurityScheme) *openapi3.SecurityScheme { - if scheme == nil { - return nil - } - oasSecurityScheme := &openapi3.SecurityScheme{ - APIKeySecurityScheme: OAS3APIKey(scheme, scheme.APIKey), - HTTPSecurityScheme: OAS3HTTPBearer(scheme.HTTPBearer, scheme.Description), - OAuth2SecurityScheme: OAS3OAuth2SecurityScheme(scheme.OAuth2, scheme.Description), - } - if oasSecurityScheme.APIKeySecurityScheme == nil && - oasSecurityScheme.HTTPSecurityScheme == nil && - oasSecurityScheme.OAuth2SecurityScheme == nil { - return nil // No valid security scheme defined - } - return oasSecurityScheme -} - -func OAS3APIKey(scheme *openapi.SecurityScheme, apiKey *openapi.SecuritySchemeAPIKey) *openapi3.APIKeySecurityScheme { - if apiKey == nil { - return nil - } - return &openapi3.APIKeySecurityScheme{ - Description: scheme.Description, - Name: apiKey.Name, - In: openapi3.APIKeySecuritySchemeIn(apiKey.In), - } -} - -func OAS3HTTPBearer( - securityScheme *openapi.SecuritySchemeHTTPBearer, - description *string, -) *openapi3.HTTPSecurityScheme { - if securityScheme == nil { - return nil - } - return &openapi3.HTTPSecurityScheme{ - Description: description, - Scheme: securityScheme.Scheme, - BearerFormat: securityScheme.BearerFormat, - } -} - -func OAS3OAuth2SecurityScheme( - oauth2 *openapi.SecuritySchemeOAuth2, - description *string, -) *openapi3.OAuth2SecurityScheme { - if oauth2 == nil { - return nil - } - return &openapi3.OAuth2SecurityScheme{ - Description: description, - Flows: OAS3Oauth2Flows(oauth2.Flows), - } -} - -func OAS3Oauth2Flows(flows openapi.OAuthFlows) openapi3.OAuthFlows { - return openapi3.OAuthFlows{ - Implicit: OAS3OauthFlowsImplicit(flows.Implicit), - Password: OAS3OauthFlowsPassword(flows.Password), - ClientCredentials: OAS3OauthFlowsClientCredentials(flows.ClientCredentials), - AuthorizationCode: OAS3OauthFlowsAuthorizationCode(flows.AuthorizationCode), - } -} - -func OAS3OauthFlowsImplicit(flows *openapi.OAuthFlowsImplicit) *openapi3.ImplicitOAuthFlow { - if flows == nil { - return nil - } - return &openapi3.ImplicitOAuthFlow{ - AuthorizationURL: flows.AuthorizationURL, - RefreshURL: flows.RefreshURL, - Scopes: flows.Scopes, - MapOfAnything: flows.MapOfAnything, - } -} - -func OAS3OauthFlowsPassword(flows *openapi.OAuthFlowsPassword) *openapi3.PasswordOAuthFlow { - if flows == nil { - return nil - } - return &openapi3.PasswordOAuthFlow{ - TokenURL: flows.TokenURL, - RefreshURL: flows.RefreshURL, - Scopes: flows.Scopes, - MapOfAnything: flows.MapOfAnything, - } -} - -func OAS3OauthFlowsClientCredentials(flows *openapi.OAuthFlowsClientCredentials) *openapi3.ClientCredentialsFlow { - if flows == nil { - return nil - } - return &openapi3.ClientCredentialsFlow{ - TokenURL: flows.TokenURL, - Scopes: flows.Scopes, - MapOfAnything: flows.MapOfAnything, - } -} - -func OAS3OauthFlowsAuthorizationCode(flows *openapi.OAuthFlowsAuthorizationCode) *openapi3.AuthorizationCodeOAuthFlow { - if flows == nil { - return nil - } - return &openapi3.AuthorizationCodeOAuthFlow{ - AuthorizationURL: flows.AuthorizationURL, - TokenURL: flows.TokenURL, - RefreshURL: flows.RefreshURL, - Scopes: flows.Scopes, - MapOfAnything: flows.MapOfAnything, - } -} - -func StringMapToEncodingMap3(enc map[string]string) map[string]openapi3.Encoding { - res := map[string]openapi3.Encoding{} - for k, v := range enc { - rv := v - res[k] = openapi3.Encoding{ - ContentType: &rv, - } - } - return res -} diff --git a/internal/mapper/openapi31.go b/internal/mapper/openapi31.go deleted file mode 100644 index 7a7f595..0000000 --- a/internal/mapper/openapi31.go +++ /dev/null @@ -1,233 +0,0 @@ -package mapper - -import ( - "github.com/oaswrap/spec/openapi" - "github.com/swaggest/openapi-go/openapi31" -) - -func OAS31Contact(contact *openapi.Contact) *openapi31.Contact { - if contact == nil { - return nil - } - result := &openapi31.Contact{ - MapOfAnything: contact.MapOfAnything, - } - if contact.Name != "" { - result.Name = &contact.Name - } - if contact.URL != "" { - result.URL = &contact.URL - } - if contact.Email != "" { - result.Email = &contact.Email - } - return result -} - -func OAS31License(license *openapi.License) *openapi31.License { - if license == nil { - return nil - } - result := &openapi31.License{ - Name: license.Name, - MapOfAnything: license.MapOfAnything, - } - if license.URL != "" { - result.URL = &license.URL - } - return result -} - -func OAS31ExternalDocs(externalDocs *openapi.ExternalDocs) *openapi31.ExternalDocumentation { - if externalDocs == nil { - return nil - } - result := &openapi31.ExternalDocumentation{ - URL: externalDocs.URL, - MapOfAnything: externalDocs.MapOfAnything, - } - if externalDocs.Description != "" { - result.Description = &externalDocs.Description - } - return result -} - -func OAS31Tags(tags []openapi.Tag) []openapi31.Tag { - if len(tags) == 0 { - return nil - } - result := make([]openapi31.Tag, 0, len(tags)) - for _, tag := range tags { - result = append(result, OAS31Tag(tag)) - } - return result -} - -func OAS31Tag(tag openapi.Tag) openapi31.Tag { - result := openapi31.Tag{ - Name: tag.Name, - MapOfAnything: tag.MapOfAnything, - } - if tag.Description != "" { - result.Description = &tag.Description - } - if tag.ExternalDocs != nil { - result.ExternalDocs = OAS31ExternalDocs(tag.ExternalDocs) - } - return result -} - -func OAS31Servers(servers []openapi.Server) []openapi31.Server { - if len(servers) == 0 { - return nil - } - result := make([]openapi31.Server, 0, len(servers)) - for _, server := range servers { - result = append(result, OAS31Server(server)) - } - return result -} - -func OAS31Server(server openapi.Server) openapi31.Server { - var variables map[string]openapi31.ServerVariable - - if len(server.Variables) > 0 { - variables = make(map[string]openapi31.ServerVariable, len(server.Variables)) - for name, variable := range server.Variables { - oasServerVariable := openapi31.ServerVariable{ - Default: variable.Default, - Enum: variable.Enum, - MapOfAnything: variable.MapOfAnything, - } - if variable.Description != "" { - description := variable.Description - oasServerVariable.Description = &description - } - variables[name] = oasServerVariable - } - } - - return openapi31.Server{ - URL: server.URL, - Description: server.Description, - Variables: variables, - } -} - -func OAS31SecurityScheme(scheme *openapi.SecurityScheme) *openapi31.SecurityScheme { - if scheme == nil { - return nil - } - openapiScheme := &openapi31.SecurityScheme{ - Description: scheme.Description, - MapOfAnything: scheme.MapOfAnything, - APIKey: OAS31APIKey(scheme.APIKey), - HTTPBearer: OAS31HTTPBearer(scheme.HTTPBearer), - Oauth2: OAS31SecuritySchemeOauth2(scheme.OAuth2), - } - if openapiScheme.APIKey == nil && openapiScheme.HTTPBearer == nil && openapiScheme.Oauth2 == nil { - return nil // No valid security scheme defined - } - return openapiScheme -} - -func OAS31APIKey(apiKey *openapi.SecuritySchemeAPIKey) *openapi31.SecuritySchemeAPIKey { - if apiKey == nil { - return nil - } - return &openapi31.SecuritySchemeAPIKey{ - Name: apiKey.Name, - In: openapi31.SecuritySchemeAPIKeyIn(apiKey.In), - } -} - -func OAS31HTTPBearer(scheme *openapi.SecuritySchemeHTTPBearer) *openapi31.SecuritySchemeHTTPBearer { - if scheme == nil { - return nil - } - return &openapi31.SecuritySchemeHTTPBearer{ - Scheme: scheme.Scheme, - BearerFormat: scheme.BearerFormat, - } -} - -func OAS31SecuritySchemeOauth2(oauth2 *openapi.SecuritySchemeOAuth2) *openapi31.SecuritySchemeOauth2 { - if oauth2 == nil { - return nil - } - return &openapi31.SecuritySchemeOauth2{ - Flows: OAS31Oauth2Flows(oauth2.Flows), - } -} - -func OAS31Oauth2Flows(flows openapi.OAuthFlows) openapi31.OauthFlows { - return openapi31.OauthFlows{ - Implicit: OAS31OauthFlowsImplicit(flows.Implicit), - Password: OAS31OauthFlowsPassword(flows.Password), - ClientCredentials: OAS31OauthFlowsClientCredentials(flows.ClientCredentials), - AuthorizationCode: OAS31OauthFlowsAuthorizationCode(flows.AuthorizationCode), - } -} - -func OAS31OauthFlowsImplicit(flows *openapi.OAuthFlowsImplicit) *openapi31.OauthFlowsDefsImplicit { - if flows == nil { - return nil - } - return &openapi31.OauthFlowsDefsImplicit{ - AuthorizationURL: flows.AuthorizationURL, - RefreshURL: flows.RefreshURL, - Scopes: flows.Scopes, - MapOfAnything: flows.MapOfAnything, - } -} - -func OAS31OauthFlowsPassword(flows *openapi.OAuthFlowsPassword) *openapi31.OauthFlowsDefsPassword { - if flows == nil { - return nil - } - return &openapi31.OauthFlowsDefsPassword{ - TokenURL: flows.TokenURL, - RefreshURL: flows.RefreshURL, - Scopes: flows.Scopes, - MapOfAnything: flows.MapOfAnything, - } -} - -func OAS31OauthFlowsClientCredentials( - flows *openapi.OAuthFlowsClientCredentials, -) *openapi31.OauthFlowsDefsClientCredentials { - if flows == nil { - return nil - } - return &openapi31.OauthFlowsDefsClientCredentials{ - TokenURL: flows.TokenURL, - Scopes: flows.Scopes, - MapOfAnything: flows.MapOfAnything, - } -} - -func OAS31OauthFlowsAuthorizationCode( - flows *openapi.OAuthFlowsAuthorizationCode, -) *openapi31.OauthFlowsDefsAuthorizationCode { - if flows == nil { - return nil - } - return &openapi31.OauthFlowsDefsAuthorizationCode{ - AuthorizationURL: flows.AuthorizationURL, - TokenURL: flows.TokenURL, - RefreshURL: flows.RefreshURL, - Scopes: flows.Scopes, - MapOfAnything: flows.MapOfAnything, - } -} - -func StringMapToEncodingMap31(enc map[string]string) map[string]openapi31.Encoding { - res := map[string]openapi31.Encoding{} - for k, v := range enc { - rv := v - res[k] = openapi31.Encoding{ - ContentType: &rv, - } - } - return res -} diff --git a/internal/mapper/openapi_test.go b/internal/mapper/openapi_test.go deleted file mode 100644 index c3317c2..0000000 --- a/internal/mapper/openapi_test.go +++ /dev/null @@ -1,1184 +0,0 @@ -package mapper_test - -import ( - "testing" - - "github.com/oaswrap/spec/internal/mapper" - "github.com/oaswrap/spec/openapi" - "github.com/oaswrap/spec/pkg/util" - "github.com/stretchr/testify/assert" - "github.com/swaggest/openapi-go/openapi3" - "github.com/swaggest/openapi-go/openapi31" -) - -func TestOASContact(t *testing.T) { - tests := []struct { - name string - contact *openapi.Contact - expected3 *openapi3.Contact - expected31 *openapi31.Contact - }{ - { - name: "nil contact", - contact: nil, - expected3: nil, - }, - { - name: "empty contact", - contact: &openapi.Contact{}, - expected3: &openapi3.Contact{}, - expected31: &openapi31.Contact{}, - }, - { - name: "contact with name only", - contact: &openapi.Contact{Name: "John Doe"}, - expected3: &openapi3.Contact{Name: util.PtrOf("John Doe")}, - expected31: &openapi31.Contact{Name: util.PtrOf("John Doe")}, - }, - { - name: "contact with URL only", - contact: &openapi.Contact{URL: "https://example.com"}, - expected3: &openapi3.Contact{URL: util.PtrOf("https://example.com")}, - expected31: &openapi31.Contact{URL: util.PtrOf("https://example.com")}, - }, - { - name: "contact with email only", - contact: &openapi.Contact{Email: "john@example.com"}, - expected3: &openapi3.Contact{Email: util.PtrOf("john@example.com")}, - expected31: &openapi31.Contact{Email: util.PtrOf("john@example.com")}, - }, - { - name: "contact with all fields", - contact: &openapi.Contact{ - Name: "John Doe", - URL: "https://example.com", - Email: "john@example.com", - MapOfAnything: map[string]interface{}{ - "x-custom": "value", - }, - }, - expected3: &openapi3.Contact{ - Name: util.PtrOf("John Doe"), - URL: util.PtrOf("https://example.com"), - Email: util.PtrOf("john@example.com"), - MapOfAnything: map[string]interface{}{ - "x-custom": "value", - }, - }, - expected31: &openapi31.Contact{ - Name: util.PtrOf("John Doe"), - URL: util.PtrOf("https://example.com"), - Email: util.PtrOf("john@example.com"), - MapOfAnything: map[string]interface{}{ - "x-custom": "value", - }, - }, - }, - { - name: "contact with MapOfAnything only", - contact: &openapi.Contact{ - MapOfAnything: map[string]interface{}{ - "x-vendor": "extension", - }, - }, - expected3: &openapi3.Contact{ - MapOfAnything: map[string]interface{}{ - "x-vendor": "extension", - }, - }, - expected31: &openapi31.Contact{ - MapOfAnything: map[string]interface{}{ - "x-vendor": "extension", - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result3 := mapper.OAS3Contact(tt.contact) - assert.Equal(t, tt.expected3, result3, "OpenAPI 3 Contact mapping failed") - - result31 := mapper.OAS31Contact(tt.contact) - assert.Equal(t, tt.expected31, result31, "OpenAPI 3.1 Contact mapping failed") - }) - } -} - -func TestOASLicense(t *testing.T) { - tests := []struct { - name string - license *openapi.License - expected3 *openapi3.License - expected31 *openapi31.License - }{ - { - name: "nil license", - license: nil, - expected3: nil, - expected31: nil, - }, - { - name: "empty license", - license: &openapi.License{}, - expected3: &openapi3.License{}, - expected31: &openapi31.License{}, - }, - { - name: "license with name only", - license: &openapi.License{Name: "MIT"}, - expected3: &openapi3.License{Name: "MIT"}, - expected31: &openapi31.License{Name: "MIT"}, - }, - { - name: "license with all fields", - license: &openapi.License{ - Name: "Apache 2.0", - URL: "https://www.apache.org/licenses/LICENSE-2.0.html", - MapOfAnything: map[string]interface{}{ - "x-custom": "license-extension", - }, - }, - expected3: &openapi3.License{ - Name: "Apache 2.0", - URL: util.PtrOf("https://www.apache.org/licenses/LICENSE-2.0.html"), - MapOfAnything: map[string]interface{}{ - "x-custom": "license-extension", - }, - }, - expected31: &openapi31.License{ - Name: "Apache 2.0", - URL: util.PtrOf("https://www.apache.org/licenses/LICENSE-2.0.html"), - MapOfAnything: map[string]interface{}{ - "x-custom": "license-extension", - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result3 := mapper.OAS3License(tt.license) - assert.Equal(t, tt.expected3, result3, "OpenAPI 3 License mapping failed") - result31 := mapper.OAS31License(tt.license) - assert.Equal(t, tt.expected31, result31, "OpenAPI 3.1 License mapping failed") - }) - } -} - -func TestOASExternalDocs(t *testing.T) { - tests := []struct { - name string - external *openapi.ExternalDocs - expected3 *openapi3.ExternalDocumentation - expected31 *openapi31.ExternalDocumentation - }{ - { - name: "nil external docs", - external: nil, - expected3: nil, - expected31: nil, - }, - { - name: "empty external docs", - external: &openapi.ExternalDocs{}, - expected3: &openapi3.ExternalDocumentation{}, - expected31: &openapi31.ExternalDocumentation{}, - }, - { - name: "external docs with URL only", - external: &openapi.ExternalDocs{ - URL: "https://example.com/docs", - }, - expected3: &openapi3.ExternalDocumentation{ - URL: "https://example.com/docs", - }, - expected31: &openapi31.ExternalDocumentation{ - URL: "https://example.com/docs", - }, - }, - { - name: "external docs with description only", - external: &openapi.ExternalDocs{ - Description: "API documentation", - }, - expected3: &openapi3.ExternalDocumentation{ - Description: util.PtrOf("API documentation"), - }, - expected31: &openapi31.ExternalDocumentation{ - Description: util.PtrOf("API documentation"), - }, - }, - { - name: "external docs with all fields", - external: &openapi.ExternalDocs{ - URL: "https://example.com/docs", - Description: "API documentation", - MapOfAnything: map[string]interface{}{ - "x-custom": "external-docs-extension", - }, - }, - expected3: &openapi3.ExternalDocumentation{ - URL: "https://example.com/docs", - Description: util.PtrOf("API documentation"), - MapOfAnything: map[string]interface{}{ - "x-custom": "external-docs-extension", - }, - }, - expected31: &openapi31.ExternalDocumentation{ - URL: "https://example.com/docs", - Description: util.PtrOf("API documentation"), - MapOfAnything: map[string]interface{}{ - "x-custom": "external-docs-extension", - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result3 := mapper.OAS3ExternalDocs(tt.external) - assert.Equal(t, tt.expected3, result3, "OpenAPI 3 ExternalDocs mapping failed") - result31 := mapper.OAS31ExternalDocs(tt.external) - assert.Equal(t, tt.expected31, result31, "OpenAPI 3.1 ExternalDocs mapping failed") - }) - } -} - -func TestOASTags(t *testing.T) { - tests := []struct { - name string - tags []openapi.Tag - expected3 []openapi3.Tag - expected31 []openapi31.Tag - }{ - { - name: "empty tags", - tags: []openapi.Tag{}, - expected3: nil, - expected31: nil, - }, - { - name: "single tag", - tags: []openapi.Tag{ - {Name: "example"}, - }, - expected3: []openapi3.Tag{ - {Name: "example"}, - }, - expected31: []openapi31.Tag{ - {Name: "example"}, - }, - }, - { - name: "multiple tags", - tags: []openapi.Tag{ - {Name: "tag1"}, - {Name: "tag2", Description: "Description for tag2"}, - }, - expected3: []openapi3.Tag{ - {Name: "tag1"}, - {Name: "tag2", Description: util.PtrOf("Description for tag2")}, - }, - expected31: []openapi31.Tag{ - {Name: "tag1"}, - {Name: "tag2", Description: util.PtrOf("Description for tag2")}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result3 := mapper.OAS3Tags(tt.tags) - assert.Equal(t, tt.expected3, result3, "OpenAPI 3 Tags mapping failed") - - result31 := mapper.OAS31Tags(tt.tags) - assert.Equal(t, tt.expected31, result31, "OpenAPI 3.1 Tags mapping failed") - }) - } -} - -func TestOASTag(t *testing.T) { - tests := []struct { - name string - tag openapi.Tag - expected3 openapi3.Tag - expected31 openapi31.Tag - }{ - { - name: "empty tag", - tag: openapi.Tag{}, - expected3: openapi3.Tag{ - Name: "", - Description: nil, - MapOfAnything: nil, - }, - expected31: openapi31.Tag{ - Name: "", - Description: nil, - MapOfAnything: nil, - }, - }, - { - name: "tag with name only", - tag: openapi.Tag{Name: "example"}, - expected3: openapi3.Tag{ - Name: "example", - Description: nil, - MapOfAnything: nil, - }, - expected31: openapi31.Tag{ - Name: "example", - Description: nil, - MapOfAnything: nil, - }, - }, - { - name: "tag with description", - tag: openapi.Tag{Name: "example", Description: "An example tag"}, - expected3: openapi3.Tag{ - Name: "example", - Description: util.PtrOf("An example tag"), - MapOfAnything: nil, - }, - expected31: openapi31.Tag{ - Name: "example", - Description: util.PtrOf("An example tag"), - MapOfAnything: nil, - }, - }, - { - name: "tag with external docs", - tag: openapi.Tag{ - Name: "example", - Description: "An example tag", - ExternalDocs: &openapi.ExternalDocs{ - URL: "https://example.com/docs", - Description: "External documentation for the example tag", - MapOfAnything: map[string]interface{}{ - "x-custom": "value", - }, - }, - }, - expected3: openapi3.Tag{ - Name: "example", - Description: util.PtrOf("An example tag"), - ExternalDocs: &openapi3.ExternalDocumentation{ - URL: "https://example.com/docs", - Description: util.PtrOf("External documentation for the example tag"), - MapOfAnything: map[string]interface{}{ - "x-custom": "value", - }, - }, - }, - expected31: openapi31.Tag{ - Name: "example", - Description: util.PtrOf("An example tag"), - ExternalDocs: &openapi31.ExternalDocumentation{ - URL: "https://example.com/docs", - Description: util.PtrOf("External documentation for the example tag"), - MapOfAnything: map[string]interface{}{ - "x-custom": "value", - }, - }, - }, - }, - { - name: "tag with all fields", - tag: openapi.Tag{ - Name: "example", - Description: "An example tag", - ExternalDocs: &openapi.ExternalDocs{ - URL: "https://example.com/docs", - Description: "External documentation for the example tag", - MapOfAnything: map[string]interface{}{ - "x-custom": "value", - }, - }, - MapOfAnything: map[string]interface{}{ - "x-vendor": "example-tag-extension", - }, - }, - expected3: openapi3.Tag{ - Name: "example", - Description: util.PtrOf("An example tag"), - ExternalDocs: &openapi3.ExternalDocumentation{ - URL: "https://example.com/docs", - Description: util.PtrOf("External documentation for the example tag"), - MapOfAnything: map[string]interface{}{ - "x-custom": "value", - }, - }, - MapOfAnything: map[string]interface{}{ - "x-vendor": "example-tag-extension", - }, - }, - expected31: openapi31.Tag{ - Name: "example", - Description: util.PtrOf("An example tag"), - ExternalDocs: &openapi31.ExternalDocumentation{ - URL: "https://example.com/docs", - Description: util.PtrOf("External documentation for the example tag"), - MapOfAnything: map[string]interface{}{ - "x-custom": "value", - }, - }, - MapOfAnything: map[string]interface{}{ - "x-vendor": "example-tag-extension", - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result3 := mapper.OAS3Tag(tt.tag) - assert.Equal(t, tt.expected3, result3, "OpenAPI 3 Tag mapping failed") - - result31 := mapper.OAS31Tag(tt.tag) - assert.Equal(t, tt.expected31, result31, "OpenAPI 3.1 Tag mapping failed") - }) - } -} - -func TestOASServers(t *testing.T) { - tests := []struct { - name string - servers []openapi.Server - expected3 []openapi3.Server - expected31 []openapi31.Server - }{ - { - name: "empty servers", - servers: []openapi.Server{}, - expected3: nil, - expected31: nil, - }, - { - name: "single server", - servers: []openapi.Server{ - {URL: "https://api.example.com"}, - }, - expected3: []openapi3.Server{ - {URL: "https://api.example.com"}, - }, - expected31: []openapi31.Server{ - {URL: "https://api.example.com"}, - }, - }, - { - name: "multiple servers", - servers: []openapi.Server{ - {URL: "https://api1.example.com"}, - {URL: "https://api2.example.com", Description: util.PtrOf("Second API server")}, - }, - expected3: []openapi3.Server{ - {URL: "https://api1.example.com"}, - {URL: "https://api2.example.com", Description: util.PtrOf("Second API server")}, - }, - expected31: []openapi31.Server{ - {URL: "https://api1.example.com"}, - {URL: "https://api2.example.com", Description: util.PtrOf("Second API server")}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result3 := mapper.OAS3Servers(tt.servers) - assert.Equal(t, tt.expected3, result3, "OpenAPI 3 Servers mapping failed") - - result31 := mapper.OAS31Servers(tt.servers) - assert.Equal(t, tt.expected31, result31, "OpenAPI 3.1 Servers mapping failed") - }) - } -} - -func TestOASServer(t *testing.T) { - tests := []struct { - name string - server openapi.Server - expected3 openapi3.Server - expected31 openapi31.Server - }{ - { - name: "empty server", - server: openapi.Server{}, - expected3: openapi3.Server{ - URL: "", - Description: nil, - MapOfAnything: nil, - Variables: nil, - }, - expected31: openapi31.Server{ - URL: "", - Description: nil, - MapOfAnything: nil, - Variables: nil, - }, - }, - { - name: "server with URL only", - server: openapi.Server{URL: "https://api.example.com"}, - expected3: openapi3.Server{ - URL: "https://api.example.com", - Description: nil, - MapOfAnything: nil, - Variables: nil, - }, - expected31: openapi31.Server{ - URL: "https://api.example.com", - Description: nil, - MapOfAnything: nil, - Variables: nil, - }, - }, - { - name: "server with description", - server: openapi.Server{URL: "https://api.example.com", Description: util.PtrOf("API server")}, - expected3: openapi3.Server{ - URL: "https://api.example.com", - Description: util.PtrOf("API server"), - MapOfAnything: nil, - Variables: nil, - }, - expected31: openapi31.Server{ - URL: "https://api.example.com", - Description: util.PtrOf("API server"), - MapOfAnything: nil, - Variables: nil, - }, - }, - { - name: "server with variables", - server: openapi.Server{ - URL: "https://api.example.com", - Description: util.PtrOf("API server"), - Variables: map[string]openapi.ServerVariable{ - "port": { - Enum: []string{"8080", "8443"}, - Default: "8080", - Description: "Server port", - MapOfAnything: map[string]any{ - "x-custom": "value", - }, - }, - }, - }, - expected3: openapi3.Server{ - URL: "https://api.example.com", - Description: util.PtrOf("API server"), - MapOfAnything: nil, - Variables: map[string]openapi3.ServerVariable{ - "port": { - Enum: []string{"8080", "8443"}, - Default: "8080", - Description: util.PtrOf("Server port"), - MapOfAnything: map[string]any{ - "x-custom": "value", - }, - }, - }, - }, - expected31: openapi31.Server{ - URL: "https://api.example.com", - Description: util.PtrOf("API server"), - MapOfAnything: nil, - Variables: map[string]openapi31.ServerVariable{ - "port": { - Enum: []string{"8080", "8443"}, - Default: "8080", - Description: util.PtrOf("Server port"), - MapOfAnything: map[string]any{ - "x-custom": "value", - }, - }, - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result3 := mapper.OAS3Server(tt.server) - assert.Equal(t, tt.expected3, result3, "OpenAPI 3 Server mapping failed") - - result31 := mapper.OAS31Server(tt.server) - assert.Equal(t, tt.expected31, result31, "OpenAPI 3.1 Server mapping failed") - }) - } -} - -func TestOASSecurityScheme(t *testing.T) { - tests := []struct { - name string - scheme *openapi.SecurityScheme - expected3 *openapi3.SecurityScheme - expected31 *openapi31.SecurityScheme - }{ - { - name: "nil scheme", - scheme: nil, - expected3: nil, - expected31: nil, - }, - { - name: "empty scheme", - scheme: &openapi.SecurityScheme{}, - expected3: nil, - expected31: nil, - }, - { - name: "API Key scheme", - scheme: &openapi.SecurityScheme{ - APIKey: &openapi.SecuritySchemeAPIKey{Name: "api_key", In: "header"}, - }, - expected3: &openapi3.SecurityScheme{ - APIKeySecurityScheme: &openapi3.APIKeySecurityScheme{ - Name: "api_key", - In: openapi3.APIKeySecuritySchemeIn("header"), - }, - }, - expected31: &openapi31.SecurityScheme{ - APIKey: &openapi31.SecuritySchemeAPIKey{ - Name: "api_key", - In: openapi31.SecuritySchemeAPIKeyIn("header"), - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result3 := mapper.OAS3SecurityScheme(tt.scheme) - assert.Equal(t, tt.expected3, result3, "OpenAPI 3 Security Scheme mapping failed") - - result31 := mapper.OAS31SecurityScheme(tt.scheme) - assert.Equal(t, tt.expected31, result31, "OpenAPI 3.1 Security Scheme mapping failed") - }) - } -} - -func TestOASAPIKey(t *testing.T) { - tests := []struct { - name string - apiKey *openapi.SecuritySchemeAPIKey - expected3 *openapi3.APIKeySecurityScheme - expected31 *openapi31.SecuritySchemeAPIKey - }{ - { - name: "nil apiKey", - apiKey: nil, - expected3: nil, - expected31: nil, - }, - { - name: "empty apiKey", - apiKey: &openapi.SecuritySchemeAPIKey{ - Name: "", - In: "", - }, - expected3: &openapi3.APIKeySecurityScheme{ - Name: "", - In: openapi3.APIKeySecuritySchemeIn(""), - }, - expected31: &openapi31.SecuritySchemeAPIKey{ - Name: "", - In: openapi31.SecuritySchemeAPIKeyIn(""), - }, - }, - { - name: "apiKey with name only", - apiKey: &openapi.SecuritySchemeAPIKey{ - Name: "api_key", - In: "", - }, - expected3: &openapi3.APIKeySecurityScheme{ - Name: "api_key", - In: openapi3.APIKeySecuritySchemeIn(""), - }, - expected31: &openapi31.SecuritySchemeAPIKey{ - Name: "api_key", - In: openapi31.SecuritySchemeAPIKeyIn(""), - }, - }, - { - name: "apiKey with in header", - apiKey: &openapi.SecuritySchemeAPIKey{ - Name: "X-API-Key", - In: "header", - }, - expected3: &openapi3.APIKeySecurityScheme{ - Name: "X-API-Key", - In: openapi3.APIKeySecuritySchemeIn("header"), - }, - expected31: &openapi31.SecuritySchemeAPIKey{ - Name: "X-API-Key", - In: openapi31.SecuritySchemeAPIKeyIn("header"), - }, - }, - { - name: "apiKey with in query", - apiKey: &openapi.SecuritySchemeAPIKey{ - Name: "api_key", - In: "query", - }, - expected3: &openapi3.APIKeySecurityScheme{ - Name: "api_key", - In: openapi3.APIKeySecuritySchemeIn("query"), - }, - expected31: &openapi31.SecuritySchemeAPIKey{ - Name: "api_key", - In: openapi31.SecuritySchemeAPIKeyIn("query"), - }, - }, - { - name: "apiKey with in cookie", - apiKey: &openapi.SecuritySchemeAPIKey{ - Name: "sessionId", - In: "cookie", - }, - expected3: &openapi3.APIKeySecurityScheme{ - Name: "sessionId", - In: openapi3.APIKeySecuritySchemeIn("cookie"), - }, - expected31: &openapi31.SecuritySchemeAPIKey{ - Name: "sessionId", - In: openapi31.SecuritySchemeAPIKeyIn("cookie"), - }, - }, - { - name: "apiKey with all fields", - apiKey: &openapi.SecuritySchemeAPIKey{ - Name: "Authorization", - In: "header", - }, - expected3: &openapi3.APIKeySecurityScheme{ - Name: "Authorization", - In: openapi3.APIKeySecuritySchemeIn("header"), - }, - expected31: &openapi31.SecuritySchemeAPIKey{ - Name: "Authorization", - In: openapi31.SecuritySchemeAPIKeyIn("header"), - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := mapper.OAS31APIKey(tt.apiKey) - assert.Equal(t, tt.expected31, result) - }) - } -} - -func TestOASHTTPBearer(t *testing.T) { - tests := []struct { - name string - scheme *openapi.SecuritySchemeHTTPBearer - expected3 *openapi3.HTTPSecurityScheme - expected31 *openapi31.SecuritySchemeHTTPBearer - }{ - { - name: "nil scheme", - scheme: nil, - expected3: nil, - expected31: nil, - }, - { - name: "empty scheme", - scheme: &openapi.SecuritySchemeHTTPBearer{ - Scheme: "", - }, - expected3: &openapi3.HTTPSecurityScheme{ - Scheme: "", - }, - expected31: &openapi31.SecuritySchemeHTTPBearer{ - Scheme: "", - }, - }, - { - name: "scheme with scheme only", - scheme: &openapi.SecuritySchemeHTTPBearer{ - Scheme: "bearer", - }, - expected3: &openapi3.HTTPSecurityScheme{ - Scheme: "bearer", - }, - expected31: &openapi31.SecuritySchemeHTTPBearer{ - Scheme: "bearer", - }, - }, - { - name: "scheme with all fields", - scheme: &openapi.SecuritySchemeHTTPBearer{ - Scheme: "bearer", - BearerFormat: util.PtrOf("JWT"), - }, - expected3: &openapi3.HTTPSecurityScheme{ - Scheme: "bearer", - BearerFormat: util.PtrOf("JWT"), - }, - expected31: &openapi31.SecuritySchemeHTTPBearer{ - Scheme: "bearer", - BearerFormat: util.PtrOf("JWT"), - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result3 := mapper.OAS3HTTPBearer(tt.scheme, nil) - assert.Equal(t, tt.expected3, result3) - result31 := mapper.OAS31HTTPBearer(tt.scheme) - assert.Equal(t, tt.expected31, result31) - }) - } -} - -func TestOASOauth2Flows(t *testing.T) { - tests := []struct { - name string - flows openapi.OAuthFlows - expected3 openapi3.OAuthFlows - expected31 openapi31.OauthFlows - }{ - { - name: "empty flows", - flows: openapi.OAuthFlows{}, - expected3: openapi3.OAuthFlows{ - Implicit: nil, - Password: nil, - ClientCredentials: nil, - AuthorizationCode: nil, - }, - expected31: openapi31.OauthFlows{ - Implicit: nil, - Password: nil, - ClientCredentials: nil, - AuthorizationCode: nil, - }, - }, - { - name: "flows with implicit only", - flows: openapi.OAuthFlows{ - Implicit: &openapi.OAuthFlowsImplicit{ - AuthorizationURL: "https://example.com/auth", - RefreshURL: util.PtrOf("https://example.com/refresh"), - Scopes: map[string]string{ - "read": "Read access", - "write": "Write access", - }, - MapOfAnything: map[string]interface{}{ - "x-custom": "implicit-flow", - }, - }, - }, - expected3: openapi3.OAuthFlows{ - Implicit: &openapi3.ImplicitOAuthFlow{ - AuthorizationURL: "https://example.com/auth", - RefreshURL: util.PtrOf("https://example.com/refresh"), - Scopes: map[string]string{ - "read": "Read access", - "write": "Write access", - }, - MapOfAnything: map[string]interface{}{ - "x-custom": "implicit-flow", - }, - }, - }, - expected31: openapi31.OauthFlows{ - Implicit: &openapi31.OauthFlowsDefsImplicit{ - AuthorizationURL: "https://example.com/auth", - RefreshURL: util.PtrOf("https://example.com/refresh"), - Scopes: map[string]string{ - "read": "Read access", - "write": "Write access", - }, - MapOfAnything: map[string]interface{}{ - "x-custom": "implicit-flow", - }, - }, - }, - }, - { - name: "flows with password only", - flows: openapi.OAuthFlows{ - Password: &openapi.OAuthFlowsPassword{ - TokenURL: "https://example.com/token", - RefreshURL: util.PtrOf("https://example.com/refresh"), - Scopes: map[string]string{ - "admin": "Admin access", - }, - MapOfAnything: map[string]interface{}{ - "x-vendor": "password-flow", - }, - }, - }, - expected3: openapi3.OAuthFlows{ - Password: &openapi3.PasswordOAuthFlow{ - TokenURL: "https://example.com/token", - RefreshURL: util.PtrOf("https://example.com/refresh"), - Scopes: map[string]string{ - "admin": "Admin access", - }, - MapOfAnything: map[string]interface{}{ - "x-vendor": "password-flow", - }, - }, - }, - expected31: openapi31.OauthFlows{ - Password: &openapi31.OauthFlowsDefsPassword{ - TokenURL: "https://example.com/token", - RefreshURL: util.PtrOf("https://example.com/refresh"), - Scopes: map[string]string{ - "admin": "Admin access", - }, - MapOfAnything: map[string]interface{}{ - "x-vendor": "password-flow", - }, - }, - }, - }, - { - name: "flows with client credentials only", - flows: openapi.OAuthFlows{ - ClientCredentials: &openapi.OAuthFlowsClientCredentials{ - TokenURL: "https://example.com/token", - Scopes: map[string]string{ - "service": "Service access", - }, - MapOfAnything: map[string]interface{}{ - "x-internal": true, - }, - }, - }, - expected3: openapi3.OAuthFlows{ - ClientCredentials: &openapi3.ClientCredentialsFlow{ - TokenURL: "https://example.com/token", - Scopes: map[string]string{ - "service": "Service access", - }, - MapOfAnything: map[string]interface{}{ - "x-internal": true, - }, - }, - }, - expected31: openapi31.OauthFlows{ - ClientCredentials: &openapi31.OauthFlowsDefsClientCredentials{ - TokenURL: "https://example.com/token", - Scopes: map[string]string{ - "service": "Service access", - }, - MapOfAnything: map[string]interface{}{ - "x-internal": true, - }, - }, - }, - }, - { - name: "flows with authorization code only", - flows: openapi.OAuthFlows{ - AuthorizationCode: &openapi.OAuthFlowsAuthorizationCode{ - AuthorizationURL: "https://example.com/auth", - TokenURL: "https://example.com/token", - RefreshURL: util.PtrOf("https://example.com/refresh"), - Scopes: map[string]string{ - "profile": "Profile access", - "email": "Email access", - }, - MapOfAnything: map[string]interface{}{ - "x-flow-type": "authorization-code", - }, - }, - }, - expected3: openapi3.OAuthFlows{ - AuthorizationCode: &openapi3.AuthorizationCodeOAuthFlow{ - AuthorizationURL: "https://example.com/auth", - TokenURL: "https://example.com/token", - RefreshURL: util.PtrOf("https://example.com/refresh"), - Scopes: map[string]string{ - "profile": "Profile access", - "email": "Email access", - }, - MapOfAnything: map[string]interface{}{ - "x-flow-type": "authorization-code", - }, - }, - }, - expected31: openapi31.OauthFlows{ - Implicit: nil, - Password: nil, - ClientCredentials: nil, - AuthorizationCode: &openapi31.OauthFlowsDefsAuthorizationCode{ - AuthorizationURL: "https://example.com/auth", - TokenURL: "https://example.com/token", - RefreshURL: util.PtrOf("https://example.com/refresh"), - Scopes: map[string]string{ - "profile": "Profile access", - "email": "Email access", - }, - MapOfAnything: map[string]interface{}{ - "x-flow-type": "authorization-code", - }, - }, - }, - }, - { - name: "flows with all flow types", - flows: openapi.OAuthFlows{ - Implicit: &openapi.OAuthFlowsImplicit{ - AuthorizationURL: "https://example.com/implicit/auth", - Scopes: map[string]string{ - "read": "Read access", - }, - }, - Password: &openapi.OAuthFlowsPassword{ - TokenURL: "https://example.com/password/token", - Scopes: map[string]string{ - "write": "Write access", - }, - }, - ClientCredentials: &openapi.OAuthFlowsClientCredentials{ - TokenURL: "https://example.com/client/token", - Scopes: map[string]string{ - "admin": "Admin access", - }, - }, - AuthorizationCode: &openapi.OAuthFlowsAuthorizationCode{ - AuthorizationURL: "https://example.com/code/auth", - TokenURL: "https://example.com/code/token", - Scopes: map[string]string{ - "full": "Full access", - }, - }, - }, - expected3: openapi3.OAuthFlows{ - Implicit: &openapi3.ImplicitOAuthFlow{ - AuthorizationURL: "https://example.com/implicit/auth", - Scopes: map[string]string{ - "read": "Read access", - }, - }, - Password: &openapi3.PasswordOAuthFlow{ - TokenURL: "https://example.com/password/token", - Scopes: map[string]string{ - "write": "Write access", - }, - }, - ClientCredentials: &openapi3.ClientCredentialsFlow{ - TokenURL: "https://example.com/client/token", - Scopes: map[string]string{ - "admin": "Admin access", - }, - }, - AuthorizationCode: &openapi3.AuthorizationCodeOAuthFlow{ - AuthorizationURL: "https://example.com/code/auth", - TokenURL: "https://example.com/code/token", - Scopes: map[string]string{ - "full": "Full access", - }, - }, - }, - expected31: openapi31.OauthFlows{ - Implicit: &openapi31.OauthFlowsDefsImplicit{ - AuthorizationURL: "https://example.com/implicit/auth", - Scopes: map[string]string{ - "read": "Read access", - }, - }, - Password: &openapi31.OauthFlowsDefsPassword{ - TokenURL: "https://example.com/password/token", - Scopes: map[string]string{ - "write": "Write access", - }, - }, - ClientCredentials: &openapi31.OauthFlowsDefsClientCredentials{ - TokenURL: "https://example.com/client/token", - Scopes: map[string]string{ - "admin": "Admin access", - }, - }, - AuthorizationCode: &openapi31.OauthFlowsDefsAuthorizationCode{ - AuthorizationURL: "https://example.com/code/auth", - TokenURL: "https://example.com/code/token", - Scopes: map[string]string{ - "full": "Full access", - }, - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result3 := mapper.OAS3Oauth2Flows(tt.flows) - assert.Equal(t, tt.expected3, result3, "OpenAPI 3 OAuth2 Flows mapping failed") - result31 := mapper.OAS31Oauth2Flows(tt.flows) - assert.Equal(t, tt.expected31, result31, "OpenAPI 3.1 OAuth2 Flows mapping failed") - }) - } -} - -func TestStringMapToEncodingMap(t *testing.T) { - tests := []struct { - name string - input map[string]string - expected3 map[string]openapi3.Encoding - expected31 map[string]openapi31.Encoding - }{ - { - name: "empty map", - input: map[string]string{}, - expected3: map[string]openapi3.Encoding{}, - expected31: map[string]openapi31.Encoding{}, - }, - { - name: "single encoding", - input: map[string]string{ - "field1": "application/json", - }, - expected3: map[string]openapi3.Encoding{ - "field1": { - ContentType: util.PtrOf("application/json"), - }, - }, - expected31: map[string]openapi31.Encoding{ - "field1": { - ContentType: util.PtrOf("application/json"), - }, - }, - }, - { - name: "multiple encodings", - input: map[string]string{ - "field1": "application/json", - "field2": "text/plain", - }, - expected3: map[string]openapi3.Encoding{ - "field1": { - ContentType: util.PtrOf("application/json"), - }, - "field2": { - ContentType: util.PtrOf("text/plain"), - }, - }, - expected31: map[string]openapi31.Encoding{ - "field1": { - ContentType: util.PtrOf("application/json"), - }, - "field2": { - ContentType: util.PtrOf("text/plain"), - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result3 := mapper.StringMapToEncodingMap3(tt.input) - assert.Equal(t, tt.expected3, result3, "String map to OpenAPI 3 Encoding map conversion failed") - result31 := mapper.StringMapToEncodingMap31(tt.input) - assert.Equal(t, tt.expected31, result31, "String map to OpenAPI 3.1 Encoding map conversion failed") - }) - } -} diff --git a/internal/reflect/converter.go b/internal/reflect/converter.go new file mode 100644 index 0000000..e85c76f --- /dev/null +++ b/internal/reflect/converter.go @@ -0,0 +1,129 @@ +package reflect + +import ( + "reflect" + + "github.com/oaswrap/spec/openapi" +) + +//nolint:funlen // covers full OpenAPI scalar/collection/struct mapping in one switch for readability. +func (r *Reflector) SchemaForType(t reflect.Type, mode SchemaMode, field *reflect.StructField) *openapi.Schema { + nullable := false + for t != nil && t.Kind() == reflect.Pointer { + nullable = true + t = t.Elem() + } + if t == nil { + return &openapi.Schema{} + } + if mapped := r.TypeMapping[t]; mapped != nil { + t = mapped + } + if schema := r.SchemaFromTypeExposer(t); schema != nil { + r.ApplyNullable(schema, nullable) + if field != nil { + r.ApplySchemaTags(schema, *field) + } + return schema + } + if mode == SchemaUseComponent && IsComponentType(t) && !r.InlineRefs() { + schema := r.RefSchema(t) + r.ApplyNullable(schema, nullable) + if field != nil { + r.ApplySchemaTags(schema, *field) + } + return schema + } + + var schema *openapi.Schema + switch t.Kind() { //nolint:exhaustive // only interested in types supported by OpenAPI + case reflect.Bool: + schema = &openapi.Schema{Type: "boolean"} + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32: + schema = &openapi.Schema{Type: "integer", Format: "int32"} + case reflect.Int64: + schema = &openapi.Schema{Type: "integer", Format: "int64"} + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32: + minVal := 0.0 + schema = &openapi.Schema{Type: "integer", Format: "int32", Minimum: &minVal} + case reflect.Uint64, reflect.Uintptr: + minVal := 0.0 + schema = &openapi.Schema{Type: "integer", Format: "int64", Minimum: &minVal} + case reflect.Float32: + schema = &openapi.Schema{Type: "number", Format: "float"} + case reflect.Float64: + schema = &openapi.Schema{Type: "number", Format: "double"} + case reflect.String: + schema = &openapi.Schema{Type: "string"} + case reflect.Slice, reflect.Array: + if t.Elem().Kind() == reflect.Uint8 { + if IsOpenAPI30(r.Config.OpenAPIVersion) { + schema = &openapi.Schema{Type: "string", Format: "byte"} + } else { + schema = &openapi.Schema{Type: "string", ContentEncoding: "base64"} + } + break + } + schema = &openapi.Schema{Type: "array", Items: r.SchemaForType(t.Elem(), SchemaUseComponent, nil)} + case reflect.Map: + schema = &openapi.Schema{ + Type: "object", + AdditionalProperties: r.SchemaForType(t.Elem(), SchemaUseComponent, nil), + } + case reflect.Struct: + if IsTime(t) { + schema = &openapi.Schema{Type: "string", Format: "date-time"} + } else { + schema = r.StructSchema(t, "json", false, mode) + } + case reflect.Interface: + schema = &openapi.Schema{} + default: + schema = &openapi.Schema{} + } + r.ApplyNullable(schema, nullable) + if field != nil { + r.ApplySchemaTags(schema, *field) + } + return schema +} + +func (r *Reflector) ApplyNullable(schema *openapi.Schema, nullable bool) { + if !nullable || schema == nil { + return + } + if IsOpenAPI30(r.Config.OpenAPIVersion) { + if schema.Ref != "" { + ref := schema.Ref + *schema = openapi.Schema{ + AllOf: []*openapi.Schema{{Ref: ref}}, + Nullable: true, + } + return + } + schema.Nullable = true + return + } + if schema.Ref != "" { + ref := schema.Ref + *schema = openapi.Schema{ + AnyOf: []*openapi.Schema{ + {Ref: ref}, + {Type: "null"}, + }, + } + return + } + if typ, ok := schema.Type.(string); ok && typ != "" { + schema.Type = []string{typ, "null"} + } +} + +func IsOpenAPI30(version string) bool { + return version == openapi.Version300 || version == openapi.Version301 || version == openapi.Version302 || + version == openapi.Version303 || version == openapi.Version304 +} + +func IsComponentType(t reflect.Type) bool { + return t.Kind() == reflect.Struct && t.Name() != "" && !IsTime(t) +} diff --git a/internal/reflect/converter_test.go b/internal/reflect/converter_test.go new file mode 100644 index 0000000..82db3c0 --- /dev/null +++ b/internal/reflect/converter_test.go @@ -0,0 +1,134 @@ +package reflect_test + +import ( + std_reflect "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oaswrap/spec" + "github.com/oaswrap/spec/internal/reflect" + "github.com/oaswrap/spec/openapi" + "github.com/oaswrap/spec/option" +) + +type CustomSlug string + +func (*CustomSlug) OpenAPISchema(version string) *openapi.Schema { + if strings.HasPrefix(version, "3.0.") { + return &openapi.Schema{Type: "string", Format: "slug"} + } + return &openapi.Schema{Type: []string{"string", "null"}, Format: "slug"} +} + +type CustomSchemaPayload struct { + ID CustomSlug `json:"id" description:"Stable identifier"` +} + +type SchemaExposerType struct{} + +func (SchemaExposerType) OpenAPISchema(_ string) *openapi.Schema { + return &openapi.Schema{Type: "string", Description: "Exposed"} +} + +func TestConverter_SchemaForType(t *testing.T) { + cfg := &openapi.Config{OpenAPIVersion: openapi.Version312} + r := reflect.NewReflector(cfg) + + tests := []struct { + name string + val any + expected string + }{ + {"Int64", int64(0), "integer"}, + {"Uint32", uint32(0), "integer"}, + {"Float32", float32(0), "number"}, + {"ByteSlice", []byte{0}, "string"}, + {"Map", map[string]int{"a": 1}, "object"}, + {"Interface", any(nil), ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + typ := std_reflect.TypeOf(tt.val) + if tt.name == "Interface" { + typ = std_reflect.TypeFor[any]() + } + schema := r.SchemaForType(typ, reflect.SchemaInline, nil) + if tt.expected != "" { + assert.Equal(t, tt.expected, schema.Type) + } + }) + } +} + +func TestConverter_ApplyNullable_EdgeCases(t *testing.T) { + t.Run("OpenAPI304_Ref", func(t *testing.T) { + r := reflect.NewReflector(&openapi.Config{OpenAPIVersion: openapi.Version304}) + schema := &openapi.Schema{Ref: "#/components/schemas/User"} + r.ApplyNullable(schema, true) + assert.Len(t, schema.AllOf, 1) + assert.True(t, schema.Nullable) + }) + + t.Run("OpenAPI312_Ref", func(t *testing.T) { + r := reflect.NewReflector(&openapi.Config{OpenAPIVersion: openapi.Version312}) + schema := &openapi.Schema{Ref: "#/components/schemas/User"} + r.ApplyNullable(schema, true) + assert.Len(t, schema.AnyOf, 2) + assert.Equal(t, "null", schema.AnyOf[1].Type) + }) +} + +func TestConverter_CustomSchemaExposer(t *testing.T) { + r := spec.NewRouter(option.WithTitle("Custom Schema"), option.WithVersion("1.0.0")) + r.Post("/payload", option.Request(new(CustomSchemaPayload)), option.Response(204, nil)) + + raw, err := r.GenerateSchema("json") + require.NoError(t, err) + + id := generatedComponentProperty(t, raw, "CustomSchemaPayload", "id") + assert.Equal(t, "slug", id["format"]) + assert.Equal(t, "Stable identifier", id["description"]) +} + +func TestConverter_Nullable(t *testing.T) { + t.Run("OpenAPI304", func(t *testing.T) { + r := spec.NewRouter(option.WithOpenAPIVersion(openapi.Version304)) + r.Post("/payload", option.Request(new(ReflectionVersionPayload)), option.Response(204, nil)) + + raw, err := r.GenerateSchema("json") + require.NoError(t, err) + + owner := generatedComponentProperty(t, raw, "ReflectionVersionPayload", "owner") + assert.NotContains(t, owner, "$ref", "nullable component refs must not emit $ref siblings in 3.0") + assert.Equal(t, true, owner["nullable"]) + }) + + t.Run("OpenAPI312", func(t *testing.T) { + r := spec.NewRouter(option.WithOpenAPIVersion(openapi.Version312)) + r.Post("/payload", option.Request(new(ReflectionVersionPayload312)), option.Response(204, nil)) + + raw, err := r.GenerateSchema("json") + require.NoError(t, err) + + name := generatedComponentProperty(t, raw, "ReflectionVersionPayload312", "name") + typ := name["type"].([]any) + assert.Equal(t, []any{"string", "null"}, typ) + }) +} + +func TestConverter_SchemaExposer(t *testing.T) { + r := spec.NewRouter(option.WithTitle("Exposer")) + r.Get("/exposer", option.Response(200, SchemaExposerType{})) + _, err := r.GenerateSchema("yaml") + require.NoError(t, err) + doc := r.Document() + assert.Equal( + t, + "Exposed", + doc.Paths["/exposer"].Get.Responses["200"].Content["application/json"].Schema.Description, + ) +} diff --git a/internal/reflect/exposer_test.go b/internal/reflect/exposer_test.go new file mode 100644 index 0000000..55a0d2e --- /dev/null +++ b/internal/reflect/exposer_test.go @@ -0,0 +1,33 @@ +package reflect_test + +import ( + std_reflect "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oaswrap/spec/internal/reflect" + "github.com/oaswrap/spec/openapi" +) + +type staticExposerType struct{} + +func (staticExposerType) OpenAPISchema() *openapi.Schema { + return &openapi.Schema{Type: "integer", Description: "Static Exposed"} +} + +func TestReflector_ExposerBranches(t *testing.T) { + r := reflect.NewReflector(&openapi.Config{OpenAPIVersion: openapi.Version312}) + + assert.Nil(t, r.SchemaFromValueExposer(nil)) + require.NotNil(t, r.SchemaFromValueExposer(SchemaExposerType{})) + assert.Equal(t, "Exposed", r.SchemaFromValueExposer(SchemaExposerType{}).Description) + require.NotNil(t, r.SchemaFromValueExposer(staticExposerType{})) + assert.Equal(t, "Static Exposed", r.SchemaFromValueExposer(staticExposerType{}).Description) + + assert.Nil(t, r.SchemaFromTypeExposer(nil)) + assert.Nil(t, r.SchemaFromTypeExposer(std_reflect.TypeFor[any]())) + require.NotNil(t, r.SchemaFromTypeExposer(std_reflect.TypeFor[SchemaExposerType]())) + assert.Equal(t, "Exposed", r.SchemaFromTypeExposer(std_reflect.TypeFor[SchemaExposerType]()).Description) +} diff --git a/internal/reflect/reflect_test.go b/internal/reflect/reflect_test.go new file mode 100644 index 0000000..b3eaeb0 --- /dev/null +++ b/internal/reflect/reflect_test.go @@ -0,0 +1,47 @@ +package reflect_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +type User struct { + ID string `json:"id" required:"true"` + Name string `json:"name"` +} + +type ReflectionVersionPayload struct { + Name string `json:"name" const:"fixed" examples:"fixed,other" contentEncoding:"base64" contentMediaType:"text/plain" nullable:"true" exclusiveMaximum:"true"` + Owner *User `json:"owner"` +} + +type ReflectionVersionPayload312 struct { + Name string `json:"name" const:"fixed" examples:"fixed,other" contentEncoding:"base64" contentMediaType:"text/plain" nullable:"true"` + Score int `json:"score" exclusiveMinimum:"0"` +} + +func generatedComponentProperty(t *testing.T, raw []byte, componentName, propertyName string) map[string]any { + t.Helper() + var doc map[string]any + err := json.Unmarshal(raw, &doc) + require.NoError(t, err) + + components, ok := doc["components"].(map[string]any) + require.True(t, ok, "missing components") + + schemas, ok := components["schemas"].(map[string]any) + require.True(t, ok, "missing components.schemas") + + component, ok := schemas[componentName].(map[string]any) + require.True(t, ok, "missing component %s", componentName) + + properties, ok := component["properties"].(map[string]any) + require.True(t, ok, "missing component properties for %s", componentName) + + property, ok := properties[propertyName].(map[string]any) + require.True(t, ok, "missing component property %s.%s", componentName, propertyName) + + return property +} diff --git a/internal/reflect/reflector.go b/internal/reflect/reflector.go new file mode 100644 index 0000000..e03c4ad --- /dev/null +++ b/internal/reflect/reflector.go @@ -0,0 +1,253 @@ +package reflect + +import ( + "reflect" + "time" + + "github.com/oaswrap/spec/openapi" +) + +type SchemaMode int + +const ( + SchemaInline SchemaMode = iota + SchemaUseComponent +) + +type Reflector struct { + Config *openapi.Config + Components map[string]*openapi.Schema + Names map[reflect.Type]string + Generating map[reflect.Type]bool + TypeMapping map[reflect.Type]reflect.Type +} + +func NewReflector(cfg *openapi.Config) *Reflector { + r := &Reflector{ + Config: cfg, + Components: map[string]*openapi.Schema{}, + Names: map[reflect.Type]string{}, + Generating: map[reflect.Type]bool{}, + TypeMapping: map[reflect.Type]reflect.Type{}, + } + if cfg.ReflectorConfig != nil { + for _, tm := range cfg.ReflectorConfig.TypeMappings { + src := IndirectType(reflect.TypeOf(tm.Src)) + dst := IndirectType(reflect.TypeOf(tm.Dst)) + if src != nil && dst != nil { + r.TypeMapping[src] = dst + } + } + } + return r +} + +func (r *Reflector) RequestParts( + value any, + ct string, +) ([]*openapi.Parameter, *openapi.Schema) { + t := IndirectType(reflect.TypeOf(value)) + if t == nil { + return nil, nil + } + if mapped := r.TypeMapping[t]; mapped != nil { + t = mapped + } + if t.Kind() != reflect.Struct || IsTime(t) { + return nil, r.SchemaForType(t, SchemaUseComponent, nil) + } + + var params []*openapi.Parameter + bodyTag := BodyNameTag(ct) + hasBody := false + hasParam := false + ForEachField(t, func(field reflect.StructField) { + if paramIn, name, ok := r.ParameterField(field); ok { + hasParam = true + params = append(params, r.ParameterSchema(field, paramIn, name)) + } + if TagName(field, bodyTag) != "" || (bodyTag != "json" && TagName(field, "json") != "") { + hasBody = true + } + }) + if !hasParam { + return nil, r.SchemaForType(t, SchemaUseComponent, nil) + } + if !hasBody { + return params, nil + } + body := r.StructSchema(t, bodyTag, true, SchemaInline) + if len(body.Properties) == 0 { + body = nil + } + return params, body +} + +func (r *Reflector) ParameterField(field reflect.StructField) (string, string, bool) { + tagPairs := []struct { + in openapi.ParameterIn + tag string + }{ + {openapi.ParameterInPath, "path"}, + {openapi.ParameterInQuery, "query"}, + {openapi.ParameterInHeader, "header"}, + {openapi.ParameterInCookie, "cookie"}, + } + if r.Config.OpenAPIVersion == openapi.Version320 { + tagPairs = append(tagPairs, struct { + in openapi.ParameterIn + tag string + }{openapi.ParameterInQueryString, "querystring"}) + } + custom := map[openapi.ParameterIn]string{} + if r.Config.ReflectorConfig != nil { + for in, tag := range r.Config.ReflectorConfig.ParameterTagMapping { + custom[in] = tag + } + } + for _, pair := range tagPairs { + if tag, ok := custom[pair.in]; ok { + delete(custom, pair.in) + if tag != "" && tag != pair.tag { + tagPairs = append(tagPairs, struct { + in openapi.ParameterIn + tag string + }{pair.in, tag}) + } + } + } + for in, tag := range custom { + tagPairs = append(tagPairs, struct { + in openapi.ParameterIn + tag string + }{in, tag}) + } + for _, pair := range tagPairs { + in, tag := pair.in, pair.tag + if name := TagName(field, tag); name != "" { + return string(in), name, true + } + } + return "", "", false +} + +func (r *Reflector) ParameterSchema(field reflect.StructField, in, name string) *openapi.Parameter { + schema := r.SchemaForType(field.Type, SchemaInline, &field) + return &openapi.Parameter{ + Name: name, + In: in, + Description: field.Tag.Get("description"), + Required: in == string(openapi.ParameterInPath) || BoolTag(field.Tag.Get("required")), + Deprecated: BoolTag(field.Tag.Get("deprecated")), + Schema: schema, + } +} + +func (r *Reflector) SchemaForValue(value any, mode SchemaMode) *openapi.Schema { + if ov, ok := value.(OneOfValue); ok { + values := ov.GetValues() + schemas := make([]*openapi.Schema, 0, len(values)) + for _, item := range values { + schemas = append(schemas, r.SchemaForValue(item, mode)) + } + return &openapi.Schema{OneOf: schemas} + } + if schema, ok := value.(*openapi.Schema); ok { + return schema + } + if schema := r.SchemaFromValueExposer(value); schema != nil { + return schema + } + return r.SchemaForType(IndirectType(reflect.TypeOf(value)), mode, nil) +} + +func (r *Reflector) RefSchema(t reflect.Type) *openapi.Schema { + name := r.TypeName(t) + if _, ok := r.Components[name]; ok { + return &openapi.Schema{Ref: "#/components/schemas/" + name} + } + if r.Generating[t] { + return &openapi.Schema{Ref: "#/components/schemas/" + name} + } + r.Generating[t] = true + r.Components[name] = &openapi.Schema{} + r.Components[name] = r.StructSchema(t, "json", false, SchemaInline) + delete(r.Generating, t) + return &openapi.Schema{Ref: "#/components/schemas/" + name} +} + +func (r *Reflector) StructSchema( + t reflect.Type, + nameTag string, + onlyTagged bool, + mode SchemaMode, +) *openapi.Schema { + schema := &openapi.Schema{Type: "object", Properties: map[string]*openapi.Schema{}} + ForEachField(t, func(field reflect.StructField) { + if IgnoredField(field, nameTag) { + return + } + name := TagName(field, nameTag) + if name == "" && nameTag != "json" { + name = TagName(field, "json") + } + if name == "" { + if onlyTagged { + return + } + name = LowerCamel(field.Name) + } + prop := r.SchemaForType(field.Type, mode, &field) + schema.Properties[name] = prop + if BoolTag(field.Tag.Get("required")) { + schema.Required = append(schema.Required, name) + } + }) + if len(schema.Properties) == 0 { + schema.Properties = nil + } + return schema +} + +// SchemaExposer lets a value provide an OpenAPI schema for a specific version. +type SchemaExposer interface { + OpenAPISchema(version string) *openapi.Schema +} + +// StaticSchemaExposer lets a value provide a version-independent OpenAPI schema. +type StaticSchemaExposer interface { + OpenAPISchema() *openapi.Schema +} + +// OneOfValue represents a reflected one-of union container. +type OneOfValue interface { + GetValues() []any +} + +func (r *Reflector) SchemaFromValueExposer(value any) *openapi.Schema { + if value == nil { + return nil + } + if exposer, ok := value.(SchemaExposer); ok { + return exposer.OpenAPISchema(r.Config.OpenAPIVersion) + } + if exposer, ok := value.(StaticSchemaExposer); ok { + return exposer.OpenAPISchema() + } + return nil +} + +func (r *Reflector) SchemaFromTypeExposer(t reflect.Type) *openapi.Schema { + if t == nil { + return nil + } + if t.Kind() == reflect.Interface { + return nil + } + value := reflect.New(t).Interface() + return r.SchemaFromValueExposer(value) +} + +func IsTime(t reflect.Type) bool { + return t == reflect.TypeFor[time.Time]() +} diff --git a/internal/reflect/reflector_test.go b/internal/reflect/reflector_test.go new file mode 100644 index 0000000..42e5c91 --- /dev/null +++ b/internal/reflect/reflector_test.go @@ -0,0 +1,195 @@ +package reflect_test + +import ( + std_reflect "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oaswrap/spec" + "github.com/oaswrap/spec/internal/reflect" + "github.com/oaswrap/spec/internal/testutil/dto" + "github.com/oaswrap/spec/openapi" + "github.com/oaswrap/spec/option" +) + +func TestReflector_ParameterSchema(t *testing.T) { + cfg := &openapi.Config{OpenAPIVersion: openapi.Version304} + r := reflect.NewReflector(cfg) + + type ParamStruct struct { + ID string `path:"id" description:"User ID" required:"true" deprecated:"true"` + } + f, _ := std_reflect.TypeFor[ParamStruct]().FieldByName("ID") + + p := r.ParameterSchema(f, "path", "id") + assert.Equal(t, "id", p.Name) + assert.Equal(t, "path", p.In) + assert.Equal(t, "User ID", p.Description) + assert.True(t, p.Required) + assert.True(t, p.Deprecated) + assert.Equal(t, "string", p.Schema.Type) +} + +func TestReflector_SchemaForValue(t *testing.T) { + cfg := &openapi.Config{OpenAPIVersion: openapi.Version304} + r := reflect.NewReflector(cfg) + + t.Run("OneOf", func(t *testing.T) { + val := spec.OneOf(1, "two") + schema := r.SchemaForValue(val, reflect.SchemaInline) + assert.Len(t, schema.OneOf, 2) + }) + + t.Run("SchemaPointer", func(t *testing.T) { + expected := &openapi.Schema{Type: "boolean"} + schema := r.SchemaForValue(expected, reflect.SchemaInline) + assert.Equal(t, expected, schema) + }) +} + +func TestReflector_Config(t *testing.T) { + t.Run("InterceptDefName", func(t *testing.T) { + r := spec.NewRouter(option.WithReflectorConfig( + option.InterceptDefName(func(_ std_reflect.Type, _ string) string { + return "CustomName" + }), + )) + type NamedStruct struct{ Foo string } + r.Get("/ping", option.Response(200, NamedStruct{})) + _, err := r.GenerateSchema("yaml") + require.NoError(t, err) + doc := r.Document() + assert.Contains(t, doc.Components.Schemas, "CustomName") + }) + + t.Run("DuplicateNames", func(t *testing.T) { + r := spec.NewRouter( + option.WithReflectorConfig(option.InterceptDefName(func(_ std_reflect.Type, _ string) string { + return "Collision" + })), + ) + + type TypeA struct{ Foo string } + type TypeB struct{ Bar string } + + r.Get("/a", option.Response(200, TypeA{})) + r.Get("/b", option.Response(200, TypeB{})) + + _, err := r.GenerateSchema("yaml") + require.NoError(t, err) + doc := r.Document() + + assert.Contains(t, doc.Components.Schemas, "Collision") + assert.Contains(t, doc.Components.Schemas, "Collision2") + }) + + t.Run("DefaultDefNameUsesPkgPrefixExceptCallerPackage", func(t *testing.T) { + r := spec.NewRouter() + + type SamePkgModel struct{ Foo string } + r.Get("/same", option.Response(200, SamePkgModel{})) + r.Get("/other", option.Response(200, dto.Pet{})) + + _, err := r.GenerateSchema("yaml") + require.NoError(t, err) + doc := r.Document() + + assert.Contains(t, doc.Components.Schemas, "SamePkgModel") + assert.Contains(t, doc.Components.Schemas, "DtoPet") + }) + + t.Run("StripDefNamePrefixCanStripGeneratedPkgPrefix", func(t *testing.T) { + r := spec.NewRouter( + option.WithReflectorConfig( + option.StripDefNamePrefix("Dto"), + ), + ) + + r.Get("/other", option.Response(200, dto.Pet{})) + + _, err := r.GenerateSchema("yaml") + require.NoError(t, err) + doc := r.Document() + + assert.Contains(t, doc.Components.Schemas, "Pet") + assert.NotContains(t, doc.Components.Schemas, "DtoPet") + }) +} + +func TestReflector_ParameterField_CustomMappingKeepsDefaultTag(t *testing.T) { + cfg := option.WithOpenAPIConfig( + option.WithReflectorConfig(option.ParameterTagMapping(openapi.ParameterInPath, "param")), + ) + r := reflect.NewReflector(cfg) + + type Request struct { + ID int `path:"id" required:"true"` + } + + params, _ := r.RequestParts(Request{}, "") + require.Len(t, params, 1) + assert.Equal(t, "id", params[0].Name) + assert.Equal(t, "path", params[0].In) + assert.True(t, params[0].Required) +} + +func TestReflector_RequestPartsAndStructSchemaBranches(t *testing.T) { + cfg := option.WithOpenAPIConfig() + r := reflect.NewReflector(cfg) + + t.Run("non-struct uses schema component", func(t *testing.T) { + params, body := r.RequestParts(123, "") + assert.Nil(t, params) + require.NotNil(t, body) + assert.Equal(t, "integer", body.Type) + }) + + t.Run("only params without body", func(t *testing.T) { + type Req struct { + ID string `path:"id" required:"true"` + } + params, body := r.RequestParts(Req{}, "") + require.Len(t, params, 1) + assert.Equal(t, "id", params[0].Name) + assert.Nil(t, body) + }) + + t.Run("params with explicit body field", func(t *testing.T) { + type Req struct { + ID string `path:"id" required:"true"` + Name string `json:"name"` + } + params, body := r.RequestParts(Req{}, "application/json") + require.Len(t, params, 1) + require.NotNil(t, body) + assert.Contains(t, body.Properties, "name") + }) + + t.Run("body tag for form media type", func(t *testing.T) { + type Req struct { + ID string `path:"id" required:"true"` + Email string `form:"email"` + } + params, body := r.RequestParts(Req{}, "application/x-www-form-urlencoded") + require.Len(t, params, 1) + require.NotNil(t, body) + assert.Contains(t, body.Properties, "email") + }) + + t.Run("type mapping applied before request analysis", func(t *testing.T) { + type Src struct { + ID string `path:"id" required:"true"` + } + type Dst struct { + Name string `json:"name"` + } + cfg := option.WithOpenAPIConfig(option.WithReflectorConfig(option.TypeMapping(Src{}, Dst{}))) + rr := reflect.NewReflector(cfg) + params, body := rr.RequestParts(Src{}, "") + assert.Nil(t, params) + require.NotNil(t, body) + assert.Equal(t, "#/components/schemas/Dst", body.Ref) + }) +} diff --git a/internal/reflect/tags.go b/internal/reflect/tags.go new file mode 100644 index 0000000..8117e4a --- /dev/null +++ b/internal/reflect/tags.go @@ -0,0 +1,286 @@ +package reflect + +import ( + "encoding/json" + "math" + "reflect" + "strconv" + "strings" + + "github.com/oaswrap/spec/openapi" +) + +//nolint:gocyclo,cyclop,funlen // struct tags map to many independent schema fields. +func (r *Reflector) ApplySchemaTags(schema *openapi.Schema, field reflect.StructField) { + tag := field.Tag + if v := tag.Get("type"); v != "" { + schema.Type = ParseTypeTag(v, r.Config.OpenAPIVersion) + } + if v := tag.Get("title"); v != "" { + schema.Title = v + } + if v := tag.Get("description"); v != "" { + schema.Description = v + } + if v := tag.Get("format"); v != "" { + schema.Format = v + } + if v := tag.Get("pattern"); v != "" { + schema.Pattern = v + } + if v := tag.Get("default"); v != "" { + schema.Default = ParseTagValue(v) + } + if v := tag.Get("example"); v != "" { + schema.Example = ParseTagValue(v) + } + if v := tag.Get("examples"); v != "" && !IsOpenAPI30(r.Config.OpenAPIVersion) { + schema.Examples = ParseTagValues(v) + } + if v := tag.Get("enum"); v != "" { + schema.Enum = ParseTagValues(v) + } + if v := tag.Get("const"); v != "" && !IsOpenAPI30(r.Config.OpenAPIVersion) { + schema.Const = ParseTagValue(v) + } + if v := FloatTag(tag.Get("multipleOf")); v != nil { + schema.MultipleOf = v + } + if v := FloatTag(tag.Get("maximum")); v != nil { + schema.Maximum = v + } + if v := FloatTag(tag.Get("minimum")); v != nil { + schema.Minimum = v + } + r.ApplyExclusiveLimit(schema, tag, "exclusiveMaximum") + r.ApplyExclusiveLimit(schema, tag, "exclusiveMinimum") + if v := IntTag(tag.Get("maxLength")); v != nil { + schema.MaxLength = v + } + if v := IntTag(tag.Get("minLength")); v != nil { + schema.MinLength = v + } + if v := IntTag(tag.Get("maxItems")); v != nil { + schema.MaxItems = v + } + if v := IntTag(tag.Get("minItems")); v != nil { + schema.MinItems = v + } + if v := IntTag(tag.Get("maxProperties")); v != nil { + schema.MaxProperties = v + } + if v := IntTag(tag.Get("minProperties")); v != nil { + schema.MinProperties = v + } + if v := tag.Get("uniqueItems"); v != "" { + b := BoolTag(v) + schema.UniqueItems = &b + } + if BoolTag(tag.Get("nullable")) { + r.ApplyNullable(schema, true) + } + if BoolTag(tag.Get("deprecated")) { + schema.Deprecated = true + } + if BoolTag(tag.Get("readOnly")) { + schema.ReadOnly = true + } + if BoolTag(tag.Get("writeOnly")) { + schema.WriteOnly = true + } + if v := tag.Get("contentEncoding"); v != "" && !IsOpenAPI30(r.Config.OpenAPIVersion) { + schema.ContentEncoding = v + } + if v := tag.Get("contentMediaType"); v != "" && !IsOpenAPI30(r.Config.OpenAPIVersion) { + schema.ContentMediaType = v + } + r.ApplyXMLTags(schema, tag) +} + +func (r *Reflector) ApplyExclusiveLimit(schema *openapi.Schema, tag reflect.StructTag, key string) { + value := tag.Get(key) + if value == "" { + return + } + if IsOpenAPI30(r.Config.OpenAPIVersion) { + schemaValue := BoolTag(value) + if key == "exclusiveMaximum" { + schema.ExclusiveMaximum = schemaValue + } else { + schema.ExclusiveMinimum = schemaValue + } + return + } + if schemaValue := FloatTag(value); schemaValue != nil { + if key == "exclusiveMaximum" { + schema.ExclusiveMaximum = *schemaValue + } else { + schema.ExclusiveMinimum = *schemaValue + } + } +} + +func (r *Reflector) ApplyXMLTags(schema *openapi.Schema, tag reflect.StructTag) { + xmlName := tag.Get("xmlName") + xmlNamespace := tag.Get("xmlNamespace") + xmlPrefix := tag.Get("xmlPrefix") + xmlAttribute := tag.Get("xmlAttribute") + xmlWrapped := tag.Get("xmlWrapped") + xmlNodeType := tag.Get("xmlNodeType") + if xmlName == "" && xmlNamespace == "" && xmlPrefix == "" && xmlAttribute == "" && xmlWrapped == "" && + xmlNodeType == "" { + return + } + if schema.XML == nil { + schema.XML = &openapi.XML{} + } + schema.XML.Name = xmlName + schema.XML.Namespace = xmlNamespace + schema.XML.Prefix = xmlPrefix + if xmlAttribute != "" && (r.Config.OpenAPIVersion != openapi.Version320 || xmlNodeType == "") { + schema.XML.Attribute = BoolTag(xmlAttribute) + } + if xmlWrapped != "" && (r.Config.OpenAPIVersion != openapi.Version320 || xmlNodeType == "") { + schema.XML.Wrapped = BoolTag(xmlWrapped) + } + if xmlNodeType != "" && r.Config.OpenAPIVersion == openapi.Version320 { + if schema.XML.Extra == nil { + schema.XML.Extra = map[string]any{} + } + schema.XML.Extra["nodeType"] = xmlNodeType + } + if schema.XML.Name == "" && schema.XML.Namespace == "" && schema.XML.Prefix == "" && !schema.XML.Attribute && + !schema.XML.Wrapped && + len(schema.XML.Extra) == 0 { + schema.XML = nil + } +} + +func ParseTagValue(value string) any { + var decoded any + if json.Unmarshal([]byte(value), &decoded) == nil { + return NormalizeTagValue(decoded) + } + if b, err := strconv.ParseBool(value); err == nil { + return b + } + if i, err := strconv.ParseInt(value, 10, 64); err == nil { + return i + } + if f, err := strconv.ParseFloat(value, 64); err == nil { + return f + } + return value +} + +func NormalizeTagValue(value any) any { + switch typed := value.(type) { + case []any: + out := make([]any, 0, len(typed)) + for _, item := range typed { + out = append(out, NormalizeTagValue(item)) + } + return out + case map[string]any: + out := make(map[string]any, len(typed)) + for key, item := range typed { + out[key] = NormalizeTagValue(item) + } + return out + case float64: + if math.Trunc(typed) == typed && typed >= math.MinInt64 && typed <= math.MaxInt64 { + return int64(typed) + } + return typed + default: + return value + } +} + +func ParseTagValues(value string) []any { + var decoded []any + if json.Unmarshal([]byte(value), &decoded) == nil { + return decoded + } + parts := strings.Split(value, ",") + out := make([]any, 0, len(parts)) + for _, part := range parts { + out = append(out, ParseTagValue(strings.TrimSpace(part))) + } + return out +} + +func ParseTypeTag(value string, version string) any { + parts := strings.Split(value, ",") + if IsOpenAPI30(version) || len(parts) == 1 { + return strings.TrimSpace(parts[0]) + } + types := make([]string, 0, len(parts)) + for _, part := range parts { + if trimmed := strings.TrimSpace(part); trimmed != "" { + types = append(types, trimmed) + } + } + if len(types) == 1 { + return types[0] + } + return types +} + +func TagName(field reflect.StructField, key string) string { + raw := field.Tag.Get(key) + if raw == "-" { + return "" + } + if raw == "" { + return "" + } + name, _, _ := strings.Cut(raw, ",") + if name == "" || name == "-" { + return "" + } + return name +} + +func IgnoredField(field reflect.StructField, key string) bool { + if field.Tag.Get(key) == "-" { + return true + } + return key != "json" && field.Tag.Get("json") == "-" +} + +func BoolTag(value string) bool { + b, _ := strconv.ParseBool(value) + return b +} + +func IntTag(value string) *int { + if value == "" { + return nil + } + i, err := strconv.Atoi(value) + if err != nil { + return nil + } + return &i +} + +func FloatTag(value string) *float64 { + if value == "" { + return nil + } + f, err := strconv.ParseFloat(value, 64) + if err != nil { + return nil + } + return &f +} + +func BodyNameTag(contentType string) string { + switch { + case strings.Contains(contentType, "x-www-form-urlencoded"), strings.Contains(contentType, "multipart/form-data"): + return "form" + default: + return "json" + } +} diff --git a/internal/reflect/tags_test.go b/internal/reflect/tags_test.go new file mode 100644 index 0000000..65ced9a --- /dev/null +++ b/internal/reflect/tags_test.go @@ -0,0 +1,178 @@ +package reflect_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oaswrap/spec" + "github.com/oaswrap/spec/openapi" + "github.com/oaswrap/spec/option" +) + +type XMLType struct { + Value string `xmlName:"val" xmlNamespace:"ns" xmlPrefix:"p" xmlAttribute:"true"` +} + +func TestTags_OpenAPI304(t *testing.T) { + r := spec.NewRouter(option.WithTitle("Reflection 3.0"), option.WithVersion("1.0.0")) + r.Post("/payload", option.Request(new(ReflectionVersionPayload)), option.Response(204, nil)) + + raw, err := r.GenerateSchema("json") + require.NoError(t, err) + + text := string(raw) + for _, forbidden := range []string{`"const"`, `"examples"`, `"contentEncoding"`, `"contentMediaType"`} { + assert.NotContains(t, text, forbidden, "OpenAPI 3.0.x output contains forbidden keyword") + } + + name := generatedComponentProperty(t, raw, "ReflectionVersionPayload", "name") + assert.Equal(t, true, name["nullable"]) + assert.Equal(t, true, name["exclusiveMaximum"]) +} + +func TestTags_OpenAPI312(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version312), + option.WithTitle("Reflection 3.1"), + option.WithVersion("1.0.0"), + ) + r.Post("/payload", option.Request(new(ReflectionVersionPayload312)), option.Response(204, nil)) + + raw, err := r.GenerateSchema("json") + require.NoError(t, err) + + name := generatedComponentProperty(t, raw, "ReflectionVersionPayload312", "name") + assert.Equal(t, "fixed", name["const"]) + assert.Contains(t, name, "examples") + assert.Equal(t, "base64", name["contentEncoding"]) + assert.Equal(t, "text/plain", name["contentMediaType"]) + + score := generatedComponentProperty(t, raw, "ReflectionVersionPayload312", "score") + assert.InDelta(t, 0.0, score["exclusiveMinimum"].(float64), 0.0001) +} + +func TestTags_XML(t *testing.T) { + t.Run("Standard", func(t *testing.T) { + r := spec.NewRouter(option.WithTitle("XML")) + r.Get("/xml", option.Response(200, XMLType{})) + _, err := r.GenerateSchema("yaml") + require.NoError(t, err) + doc := r.Document() + schema := doc.Components.Schemas["XMLType"] + assert.NotNil(t, schema.Properties["value"].XML) + }) + + t.Run("XMLNodeType", func(t *testing.T) { + r := spec.NewRouter(option.WithOpenAPIVersion(openapi.Version320)) + type XMLNode struct { + Attr string `json:"attr" xmlAttribute:"true" xmlNodeType:"attribute"` + } + r.Get("/xml-node", option.Response(200, XMLNode{})) + _, err := r.GenerateSchema("yaml") + require.NoError(t, err) + doc := r.Document() + schema := doc.Components.Schemas["XMLNode"].Properties["attr"] + if assert.NotNil(t, schema.XML) { + assert.Equal(t, "attribute", schema.XML.Extra["nodeType"]) + } + }) +} + +func TestTags_Values(t *testing.T) { + type TagValueType struct { + Int int `json:"int" default:"123"` + Bool bool `json:"bool" default:"true"` + Float float64 `json:"float" default:"1.23"` + String string `json:"string" default:"foo"` + JSON []int `json:"json" default:"[1,2,3]"` + } + r := spec.NewRouter(option.WithTitle("Tags")) + r.Get("/tags", option.Response(200, TagValueType{})) + _, err := r.GenerateSchema("yaml") + require.NoError(t, err) + doc := r.Document() + props := doc.Components.Schemas["TagValueType"].Properties + assert.Equal(t, int64(123), props["int"].Default) + assert.Equal(t, true, props["bool"].Default) +} + +func TestTags_Constraints(t *testing.T) { + t.Run("OpenAPI304_Const", func(t *testing.T) { + r := spec.NewRouter(option.WithOpenAPIVersion(openapi.Version304)) + type V304Type struct { + Const string `json:"const" const:"foo"` + } + r.Get("/v304", option.Response(200, V304Type{})) + _, err := r.GenerateSchema("yaml") + require.NoError(t, err) + doc := r.Document() + assert.Nil(t, doc.Components.Schemas["V304Type"].Const) + }) + + t.Run("MultiType", func(t *testing.T) { + r := spec.NewRouter(option.WithOpenAPIVersion(openapi.Version312)) + type MultiType struct { + Value any `json:"value" type:"string,integer"` + } + r.Get("/multi", option.Response(200, MultiType{})) + _, err := r.GenerateSchema("yaml") + require.NoError(t, err) + doc := r.Document() + schema := doc.Components.Schemas["MultiType"].Properties["value"] + assert.Equal(t, []string{"string", "integer"}, schema.Type) + }) + + t.Run("IntTags", func(t *testing.T) { + r := spec.NewRouter() + type IntTagType struct { + Val string `json:"val" maxLength:"10" minLength:"5" maxItems:"3" minItems:"1" maxProperties:"4" minProperties:"2"` + } + r.Get("/int", option.Response(200, IntTagType{})) + _, err := r.GenerateSchema("yaml") + require.NoError(t, err) + doc := r.Document() + schema := doc.Components.Schemas["IntTagType"].Properties["val"] + assert.Equal(t, 10, *schema.MaxLength) + assert.Equal(t, 5, *schema.MinLength) + assert.Equal(t, 3, *schema.MaxItems) + assert.Equal(t, 1, *schema.MinItems) + assert.Equal(t, 4, *schema.MaxProperties) + assert.Equal(t, 2, *schema.MinProperties) + }) + + t.Run("FloatTags", func(t *testing.T) { + r := spec.NewRouter() + type FloatTagType struct { + Val float64 `json:"val" multipleOf:"2.5" maximum:"10.5" minimum:"1.5"` + } + r.Get("/float", option.Response(200, FloatTagType{})) + _, err := r.GenerateSchema("yaml") + require.NoError(t, err) + doc := r.Document() + schema := doc.Components.Schemas["FloatTagType"].Properties["val"] + assert.InDelta(t, 2.5, *schema.MultipleOf, 1e-9) + assert.InDelta(t, 10.5, *schema.Maximum, 1e-9) + assert.InDelta(t, 1.5, *schema.Minimum, 1e-9) + }) + + t.Run("OtherTags", func(t *testing.T) { + r := spec.NewRouter() + type OtherTagType struct { + Val string `json:"val" pattern:".*" uniqueItems:"true" readOnly:"true" deprecated:"true" title:"T" example:"E"` + } + r.Get("/other", option.Response(200, OtherTagType{})) + _, err := r.GenerateSchema("yaml") + require.NoError(t, err) + doc := r.Document() + schema := doc.Components.Schemas["OtherTagType"].Properties["val"] + assert.Equal(t, ".*", schema.Pattern) + assert.True(t, *schema.UniqueItems) + assert.True(t, schema.ReadOnly) + assert.False(t, schema.WriteOnly) + assert.True(t, schema.Deprecated) + assert.Equal(t, "T", schema.Title) + assert.Equal(t, "E", schema.Example) + }) +} diff --git a/internal/reflect/utils.go b/internal/reflect/utils.go new file mode 100644 index 0000000..47e38b7 --- /dev/null +++ b/internal/reflect/utils.go @@ -0,0 +1,136 @@ +package reflect + +import ( + "fmt" + "path" + "reflect" + "regexp" + "strings" + "unicode" +) + +func (r *Reflector) TypeName(t reflect.Type) string { + if name, ok := r.Names[t]; ok { + return name + } + name := SanitizeTypeName(t.Name()) + name = sanitizeDefName(t, name, r.callerPkgPath()) + for _, prefix := range r.StripPrefixes() { + name = strings.TrimPrefix(name, prefix) + } + if r.Config.ReflectorConfig != nil && r.Config.ReflectorConfig.InterceptDefName != nil { + name = r.Config.ReflectorConfig.InterceptDefName(t, name) + } + if name == "" { + name = "Schema" + } + base := name + i := 2 + for usedType, usedName := range r.Names { + if usedName == name && usedType != t { + name = fmt.Sprintf("%s%d", base, i) + i++ + } + } + r.Names[t] = name + return name +} + +func sanitizeDefName(t reflect.Type, defaultDefName, callerPkgPath string) string { + if callerPkgPath == "" || defaultDefName == "" || t == nil || t.PkgPath() == "" || t.PkgPath() == callerPkgPath { + return defaultDefName + } + pkgName := path.Base(t.PkgPath()) + if pkgName == "" { + return defaultDefName + } + pkgName = strings.ToUpper(pkgName[:1]) + pkgName[1:] + return pkgName + defaultDefName +} + +func (r *Reflector) callerPkgPath() string { + if r.Config == nil || r.Config.ReflectorConfig == nil { + return "" + } + return r.Config.ReflectorConfig.DefNameCallerPkg +} + +func (r *Reflector) StripPrefixes() []string { + if r.Config.ReflectorConfig == nil { + return nil + } + return r.Config.ReflectorConfig.StripDefNamePrefix +} + +func (r *Reflector) InlineRefs() bool { + return r.Config.ReflectorConfig != nil && r.Config.ReflectorConfig.InlineRefs +} + +func IndirectType(t reflect.Type) reflect.Type { + for t != nil && t.Kind() == reflect.Pointer { + t = t.Elem() + } + return t +} + +func ForEachField(t reflect.Type, fn func(reflect.StructField)) { + for i := range t.NumField() { + field := t.Field(i) + if field.PkgPath != "" && !field.Anonymous { + continue + } + if field.Anonymous && IndirectType(field.Type).Kind() == reflect.Struct && TagName(field, "json") == "" { + ForEachField(IndirectType(field.Type), fn) + continue + } + fn(field) + } +} + +func LowerCamel(value string) string { + if value == "" { + return value + } + runes := []rune(value) + runes[0] = unicode.ToLower(runes[0]) + return string(runes) +} + +var genericNameRe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) + +func SanitizeTypeName(name string) string { + if name == "" { + return "" + } + + // Handle slices: []Foo -> FooList + if strings.HasPrefix(name, "[]") { + return SanitizeTypeName(name[2:]) + "List" + } + + // Handle pointers + name = strings.TrimLeft(name, "*") + + // Handle generics: BaseResponse[github.com/foo.User] + if start := strings.Index(name, "["); start != -1 && strings.HasSuffix(name, "]") { + base := name[:start] + inner := name[start+1 : len(name)-1] + + // Split multiple generic params: Map[string, int] + parts := strings.Split(inner, ",") + for i, p := range parts { + parts[i] = SanitizeTypeName(strings.TrimSpace(p)) + } + return SanitizeTypeName(base) + strings.Join(parts, "") + } + + // For names with package paths: github.com/foo.User -> User + // Note: reflect.Type.Name() usually only contains the local name for defined types, + // but for generic instances it includes full paths for parameters. + if lastDot := strings.LastIndex(name, "."); lastDot != -1 { + name = name[lastDot+1:] + } + + // Final cleanup for any remaining characters + return genericNameRe.ReplaceAllString(name, "") +} diff --git a/internal/reflect/utils_test.go b/internal/reflect/utils_test.go new file mode 100644 index 0000000..c4472c6 --- /dev/null +++ b/internal/reflect/utils_test.go @@ -0,0 +1,67 @@ +package reflect + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSanitizeTypeName(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"", ""}, + {"Foo", "Foo"}, + {"*Foo", "Foo"}, + {"[]Foo", "FooList"}, + {"[][]Foo", "FooListList"}, + {"github.com/foo.User", "User"}, + {"BaseResponse[github.com/foo.User]", "BaseResponseUser"}, + {"Map[string, int]", "Mapstringint"}, + {"Complex[[]string, *int]", "ComplexstringListint"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.expected, SanitizeTypeName(tt.input)) + }) + } +} + +func TestLowerCamel(t *testing.T) { + assert.Equal(t, "fooBar", LowerCamel("FooBar")) + assert.Equal(t, "foo", LowerCamel("Foo")) + assert.Empty(t, LowerCamel("")) +} + +func TestIndirectType(t *testing.T) { + type Foo struct{} + // f := Foo{} + typ := reflect.TypeFor[Foo]() + assert.Equal(t, typ, IndirectType(typ)) + assert.Equal(t, typ, IndirectType(reflect.TypeFor[*Foo]())) + assert.Equal(t, typ, IndirectType(reflect.TypeFor[**Foo]())) +} + +func TestForEachField(t *testing.T) { + type Inner struct { + InnerField string `json:"inner"` + } + type Outer struct { + Inner `json:",inline"` + + OuterField string `json:"outer"` + _ string + } + + fields := []string{} + ForEachField(reflect.TypeFor[Outer](), func(f reflect.StructField) { + fields = append(fields, f.Name) + }) + + assert.Contains(t, fields, "OuterField") + assert.Contains(t, fields, "InnerField") + assert.NotContains(t, fields, "Inner") // Inner is inlined +} diff --git a/pkg/dto/pet.go b/internal/testutil/dto/types.go similarity index 69% rename from pkg/dto/pet.go rename to internal/testutil/dto/types.go index fd9653e..eb8b180 100644 --- a/pkg/dto/pet.go +++ b/internal/testutil/dto/types.go @@ -5,11 +5,13 @@ import ( "time" ) +// Common types used in Petstore tests. + type Pet struct { ID int `json:"id"` Name string `json:"name"` Type string `json:"type"` - Status string `json:"status" enum:"available,pending,sold"` + Status string `json:"status" enum:"available,pending,sold"` Category Category `json:"category"` Tags []Tag `json:"tags"` PhotoURLs []string `json:"photoUrls"` @@ -27,19 +29,19 @@ type Category struct { type UpdatePetWithFormRequest struct { ID int `path:"petId" required:"true"` - Name string ` required:"true" formData:"name"` - Status string ` formData:"status" enum:"available,pending,sold"` + Name string `required:"true" formData:"name"` + Status string `formData:"status" enum:"available,pending,sold"` } type UploadImageRequest struct { ID int64 `params:"petId" path:"petId"` - AdditionalMetaData string ` query:"additionalMetadata"` - _ *multipart.File ` contentType:"application/octet-stream"` + AdditionalMetaData string `query:"additionalMetadata"` + _ *multipart.File `contentType:"application/octet-stream"` } type DeletePetRequest struct { ID int `path:"petId" required:"true"` - APIKey string ` header:"api_key"` + APIKey string `header:"api_key"` } type Order struct { @@ -47,7 +49,7 @@ type Order struct { PetID int `json:"petId"` Quantity int `json:"quantity"` ShipDate time.Time `json:"shipDate"` - Status string `json:"status" enum:"placed,approved,delivered"` + Status string `json:"status" enum:"placed,approved,delivered"` Complete bool `json:"complete"` } diff --git a/internal/testutil/types.go b/internal/testutil/types.go new file mode 100644 index 0000000..726719f --- /dev/null +++ b/internal/testutil/types.go @@ -0,0 +1,35 @@ +package testutil + +import ( + "flag" + "os" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" +) + +// Update is a test flag for updating golden files. +var Update = flag.Bool("update", false, "update golden files") + +// AssertGolden compares the generated schema with a golden file. +func AssertGolden(t *testing.T, schema []byte, goldenFile string) { + t.Helper() + + if *Update { + err := os.MkdirAll(filepath.Dir(goldenFile), 0750) + require.NoError(t, err, "failed to create golden file directory") + err = os.WriteFile(goldenFile, schema, 0600) + require.NoError(t, err, "failed to write golden file") + t.Logf("Updated golden file: %s", goldenFile) + } + + want, err := os.ReadFile(goldenFile) + require.NoError(t, err, "failed to read golden file %s", goldenFile) + + diff := cmp.Diff(string(want), string(schema)) + if diff != "" { + t.Errorf("OpenAPI schema mismatch (-want +got):\n%s", diff) + } +} diff --git a/internal/validate/common_test.go b/internal/validate/common_test.go new file mode 100644 index 0000000..5319190 --- /dev/null +++ b/internal/validate/common_test.go @@ -0,0 +1,25 @@ +package validate_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type GetUserRequest struct { + ID string `path:"id" required:"true" description:"User identifier"` +} + +type User struct { + ID string `json:"id" required:"true"` + Name string `json:"name"` +} + +func assertValidationContains(t *testing.T, err error, messages ...string) { + t.Helper() + require.Error(t, err) + for _, message := range messages { + assert.Contains(t, err.Error(), message) + } +} diff --git a/internal/validate/components.go b/internal/validate/components.go new file mode 100644 index 0000000..399be97 --- /dev/null +++ b/internal/validate/components.go @@ -0,0 +1,104 @@ +package validate + +import ( + "fmt" + + "github.com/oaswrap/spec/internal/reflect" + "github.com/oaswrap/spec/openapi" +) + +func ValidateComponents( + components *openapi.Components, + version string, + operationIDs map[string]string, + securitySchemes map[string]*openapi.SecurityScheme, + componentParameters map[string]*openapi.Parameter, +) []error { + var errs []error + errs = append(errs, ValidateComponentKeys("schemas", components.Schemas)...) + errs = append(errs, ValidateComponentKeys("responses", components.Responses)...) + errs = append(errs, ValidateComponentKeys("parameters", components.Parameters)...) + errs = append(errs, ValidateComponentKeys("examples", components.Examples)...) + errs = append(errs, ValidateComponentKeys("requestBodies", components.RequestBodies)...) + errs = append(errs, ValidateComponentKeys("headers", components.Headers)...) + errs = append(errs, ValidateComponentKeys("securitySchemes", components.SecuritySchemes)...) + errs = append(errs, ValidateComponentKeys("links", components.Links)...) + errs = append(errs, ValidateComponentKeys("callbacks", components.Callbacks)...) + errs = append(errs, ValidateComponentKeys("pathItems", components.PathItems)...) + errs = append(errs, ValidateComponentKeys("mediaTypes", components.MediaTypes)...) + if reflect.IsOpenAPI30(version) && len(components.PathItems) > 0 { + errs = append(errs, fmt.Errorf("components.pathItems requires OpenAPI 3.1.x or 3.2.0")) + } + if version != openapi.Version320 && len(components.MediaTypes) > 0 { + errs = append(errs, fmt.Errorf("components.mediaTypes requires OpenAPI 3.2.0")) + } + for name, schema := range components.Schemas { + errs = append(errs, ValidateSchema("components.schemas."+name, schema, version, map[*openapi.Schema]bool{})...) + } + for name, response := range components.Responses { + errs = append(errs, ValidateResponse("components.responses."+name, response, version)...) + } + for name, parameter := range components.Parameters { + errs = append( + errs, + ValidateParameters( + "components.parameters."+name, + []*openapi.Parameter{parameter}, + version, + componentParameters, + )...) + } + for name, example := range components.Examples { + errs = append(errs, ValidateExample("components.examples."+name, example, version)...) + } + for name, body := range components.RequestBodies { + errs = append(errs, ValidateRequestBody("components.requestBodies."+name, body, version)...) + } + for name, header := range components.Headers { + errs = append(errs, ValidateHeader("components.headers."+name, header, version)...) + } + for name, scheme := range components.SecuritySchemes { + errs = append(errs, ValidateSecurityScheme("components.securitySchemes."+name, scheme, version)...) + } + for name, link := range components.Links { + errs = append(errs, ValidateLink("components.links."+name, link, version)...) + } + for name, callback := range components.Callbacks { + errs = append( + errs, + ValidateCallback( + "components.callbacks."+name, + callback, + version, + operationIDs, + securitySchemes, + componentParameters, + )...) + } + for name, pathItem := range components.PathItems { + errs = append( + errs, + ValidatePathItemOperations( + "components.pathItems."+name, + pathItem, + version, + operationIDs, + securitySchemes, + componentParameters, + )...) + } + for name, mediaType := range components.MediaTypes { + errs = append(errs, ValidateMediaType("components.mediaTypes."+name, "", mediaType, version)...) + } + return errs +} + +func ValidateComponentKeys[T any](kind string, values map[string]T) []error { + var errs []error + for key := range values { + if !componentRe.MatchString(key) { + errs = append(errs, fmt.Errorf("components.%s key %q must match %s", kind, key, componentRe.String())) + } + } + return errs +} diff --git a/internal/validate/components_test.go b/internal/validate/components_test.go new file mode 100644 index 0000000..ec88e1d --- /dev/null +++ b/internal/validate/components_test.go @@ -0,0 +1,113 @@ +package validate_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/oaswrap/spec" + "github.com/oaswrap/spec/openapi" + "github.com/oaswrap/spec/option" +) + +func TestValidate_OpenAPI320_SecurityRequirements(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version320), + option.WithSecurity("apiKeyAuth", option.SecurityAPIKey("X-API-Key", "header")), + option.WithGlobalSecurity("apiKeyAuth", "admin"), + option.WithGlobalSecurity("https://security.example/schemes/custom", "operator"), + ) + r.Get("/secure", option.Response(200, nil)) + + assert.NoError(t, r.Validate()) +} + +func TestValidate_OpenAPI320_OAuthDeviceAuth(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version320), + option.WithSecurity("device", option.SecurityOAuth2(openapi.OAuthFlows{ + DeviceAuthorization: &openapi.OAuthFlow{ + DeviceAuthorizationURL: "https://auth.example/device", + TokenURL: "https://auth.example/token", + Scopes: map[string]string{}, + }, + })), + option.WithGlobalSecurity("device"), + ) + r.Get("/device", option.Response(200, nil)) + + assert.NoError(t, r.Validate()) +} + +func TestValidateSecurityScheme_Errors(t *testing.T) { + t.Run("MissingRequiredFields", func(t *testing.T) { + r := spec.NewRouter( + option.WithComponentSecurityScheme("apiKey", &openapi.SecurityScheme{Type: "apiKey"}), + option.WithComponentSecurityScheme("http", &openapi.SecurityScheme{Type: "http"}), + option.WithComponentSecurityScheme("oauth2", &openapi.SecurityScheme{Type: "oauth2"}), + option.WithComponentSecurityScheme("oidc", &openapi.SecurityScheme{Type: "openIdConnect"}), + ) + err := r.Validate() + assertValidationContains(t, err, + "components.securitySchemes.apiKey.name is required for apiKey", + "components.securitySchemes.apiKey.in must be query, header, or cookie for apiKey", + "components.securitySchemes.http.scheme is required for http", + "components.securitySchemes.oauth2.flows is required for oauth2", + "components.securitySchemes.oidc.openIdConnectUrl is required for openIdConnect", + ) + }) + + t.Run("InvalidType", func(t *testing.T) { + r := spec.NewRouter(option.WithComponentSecurityScheme("bad", &openapi.SecurityScheme{Type: "invalid"})) + err := r.Validate() + assertValidationContains(t, err, "type must be one of apiKey, http, mutualTLS, oauth2, or openIdConnect") + }) + + t.Run("OpenAPI304_OAuth2Metadata", func(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version304), + option.WithComponentSecurityScheme( + "oa", + &openapi.SecurityScheme{Type: "oauth2", OAuth2MetadataURL: "https://ex.com"}, + ), + ) + err := r.Validate() + assertValidationContains(t, err, "oauth2MetadataUrl and deprecated require OpenAPI 3.2.0") + }) +} + +func TestValidateOAuthFlows_Errors(t *testing.T) { + t.Run("MissingFlows", func(t *testing.T) { + r := spec.NewRouter(option.WithSecurity("oa", option.SecurityOAuth2(openapi.OAuthFlows{}))) + err := r.Validate() + assertValidationContains(t, err, "must define at least one OAuth flow") + }) + + t.Run("OpenAPI312_DeviceAuth", func(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version312), + option.WithSecurity("oa", option.SecurityOAuth2(openapi.OAuthFlows{ + DeviceAuthorization: &openapi.OAuthFlow{Scopes: map[string]string{}}, + })), + ) + err := r.Validate() + assertValidationContains(t, err, "deviceAuthorization requires OpenAPI 3.2.0") + }) + + t.Run("MissingURLs", func(t *testing.T) { + r := spec.NewRouter(option.WithSecurity("oa", option.SecurityOAuth2(openapi.OAuthFlows{ + Implicit: &openapi.OAuthFlow{Scopes: map[string]string{}}, + Password: &openapi.OAuthFlow{Scopes: map[string]string{}}, + ClientCredentials: &openapi.OAuthFlow{Scopes: map[string]string{}}, + AuthorizationCode: &openapi.OAuthFlow{Scopes: map[string]string{}}, + }))) + err := r.Validate() + assertValidationContains(t, err, + "implicit.authorizationUrl is required", + "password.tokenUrl is required", + "clientCredentials.tokenUrl is required", + "authorizationCode.authorizationUrl is required", + "authorizationCode.tokenUrl is required", + ) + }) +} diff --git a/internal/validate/document.go b/internal/validate/document.go new file mode 100644 index 0000000..6f5ace9 --- /dev/null +++ b/internal/validate/document.go @@ -0,0 +1,183 @@ +package validate + +import ( + "fmt" + "slices" + "strings" + + "github.com/oaswrap/spec/internal/reflect" + "github.com/oaswrap/spec/openapi" +) + +//nolint:gocognit // validates top-level document rules in one traversal for coherent errors. +func ValidateDocument(doc *openapi.Document, version string) []error { + var errs []error + if doc.OpenAPI != "" && doc.OpenAPI != version { + errs = append(errs, fmt.Errorf("openapi must be %s, got %s", version, doc.OpenAPI)) + } + if doc.Self != "" && !IsURIReference(doc.Self) { + errs = append(errs, fmt.Errorf("$self must be a URI reference")) + } + if doc.JSONSchemaDialect != "" && !IsAbsoluteURI(doc.JSONSchemaDialect) { + errs = append(errs, fmt.Errorf("jsonSchemaDialect must be a URI")) + } + if doc.Info.Title == "" { + errs = append(errs, fmt.Errorf("info.title is required")) + } + if doc.Info.Version == "" { + errs = append(errs, fmt.Errorf("info.version is required")) + } + errs = append(errs, ValidateInfo(doc.Info, version)...) + if doc.Paths == nil { + errs = append(errs, fmt.Errorf("paths is required")) + } + if reflect.IsOpenAPI30(version) { + if doc.JSONSchemaDialect != "" { + errs = append(errs, fmt.Errorf("jsonSchemaDialect requires OpenAPI 3.1.x or 3.2.0")) + } + if len(doc.Webhooks) > 0 { + errs = append(errs, fmt.Errorf("webhooks requires OpenAPI 3.1.x or 3.2.0")) + } + } + if IsOpenAPI31(version) || IsOpenAPI32(version) { + if len(doc.Paths) == 0 && len(doc.Webhooks) == 0 && doc.Components == nil { + errs = append(errs, fmt.Errorf("one of paths, components, or webhooks is required")) + } + } + for i := range doc.Servers { + errs = append(errs, ValidateServer(fmt.Sprintf("servers[%d]", i), &doc.Servers[i])...) + } + if doc.ExternalDocs != nil && doc.ExternalDocs.URL == "" { + errs = append(errs, fmt.Errorf("externalDocs.url is required")) + } + securitySchemes := map[string]*openapi.SecurityScheme{} + componentParameters := map[string]*openapi.Parameter{} + if doc.Components != nil { + securitySchemes = doc.Components.SecuritySchemes + componentParameters = doc.Components.Parameters + } + errs = append(errs, ValidateSecurityRequirements("security", doc.Security, securitySchemes, version)...) + operationIDs := map[string]string{} + normalizedPaths := map[string]string{} + for path, item := range doc.Paths { + if normalized := NormalizeTemplatedPath(path); normalized != path { + if previous, exists := normalizedPaths[normalized]; exists && previous != path { + errs = append(errs, fmt.Errorf("path %q conflicts with equivalent templated path %q", path, previous)) + } else { + normalizedPaths[normalized] = path + } + } + errs = append( + errs, + ValidatePathItem(path, item, version, operationIDs, securitySchemes, componentParameters)...) + } + for name, item := range doc.Webhooks { + errs = append( + errs, + ValidatePathItemOperations( + "webhooks."+name, + item, + version, + operationIDs, + securitySchemes, + componentParameters, + )...) + } + if doc.Components != nil { + errs = append( + errs, + ValidateComponents(doc.Components, version, operationIDs, securitySchemes, componentParameters)...) + } + errs = append(errs, ValidateTags(doc.Tags, version)...) + errs = append(errs, ValidateReferenceTargets(doc)...) + return errs +} + +func ValidateTags(tags []openapi.Tag, version string) []error { + var errs []error + tagByName := make(map[string]int, len(tags)) + for i, tag := range tags { + if tag.Name == "" { + errs = append(errs, fmt.Errorf("tags[%d].name is required", i)) + } else if previous, exists := tagByName[tag.Name]; exists { + errs = append(errs, fmt.Errorf("tags[%d].name %q duplicates tags[%d].name", i, tag.Name, previous)) + } else { + tagByName[tag.Name] = i + } + if version != openapi.Version320 && (tag.Summary != "" || tag.Parent != "" || tag.Kind != "") { + errs = append(errs, fmt.Errorf("tags[%d] summary, parent, and kind require OpenAPI 3.2.0", i)) + } + if tag.ExternalDocs != nil && tag.ExternalDocs.URL == "" { + errs = append(errs, fmt.Errorf("tags[%d].externalDocs.url is required", i)) + } + } + if version == openapi.Version320 { + errs = append(errs, ValidateTagParents(tags, tagByName)...) + } + return errs +} + +func ValidateTagParents(tags []openapi.Tag, tagByName map[string]int) []error { + var errs []error + for i, tag := range tags { + if tag.Name == "" || tag.Parent == "" { + continue + } + if _, exists := tagByName[tag.Parent]; !exists { + errs = append(errs, fmt.Errorf("tags[%d].parent %q must reference an existing tag", i, tag.Parent)) + continue + } + seen := map[string]bool{tag.Name: true} + for parent := tag.Parent; parent != ""; { + if seen[parent] { + errs = append(errs, fmt.Errorf("tags[%d].parent creates a circular tag parent reference", i)) + break + } + seen[parent] = true + parentIndex := tagByName[parent] + parent = tags[parentIndex].Parent + } + } + return errs +} + +func ValidateInfo(info openapi.Info, version string) []error { + var errs []error + if reflect.IsOpenAPI30(version) && info.Summary != "" { + errs = append(errs, fmt.Errorf("info.summary requires OpenAPI 3.1.x or 3.2.0")) + } + if info.Contact != nil && info.Contact.Email != "" && !strings.Contains(info.Contact.Email, "@") { + errs = append(errs, fmt.Errorf("info.contact.email must be an email address")) + } + if info.License != nil { + if info.License.Name == "" { + errs = append(errs, fmt.Errorf("info.license.name is required")) + } + if reflect.IsOpenAPI30(version) && info.License.Identifier != "" { + errs = append(errs, fmt.Errorf("info.license.identifier requires OpenAPI 3.1.x or 3.2.0")) + } + if info.License.Identifier != "" && info.License.URL != "" { + errs = append(errs, fmt.Errorf("info.license.identifier and info.license.url are mutually exclusive")) + } + } + return errs +} + +func ValidateServer(context string, server *openapi.Server) []error { + var errs []error + if server == nil { + return nil + } + if server.URL == "" { + errs = append(errs, fmt.Errorf("%s.url is required", context)) + } + for name, variable := range server.Variables { + if variable.Default == "" { + errs = append(errs, fmt.Errorf("%s.variables.%s.default is required", context, name)) + } + if len(variable.Enum) > 0 && !slices.Contains(variable.Enum, variable.Default) { + errs = append(errs, fmt.Errorf("%s.variables.%s.default must be one of enum values", context, name)) + } + } + return errs +} diff --git a/internal/validate/document_test.go b/internal/validate/document_test.go new file mode 100644 index 0000000..8ce64a8 --- /dev/null +++ b/internal/validate/document_test.go @@ -0,0 +1,163 @@ +package validate_test + +import ( + "testing" + + "github.com/oaswrap/spec" + "github.com/oaswrap/spec/openapi" + "github.com/oaswrap/spec/option" +) + +func TestValidate_Document_OpenAPI304_RejectsNewerFields(t *testing.T) { + r := spec.NewRouter( + option.WithTitle("Invalid 3.0"), + option.WithVersion("1.0.0"), + option.WithInfoSummary("summary"), + option.WithJSONSchemaDialect("https://spec.openapis.org/oas/3.1/dialect/base"), + option.WithLicense(openapi.License{Name: "Apache 2.0", Identifier: "Apache-2.0"}), + option.WithComponentPathItem("Reusable", &openapi.PathItem{ + Get: &openapi.Operation{Responses: map[string]*openapi.Response{"200": {Description: "OK"}}}, + }), + option.WithComponentMediaType("json-seq", &openapi.MediaType{ + ItemSchema: &openapi.Schema{Type: "object"}, + }), + option.WithComponentSchema("NewerSchema", &openapi.Schema{ + Type: "object", + Defs: map[string]*openapi.Schema{ + "ID": {Type: "string"}, + }, + }), + option.WithDocument(func(doc *openapi.Document) { + doc.Webhooks = map[string]*openapi.PathItem{ + "created": { + Post: &openapi.Operation{Responses: map[string]*openapi.Response{"202": {Description: "Accepted"}}}, + }, + } + }), + ) + r.Get("/users/{id}", option.Request(new(GetUserRequest)), option.Response(200, new(User))) + + err := r.Validate() + assertValidationContains(t, err, + "info.summary requires OpenAPI 3.1.x or 3.2.0", + "info.license.identifier requires OpenAPI 3.1.x or 3.2.0", + "jsonSchemaDialect requires OpenAPI 3.1.x or 3.2.0", + "webhooks requires OpenAPI 3.1.x or 3.2.0", + "components.pathItems requires OpenAPI 3.1.x or 3.2.0", + "components.mediaTypes requires OpenAPI 3.2.0", + "contains JSON Schema dialect fields", + ) +} + +func TestValidate_Document_OpenAPI312_Rejects320Fields(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version312), + option.WithTitle("Invalid 3.1"), + option.WithVersion("1.0.0"), + option.WithComponentMediaType("json-seq", &openapi.MediaType{ + ItemSchema: &openapi.Schema{Type: "object"}, + }), + option.WithComponentSchema("With32Extras", &openapi.Schema{ + Type: "object", + Discriminator: &openapi.Discriminator{ + PropertyName: "kind", + Extra: map[string]any{"defaultMapping": "Other"}, + }, + XML: &openapi.XML{Extra: map[string]any{"nodeType": "element"}}, + }), + option.WithDocument(func(doc *openapi.Document) { + doc.Paths["/search"] = &openapi.PathItem{ + Query: &openapi.Operation{ + Responses: map[string]*openapi.Response{"200": {Description: "OK"}}, + }, + } + }), + ) + r.Get("/search", option.Response(200, new([]User))) + + err := r.Validate() + assertValidationContains(t, err, + "QUERY operation at /search requires OpenAPI 3.2.0", + "components.mediaTypes requires OpenAPI 3.2.0", + "components.mediaTypes.json-seq.itemSchema requires OpenAPI 3.2.0", + "components.schemas.With32Extras.discriminator.defaultMapping requires OpenAPI 3.2.0", + "components.schemas.With32Extras.xml.nodeType requires OpenAPI 3.2.0", + ) +} + +func TestValidate_Document_URIFields(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version312), + option.WithSelf("not a uri"), + option.WithJSONSchemaDialect("relative-dialect"), + ) + r.Get("/uri", option.Response(204, nil)) + + err := r.Validate() + assertValidationContains(t, err, + "$self must be a URI reference", + "jsonSchemaDialect must be a URI", + ) +} + +func TestValidate_Document_TagNamesUnique(t *testing.T) { + r := spec.NewRouter( + option.WithTags( + openapi.Tag{Name: "users"}, + openapi.Tag{Name: "users"}, + ), + ) + r.Get("/tags", option.Response(204, nil)) + + err := r.Validate() + assertValidationContains(t, err, `tags[1].name "users" duplicates tags[0].name`) +} + +func TestValidate_Document_OpenAPI320_TagParentExists(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version320), + option.WithTags( + openapi.Tag{Name: "users", Parent: "missing"}, + ), + ) + r.Get("/tags", option.Response(204, nil)) + + err := r.Validate() + assertValidationContains(t, err, `tags[0].parent "missing" must reference an existing tag`) +} + +func TestValidate_Document_OpenAPI320_TagParentCircular(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version320), + option.WithTags( + openapi.Tag{Name: "users", Parent: "accounts"}, + openapi.Tag{Name: "accounts", Parent: "users"}, + ), + ) + r.Get("/tags", option.Response(204, nil)) + + err := r.Validate() + assertValidationContains(t, err, "tags[0].parent creates a circular tag parent reference") +} + +func TestValidate_Document_Server(t *testing.T) { + t.Run("Invalid URL", func(t *testing.T) { + r := spec.NewRouter(option.WithServer("")) + err := r.Validate() + assertValidationContains(t, err, "servers[0].url is required") + }) + + t.Run("Invalid Variables", func(t *testing.T) { + r := spec.NewRouter( + option.WithServer("https://{host}", option.ServerVariables(map[string]openapi.ServerVariable{ + "host": {Default: ""}, + "port": {Default: "80", Enum: []string{"8080"}}, + })), + ) + err := r.Validate() + assertValidationContains(t, err, + "servers[0].variables.host.default is required", + "servers[0].variables.port.default must be one of enum values", + ) + }) +} diff --git a/internal/validate/operation.go b/internal/validate/operation.go new file mode 100644 index 0000000..6100be8 --- /dev/null +++ b/internal/validate/operation.go @@ -0,0 +1,642 @@ +package validate + +import ( + "fmt" + "slices" + "strings" + + "github.com/oaswrap/spec/internal/reflect" + "github.com/oaswrap/spec/openapi" +) + +func ValidateOperation( + context string, + op *openapi.Operation, + version string, + operationIDs map[string]string, + securitySchemes map[string]*openapi.SecurityScheme, + componentParameters map[string]*openapi.Parameter, +) []error { + var errs []error + if op.OperationID != "" { + if previous, exists := operationIDs[op.OperationID]; exists { + errs = append(errs, fmt.Errorf("%s operationId %q duplicates %s", context, op.OperationID, previous)) + } else { + operationIDs[op.OperationID] = context + } + } + if len(op.Responses) == 0 { + errs = append(errs, fmt.Errorf("%s responses is required", context)) + } + for i := range op.Servers { + errs = append(errs, ValidateServer(fmt.Sprintf("%s.servers[%d]", context, i), &op.Servers[i])...) + } + if op.ExternalDocs != nil && op.ExternalDocs.URL == "" { + errs = append(errs, fmt.Errorf("%s.externalDocs.url is required", context)) + } + errs = append(errs, ValidateParameters(context+".parameters", op.Parameters, version, componentParameters)...) + if op.RequestBody != nil { + errs = append(errs, ValidateRequestBody(context+".requestBody", op.RequestBody, version)...) + } + for code, response := range op.Responses { + if code != "default" && !responseCodeRe.MatchString(code) { + errs = append( + errs, + fmt.Errorf("%s.responses.%s must be default, a status code, or a status code range", context, code), + ) + } + errs = append(errs, ValidateResponse(context+".responses."+code, response, version)...) + } + for name, callback := range op.Callbacks { + errs = append( + errs, + ValidateCallback( + context+".callbacks."+name, + callback, + version, + operationIDs, + securitySchemes, + componentParameters, + )...) + } + errs = append(errs, ValidateSecurityRequirements(context+".security", op.Security, securitySchemes, version)...) + return errs +} + +//nolint:gocognit,gocyclo,cyclop,funlen // parameter validation intentionally aggregates many independent OpenAPI constraints. +func ValidateParameters( + context string, + params []*openapi.Parameter, + version string, + componentParameters map[string]*openapi.Parameter, +) []error { + var errs []error + seen := map[string]struct{}{} + for i, param := range params { + paramContext := fmt.Sprintf("%s[%d]", context, i) + if param == nil { + errs = append(errs, fmt.Errorf("%s is required", paramContext)) + continue + } + if param.Ref != "" { + if HasParameterRefSiblings(param, version) { + errs = append(errs, fmt.Errorf("%s must not define siblings with $ref", paramContext)) + } + if resolved := ResolveParameterRef(param.Ref, componentParameters); resolved != nil { + key := resolved.Name + "\x00" + resolved.In + if _, exists := seen[key]; exists { + errs = append( + errs, + fmt.Errorf("%s duplicates parameter %q in %q", paramContext, resolved.Name, resolved.In), + ) + } + seen[key] = struct{}{} + } + continue + } + if param.Summary != "" { + errs = append(errs, fmt.Errorf("%s.summary is only allowed with $ref", paramContext)) + } + if param.Name == "" { + errs = append(errs, fmt.Errorf("%s.name is required", paramContext)) + } + if param.In == "" { + errs = append(errs, fmt.Errorf("%s.in is required", paramContext)) + } else if !IsValidParameterIn(param.In) { + errs = append( + errs, + fmt.Errorf("%s.in must be one of query, querystring, header, path, or cookie", paramContext), + ) + } + if param.In == string(openapi.ParameterInQueryString) && version != openapi.Version320 { + errs = append(errs, fmt.Errorf("%s querystring parameters require OpenAPI 3.2.0", paramContext)) + } + if param.In == string(openapi.ParameterInPath) && !param.Required { + errs = append(errs, fmt.Errorf("%s path parameter must be required", paramContext)) + } + key := param.Name + "\x00" + param.In + if _, exists := seen[key]; exists { + errs = append(errs, fmt.Errorf("%s duplicates parameter %q in %q", paramContext, param.Name, param.In)) + } + seen[key] = struct{}{} + if param.Schema != nil && len(param.Content) > 0 { + errs = append(errs, fmt.Errorf("%s schema and content are mutually exclusive", paramContext)) + } + if param.Schema == nil && len(param.Content) == 0 { + errs = append(errs, fmt.Errorf("%s must define schema or content", paramContext)) + } + if len(param.Content) > 1 { + errs = append(errs, fmt.Errorf("%s content must contain only one media type", paramContext)) + } + if param.Example != nil && len(param.Examples) > 0 { + errs = append(errs, fmt.Errorf("%s example and examples are mutually exclusive", paramContext)) + } + if param.In == string(openapi.ParameterInQueryString) { + if param.Schema != nil { + errs = append(errs, fmt.Errorf("%s querystring parameter must use content", paramContext)) + } + if len(param.Content) == 0 { + errs = append(errs, fmt.Errorf("%s querystring parameter content is required", paramContext)) + } + if param.Style != "" || param.Explode != nil || param.AllowReserved || param.AllowEmptyValue { + errs = append( + errs, + fmt.Errorf( + "%s style, explode, allowReserved, and allowEmptyValue must not be used with querystring", + paramContext, + ), + ) + } + } + errs = append(errs, ValidateParameterSerialization(paramContext, param, version)...) + errs = append( + errs, + ValidateSchema(paramContext+".schema", param.Schema, version, map[*openapi.Schema]bool{})...) + for mediaType, content := range param.Content { + errs = append(errs, ValidateMediaType(paramContext+".content."+mediaType, mediaType, content, version)...) + } + for name, example := range param.Examples { + errs = append(errs, ValidateExample(paramContext+".examples."+name, example, version)...) + } + } + return errs +} + +func ValidateQueryParameterMix(context string, params []*openapi.Parameter) []error { + var queryCount, querystringCount int + for _, param := range params { + if param == nil || param.Ref != "" { + continue + } + switch param.In { + case string(openapi.ParameterInQuery): + queryCount++ + case string(openapi.ParameterInQueryString): + querystringCount++ + } + } + if querystringCount > 1 { + return []error{fmt.Errorf("%s must not define more than one querystring parameter", context)} + } + if querystringCount > 0 && queryCount > 0 { + return []error{fmt.Errorf("%s must not mix query and querystring parameters", context)} + } + return nil +} + +func ValidateRequestBody(context string, body *openapi.RequestBody, version string) []error { + var errs []error + if body == nil { + return nil + } + if body.Ref != "" { + if BodyRefHasInvalidSiblings(body, version) { + errs = append(errs, fmt.Errorf("%s must not define siblings with $ref", context)) + } + return errs + } + if body.Summary != "" { + errs = append(errs, fmt.Errorf("%s.summary is only allowed with $ref", context)) + } + if len(body.Content) == 0 { + errs = append(errs, fmt.Errorf("%s.content is required", context)) + } + for mediaType, content := range body.Content { + errs = append(errs, ValidateMediaType(context+".content."+mediaType, mediaType, &content, version)...) + } + return errs +} + +func ValidateResponse(context string, response *openapi.Response, version string) []error { + var errs []error + if response == nil { + return []error{fmt.Errorf("%s is required", context)} + } + if response.Ref != "" { + if ResponseRefHasInvalidSiblings(response, version) { + errs = append(errs, fmt.Errorf("%s must not define siblings with $ref", context)) + } + return errs + } + if version != openapi.Version320 && response.Summary != "" { + errs = append(errs, fmt.Errorf("%s.summary requires OpenAPI 3.2.0", context)) + } + if version != openapi.Version320 && response.Description == "" { + errs = append(errs, fmt.Errorf("%s.description is required", context)) + } + for name, header := range response.Headers { + errs = append(errs, ValidateHeader(context+".headers."+name, header, version)...) + } + for mediaType, content := range response.Content { + errs = append(errs, ValidateMediaType(context+".content."+mediaType, mediaType, &content, version)...) + } + for name, link := range response.Links { + errs = append(errs, ValidateLink(context+".links."+name, link, version)...) + } + return errs +} + +func ValidateHeader(context string, header *openapi.Header, version string) []error { + var errs []error + if header == nil { + return []error{fmt.Errorf("%s is required", context)} + } + if header.Ref != "" { + if HeaderRefHasInvalidSiblings(header, version) { + errs = append(errs, fmt.Errorf("%s must not define siblings with $ref", context)) + } + return errs + } + if header.Summary != "" { + errs = append(errs, fmt.Errorf("%s.summary is only allowed with $ref", context)) + } + if header.Schema != nil && len(header.Content) > 0 { + errs = append(errs, fmt.Errorf("%s schema and content are mutually exclusive", context)) + } + if header.Schema == nil && len(header.Content) == 0 { + errs = append(errs, fmt.Errorf("%s must define schema or content", context)) + } + if len(header.Content) > 1 { + errs = append(errs, fmt.Errorf("%s content must contain only one media type", context)) + } + if header.Example != nil && len(header.Examples) > 0 { + errs = append(errs, fmt.Errorf("%s example and examples are mutually exclusive", context)) + } + if header.AllowEmptyValue { + errs = append(errs, fmt.Errorf("%s allowEmptyValue is not allowed for headers", context)) + } + if header.AllowReserved { + errs = append(errs, fmt.Errorf("%s allowReserved is not allowed for headers", context)) + } + if header.Style != "" && header.Style != "simple" { + errs = append(errs, fmt.Errorf("%s.style must be simple for headers", context)) + } + errs = append(errs, ValidateSchema(context+".schema", header.Schema, version, map[*openapi.Schema]bool{})...) + for mediaType, content := range header.Content { + errs = append(errs, ValidateMediaType(context+".content."+mediaType, mediaType, content, version)...) + } + for name, example := range header.Examples { + errs = append(errs, ValidateExample(context+".examples."+name, example, version)...) + } + return errs +} + +//nolint:gocognit // media type validation aggregates independent OpenAPI constraints. +func ValidateMediaType(context, mediaTypeName string, mediaType *openapi.MediaType, version string) []error { + var errs []error + if mediaType == nil { + return []error{fmt.Errorf("%s is required", context)} + } + if mediaType.Ref != "" { + if MediaTypeRefHasInvalidSiblings(mediaType, version) { + errs = append(errs, fmt.Errorf("%s must not define siblings with $ref", context)) + } + return errs + } + if mediaType.Summary != "" || mediaType.Description != "" { + errs = append(errs, fmt.Errorf("%s summary and description are only allowed with $ref", context)) + } + if len(mediaType.Encoding) > 0 && !MediaTypeAllowsNamedEncoding(mediaTypeName) { + errs = append( + errs, + fmt.Errorf("%s.encoding requires multipart or application/x-www-form-urlencoded media type", context), + ) + } + if len(mediaType.PrefixEncoding) > 0 && !MediaTypeIsMultipart(mediaTypeName) { + errs = append(errs, fmt.Errorf("%s.prefixEncoding requires multipart media type", context)) + } + if mediaType.ItemEncoding != nil && !MediaTypeIsMultipart(mediaTypeName) { + errs = append(errs, fmt.Errorf("%s.itemEncoding requires multipart media type", context)) + } + if version != openapi.Version320 { + if mediaType.ItemSchema != nil { + errs = append(errs, fmt.Errorf("%s.itemSchema requires OpenAPI 3.2.0", context)) + } + if len(mediaType.PrefixEncoding) > 0 { + errs = append(errs, fmt.Errorf("%s.prefixEncoding requires OpenAPI 3.2.0", context)) + } + if mediaType.ItemEncoding != nil { + errs = append(errs, fmt.Errorf("%s.itemEncoding requires OpenAPI 3.2.0", context)) + } + } + if mediaType.Example != nil && len(mediaType.Examples) > 0 { + errs = append(errs, fmt.Errorf("%s example and examples are mutually exclusive", context)) + } + if len(mediaType.Encoding) > 0 && (len(mediaType.PrefixEncoding) > 0 || mediaType.ItemEncoding != nil) { + errs = append(errs, fmt.Errorf("%s encoding must not be used with prefixEncoding or itemEncoding", context)) + } + if (len(mediaType.PrefixEncoding) > 0 || mediaType.ItemEncoding != nil) && mediaType.ItemSchema == nil && + !SchemaTypeIncludesArray(mediaType.Schema) { + errs = append( + errs, + fmt.Errorf("%s prefixEncoding or itemEncoding requires itemSchema or an array schema", context), + ) + } + errs = append(errs, ValidateSchema(context+".schema", mediaType.Schema, version, map[*openapi.Schema]bool{})...) + errs = append( + errs, + ValidateSchema(context+".itemSchema", mediaType.ItemSchema, version, map[*openapi.Schema]bool{})...) + for name, encoding := range mediaType.Encoding { + errs = append(errs, ValidateEncoding(context+".encoding."+name, encoding, version)...) + } + for i, encoding := range mediaType.PrefixEncoding { + errs = append(errs, ValidateEncoding(fmt.Sprintf("%s.prefixEncoding[%d]", context, i), encoding, version)...) + } + if mediaType.ItemEncoding != nil { + errs = append(errs, ValidateEncoding(context+".itemEncoding", mediaType.ItemEncoding, version)...) + } + for name, example := range mediaType.Examples { + errs = append(errs, ValidateExample(context+".examples."+name, example, version)...) + } + return errs +} + +func ValidateEncoding(context string, encoding *openapi.Encoding, version string) []error { + var errs []error + if encoding == nil { + return []error{fmt.Errorf("%s is required", context)} + } + if version != openapi.Version320 { + if len(encoding.PrefixEncoding) > 0 { + errs = append(errs, fmt.Errorf("%s.prefixEncoding requires OpenAPI 3.2.0", context)) + } + if encoding.ItemEncoding != nil { + errs = append(errs, fmt.Errorf("%s.itemEncoding requires OpenAPI 3.2.0", context)) + } + } + for name, header := range encoding.Headers { + errs = append(errs, ValidateHeader(context+".headers."+name, header, version)...) + } + for name, nested := range encoding.Encoding { + errs = append(errs, ValidateEncoding(context+".encoding."+name, nested, version)...) + } + for i, nested := range encoding.PrefixEncoding { + errs = append(errs, ValidateEncoding(fmt.Sprintf("%s.prefixEncoding[%d]", context, i), nested, version)...) + } + if encoding.ItemEncoding != nil { + errs = append(errs, ValidateEncoding(context+".itemEncoding", encoding.ItemEncoding, version)...) + } + return errs +} + +func ValidateExample(context string, example *openapi.Example, version string) []error { + var errs []error + if example == nil { + return []error{fmt.Errorf("%s is required", context)} + } + if example.Ref != "" { + if ExampleRefHasInvalidSiblings(example, version) { + errs = append(errs, fmt.Errorf("%s must not define siblings with $ref", context)) + } + return errs + } + if version != openapi.Version320 && example.DataValue != nil { + errs = append(errs, fmt.Errorf("%s.dataValue requires OpenAPI 3.2.0", context)) + } + if version != openapi.Version320 && example.SerializedValue != "" { + errs = append(errs, fmt.Errorf("%s.serializedValue requires OpenAPI 3.2.0", context)) + } + if example.Value != nil && example.ExternalValue != "" { + errs = append(errs, fmt.Errorf("%s value and externalValue are mutually exclusive", context)) + } + if example.DataValue != nil && example.Value != nil { + errs = append(errs, fmt.Errorf("%s dataValue and value are mutually exclusive", context)) + } + if example.SerializedValue != "" && (example.Value != nil || example.ExternalValue != "") { + errs = append( + errs, + fmt.Errorf("%s serializedValue is mutually exclusive with value and externalValue", context), + ) + } + if HasSerializedExample(example) { + errs = append(errs, fmt.Errorf("%s.serializedExample is not an OpenAPI field; use serializedValue", context)) + } + return errs +} + +func ValidateLink(context string, link *openapi.Link, version string) []error { + if link == nil { + return []error{fmt.Errorf("%s is required", context)} + } + if link.Ref != "" && + LinkRefHasInvalidSiblings(link, version) { + return []error{fmt.Errorf("%s must not define siblings with $ref", context)} + } + if link.Ref == "" && link.Summary != "" { + return []error{fmt.Errorf("%s.summary is only allowed with $ref", context)} + } + if link.OperationRef != "" && link.OperationID != "" { + return []error{fmt.Errorf("%s operationRef and operationId are mutually exclusive", context)} + } + if link.Ref == "" && link.OperationRef == "" && link.OperationID == "" { + return []error{fmt.Errorf("%s must define operationRef or operationId", context)} + } + return nil +} + +func ValidateCallback( + context string, + callback *openapi.Callback, + version string, + operationIDs map[string]string, + securitySchemes map[string]*openapi.SecurityScheme, + componentParameters map[string]*openapi.Parameter, +) []error { + var errs []error + if callback == nil { + return []error{fmt.Errorf("%s is required", context)} + } + if callback.Ref != "" { + if CallbackRefHasInvalidSiblings(callback, version) { + errs = append(errs, fmt.Errorf("%s must not define siblings with $ref", context)) + } + return errs + } + if len(callback.Expressions) == 0 { + errs = append(errs, fmt.Errorf("%s must define at least one callback expression", context)) + } + for expression, pathItem := range callback.Expressions { + if pathItem == nil { + errs = append(errs, fmt.Errorf("%s.%s is required", context, expression)) + continue + } + errs = append( + errs, + ValidatePathItemOperations( + context+"."+expression, + pathItem, + version, + operationIDs, + securitySchemes, + componentParameters, + )...) + } + return errs +} + +func ValidateSecurityRequirements( + context string, + requirements []openapi.SecurityRequirement, + schemes map[string]*openapi.SecurityScheme, + version string, +) []error { + var errs []error + for i, requirement := range requirements { + for name, scopes := range requirement { + scheme := schemes[name] + if scheme == nil { + if !SecurityRequirementMayUseURI(name, version) { + errs = append(errs, fmt.Errorf("%s[%d] references undefined security scheme %q", context, i, name)) + } + continue + } + if reflect.IsOpenAPI30(version) && scheme.Type != "oauth2" && scheme.Type != "openIdConnect" && + len(scopes) > 0 { + errs = append( + errs, + fmt.Errorf("%s[%d].%s scopes are only allowed for oauth2 or openIdConnect", context, i, name), + ) + } + } + } + return errs +} + +func ValidateSecurityScheme(context string, scheme *openapi.SecurityScheme, version string) []error { + var errs []error + if scheme == nil { + return []error{fmt.Errorf("%s is required", context)} + } + if scheme.Ref != "" { + if SecuritySchemeRefHasInvalidSiblings(scheme, version) { + errs = append(errs, fmt.Errorf("%s must not define siblings with $ref", context)) + } + return errs + } + if scheme.Summary != "" { + errs = append(errs, fmt.Errorf("%s.summary is only allowed with $ref", context)) + } + if !slices.Contains([]string{"apiKey", "http", "mutualTLS", "oauth2", "openIdConnect"}, scheme.Type) { + errs = append( + errs, + fmt.Errorf("%s.type must be one of apiKey, http, mutualTLS, oauth2, or openIdConnect", context), + ) + } + if version != openapi.Version320 && + (scheme.OAuth2MetadataURL != "" || scheme.Deprecated || ExtraHas(scheme.Extra, "oauth2MetadataUrl", "deprecated")) { + errs = append(errs, fmt.Errorf("%s oauth2MetadataUrl and deprecated require OpenAPI 3.2.0", context)) + } + metadataURL, metadataURLPresent := securitySchemeOAuth2MetadataURL(scheme) + if metadataURLPresent { + if scheme.Type != "oauth2" { + errs = append(errs, fmt.Errorf("%s.oauth2MetadataUrl is only allowed for oauth2 security schemes", context)) + } + if !IsHTTPSURI(metadataURL) { + errs = append(errs, fmt.Errorf("%s.oauth2MetadataUrl must be an HTTPS URI", context)) + } + } + switch scheme.Type { + case "apiKey": + if scheme.Name == "" { + errs = append(errs, fmt.Errorf("%s.name is required for apiKey", context)) + } + if !slices.Contains( + []string{ + string(openapi.SecuritySchemeAPIKeyInQuery), + string(openapi.SecuritySchemeAPIKeyInHeader), + string(openapi.SecuritySchemeAPIKeyInCookie), + }, + string(scheme.In), + ) { + errs = append(errs, fmt.Errorf("%s.in must be query, header, or cookie for apiKey", context)) + } + case "http": + if scheme.Scheme == "" { + errs = append(errs, fmt.Errorf("%s.scheme is required for http", context)) + } + case "oauth2": + if scheme.Flows == nil { + errs = append(errs, fmt.Errorf("%s.flows is required for oauth2", context)) + } else { + errs = append(errs, ValidateOAuthFlows(context+".flows", scheme.Flows, version)...) + } + case "openIdConnect": + if scheme.OpenIDConnectURL == "" { + errs = append(errs, fmt.Errorf("%s.openIdConnectUrl is required for openIdConnect", context)) + } + } + return errs +} + +func ValidateOAuthFlows(context string, flows *openapi.OAuthFlows, version string) []error { + var errs []error + if version != openapi.Version320 && + (flows.DeviceAuthorization != nil || ExtraHas(flows.Extra, "deviceAuthorization")) { + errs = append(errs, fmt.Errorf("%s.deviceAuthorization requires OpenAPI 3.2.0", context)) + } + if flows.Implicit != nil { + errs = append(errs, ValidateOAuthFlow(context+".implicit", flows.Implicit, version, true, false)...) + } + if flows.Password != nil { + errs = append(errs, ValidateOAuthFlow(context+".password", flows.Password, version, false, true)...) + } + if flows.ClientCredentials != nil { + errs = append( + errs, + ValidateOAuthFlow(context+".clientCredentials", flows.ClientCredentials, version, false, true)...) + } + if flows.AuthorizationCode != nil { + errs = append( + errs, + ValidateOAuthFlow(context+".authorizationCode", flows.AuthorizationCode, version, true, true)...) + } + if flows.DeviceAuthorization != nil { + errs = append( + errs, + ValidateOAuthFlow(context+".deviceAuthorization", flows.DeviceAuthorization, version, false, true)...) + } + if flows.Implicit == nil && flows.Password == nil && flows.ClientCredentials == nil && + flows.AuthorizationCode == nil && + flows.DeviceAuthorization == nil && + !ExtraHas(flows.Extra, "deviceAuthorization") { + errs = append(errs, fmt.Errorf("%s must define at least one OAuth flow", context)) + } + return errs +} + +func securitySchemeOAuth2MetadataURL(scheme *openapi.SecurityScheme) (string, bool) { + if scheme.OAuth2MetadataURL != "" { + return scheme.OAuth2MetadataURL, true + } + if raw, ok := scheme.Extra["oauth2MetadataUrl"]; ok { + value, _ := raw.(string) + return value, true + } + return "", false +} + +func ValidateOAuthFlow( + context string, + flow *openapi.OAuthFlow, + version string, + needsAuthorizationURL, needsTokenURL bool, +) []error { + var errs []error + if needsAuthorizationURL && flow.AuthorizationURL == "" { + errs = append(errs, fmt.Errorf("%s.authorizationUrl is required", context)) + } + if strings.HasSuffix(context, ".deviceAuthorization") && flow.DeviceAuthorizationURL == "" { + errs = append(errs, fmt.Errorf("%s.deviceAuthorizationUrl is required", context)) + } + if needsTokenURL && flow.TokenURL == "" { + errs = append(errs, fmt.Errorf("%s.tokenUrl is required", context)) + } + if flow.Scopes == nil { + errs = append(errs, fmt.Errorf("%s.scopes is required", context)) + } + if version != openapi.Version320 && + (flow.DeviceAuthorizationURL != "" || ExtraHas(flow.Extra, "deviceAuthorizationUrl")) { + errs = append(errs, fmt.Errorf("%s.deviceAuthorizationUrl requires OpenAPI 3.2.0", context)) + } + return errs +} diff --git a/internal/validate/operation_internal_test.go b/internal/validate/operation_internal_test.go new file mode 100644 index 0000000..c993e43 --- /dev/null +++ b/internal/validate/operation_internal_test.go @@ -0,0 +1,64 @@ +package validate + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/oaswrap/spec/openapi" +) + +func TestValidateLink_Direct(t *testing.T) { + errs := ValidateLink("ctx", nil, openapi.Version304) + assert.Len(t, errs, 1) + assert.Contains(t, errs[0].Error(), "ctx is required") + + errs = ValidateLink("ctx", &openapi.Link{Summary: "x"}, openapi.Version304) + assert.Len(t, errs, 1) + assert.Contains(t, errs[0].Error(), "summary is only allowed with $ref") + + errs = ValidateLink("ctx", &openapi.Link{OperationRef: "/a", OperationID: "id"}, openapi.Version304) + assert.Len(t, errs, 1) + assert.Contains(t, errs[0].Error(), "operationRef and operationId are mutually exclusive") + + errs = ValidateLink("ctx", &openapi.Link{}, openapi.Version304) + assert.Len(t, errs, 1) + assert.Contains(t, errs[0].Error(), "must define operationRef or operationId") +} + +func TestValidateExample_Direct(t *testing.T) { + errs := ValidateExample("ctx", nil, openapi.Version304) + assert.Len(t, errs, 1) + assert.Contains(t, errs[0].Error(), "ctx is required") + + errs = ValidateExample("ctx", &openapi.Example{ + SerializedValue: "x", + Value: "y", + }, openapi.Version320) + assert.Len(t, errs, 1) + assert.Contains(t, errs[0].Error(), "serializedValue is mutually exclusive with value and externalValue") +} + +func TestSecuritySchemeOAuth2MetadataURL(t *testing.T) { + value, ok := securitySchemeOAuth2MetadataURL(&openapi.SecurityScheme{ + OAuth2MetadataURL: "https://auth.example/.well-known/oauth-authorization-server", + }) + assert.True(t, ok) + assert.Equal(t, "https://auth.example/.well-known/oauth-authorization-server", value) + + value, ok = securitySchemeOAuth2MetadataURL(&openapi.SecurityScheme{ + Extra: map[string]any{"oauth2MetadataUrl": "https://auth.example/metadata"}, + }) + assert.True(t, ok) + assert.Equal(t, "https://auth.example/metadata", value) + + value, ok = securitySchemeOAuth2MetadataURL(&openapi.SecurityScheme{ + Extra: map[string]any{"oauth2MetadataUrl": 1}, + }) + assert.True(t, ok) + assert.Empty(t, value) + + value, ok = securitySchemeOAuth2MetadataURL(&openapi.SecurityScheme{}) + assert.False(t, ok) + assert.Empty(t, value) +} diff --git a/internal/validate/operation_test.go b/internal/validate/operation_test.go new file mode 100644 index 0000000..81fd8c9 --- /dev/null +++ b/internal/validate/operation_test.go @@ -0,0 +1,503 @@ +package validate_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oaswrap/spec" + "github.com/oaswrap/spec/internal/validate" + "github.com/oaswrap/spec/openapi" + "github.com/oaswrap/spec/option" +) + +func TestValidateParameterRules(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version320), + option.WithTitle("Invalid Parameters"), + option.WithVersion("1.0.0"), + ) + r.Get("/items", + option.CustomizeOperation(func(op *openapi.Operation) { + op.Parameters = append(op.Parameters, + &openapi.Parameter{Name: "q", In: "query", Schema: &openapi.Schema{Type: "string"}}, + &openapi.Parameter{Name: "raw", In: "querystring", Schema: &openapi.Schema{Type: "string"}}, + ) + }), + option.Response(200, new([]User)), + ) + + err := r.Validate() + assertValidationContains(t, err, + "querystring parameter must use content", + "querystring parameter content is required", + "must not mix query and querystring parameters", + ) +} + +func TestValidateParameterRequiresSchemaOrContent(t *testing.T) { + r := spec.NewRouter( + option.WithTitle("Invalid Parameters"), + option.WithVersion("1.0.0"), + ) + r.Get("/items", + option.CustomizeOperation(func(op *openapi.Operation) { + op.Parameters = append(op.Parameters, &openapi.Parameter{Name: "q", In: "query"}) + }), + option.Response(200, new([]User)), + ) + + err := r.Validate() + assertValidationContains(t, err, "parameters[0] must define schema or content") +} + +func TestValidateMediaTypeEncodingRestrictions(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version320), + option.WithTitle("Invalid Media Encoding"), + option.WithVersion("1.0.0"), + option.WithDocument(func(doc *openapi.Document) { + doc.Paths["/encoded"].Post.RequestBody = &openapi.RequestBody{ + Content: map[string]openapi.MediaType{ + "application/json": { + Schema: &openapi.Schema{Type: "array", Items: &openapi.Schema{Type: "string"}}, + ItemSchema: &openapi.Schema{Type: "string"}, + Encoding: map[string]*openapi.Encoding{"field": {ContentType: "text/plain"}}, + PrefixEncoding: []*openapi.Encoding{{ContentType: "text/plain"}}, + ItemEncoding: &openapi.Encoding{ContentType: "text/plain"}, + }, + }, + } + }), + ) + r.Post("/encoded", option.Response(204, nil)) + + err := r.Validate() + assertValidationContains(t, err, + "encoding requires multipart or application/x-www-form-urlencoded media type", + "prefixEncoding requires multipart media type", + "itemEncoding requires multipart media type", + ) +} + +func TestValidateMediaTypeEncodingAllowsFormAndMultipart(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version320), + option.WithTitle("Media Encoding"), + option.WithVersion("1.0.0"), + option.WithDocument(func(doc *openapi.Document) { + doc.Paths["/form"].Post.RequestBody = &openapi.RequestBody{ + Content: map[string]openapi.MediaType{ + "application/x-www-form-urlencoded": { + Schema: &openapi.Schema{Type: "object"}, + Encoding: map[string]*openapi.Encoding{"field": {ContentType: "text/plain"}}, + }, + "multipart/mixed": { + Schema: &openapi.Schema{Type: "array", Items: &openapi.Schema{Type: "string"}}, + PrefixEncoding: []*openapi.Encoding{{ContentType: "text/plain"}}, + ItemEncoding: &openapi.Encoding{ContentType: "text/plain"}, + }, + }, + } + }), + ) + r.Post("/form", option.Response(204, nil)) + + assert.NoError(t, r.Validate()) +} + +func TestValidateOperationIDAndSecurityReferences(t *testing.T) { + r := spec.NewRouter(option.WithTitle("Invalid Operations"), option.WithVersion("1.0.0")) + r.Get("/a", option.OperationID("same"), option.Security("missing"), option.Response(204, nil)) + r.Get("/b", option.OperationID("same"), option.Response(204, nil)) + + err := r.Validate() + assertValidationContains(t, err, + `operationId "same" duplicates`, + `references undefined security scheme "missing"`, + ) +} + +func TestValidateCallbackObject(t *testing.T) { + r := spec.NewRouter( + option.WithTitle("Callbacks"), + option.WithVersion("1.0.0"), + ) + r.Post("/subscriptions", + option.CustomizeOperation(func(op *openapi.Operation) { + op.Callbacks = map[string]*openapi.Callback{ + "onEvent": { + Expressions: map[string]*openapi.PathItem{ + "{$request.body#/callbackUrl}": { + Post: &openapi.Operation{ + Responses: map[string]*openapi.Response{ + "200": {Description: "Callback accepted"}, + }, + }, + }, + }, + }, + } + }), + option.Response(202, nil), + ) + + raw, err := r.MarshalJSON() + require.NoError(t, err) + assert.Contains(t, string(raw), `"onEvent"`) + assert.Contains(t, string(raw), `"{$request.body#/callbackUrl}"`) +} + +func TestValidateHeaderAndLinkRules(t *testing.T) { + r := spec.NewRouter( + option.WithTitle("Invalid Header and Link"), + option.WithVersion("1.0.0"), + option.WithDocument(func(doc *openapi.Document) { + resp := doc.Paths["/invalid"].Get.Responses["200"] + resp.Headers = map[string]*openapi.Header{ + "X-Bad": { + Schema: &openapi.Schema{Type: "string"}, + Style: "form", + AllowEmptyValue: true, + }, + } + resp.Links = map[string]*openapi.Link{ + "missingTarget": {Description: "No operation target"}, + } + }), + ) + r.Get("/invalid", option.Response(200, nil)) + + err := r.Validate() + assertValidationContains(t, err, + "headers.X-Bad allowEmptyValue is not allowed for headers", + "headers.X-Bad.style must be simple for headers", + "links.missingTarget must define operationRef or operationId", + ) +} + +func TestValidate_OpenAPI320_ExampleSerializedValue(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version320), + option.WithTitle("Examples"), + option.WithVersion("1.0.0"), + ) + r.Get("/examples", option.Response(200, "", + option.ContentType("text/plain"), + option.ContentNamedExample("encoded", nil, + option.ExampleDataValue("hello world"), + option.ExampleSerializedValue("hello%20world"), + ), + )) + + raw, err := r.MarshalJSON() + require.NoError(t, err) + assert.Contains(t, string(raw), `"serializedValue": "hello%20world"`) + assert.NotContains(t, string(raw), "serializedExample") +} + +func TestValidateRejectsDeprecatedSerializedExampleField(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version320), + option.WithDocument(func(doc *openapi.Document) { + mt := openapi.MediaType{ + Schema: &openapi.Schema{Type: "string"}, + Examples: map[string]*openapi.Example{ + "old": { + SerializedExample: "hello", + }, + }, + } + doc.Paths["/examples"].Get.Responses["200"].Content = map[string]openapi.MediaType{"text/plain": mt} + }), + ) + r.Get("/examples", option.Response(200, nil)) + + err := r.Validate() + assertValidationContains(t, err, "serializedExample is not an OpenAPI field; use serializedValue") +} + +func TestValidationEdgeCases(t *testing.T) { + t.Run("Headers", func(t *testing.T) { + r := spec.NewRouter(option.WithDocument(func(doc *openapi.Document) { + resp := doc.Paths["/headers"].Get.Responses["200"] + resp.Headers = map[string]*openapi.Header{ + "X-Custom": { + Description: "A custom header", + Schema: &openapi.Schema{Type: "string"}, + }, + } + })) + r.Get("/headers", option.Response(200, nil)) + assert.NoError(t, r.Validate()) + }) + + t.Run("Examples", func(t *testing.T) { + r := spec.NewRouter(option.WithDocument(func(doc *openapi.Document) { + mt := doc.Paths["/examples"].Get.Responses["200"].Content["application/json"] + mt.Examples = map[string]*openapi.Example{ + "test": { + Summary: "A test example", + Value: map[string]string{"foo": "bar"}, + }, + } + doc.Paths["/examples"].Get.Responses["200"].Content["application/json"] = mt + })) + r.Get("/examples", option.Response(200, nil)) + assert.NoError(t, r.Validate()) + }) + + t.Run("Links", func(t *testing.T) { + r := spec.NewRouter(option.WithDocument(func(doc *openapi.Document) { + resp := doc.Paths["/links"].Get.Responses["200"] + resp.Links = map[string]*openapi.Link{ + "userLink": { + OperationID: "getUser", + Description: "Link to user", + }, + } + })) + r.Get("/links", option.Response(200, nil)) + assert.NoError(t, r.Validate()) + }) +} + +func TestValidateEncoding(t *testing.T) { + t.Run("OpenAPI304_InvalidFields", func(t *testing.T) { + r := spec.NewRouter(option.WithOpenAPIVersion(openapi.Version304)) + r.Post("/test", option.CustomizeOperation(func(op *openapi.Operation) { + op.RequestBody = &openapi.RequestBody{ + Content: map[string]openapi.MediaType{ + "multipart/form-data": { + Encoding: map[string]*openapi.Encoding{ + "field": { + PrefixEncoding: []*openapi.Encoding{{}}, + ItemEncoding: &openapi.Encoding{}, + }, + }, + }, + }, + } + }), option.Response(204, nil)) + + err := r.Validate() + assertValidationContains(t, err, + "prefixEncoding requires OpenAPI 3.2.0", + "itemEncoding requires OpenAPI 3.2.0", + ) + }) +} + +func TestValidateExample_EdgeCases(t *testing.T) { + t.Run("VersionRestrictions", func(t *testing.T) { + r := spec.NewRouter(option.WithOpenAPIVersion(openapi.Version304)) + r.Get("/test", option.CustomizeOperation(func(op *openapi.Operation) { + op.Responses["200"] = &openapi.Response{ + Description: "OK", + Content: map[string]openapi.MediaType{ + "application/json": { + Examples: map[string]*openapi.Example{ + "ex": { + DataValue: "data", + SerializedValue: "serialized", + }, + }, + }, + }, + } + })) + + err := r.Validate() + assertValidationContains(t, err, + "dataValue requires OpenAPI 3.2.0", + "serializedValue requires OpenAPI 3.2.0", + ) + }) + + t.Run("MutuallyExclusive", func(t *testing.T) { + r := spec.NewRouter(option.WithOpenAPIVersion(openapi.Version320)) + r.Get("/test", option.CustomizeOperation(func(op *openapi.Operation) { + op.Responses["200"] = &openapi.Response{ + Description: "OK", + Content: map[string]openapi.MediaType{ + "application/json": { + Examples: map[string]*openapi.Example{ + "ex": { + Value: "v", + ExternalValue: "ev", + DataValue: "dv", + }, + }, + }, + }, + } + })) + + err := r.Validate() + assertValidationContains(t, err, + "value and externalValue are mutually exclusive", + "dataValue and value are mutually exclusive", + ) + }) +} + +func TestValidateCallback_Errors(t *testing.T) { + r := spec.NewRouter() + r.Post("/sub", option.CustomizeOperation(func(op *openapi.Operation) { + op.Callbacks = map[string]*openapi.Callback{ + "cb": {Expressions: map[string]*openapi.PathItem{}}, + } + }), option.Response(204, nil)) + + err := r.Validate() + assertValidationContains(t, err, "must define at least one callback expression") + + t.Run("NilExpressionPathItem", func(t *testing.T) { + r := spec.NewRouter() + r.Post("/sub", option.CustomizeOperation(func(op *openapi.Operation) { + op.Callbacks = map[string]*openapi.Callback{ + "cb": { + Expressions: map[string]*openapi.PathItem{ + "{$request.body#/callbackUrl}": nil, + }, + }, + } + }), option.Response(204, nil)) + + err := r.Validate() + assertValidationContains(t, err, `callbacks.cb.{$request.body#/callbackUrl} is required`) + }) + + t.Run("NilCallbackAndRefSiblings", func(t *testing.T) { + r := spec.NewRouter(option.WithOpenAPIVersion(openapi.Version304)) + r.Post("/sub", option.CustomizeOperation(func(op *openapi.Operation) { + op.Callbacks = map[string]*openapi.Callback{ + "nilCb": nil, + "refCb": { + Ref: "#/components/callbacks/Base", + Summary: "legacy-sibling", + }, + } + }), option.Response(204, nil)) + + err := r.Validate() + assertValidationContains(t, err, + "callbacks.nilCb is required", + "callbacks.refCb must not define siblings with $ref", + ) + }) +} + +func TestValidateRequestBody_Errors(t *testing.T) { + t.Run("OpenAPI304_RefSiblings", func(t *testing.T) { + r := spec.NewRouter(option.WithOpenAPIVersion(openapi.Version304)) + r.Post("/test", option.CustomizeOperation(func(op *openapi.Operation) { + op.RequestBody = &openapi.RequestBody{ + Ref: "#/components/requestBodies/Body", + Description: "sibling", + } + }), option.Response(204, nil)) + err := r.Validate() + assertValidationContains(t, err, "requestBody must not define siblings with $ref") + }) + + t.Run("EmptyContent", func(t *testing.T) { + r := spec.NewRouter() + r.Post("/test", option.CustomizeOperation(func(op *openapi.Operation) { + op.RequestBody = &openapi.RequestBody{Content: map[string]openapi.MediaType{}} + }), option.Response(204, nil)) + err := r.Validate() + assertValidationContains(t, err, "requestBody.content is required") + }) +} + +func TestValidateHeader_Errors(t *testing.T) { + t.Run("RequiredField", func(t *testing.T) { + r := spec.NewRouter() + r.Get("/test", option.CustomizeOperation(func(op *openapi.Operation) { + op.Responses["200"] = &openapi.Response{ + Description: "OK", + Headers: map[string]*openapi.Header{ + "X-Trace": {}, + }, + } + })) + err := r.Validate() + assertValidationContains(t, err, "headers.X-Trace must define schema or content") + }) + + t.Run("ComprehensiveHeaderRules", func(t *testing.T) { + r := spec.NewRouter(option.WithOpenAPIVersion(openapi.Version312)) + r.Get("/headers", option.CustomizeOperation(func(op *openapi.Operation) { + op.Responses["200"] = &openapi.Response{ + Description: "OK", + Headers: map[string]*openapi.Header{ + "X-Mixed": { + Summary: "only-for-ref", + Schema: &openapi.Schema{Type: "string"}, + Content: map[string]*openapi.MediaType{ + "text/plain": {Schema: &openapi.Schema{Type: "string"}}, + "application/json": {Schema: &openapi.Schema{Type: "string"}}, + }, + Example: "one", + Examples: map[string]*openapi.Example{"two": {Value: "two"}}, + AllowReserved: true, + }, + "X-Ref": { + Ref: "#/components/headers/TraceID", + Required: true, + }, + }, + } + })) + + err := r.Validate() + assertValidationContains(t, err, + "headers.X-Mixed.summary is only allowed with $ref", + "headers.X-Mixed schema and content are mutually exclusive", + "headers.X-Mixed content must contain only one media type", + "headers.X-Mixed example and examples are mutually exclusive", + "headers.X-Mixed allowReserved is not allowed for headers", + "headers.X-Ref must not define siblings with $ref", + ) + }) +} + +func TestValidateLink_Errors(t *testing.T) { + t.Run("MultipleTargets", func(t *testing.T) { + r := spec.NewRouter() + r.Get("/test", option.CustomizeOperation(func(op *openapi.Operation) { + op.Responses["200"] = &openapi.Response{ + Description: "OK", + Links: map[string]*openapi.Link{ + "bad": {OperationID: "op", OperationRef: "/ref"}, + }, + } + })) + err := r.Validate() + assertValidationContains(t, err, "links.bad operationRef and operationId are mutually exclusive") + }) +} + +func TestValidateParameterSerializationHelper(t *testing.T) { + assert.True(t, validate.ValidParameterStyle("path", "matrix", openapi.Version304)) + assert.True(t, validate.ValidParameterStyle("query", "deepObject", openapi.Version304)) + assert.True(t, validate.ValidParameterStyle("header", "simple", openapi.Version304)) + assert.True(t, validate.ValidParameterStyle("cookie", "form", openapi.Version312)) + assert.False(t, validate.ValidParameterStyle("cookie", "cookie", openapi.Version312)) + assert.True(t, validate.ValidParameterStyle("cookie", "cookie", openapi.Version320)) + assert.False(t, validate.ValidParameterStyle("unknown", "form", openapi.Version320)) + + errs := validate.ValidateParameterSerialization("parameters[0]", &openapi.Parameter{ + In: string(openapi.ParameterInHeader), + AllowEmptyValue: true, + Style: "form", + }, openapi.Version304) + assert.Len(t, errs, 2) + + errs = validate.ValidateParameterSerialization("parameters[1]", &openapi.Parameter{ + In: string(openapi.ParameterInQuery), + }, openapi.Version304) + assert.Empty(t, errs) +} diff --git a/internal/validate/path.go b/internal/validate/path.go new file mode 100644 index 0000000..b9bc5ad --- /dev/null +++ b/internal/validate/path.go @@ -0,0 +1,202 @@ +package validate + +import ( + "fmt" + "strings" + + "github.com/oaswrap/spec/internal/reflect" + "github.com/oaswrap/spec/openapi" +) + +func ValidatePathItem( + path string, + item *openapi.PathItem, + version string, + operationIDs map[string]string, + securitySchemes map[string]*openapi.SecurityScheme, + componentParameters map[string]*openapi.Parameter, +) []error { + var errs []error + if !strings.HasPrefix(path, "/") { + errs = append(errs, fmt.Errorf("path %q must start with /", path)) + } + errs = append( + errs, + ValidatePathItemOperations(path, item, version, operationIDs, securitySchemes, componentParameters)...) + return errs +} + +func ValidatePathItemOperations( + context string, + item *openapi.PathItem, + version string, + operationIDs map[string]string, + securitySchemes map[string]*openapi.SecurityScheme, + componentParameters map[string]*openapi.Parameter, +) []error { + var errs []error + if item == nil { + return errs + } + for i := range item.Servers { + errs = append(errs, ValidateServer(fmt.Sprintf("%s.servers[%d]", context, i), &item.Servers[i])...) + } + errs = append(errs, ValidateParameters(context+".parameters", item.Parameters, version, componentParameters)...) + if version != openapi.Version320 { + if item.Query != nil { + errs = append(errs, fmt.Errorf("QUERY operation at %s requires OpenAPI 3.2.0", context)) + } + if len(item.AdditionalOperations) > 0 { + errs = append(errs, fmt.Errorf("additionalOperations at %s requires OpenAPI 3.2.0", context)) + } + } + for method, op := range OperationsOf(item) { + if op == nil { + continue + } + opContext := fmt.Sprintf("%s %s", strings.ToUpper(method), context) + errs = append( + errs, + ValidateOperation(opContext, op, version, operationIDs, securitySchemes, componentParameters)...) + params := ResolveParameterRefs(append(item.Parameters, op.Parameters...), componentParameters) + errs = append(errs, ValidatePathParams(context, method, params)...) + errs = append(errs, ValidateQueryParameterMix(opContext, params)...) + } + for method := range item.AdditionalOperations { + if IsFixedMethod(method) { + errs = append( + errs, + fmt.Errorf("additionalOperations at %s must not contain fixed method %s", context, method), + ) + } + } + return errs +} + +func ValidatePathParams(path, method string, params []*openapi.Parameter) []error { + var errs []error + if !strings.HasPrefix(path, "/") { + return nil + } + matches := pathParamRe.FindAllStringSubmatch(path, -1) + templateNames := map[string]struct{}{} + for _, match := range matches { + templateNames[match[1]] = struct{}{} + } + declared := map[string]bool{} + for _, p := range params { + if p == nil || p.Ref != "" { + continue + } + if p.In == string(openapi.ParameterInPath) { + declared[p.Name] = p.Required + if _, ok := templateNames[p.Name]; !ok { + errs = append( + errs, + fmt.Errorf( + "%s %s path parameter %q must match a path template", + strings.ToUpper(method), + path, + p.Name, + ), + ) + } + } + } + for _, match := range matches { + name := match[1] + if required, ok := declared[name]; !ok { + errs = append(errs, fmt.Errorf("%s %s missing path parameter %q", strings.ToUpper(method), path, name)) + } else if !required { + errs = append( + errs, + fmt.Errorf("%s %s path parameter %q must be required", strings.ToUpper(method), path, name), + ) + } + } + return errs +} + +func OperationsOf(item *openapi.PathItem) map[string]*openapi.Operation { + ops := map[string]*openapi.Operation{ + "get": item.Get, + "put": item.Put, + "post": item.Post, + "delete": item.Delete, + "options": item.Options, + "head": item.Head, + "patch": item.Patch, + "trace": item.Trace, + "query": item.Query, + } + for method, op := range item.AdditionalOperations { + ops[method] = op + } + return ops +} + +func IsFixedMethod(method string) bool { + switch strings.ToLower(method) { + case "get", "put", "post", "delete", "options", "head", "patch", "trace": + return true + default: + return false + } +} + +func IsValidParameterIn(in string) bool { + switch in { + case "query", "header", "path", "cookie", "querystring": + return true + default: + return false + } +} + +func ResolveParameterRefs( + params []*openapi.Parameter, + componentParameters map[string]*openapi.Parameter, +) []*openapi.Parameter { + if len(params) == 0 { + return nil + } + out := make([]*openapi.Parameter, 0, len(params)) + for _, param := range params { + if param == nil || param.Ref == "" { + out = append(out, param) + continue + } + if resolved := ResolveParameterRef(param.Ref, componentParameters); resolved != nil { + out = append(out, resolved) + continue + } + out = append(out, param) + } + return out +} + +func ResolveParameterRef(ref string, componentParameters map[string]*openapi.Parameter) *openapi.Parameter { + const prefix = "#/components/parameters/" + if !strings.HasPrefix(ref, prefix) { + return nil + } + name := strings.TrimPrefix(ref, prefix) + name = strings.ReplaceAll(strings.ReplaceAll(name, "~1", "/"), "~0", "~") + return componentParameters[name] +} + +func HasParameterRefSiblings(param *openapi.Parameter, version string) bool { + if reflect.IsOpenAPI30(version) && (param.Summary != "" || param.Description != "") { + return true + } + return param.Name != "" || param.In != "" || param.Required || param.Deprecated || + param.AllowEmptyValue || + param.Style != "" || + param.Explode != nil || + param.AllowReserved || + param.Schema != nil || + len(param.Content) > 0 || + param.Example != nil || + len(param.Examples) > 0 || + HasInvalidReferenceExtra(param.Extra, version) +} diff --git a/internal/validate/path_test.go b/internal/validate/path_test.go new file mode 100644 index 0000000..845a357 --- /dev/null +++ b/internal/validate/path_test.go @@ -0,0 +1,115 @@ +package validate_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/oaswrap/spec" + "github.com/oaswrap/spec/internal/validate" + "github.com/oaswrap/spec/openapi" + "github.com/oaswrap/spec/option" +) + +func TestValidatePathParameterReferences(t *testing.T) { + r := spec.NewRouter( + option.WithTitle("Referenced Path Parameters"), + option.WithVersion("1.0.0"), + option.WithComponentParameter("UserID", &openapi.Parameter{ + Name: "id", + In: "path", + Required: true, + Schema: &openapi.Schema{Type: "string"}, + }), + ) + r.Get("/users/{id}", + option.CustomizeOperation(func(op *openapi.Operation) { + op.Parameters = append(op.Parameters, &openapi.Parameter{Ref: "#/components/parameters/UserID"}) + }), + option.Response(200, new(User)), + ) + + assert.NoError(t, r.Validate()) +} + +func TestValidatePathItem_Errors(t *testing.T) { + t.Run("NoLeadingSlash", func(t *testing.T) { + r := spec.NewRouter(option.WithDocument(func(doc *openapi.Document) { + doc.Paths["invalid"] = &openapi.PathItem{ + Get: &openapi.Operation{Responses: map[string]*openapi.Response{"200": {Description: "OK"}}}, + } + })) + err := r.Validate() + assertValidationContains(t, err, `path "invalid" must start with /`) + }) + + t.Run("QUERY_OpenAPI312", func(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version312), + option.WithDocument(func(doc *openapi.Document) { + doc.Paths["/search"] = &openapi.PathItem{ + Query: &openapi.Operation{Responses: map[string]*openapi.Response{"200": {Description: "OK"}}}, + } + }), + ) + err := r.Validate() + assertValidationContains(t, err, "QUERY operation at /search requires OpenAPI 3.2.0") + }) + + t.Run("AdditionalOperations_FixedMethod", func(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version320), + option.WithDocument(func(doc *openapi.Document) { + doc.Paths["/test"] = &openapi.PathItem{ + AdditionalOperations: map[string]*openapi.Operation{ + "GET": {Responses: map[string]*openapi.Response{"200": {Description: "OK"}}}, + }, + } + }), + ) + err := r.Validate() + assertValidationContains(t, err, "additionalOperations at /test must not contain fixed method GET") + }) +} + +func TestIsFixedMethodAndIsValidParameterIn(t *testing.T) { + assert.True(t, validate.IsFixedMethod("GET")) + assert.True(t, validate.IsFixedMethod("patch")) + assert.False(t, validate.IsFixedMethod("QUERY")) + assert.False(t, validate.IsFixedMethod("PURGE")) + + assert.True(t, validate.IsValidParameterIn("query")) + assert.True(t, validate.IsValidParameterIn("querystring")) + assert.False(t, validate.IsValidParameterIn("body")) +} + +func TestValidatePathParams_Direct(t *testing.T) { + errs := validate.ValidatePathParams("/users/{id}", "get", []*openapi.Parameter{ + {Name: "other", In: "path", Required: true}, + }) + assert.Len(t, errs, 2) + assert.Contains(t, errs[0].Error(), `path parameter "other" must match a path template`) + assert.Contains(t, errs[1].Error(), `missing path parameter "id"`) + + errs = validate.ValidatePathParams("/users/{id}", "get", []*openapi.Parameter{ + {Name: "id", In: "path", Required: false}, + }) + assert.Len(t, errs, 1) + assert.Contains(t, errs[0].Error(), `path parameter "id" must be required`) + + errs = validate.ValidatePathParams("users/{id}", "get", []*openapi.Parameter{ + {Name: "id", In: "path", Required: true}, + }) + assert.Empty(t, errs) +} + +func TestValidatePathItemOperations_AdditionalOpsRequires320(t *testing.T) { + item := &openapi.PathItem{ + AdditionalOperations: map[string]*openapi.Operation{ + "PURGE": {Responses: map[string]*openapi.Response{"200": {Description: "OK"}}}, + }, + } + errs := validate.ValidatePathItemOperations("/cache", item, openapi.Version312, map[string]string{}, nil, nil) + assert.NotEmpty(t, errs) + assert.Contains(t, errs[0].Error(), "additionalOperations at /cache requires OpenAPI 3.2.0") +} diff --git a/internal/validate/reference.go b/internal/validate/reference.go new file mode 100644 index 0000000..ca4fccb --- /dev/null +++ b/internal/validate/reference.go @@ -0,0 +1,149 @@ +package validate + +import ( + "encoding/json" + "fmt" + "net/url" + "reflect" + "strings" + + "github.com/oaswrap/spec/openapi" +) + +type refEntry struct { + context string + ref string + base string +} + +func ValidateReferenceTargets(doc *openapi.Document) []error { + root := map[string]any{} + raw, err := openapi.MarshalJSON(doc) + if err != nil { + return []error{fmt.Errorf("failed to serialize document for $ref validation: %w", err)} + } + err = json.Unmarshal(raw, &root) + if err != nil { + return []error{fmt.Errorf("failed to parse document for $ref validation: %w", err)} + } + + resources := map[string]any{"": root} + if doc.Self != "" { + resources[WithoutFragment(doc.Self)] = root + } + entries := collectRefEntriesAndResources(doc, resources) + + var errs []error + for _, entry := range entries { + ref := entry.ref + if strings.HasPrefix(ref, "/") { + errs = append(errs, fmt.Errorf("%s $ref %q must use #/ for local references", entry.context, ref)) + continue + } + resolved, ok := ResolveURIReference(entry.base, ref) + if !ok { + errs = append(errs, fmt.Errorf("%s $ref %q must be a URI reference", entry.context, ref)) + continue + } + parsed, err := url.Parse(resolved) + if err != nil { + errs = append(errs, fmt.Errorf("%s $ref %q must be a URI reference", entry.context, ref)) + continue + } + if _, ok := resources[urlWithoutFragment(parsed)]; !ok { + continue + } + if !ReferenceTargetExists(resolved, resources) { + errs = append(errs, fmt.Errorf("%s $ref %q points to a missing target", entry.context, ref)) + } + } + return errs +} + +//nolint:gocognit,funlen // validation traversal is complex by nature. +func collectRefEntriesAndResources(doc *openapi.Document, resources map[string]any) []refEntry { + schemaType := reflect.TypeFor[openapi.Schema]() + var out []refEntry + var walk func(value reflect.Value, context, base string) + + walk = func(value reflect.Value, context, base string) { + if !value.IsValid() { + return + } + for value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer { + if value.IsNil() { + return + } + value = value.Elem() + } + //nolint:exhaustive // only interested in container types. + switch value.Kind() { + case reflect.Struct: + if value.Type() == schemaType { + base = SchemaBaseURI(value, base) + RegisterSchemaResource(value, base, resources) + } + typ := value.Type() + for i := range value.NumField() { + field := typ.Field(i) + if !field.IsExported() { + continue + } + fieldValue := value.Field(i) + jsonName := strings.Split(field.Tag.Get("json"), ",")[0] + if jsonName == "-" { + if field.Name != "Expressions" { + continue + } + jsonName = "" + } + nextContext := context + if jsonName != "" { + if context == "" { + nextContext = jsonName + } else { + nextContext = context + "." + jsonName + } + } + if jsonName == "$ref" && fieldValue.Kind() == reflect.String { + if ref := fieldValue.String(); ref != "" { + out = append(out, refEntry{context: nextContext, ref: ref, base: base}) + } + continue + } + walk(fieldValue, nextContext, base) + } + case reflect.Slice, reflect.Array: + for i := range value.Len() { + walk(value.Index(i), fmt.Sprintf("%s[%d]", context, i), base) + } + case reflect.Map: + iter := value.MapRange() + for iter.Next() { + key := iter.Key() + keyStr := fmt.Sprintf("%v", key.Interface()) + nextContext := keyStr + if context != "" { + nextContext = context + "." + keyStr + } + mapValue := iter.Value() + if keyStr == "$ref" { + for mapValue.Kind() == reflect.Interface { + if mapValue.IsNil() { + break + } + mapValue = mapValue.Elem() + } + if mapValue.IsValid() && mapValue.Kind() == reflect.String && mapValue.String() != "" { + out = append(out, refEntry{context: nextContext, ref: mapValue.String(), base: base}) + continue + } + } + walk(iter.Value(), nextContext, base) + } + } + } + + walk(reflect.ValueOf(doc), "", doc.Self) + return out +} diff --git a/internal/validate/reference_test.go b/internal/validate/reference_test.go new file mode 100644 index 0000000..5a61ca6 --- /dev/null +++ b/internal/validate/reference_test.go @@ -0,0 +1,265 @@ +package validate_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/oaswrap/spec" + "github.com/oaswrap/spec/internal/validate" + "github.com/oaswrap/spec/openapi" + "github.com/oaswrap/spec/option" +) + +func TestValidateOpenAPI312AllowsReferenceDescription(t *testing.T) { + description := "Security scheme reference" + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version312), + option.WithComponentParameter("BaseTraceID", &openapi.Parameter{ + Name: "X-Trace-ID", + In: "header", + Required: false, + Schema: &openapi.Schema{Type: "string"}, + }), + option.WithComponentParameter("TraceID", &openapi.Parameter{ + Ref: "#/components/parameters/BaseTraceID", + Summary: "Trace", + Description: "Trace identifier", + }), + option.WithComponentSecurityScheme("BaseAuth", &openapi.SecurityScheme{ + Type: "http", + Scheme: "bearer", + }), + option.WithComponentSecurityScheme("Auth", &openapi.SecurityScheme{ + Ref: "#/components/securitySchemes/BaseAuth", + Summary: "Auth", + Description: &description, + }), + ) + r.Get("/refs", option.Response(200, nil)) + + assert.NoError(t, r.Validate()) +} + +func TestValidateOpenAPI320AllowsMediaTypeReferenceSummaryDescription(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version320), + option.WithComponentMediaType("JSON", &openapi.MediaType{ + Schema: &openapi.Schema{Type: "object"}, + }), + option.WithComponentMediaType("JSONRef", &openapi.MediaType{ + Ref: "#/components/mediaTypes/JSON", + Summary: "JSON", + Description: "JSON media type", + }), + ) + r.Get("/refs", option.CustomizeOperation(func(op *openapi.Operation) { + op.Responses["200"] = &openapi.Response{ + Description: "OK", + Content: map[string]openapi.MediaType{ + "application/json": { + Ref: "#/components/mediaTypes/JSON", + Summary: "JSON", + Description: "JSON media type", + }, + }, + } + })) + + assert.NoError(t, r.Validate()) +} + +func TestValidateRejectsReferenceSummaryOnNonReferenceObject(t *testing.T) { + r := spec.NewRouter( + option.WithDocument(func(doc *openapi.Document) { + doc.Paths["/summary"].Get.Parameters = append(doc.Paths["/summary"].Get.Parameters, &openapi.Parameter{ + Summary: "not a reference", + Name: "q", + In: "query", + Schema: &openapi.Schema{Type: "string"}, + }) + }), + ) + r.Get("/summary", option.Response(204, nil)) + + err := r.Validate() + assertValidationContains(t, err, "parameters[0].summary is only allowed with $ref") +} + +func TestValidateResponseAndReferenceRules(t *testing.T) { + r := spec.NewRouter( + option.WithTitle("Invalid Responses"), + option.WithVersion("1.0.0"), + option.WithDocument(func(doc *openapi.Document) { + doc.Components.Responses = map[string]*openapi.Response{ + "BadRef": {Ref: "#/components/responses/OK", Description: "sibling"}, + } + }), + ) + r.Get("/bad", + option.CustomizeOperation(func(op *openapi.Operation) { + op.Responses["700"] = &openapi.Response{} + }), + option.Response(204, nil), + ) + + err := r.Validate() + assertValidationContains(t, err, + "responses.700 must be default, a status code, or a status code range", + "responses.700.description is required", + "components.responses.BadRef must not define siblings with $ref", + ) +} + +func TestValidateRejectsLocalRefWithoutHashPrefix(t *testing.T) { + r := spec.NewRouter( + option.WithTitle("Invalid Ref"), + option.WithVersion("1.0.0"), + option.WithDocument(func(doc *openapi.Document) { + doc.Components.Schemas = map[string]*openapi.Schema{ + "User": {Type: "object"}, + } + doc.Paths["/users"].Get.Responses["200"] = &openapi.Response{ + Ref: "/components/schemas/User", + } + }), + ) + r.Get("/users", option.Response(200, nil)) + + err := r.Validate() + assertValidationContains(t, err, `$ref "/components/schemas/User" must use #/ for local references`) +} + +func TestValidateRejectsMissingLocalRefTarget(t *testing.T) { + r := spec.NewRouter( + option.WithTitle("Missing Ref Target"), + option.WithVersion("1.0.0"), + option.WithDocument(func(doc *openapi.Document) { + doc.Paths["/users"].Get.Responses["200"] = &openapi.Response{ + Ref: "#/components/schemas/User", + } + }), + ) + r.Get("/users", option.Response(200, nil)) + + err := r.Validate() + assertValidationContains(t, err, `$ref "#/components/schemas/User" points to a missing target`) +} + +func TestReferenceSiblingHelpers(t *testing.T) { + t.Run("BodyRef", func(t *testing.T) { + assert.True( + t, + validate.BodyRefHasInvalidSiblings(&openapi.RequestBody{Description: "legacy"}, openapi.Version304), + ) + assert.False(t, validate.BodyRefHasInvalidSiblings(&openapi.RequestBody{Description: "ok"}, openapi.Version312)) + }) + + t.Run("LinkRef", func(t *testing.T) { + assert.True(t, validate.LinkRefHasInvalidSiblings(&openapi.Link{Description: "legacy"}, openapi.Version304)) + assert.False(t, validate.LinkRefHasInvalidSiblings(&openapi.Link{Description: "ok"}, openapi.Version312)) + assert.True(t, validate.LinkRefHasInvalidSiblings(&openapi.Link{OperationID: "op"}, openapi.Version312)) + }) + + t.Run("CallbackRef", func(t *testing.T) { + assert.True(t, validate.CallbackRefHasInvalidSiblings(&openapi.Callback{Summary: "legacy"}, openapi.Version304)) + assert.False(t, validate.CallbackRefHasInvalidSiblings(&openapi.Callback{Summary: "ok"}, openapi.Version312)) + assert.True( + t, + validate.CallbackRefHasInvalidSiblings( + &openapi.Callback{Expressions: map[string]*openapi.PathItem{"/": {}}}, + openapi.Version312, + ), + ) + }) + + t.Run("MediaTypeRef", func(t *testing.T) { + assert.True( + t, + validate.MediaTypeRefHasInvalidSiblings(&openapi.MediaType{Summary: "legacy"}, openapi.Version304), + ) + assert.False(t, validate.MediaTypeRefHasInvalidSiblings(&openapi.MediaType{Summary: "ok"}, openapi.Version312)) + }) + + t.Run("SecuritySchemeRef", func(t *testing.T) { + assert.True( + t, + validate.SecuritySchemeRefHasInvalidSiblings( + &openapi.SecurityScheme{Summary: "legacy"}, + openapi.Version304, + ), + ) + assert.False( + t, + validate.SecuritySchemeRefHasInvalidSiblings(&openapi.SecurityScheme{Summary: "ok"}, openapi.Version312), + ) + }) + + assert.True(t, validate.HeaderRefHasInvalidSiblings(&openapi.Header{Description: "legacy"}, openapi.Version304)) + assert.False(t, validate.HeaderRefHasInvalidSiblings(&openapi.Header{Description: "ok"}, openapi.Version312)) + assert.True( + t, + validate.HeaderRefHasInvalidSiblings( + &openapi.Header{Extra: map[string]any{"note": "invalid"}}, + openapi.Version312, + ), + ) + + assert.True(t, validate.ExampleRefHasInvalidSiblings(&openapi.Example{Summary: "legacy"}, openapi.Version304)) + assert.False(t, validate.ExampleRefHasInvalidSiblings(&openapi.Example{Summary: "ok"}, openapi.Version312)) + assert.True( + t, + validate.ExampleRefHasInvalidSiblings(&openapi.Example{SerializedValue: "hello%20world"}, openapi.Version312), + ) + assert.True( + t, + validate.ExampleRefHasInvalidSiblings(&openapi.Example{SerializedExample: "legacy"}, openapi.Version320), + ) + assert.True( + t, + validate.ExampleRefHasInvalidSiblings( + &openapi.Example{Extra: map[string]any{"note": "invalid"}}, + openapi.Version312, + ), + ) + assert.False( + t, + validate.ExampleRefHasInvalidSiblings( + &openapi.Example{Extra: map[string]any{"summary": "ok"}}, + openapi.Version312, + ), + ) +} + +func TestExtraHelpers(t *testing.T) { + assert.True(t, validate.HasInvalidReferenceExtra(map[string]any{"summary": "legacy"}, openapi.Version304)) + assert.False(t, validate.HasInvalidReferenceExtra(map[string]any{ + "summary": "ok", + "description": "ok", + }, openapi.Version312)) + assert.True(t, validate.HasInvalidReferenceExtra(map[string]any{"invalid": true}, openapi.Version312)) + assert.False(t, validate.HasInvalidReferenceExtra(map[string]any{"x-meta": "ok"}, openapi.Version312)) + + assert.True(t, validate.HasNonExtensionExtra(map[string]any{"invalid": true})) + assert.False(t, validate.HasNonExtensionExtra(map[string]any{"x-valid": true})) +} + +func TestValidateReferenceTargets_InvalidURIReference(t *testing.T) { + doc := &openapi.Document{ + OpenAPI: openapi.Version304, + Info: openapi.Info{Title: "T", Version: "1.0.0"}, + Paths: map[string]*openapi.PathItem{ + "/x": { + Get: &openapi.Operation{ + Responses: map[string]*openapi.Response{ + "200": {Ref: "%zz"}, + }, + }, + }, + }, + } + + errs := validate.ValidateReferenceTargets(doc) + assert.NotEmpty(t, errs) + assert.Contains(t, errs[0].Error(), "must be a URI reference") +} diff --git a/internal/validate/schema.go b/internal/validate/schema.go new file mode 100644 index 0000000..763eeaf --- /dev/null +++ b/internal/validate/schema.go @@ -0,0 +1,172 @@ +package validate + +import ( + "fmt" + + "github.com/oaswrap/spec/internal/reflect" + "github.com/oaswrap/spec/openapi" +) + +//nolint:gocognit // recursive schema validation must cover many keyword branches. +func ValidateSchema(context string, schema *openapi.Schema, version string, visited map[*openapi.Schema]bool) []error { + var errs []error + if schema == nil { + return nil + } + if visited[schema] { + return nil + } + visited[schema] = true + if reflect.IsOpenAPI30(version) { + if schema.Ref != "" && HasSchemaRefSiblings(schema) { + errs = append(errs, fmt.Errorf("%s must not define siblings with $ref in OpenAPI 3.0.x", context)) + } + if schema.ReadOnly && schema.WriteOnly { + errs = append(errs, fmt.Errorf("%s must not be both readOnly and writeOnly", context)) + } + errs = append(errs, ValidateSchema304Fields(context, schema)...) + } + if version != openapi.Version320 { + if schema.Discriminator != nil && ExtraHas(schema.Discriminator.Extra, "defaultMapping") { + errs = append(errs, fmt.Errorf("%s.discriminator.defaultMapping requires OpenAPI 3.2.0", context)) + } + if schema.XML != nil && ExtraHas(schema.XML.Extra, "nodeType") { + errs = append(errs, fmt.Errorf("%s.xml.nodeType requires OpenAPI 3.2.0", context)) + } + } + for name, child := range schema.Defs { + errs = append(errs, ValidateSchema(context+".$defs."+name, child, version, visited)...) + } + for name, child := range schema.Properties { + errs = append(errs, ValidateSchema(context+".properties."+name, child, version, visited)...) + } + for name, child := range schema.PatternProperties { + errs = append(errs, ValidateSchema(context+".patternProperties."+name, child, version, visited)...) + } + errs = append(errs, ValidateSchema(context+".items", schema.Items, version, visited)...) + for i, child := range schema.PrefixItems { + errs = append(errs, ValidateSchema(fmt.Sprintf("%s.prefixItems[%d]", context, i), child, version, visited)...) + } + errs = append(errs, ValidateSchema(context+".contains", schema.Contains, version, visited)...) + errs = append( + errs, + ValidateAnySchema(context+".additionalProperties", schema.AdditionalProperties, version, visited)...) + errs = append( + errs, + ValidateAnySchema(context+".unevaluatedProperties", schema.UnevaluatedProperties, version, visited)...) + errs = append(errs, ValidateSchema(context+".propertyNames", schema.PropertyNames, version, visited)...) + for name, child := range schema.DependentSchemas { + errs = append(errs, ValidateSchema(context+".dependentSchemas."+name, child, version, visited)...) + } + for i, child := range schema.AllOf { + errs = append(errs, ValidateSchema(fmt.Sprintf("%s.allOf[%d]", context, i), child, version, visited)...) + } + for i, child := range schema.AnyOf { + errs = append(errs, ValidateSchema(fmt.Sprintf("%s.anyOf[%d]", context, i), child, version, visited)...) + } + for i, child := range schema.OneOf { + errs = append(errs, ValidateSchema(fmt.Sprintf("%s.oneOf[%d]", context, i), child, version, visited)...) + } + errs = append(errs, ValidateSchema(context+".not", schema.Not, version, visited)...) + errs = append(errs, ValidateSchema(context+".if", schema.If, version, visited)...) + errs = append(errs, ValidateSchema(context+".then", schema.Then, version, visited)...) + errs = append(errs, ValidateSchema(context+".else", schema.Else, version, visited)...) + errs = append(errs, ValidateSchema(context+".contentSchema", schema.ContentSchema, version, visited)...) + return errs +} + +func ValidateAnySchema(context string, value any, version string, visited map[*openapi.Schema]bool) []error { + switch typed := value.(type) { + case *openapi.Schema: + return ValidateSchema(context, typed, version, visited) + case openapi.Schema: + return ValidateSchema(context, &typed, version, visited) + default: + return nil + } +} + +//nolint:gocyclo,cyclop // OpenAPI 3.0.x checks enumerate many incompatible JSON Schema keywords. +func ValidateSchema304Fields(context string, schema *openapi.Schema) []error { + var errs []error + if schema.Schema != "" || schema.ID != "" || len(schema.Defs) > 0 || schema.Anchor != "" || + schema.DynamicAnchor != "" || + schema.DynamicRef != "" || + len(schema.Vocabulary) > 0 || + schema.Comment != "" { + errs = append( + errs, + fmt.Errorf("%s contains JSON Schema dialect fields that require OpenAPI 3.1.x or 3.2.0", context), + ) + } + if len(schema.Examples) > 0 || schema.Const != nil || len(schema.PatternProperties) > 0 || + len(schema.PrefixItems) > 0 || + schema.Contains != nil || + schema.MaxContains != nil || + schema.MinContains != nil || + schema.UnevaluatedProperties != nil || + schema.PropertyNames != nil || + len(schema.DependentRequired) > 0 || + len(schema.DependentSchemas) > 0 || + schema.If != nil || + schema.Then != nil || + schema.Else != nil || + schema.ContentEncoding != "" || + schema.ContentMediaType != "" || + schema.ContentSchema != nil { + errs = append( + errs, + fmt.Errorf("%s contains JSON Schema 2020-12 keywords that require OpenAPI 3.1.x or 3.2.0", context), + ) + } + if _, ok := schema.Type.([]string); ok { + errs = append(errs, fmt.Errorf("%s.type must be a string in OpenAPI 3.0.x", context)) + } + if _, ok := schema.Type.([]any); ok { + errs = append(errs, fmt.Errorf("%s.type must be a string in OpenAPI 3.0.x", context)) + } + if schema.ExclusiveMaximum != nil { + if _, ok := schema.ExclusiveMaximum.(bool); !ok { + errs = append(errs, fmt.Errorf("%s.exclusiveMaximum must be a boolean in OpenAPI 3.0.x", context)) + } + } + if schema.ExclusiveMinimum != nil { + if _, ok := schema.ExclusiveMinimum.(bool); !ok { + errs = append(errs, fmt.Errorf("%s.exclusiveMinimum must be a boolean in OpenAPI 3.0.x", context)) + } + } + if ExtraHas( + schema.Extra, + "$schema", + "$id", + "$defs", + "$anchor", + "$dynamicAnchor", + "$dynamicRef", + "$vocabulary", + "$comment", + "examples", + "const", + "patternProperties", + "prefixItems", + "contains", + "maxContains", + "minContains", + "unevaluatedProperties", + "propertyNames", + "dependentRequired", + "dependentSchemas", + "if", + "then", + "else", + "contentEncoding", + "contentMediaType", + "contentSchema", + ) { + errs = append( + errs, + fmt.Errorf("%s contains Extra JSON Schema keywords that require OpenAPI 3.1.x or 3.2.0", context), + ) + } + return errs +} diff --git a/internal/validate/schema_test.go b/internal/validate/schema_test.go new file mode 100644 index 0000000..d27381c --- /dev/null +++ b/internal/validate/schema_test.go @@ -0,0 +1,155 @@ +package validate_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/oaswrap/spec" + "github.com/oaswrap/spec/internal/validate" + "github.com/oaswrap/spec/openapi" + "github.com/oaswrap/spec/option" +) + +func TestValidateOpenAPI312SchemaIDReferenceTargets(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version312), + option.WithTitle("Schema ID Refs"), + option.WithVersion("1.0.0"), + option.WithComponentSchema("Foo", &openapi.Schema{ + ID: "https://schemas.example/foo", + Type: "object", + Defs: map[string]*openapi.Schema{ + "Bar": {Type: "string"}, + }, + Properties: map[string]*openapi.Schema{ + "bar": {Ref: "#/$defs/Bar"}, + }, + }), + ) + r.Get("/foo", option.Response(200, &openapi.Schema{Ref: "https://schemas.example/foo"})) + + assert.NoError(t, r.Validate()) +} + +func TestValidateOpenAPI312SchemaIDScopedPointerTarget(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version312), + option.WithTitle("Invalid Schema ID Refs"), + option.WithVersion("1.0.0"), + option.WithDocument(func(doc *openapi.Document) { + doc.Components.Schemas = map[string]*openapi.Schema{ + "Foo": { + ID: "https://schemas.example/foo", + Type: "object", + Properties: map[string]*openapi.Schema{ + "bar": {Ref: "#/components/schemas/Bar"}, + }, + }, + "Bar": {Type: "string"}, + } + }), + ) + r.Get("/foo", option.Response(200, &openapi.Schema{Ref: "#/components/schemas/Foo"})) + + err := r.Validate() + assertValidationContains(t, err, `$ref "#/components/schemas/Bar" points to a missing target`) +} + +func TestValidateSchema_EdgeCases(t *testing.T) { + t.Run("OpenAPI304_RefSiblings", func(t *testing.T) { + r := spec.NewRouter(option.WithOpenAPIVersion(openapi.Version304)) + r.Get("/test", option.Response(200, &openapi.Schema{ + Ref: "#/components/schemas/User", + Description: "sibling", + })) + err := r.Validate() + assertValidationContains(t, err, "must not define siblings with $ref in OpenAPI 3.0.x") + }) + + t.Run("OpenAPI304_ReadOnlyWriteOnly", func(t *testing.T) { + r := spec.NewRouter(option.WithOpenAPIVersion(openapi.Version304)) + r.Get("/test", option.Response(200, &openapi.Schema{ + ReadOnly: true, + WriteOnly: true, + })) + err := r.Validate() + assertValidationContains(t, err, "must not be both readOnly and writeOnly") + }) + + t.Run("OpenAPI312_320OnlyFields", func(t *testing.T) { + r := spec.NewRouter(option.WithOpenAPIVersion(openapi.Version312)) + r.Get("/test", option.Response(200, &openapi.Schema{ + XML: &openapi.XML{Extra: map[string]any{"nodeType": "attr"}}, + })) + err := r.Validate() + assertValidationContains(t, err, "xml.nodeType requires OpenAPI 3.2.0") + }) +} + +func TestValidateAnySchema(t *testing.T) { + t.Run("ValueType", func(t *testing.T) { + r := spec.NewRouter() + r.Get("/test", option.Response(200, &openapi.Schema{ + AdditionalProperties: openapi.Schema{Type: "string"}, + })) + assert.NoError(t, r.Validate()) + }) +} + +func TestSchemaTypeIncludesArray(t *testing.T) { + assert.False(t, validate.SchemaTypeIncludesArray(nil)) + assert.True(t, validate.SchemaTypeIncludesArray(&openapi.Schema{Type: "array"})) + assert.False(t, validate.SchemaTypeIncludesArray(&openapi.Schema{Type: "object"})) + assert.True(t, validate.SchemaTypeIncludesArray(&openapi.Schema{Type: []string{"object", "array"}})) + assert.True(t, validate.SchemaTypeIncludesArray(&openapi.Schema{Type: []any{"string", "array"}})) + assert.False(t, validate.SchemaTypeIncludesArray(&openapi.Schema{Type: []any{"string", 1}})) +} + +func TestValidateSchema304Fields_Direct(t *testing.T) { + s := &openapi.Schema{ + Schema: "https://json-schema.org/draft/2020-12/schema", + Examples: []any{"x"}, + Type: []string{"string"}, + Extra: map[string]any{"$schema": "x"}, + ExclusiveMaximum: 1.2, + ExclusiveMinimum: 0.1, + } + + errs := validate.ValidateSchema304Fields("schema", s) + assert.GreaterOrEqual(t, len(errs), 6) + assert.Contains(t, errs[0].Error(), "contains JSON Schema dialect fields") + assert.Contains(t, errs[1].Error(), "contains JSON Schema 2020-12 keywords") + + var joined strings.Builder + for _, err := range errs { + joined.WriteString(err.Error() + "\n") + } + assert.Contains(t, joined.String(), "schema.type must be a string in OpenAPI 3.0.x") + assert.Contains(t, joined.String(), "schema.exclusiveMaximum must be a boolean in OpenAPI 3.0.x") + assert.Contains(t, joined.String(), "schema.exclusiveMinimum must be a boolean in OpenAPI 3.0.x") + assert.Contains(t, joined.String(), "contains Extra JSON Schema keywords") +} + +func TestValidateSchema304Fields_TypeAnySlice(t *testing.T) { + errs := validate.ValidateSchema304Fields("schema", &openapi.Schema{ + Type: []any{"string"}, + }) + assert.Len(t, errs, 1) + assert.Contains(t, errs[0].Error(), "schema.type must be a string in OpenAPI 3.0.x") +} + +func TestValidateAnySchema_Direct(t *testing.T) { + errs := validate.ValidateAnySchema("schema", 123, openapi.Version304, map[*openapi.Schema]bool{}) + assert.Empty(t, errs) + + errs = validate.ValidateAnySchema( + "schema", + openapi.Schema{ReadOnly: true, WriteOnly: true}, + openapi.Version304, + map[*openapi.Schema]bool{}, + ) + assert.Len(t, errs, 1) + assert.Contains(t, errs[0].Error(), "must not be both readOnly and writeOnly") +} diff --git a/internal/validate/utils.go b/internal/validate/utils.go new file mode 100644 index 0000000..098b25a --- /dev/null +++ b/internal/validate/utils.go @@ -0,0 +1,478 @@ +package validate + +import ( + "encoding/json" + "fmt" + "net/url" + "reflect" + "regexp" + "slices" + "strconv" + "strings" + + spec_reflect "github.com/oaswrap/spec/internal/reflect" + "github.com/oaswrap/spec/openapi" +) + +var ( + pathParamRe = regexp.MustCompile(`\{([^{}]+)\}`) + componentRe = regexp.MustCompile(`^[a-zA-Z0-9.\-_]+$`) + responseCodeRe = regexp.MustCompile(`^[1-5]([0-9]{2}|XX)$`) +) + +func NormalizeTemplatedPath(path string) string { + return pathParamRe.ReplaceAllString(path, "{}") +} + +func ValidateParameterSerialization(context string, param *openapi.Parameter, version string) []error { + var errs []error + if param.AllowEmptyValue && param.In != string(openapi.ParameterInQuery) { + errs = append(errs, fmt.Errorf("%s allowEmptyValue is only allowed for query parameters", context)) + } + if param.Style == "" { + return errs + } + if !ValidParameterStyle(param.In, param.Style, version) { + errs = append(errs, fmt.Errorf("%s.style %q is not allowed for %s parameters", context, param.Style, param.In)) + } + return errs +} + +func ValidParameterStyle(in, style, version string) bool { + switch in { + case string(openapi.ParameterInPath): + return slices.Contains([]string{"matrix", "label", "simple"}, style) + case string(openapi.ParameterInQuery): + return slices.Contains([]string{"form", "spaceDelimited", "pipeDelimited", "deepObject"}, style) + case string(openapi.ParameterInHeader): + return style == "simple" + case string(openapi.ParameterInCookie): + if version == openapi.Version320 { + return style == "form" || style == "cookie" + } + return style == "form" + default: + return false + } +} + +func MediaTypeAllowsNamedEncoding(mediaType string) bool { + if mediaType == "" { + return true + } + return MediaTypeIsMultipart(mediaType) || MediaTypeBase(mediaType) == "application/x-www-form-urlencoded" +} + +func MediaTypeIsMultipart(mediaType string) bool { + if mediaType == "" { + return true + } + return strings.HasPrefix(MediaTypeBase(mediaType), "multipart/") +} + +func MediaTypeBase(mediaType string) string { + base, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(mediaType)), ";") + return strings.TrimSpace(base) +} + +func BodyRefHasInvalidSiblings(body *openapi.RequestBody, version string) bool { + if spec_reflect.IsOpenAPI30(version) && (body.Summary != "" || body.Description != "") { + return true + } + return len(body.Content) > 0 || body.Required || HasInvalidReferenceExtra(body.Extra, version) +} + +func ResponseRefHasInvalidSiblings(response *openapi.Response, version string) bool { + if spec_reflect.IsOpenAPI30(version) && (response.Summary != "" || response.Description != "") { + return true + } + return len(response.Headers) > 0 || len(response.Content) > 0 || len(response.Links) > 0 || + HasInvalidReferenceExtra(response.Extra, version) +} + +func HeaderRefHasInvalidSiblings(header *openapi.Header, version string) bool { + if spec_reflect.IsOpenAPI30(version) && (header.Summary != "" || header.Description != "") { + return true + } + return header.Required || header.Deprecated || header.AllowEmptyValue || + header.Style != "" || + header.Explode != nil || + header.AllowReserved || + header.Schema != nil || + len(header.Content) > 0 || + header.Example != nil || + len(header.Examples) > 0 || + HasInvalidReferenceExtra(header.Extra, version) +} + +func ExampleRefHasInvalidSiblings(example *openapi.Example, version string) bool { + if spec_reflect.IsOpenAPI30(version) && (example.Summary != "" || example.Description != "") { + return true + } + return example.DataValue != nil || + example.Value != nil || + example.ExternalValue != "" || + example.SerializedValue != "" || + HasSerializedExample(example) || + HasInvalidReferenceExtra(example.Extra, version) +} + +func HasSerializedExample(example *openapi.Example) bool { + //nolint:staticcheck // Accepted only to detect deprecated pre-fix API usage and report a validation error. + return example.SerializedExample != nil +} + +func LinkRefHasInvalidSiblings(link *openapi.Link, version string) bool { + if spec_reflect.IsOpenAPI30(version) && (link.Summary != "" || link.Description != "") { + return true + } + return link.OperationRef != "" || + link.OperationID != "" || + len(link.Parameters) > 0 || + link.RequestBody != nil || + link.Server != nil || + HasInvalidReferenceExtra(link.Extra, version) +} + +func CallbackRefHasInvalidSiblings(callback *openapi.Callback, version string) bool { + if spec_reflect.IsOpenAPI30(version) && (callback.Summary != "" || callback.Description != "") { + return true + } + return len(callback.Expressions) > 0 || + HasInvalidReferenceExtra(callback.Extra, version) +} + +func MediaTypeRefHasInvalidSiblings(mediaType *openapi.MediaType, version string) bool { + if spec_reflect.IsOpenAPI30(version) && (mediaType.Summary != "" || mediaType.Description != "") { + return true + } + return mediaType.Schema != nil || + mediaType.ItemSchema != nil || + mediaType.Example != nil || + len(mediaType.Examples) > 0 || + len(mediaType.Encoding) > 0 || + len(mediaType.PrefixEncoding) > 0 || + mediaType.ItemEncoding != nil || + HasInvalidReferenceExtra(mediaType.Extra, version) +} + +func SecuritySchemeRefHasInvalidSiblings(scheme *openapi.SecurityScheme, version string) bool { + if spec_reflect.IsOpenAPI30(version) && (scheme.Summary != "" || scheme.Description != nil) { + return true + } + return scheme.Type != "" || + scheme.Name != "" || + scheme.In != "" || + scheme.Scheme != "" || + scheme.BearerFormat != nil || + scheme.Flows != nil || + scheme.OpenIDConnectURL != "" || + scheme.OAuth2MetadataURL != "" || + scheme.Deprecated || + HasInvalidReferenceExtra(scheme.Extra, version) +} + +func HasInvalidReferenceExtra(extra map[string]any, version string) bool { + for key := range extra { + if !spec_reflect.IsOpenAPI30(version) && (key == "summary" || key == "description") { + continue + } + if !strings.HasPrefix(key, "x-") { + return true + } + } + return false +} + +func SecurityRequirementMayUseURI(name, version string) bool { + return version == openapi.Version320 && strings.ContainsAny(name, ":/.#?") +} + +//nolint:gocyclo,cyclop // explicit sibling checks keep $ref compatibility rules straightforward. +func HasSchemaRefSiblings(schema *openapi.Schema) bool { + return schema.Schema != "" || + schema.ID != "" || + len(schema.Defs) > 0 || + schema.Anchor != "" || + schema.DynamicAnchor != "" || + schema.DynamicRef != "" || + len(schema.Vocabulary) > 0 || + schema.Comment != "" || + schema.Title != "" || + schema.Description != "" || + schema.Type != nil || + schema.Format != "" || + schema.Nullable || + schema.Default != nil || + schema.Example != nil || + len(schema.Examples) > 0 || + len(schema.Enum) > 0 || + schema.Const != nil || + schema.MultipleOf != nil || + schema.Maximum != nil || + schema.ExclusiveMaximum != nil || + schema.Minimum != nil || + schema.ExclusiveMinimum != nil || + schema.MaxLength != nil || + schema.MinLength != nil || + schema.Pattern != "" || + schema.MaxItems != nil || + schema.MinItems != nil || + schema.UniqueItems != nil || + schema.MaxProperties != nil || + schema.MinProperties != nil || + len(schema.Required) > 0 || + len(schema.Properties) > 0 || + len(schema.PatternProperties) > 0 || + schema.Items != nil || + len(schema.PrefixItems) > 0 || + schema.Contains != nil || + schema.MaxContains != nil || + schema.MinContains != nil || + schema.AdditionalProperties != nil || + schema.UnevaluatedProperties != nil || + schema.PropertyNames != nil || + len(schema.DependentRequired) > 0 || + len(schema.DependentSchemas) > 0 || + len(schema.AllOf) > 0 || + len(schema.AnyOf) > 0 || + len(schema.OneOf) > 0 || + schema.Not != nil || + schema.If != nil || + schema.Then != nil || + schema.Else != nil || + schema.Deprecated || + schema.ReadOnly || + schema.WriteOnly || + schema.ContentEncoding != "" || + schema.ContentMediaType != "" || + schema.ContentSchema != nil || + schema.Discriminator != nil || + schema.XML != nil || + schema.ExternalDocs != nil || + HasNonExtensionExtra(schema.Extra) +} + +func SchemaTypeIncludesArray(schema *openapi.Schema) bool { + if schema == nil { + return false + } + switch value := schema.Type.(type) { + case string: + return value == "array" + case []string: + return slices.Contains(value, "array") + case []any: + for _, item := range value { + if item == "array" { + return true + } + } + } + return false +} + +func ExtraHas(extra map[string]any, keys ...string) bool { + for _, key := range keys { + if _, ok := extra[key]; ok { + return true + } + } + return false +} + +func HasNonExtensionExtra(extra map[string]any) bool { + for key := range extra { + if !strings.HasPrefix(key, "x-") { + return true + } + } + return false +} + +func SchemaBaseURI(value reflect.Value, base string) string { + field := value.FieldByName("ID") + if !field.IsValid() || field.Kind() != reflect.String || field.String() == "" { + return base + } + resolved, ok := ResolveURIReference(base, field.String()) + if !ok { + return base + } + return WithoutFragment(resolved) +} + +func RegisterSchemaResource(value reflect.Value, base string, resources map[string]any) { + if !value.CanInterface() { + return + } + if base != "" { + if _, exists := resources[base]; !exists { + resources[base] = MarshalAny(value.Interface()) + } + } + RegisterSchemaAnchor(value, base, "Anchor", resources) + RegisterSchemaAnchor(value, base, "DynamicAnchor", resources) +} + +func RegisterSchemaAnchor(value reflect.Value, base, fieldName string, resources map[string]any) { + field := value.FieldByName(fieldName) + if !field.IsValid() || field.Kind() != reflect.String || field.String() == "" { + return + } + target := base + "#" + field.String() + if target == "" { + return + } + if _, exists := resources[target]; !exists { + resources[target] = MarshalAny(value.Interface()) + } +} + +func MarshalAny(value any) any { + raw, err := openapi.MarshalJSON(value) + if err != nil { + return nil + } + var out any + if err = json.Unmarshal(raw, &out); err != nil { + return nil + } + return out +} + +func ResolveURIReference(base, ref string) (string, bool) { + refURL, err := url.Parse(ref) + if err != nil { + return "", false + } + if base == "" { + return refURL.String(), true + } + baseURL, err := url.Parse(base) + if err != nil { + return "", false + } + return baseURL.ResolveReference(refURL).String(), true +} + +func IsURIReference(value string) bool { + if strings.ContainsAny(value, " \t\r\n") { + return false + } + _, err := url.Parse(value) + return err == nil +} + +func IsAbsoluteURI(value string) bool { + if !IsURIReference(value) { + return false + } + parsed, err := url.Parse(value) + return err == nil && parsed.IsAbs() +} + +func IsHTTPSURI(value string) bool { + if !IsAbsoluteURI(value) { + return false + } + parsed, err := url.Parse(value) + return err == nil && strings.EqualFold(parsed.Scheme, "https") +} + +func IsLocalReference(ref string, resources map[string]any) bool { + parsed, err := url.Parse(ref) + if err != nil { + return false + } + base := urlWithoutFragment(parsed) + resource, ok := resources[base] + if !ok { + return false + } + if parsed.Fragment == "" { + return true + } + if _, ok = resources[base+"#"+parsed.Fragment]; ok { + return true + } + if strings.HasPrefix(parsed.Fragment, "/") { + return ResolveJSONPointer(resource, parsed.Fragment) != nil + } + return false +} + +func ReferenceTargetExists(ref string, resources map[string]any) bool { + if _, ok := resources[ref]; ok { + return true + } + parsed, err := url.Parse(ref) + if err != nil { + return false + } + base := urlWithoutFragment(parsed) + fragment := parsed.Fragment + if fragment == "" { + _, ok := resources[base] + return ok + } + if strings.HasPrefix(fragment, "/") { + resource, ok := resources[base] + return ok && ResolveJSONPointer(resource, fragment) != nil + } + _, ok := resources[base+"#"+fragment] + return ok +} + +func WithoutFragment(raw string) string { + parsed, err := url.Parse(raw) + if err != nil { + return raw + } + return urlWithoutFragment(parsed) +} + +func urlWithoutFragment(parsed *url.URL) string { + copyURL := *parsed + copyURL.Fragment = "" + copyURL.RawFragment = "" + return copyURL.String() +} + +func ResolveJSONPointer(root any, pointer string) any { + if pointer == "" { + return root + } + if !strings.HasPrefix(pointer, "/") { + return nil + } + current := root + for _, token := range strings.Split(pointer[1:], "/") { + token = strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~") + switch node := current.(type) { + case map[string]any: + next, ok := node[token] + if !ok { + return nil + } + current = next + case []any: + index, err := strconv.Atoi(token) + if err != nil || index < 0 || index >= len(node) { + return nil + } + current = node[index] + default: + return nil + } + } + return current +} + +func IsOpenAPI31(version string) bool { + return version == openapi.Version310 || version == openapi.Version311 || version == openapi.Version312 +} + +func IsOpenAPI32(version string) bool { + return version == openapi.Version320 +} diff --git a/internal/validate/utils_test.go b/internal/validate/utils_test.go new file mode 100644 index 0000000..51499e1 --- /dev/null +++ b/internal/validate/utils_test.go @@ -0,0 +1,162 @@ +package validate + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/oaswrap/spec/openapi" +) + +func TestNormalizeTemplatedPath(t *testing.T) { + assert.Equal(t, "/users/{}", NormalizeTemplatedPath("/users/{id}")) + assert.Equal(t, "/orgs/{}/repos/{}", NormalizeTemplatedPath("/orgs/{org}/repos/{repo}")) + assert.Equal(t, "/static", NormalizeTemplatedPath("/static")) +} + +func TestMediaTypeBase(t *testing.T) { + assert.Equal(t, "application/json", MediaTypeBase("application/json")) + assert.Equal(t, "application/json", MediaTypeBase("application/json; charset=utf-8")) + assert.Equal(t, "application/json", MediaTypeBase(" APPLICATION/JSON ; foo=bar")) +} + +func TestMediaTypeIsMultipart(t *testing.T) { + assert.True(t, MediaTypeIsMultipart("multipart/form-data")) + assert.True(t, MediaTypeIsMultipart("multipart/mixed")) + assert.False(t, MediaTypeIsMultipart("application/json")) +} + +func TestResolveJSONPointer(t *testing.T) { + root := map[string]any{ + "foo": []any{"bar", "baz"}, + "qux": map[string]any{ + "a/b": 1, + "c%d": 2, + "e~f": 3, + "g~h": 4, + }, + } + + assert.Equal(t, root, ResolveJSONPointer(root, "")) + assert.Equal(t, root["foo"], ResolveJSONPointer(root, "/foo")) + assert.Equal(t, "bar", ResolveJSONPointer(root, "/foo/0")) + assert.Equal(t, "baz", ResolveJSONPointer(root, "/foo/1")) + assert.Nil(t, ResolveJSONPointer(root, "/foo/2")) + assert.Equal(t, 1, ResolveJSONPointer(root, "/qux/a~1b")) + assert.Equal(t, 3, ResolveJSONPointer(root, "/qux/e~0f")) +} + +func TestIsAbsoluteURI(t *testing.T) { + assert.True(t, IsAbsoluteURI("https://example.com")) + assert.True(t, IsAbsoluteURI("mailto:foo@example.com")) + assert.False(t, IsAbsoluteURI("/local/path")) + assert.False(t, IsAbsoluteURI("relative")) +} + +func TestIsHTTPSURI(t *testing.T) { + assert.True(t, IsHTTPSURI("https://example.com")) + assert.False(t, IsHTTPSURI("http://example.com")) + assert.False(t, IsHTTPSURI("ftp://example.com")) +} + +func TestIsURIReference(t *testing.T) { + assert.True(t, IsURIReference("https://example.com")) + assert.True(t, IsURIReference("/path")) + assert.False(t, IsURIReference("not a uri with spaces")) +} + +func TestResolveURIReference(t *testing.T) { + tests := []struct { + base, ref string + expected string + }{ + {"", "https://example.com", "https://example.com"}, + {"https://example.com/a/b", "c", "https://example.com/a/c"}, + {"https://example.com/a/b", "/c", "https://example.com/c"}, + } + for _, tt := range tests { + got, ok := ResolveURIReference(tt.base, tt.ref) + assert.True(t, ok) + assert.Equal(t, tt.expected, got) + } +} + +func TestExtraHas(t *testing.T) { + extra := map[string]any{"foo": 1, "bar": 2} + assert.True(t, ExtraHas(extra, "foo")) + assert.True(t, ExtraHas(extra, "baz", "bar")) + assert.False(t, ExtraHas(extra, "qux")) +} + +func TestWithoutFragment(t *testing.T) { + assert.Equal(t, "https://example.com/path", WithoutFragment("https://example.com/path#frag")) + assert.Equal(t, "https://example.com/path", WithoutFragment("https://example.com/path")) + assert.Equal(t, ":// bad", WithoutFragment(":// bad")) +} + +func TestResolveURIReference_InvalidInput(t *testing.T) { + _, ok := ResolveURIReference("https://example.com", "://bad") + assert.False(t, ok) + + _, ok = ResolveURIReference("://bad", "x") + assert.False(t, ok) +} + +func TestRegisterSchemaResourceAndAnchor(t *testing.T) { + schema := &openapi.Schema{ + ID: "https://schemas.example.com/user", + Type: "object", + Anchor: "user-anchor", + DynamicAnchor: "user-dyn", + } + + resources := map[string]any{} + RegisterSchemaResource(reflect.ValueOf(*schema), schema.ID, resources) + RegisterSchemaResource(reflect.ValueOf(*schema), schema.ID, resources) // idempotent + + assert.Contains(t, resources, "https://schemas.example.com/user") + assert.Contains(t, resources, "https://schemas.example.com/user#user-anchor") + assert.Contains(t, resources, "https://schemas.example.com/user#user-dyn") + assert.Len(t, resources, 3) + + // Empty anchor should be ignored. + emptySchema := openapi.Schema{} + RegisterSchemaAnchor(reflect.ValueOf(emptySchema), "", "Anchor", resources) + assert.Len(t, resources, 3) +} + +func TestIsLocalReferenceAndReferenceTargetExists(t *testing.T) { + resources := map[string]any{ + "https://example.com/schemas/user": map[string]any{ + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + }, + "https://example.com/schemas/user#UserAnchor": map[string]any{"type": "object"}, + "": map[string]any{ + "components": map[string]any{ + "schemas": map[string]any{ + "User": map[string]any{"type": "object"}, + }, + }, + }, + } + + assert.True(t, IsLocalReference("https://example.com/schemas/user", resources)) + assert.True(t, IsLocalReference("https://example.com/schemas/user#UserAnchor", resources)) + assert.False(t, IsLocalReference("https://example.com/schemas/user#Missing", resources)) + assert.False(t, IsLocalReference("://bad", resources)) + + assert.True(t, ReferenceTargetExists("https://example.com/schemas/user", resources)) + assert.True(t, ReferenceTargetExists("https://example.com/schemas/user#UserAnchor", resources)) + assert.True(t, ReferenceTargetExists("https://example.com/schemas/user#/properties/name", resources)) + assert.False(t, ReferenceTargetExists("https://example.com/schemas/user#/properties/missing", resources)) + assert.False(t, ReferenceTargetExists("https://example.com/schemas/user#Missing", resources)) + assert.False(t, ReferenceTargetExists("://bad", resources)) +} + +func TestMarshalAny(t *testing.T) { + assert.Equal(t, map[string]any{"x": float64(1)}, MarshalAny(map[string]int{"x": 1})) + assert.Nil(t, MarshalAny(func() {})) +} diff --git a/jsonschema.go b/jsonschema.go deleted file mode 100644 index 033c989..0000000 --- a/jsonschema.go +++ /dev/null @@ -1,109 +0,0 @@ -package spec - -import ( - "fmt" - "reflect" - "regexp" - "strings" - - "github.com/oaswrap/spec/internal/debuglog" - "github.com/oaswrap/spec/openapi" - "github.com/swaggest/jsonschema-go" -) - -var genericInstRe = regexp.MustCompile(`^(\w+)\[(.+)\]$`) - -func getJSONSchemaOpts(cfg *openapi.ReflectorConfig, logger *debuglog.Logger) []func(*jsonschema.ReflectContext) { - var opts []func(*jsonschema.ReflectContext) - - if cfg == nil { - opts = append(opts, jsonschema.InterceptDefName(shortenGenericName)) - return opts - } - - if cfg.InlineRefs { - opts = append(opts, jsonschema.InlineRefs) - logger.Printf("set inline references to true") - } - if cfg.RootRef { - opts = append(opts, jsonschema.RootRef) - logger.Printf("set root reference to true") - } - if cfg.RootNullable { - opts = append(opts, jsonschema.RootNullable) - logger.Printf("set root nullable to true") - } - if len(cfg.StripDefNamePrefix) > 0 { - opts = append(opts, jsonschema.StripDefinitionNamePrefix(cfg.StripDefNamePrefix...)) - logger.LogAction("set strip definition name prefix", fmt.Sprintf("%v", cfg.StripDefNamePrefix)) - } - opts = append(opts, jsonschema.InterceptDefName(shortenGenericName)) - if cfg.InterceptDefNameFunc != nil { - opts = append(opts, jsonschema.InterceptDefName(cfg.InterceptDefNameFunc)) - logger.Printf("set custom intercept definition name function") - } - if cfg.InterceptPropFunc != nil { - opts = append(opts, jsonschema.InterceptProp(func(params jsonschema.InterceptPropParams) error { - return cfg.InterceptPropFunc(openapi.InterceptPropParams{ - Context: params.Context, - Path: params.Path, - Name: params.Name, - Field: params.Field, - PropertySchema: params.PropertySchema, - ParentSchema: params.ParentSchema, - Processed: params.Processed, - }) - })) - logger.Printf("set custom intercept property function") - } - if cfg.InterceptSchemaFunc != nil { - opts = append( - opts, - jsonschema.InterceptSchema(func(params jsonschema.InterceptSchemaParams) (bool, error) { - stop, err := cfg.InterceptSchemaFunc(openapi.InterceptSchemaParams{ - Context: params.Context, - Value: params.Value, - Schema: params.Schema, - Processed: params.Processed, - }) - return stop, err - }), - ) - logger.Printf("set custom intercept schema function") - } - - return opts -} - -// shortenGenericName converts "Page[some/pkg.Item]" to "PageItem". -func shortenGenericName(t reflect.Type, defaultDefName string) string { - m := genericInstRe.FindStringSubmatch(t.Name()) - if m == nil { - return defaultDefName - } - // Use the container name from defaultDefName, which already has the package - // prefix applied and StripDefinitionNamePrefix already run โ€” so the result - // is consistent with how non-generic struct names are generated. - containerName := m[1] - if before, _, found := strings.Cut(defaultDefName, "["); found { - containerName = before - } - args := strings.Split(m[2], ", ") - result := containerName - var sb strings.Builder - for _, arg := range args { - arg = strings.TrimPrefix(arg, "*") - var suffixSb strings.Builder - for strings.HasPrefix(arg, "[]") { - suffixSb.WriteString("List") - arg = arg[2:] - } - arg = strings.TrimPrefix(arg, "*") - if i := strings.LastIndex(arg, "."); i >= 0 { - arg = arg[i+1:] - } - sb.WriteString(arg + suffixSb.String()) - } - result += sb.String() - return result -} diff --git a/openapi/codec.go b/openapi/codec.go new file mode 100644 index 0000000..7de4e61 --- /dev/null +++ b/openapi/codec.go @@ -0,0 +1,280 @@ +package openapi + +import ( + "bytes" + "encoding/json" + "reflect" + "sort" + "strings" + + "github.com/goccy/go-yaml" +) + +// MarshalJSON marshals an OpenAPI value while merging x-* extensions into objects. +func MarshalJSON(value any) ([]byte, error) { + return json.Marshal(toSerializable(reflect.ValueOf(value), objectJSON)) +} + +const yamlIndent = 2 + +// MarshalYAML marshals an OpenAPI value while merging x-* extensions into objects. +func MarshalYAML(value any) ([]byte, error) { + return yaml.MarshalWithOptions( + toSerializable(reflect.ValueOf(value), objectYAML), + yaml.Indent(yamlIndent), + yaml.IndentSequence(true), + ) +} + +type objectMode int + +const ( + objectJSON objectMode = iota + objectYAML +) + +type orderedField struct { + Key string + Value any +} + +type orderedObject []orderedField + +func (o orderedObject) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + buf.WriteByte('{') + for i, field := range o { + if i > 0 { + buf.WriteByte(',') + } + key, err := json.Marshal(field.Key) + if err != nil { + return nil, err + } + value, err := json.Marshal(field.Value) + if err != nil { + return nil, err + } + buf.Write(key) + buf.WriteByte(':') + buf.Write(value) + } + buf.WriteByte('}') + return buf.Bytes(), nil +} + +func toSerializable(value reflect.Value, mode objectMode) any { + if !value.IsValid() { + return nil + } + for value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer { + if value.IsNil() { + return nil + } + value = value.Elem() + } + + switch value.Kind() { //nolint:exhaustive // only interested in complex types + case reflect.Struct: + return structToObject(value, mode) + case reflect.Map: + return mapToObject(value, mode) + case reflect.Slice, reflect.Array: + return sliceToSlice(value, mode) + default: + return value.Interface() + } +} + +func structToObject(value reflect.Value, mode objectMode) any { + fields := []orderedField{} + valueType := value.Type() + for i := range value.NumField() { + field := valueType.Field(i) + if field.PkgPath != "" { + continue + } + if field.Name == "Extensions" || field.Name == "Extra" { + continue + } + name, omitempty := jsonField(field) + if name == "" { + continue + } + fieldValue := value.Field(i) + if omitempty && isEmptyValue(fieldValue) { + continue + } + fields = append(fields, orderedField{ + Key: name, + Value: toSerializable(fieldValue, mode), + }) + } + for i := range value.NumField() { + field := valueType.Field(i) + if field.PkgPath != "" { + continue + } + switch field.Name { + case "Extensions": + fields = appendOrderedFields(fields, extensionFields(value.Field(i), mode)...) + case "Expressions": + fields = appendOrderedFields(fields, mapFields(value.Field(i), mode)...) + case "Extra": + fields = appendOrderedFields(fields, extraFields(value.Field(i), mode)...) + } + } + return makeObject(fields, mode) +} + +func mapToObject(value reflect.Value, mode objectMode) any { + if value.IsNil() { + return nil + } + return makeObject(mapFields(value, mode), mode) +} + +func mapFields(value reflect.Value, mode objectMode) []orderedField { + if value.Kind() != reflect.Map || value.IsNil() { + return nil + } + fields := []orderedField{} + iter := value.MapRange() + for iter.Next() { + key := iter.Key() + if key.Kind() != reflect.String { + continue + } + fields = append(fields, orderedField{ + Key: key.String(), + Value: mapValueToSerializable(iter.Value(), mode), + }) + } + sort.Slice(fields, func(i, j int) bool { + return fields[i].Key < fields[j].Key + }) + return fields +} + +func mapValueToSerializable(value reflect.Value, mode objectMode) any { + for value.Kind() == reflect.Interface { + if value.IsNil() { + return nil + } + value = value.Elem() + } + if value.Kind() == reflect.Slice && value.IsNil() { + return []any{} + } + return toSerializable(value, mode) +} + +func sliceToSlice(value reflect.Value, mode objectMode) any { + if value.Kind() == reflect.Slice && value.IsNil() { + return nil + } + out := make([]any, 0, value.Len()) + for i := range value.Len() { + out = append(out, toSerializable(value.Index(i), mode)) + } + return out +} + +func extensionFields(value reflect.Value, mode objectMode) []orderedField { + fields := []orderedField{} + if value.Kind() == reflect.Map && !value.IsNil() { + iter := value.MapRange() + for iter.Next() { + key := iter.Key() + if key.Kind() == reflect.String && strings.HasPrefix(key.String(), "x-") { + fields = append(fields, orderedField{ + Key: key.String(), + Value: toSerializable(iter.Value(), mode), + }) + } + } + } + sort.Slice(fields, func(i, j int) bool { + return fields[i].Key < fields[j].Key + }) + return fields +} + +func extraFields(value reflect.Value, mode objectMode) []orderedField { + fields := []orderedField{} + if value.Kind() == reflect.Map && !value.IsNil() { + iter := value.MapRange() + for iter.Next() { + key := iter.Key() + if key.Kind() == reflect.String { + fields = append(fields, orderedField{ + Key: key.String(), + Value: toSerializable(iter.Value(), mode), + }) + } + } + } + sort.Slice(fields, func(i, j int) bool { + return fields[i].Key < fields[j].Key + }) + return fields +} + +func makeObject(fields []orderedField, mode objectMode) any { + if mode == objectYAML { + out := yaml.MapSlice{} + for _, field := range fields { + out = append(out, yaml.MapItem{Key: field.Key, Value: field.Value}) + } + return out + } + return orderedObject(fields) +} + +func appendOrderedFields(fields []orderedField, next ...orderedField) []orderedField { + for _, field := range next { + replaced := false + for i := range fields { + if fields[i].Key == field.Key { + fields[i] = field + replaced = true + break + } + } + if !replaced { + fields = append(fields, field) + } + } + return fields +} + +func jsonField(field reflect.StructField) (string, bool) { + tag := field.Tag.Get("json") + name, opts, _ := strings.Cut(tag, ",") + if name == "-" { + return "", false + } + if name == "" { + name = field.Name + } + return name, strings.Contains(opts, "omitempty") +} + +func isEmptyValue(value reflect.Value) bool { + switch value.Kind() { //nolint:exhaustive // only interested in complex types + case reflect.Array, reflect.Map, reflect.Slice, reflect.String: + return value.Len() == 0 + case reflect.Bool: + return !value.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return value.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return value.Uint() == 0 + case reflect.Float32, reflect.Float64: + return value.Float() == 0 + case reflect.Interface, reflect.Pointer: + return value.IsNil() + default: + return value.IsZero() + } +} diff --git a/openapi/codec_test.go b/openapi/codec_test.go new file mode 100644 index 0000000..68621ea --- /dev/null +++ b/openapi/codec_test.go @@ -0,0 +1,178 @@ +package openapi + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/goccy/go-yaml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockStruct struct { + Foo string `json:"foo"` + Bar int `json:"bar,omitempty"` + Baz *string `json:"baz,omitempty"` + Extensions map[string]any `json:"-"` +} + +func TestMarshalJSON(t *testing.T) { + s := "baz" + val := mockStruct{ + Foo: "foo", + Baz: &s, + Extensions: map[string]any{"x-extra": "val"}, + } + + data, err := MarshalJSON(val) + require.NoError(t, err) + + var out map[string]any + err = json.Unmarshal(data, &out) + require.NoError(t, err) + + assert.Equal(t, "foo", out["foo"]) + assert.Equal(t, "baz", out["baz"]) + assert.Equal(t, "val", out["x-extra"]) + assert.NotContains(t, out, "bar") +} + +type extraStruct struct { + Foo string `json:"foo"` + Extra map[string]any `json:"-"` +} + +func TestMarshalExtra(t *testing.T) { + val := extraStruct{ + Foo: "foo", + Extra: map[string]any{"bar": 123}, + } + data, err := MarshalJSON(val) + require.NoError(t, err) + + var out map[string]any + err = json.Unmarshal(data, &out) + require.NoError(t, err) + + assert.Equal(t, "foo", out["foo"]) + assert.InDelta(t, 123.0, out["bar"], 0.0001) +} + +func TestToSerializable_EdgeCases(t *testing.T) { + t.Run("MapValuePointer", func(t *testing.T) { + s := "val" + m := map[string]*string{"key": &s} + res := toSerializable(reflect.ValueOf(m), objectJSON) + // orderedObject is internal, so we check the marshaled JSON + data, err := json.Marshal(res) + require.NoError(t, err) + assert.JSONEq(t, `{"key":"val"}`, string(data)) + }) + + t.Run("EmbeddedStruct", func(t *testing.T) { + type Inner struct { + InnerField string `json:"inner"` + } + type Outer struct { + Inner + + OuterField string `json:"outer"` + } + o := Outer{OuterField: "o", Inner: Inner{InnerField: "i"}} + res := toSerializable(reflect.ValueOf(o), objectJSON) + data, err := json.Marshal(res) + require.NoError(t, err) + // The codec currently doesn't flatten anonymous fields like standard encoding/json + assert.JSONEq(t, `{"outer":"o", "Inner":{"inner":"i"}}`, string(data)) + }) + + t.Run("OrderedFields", func(t *testing.T) { + type Ordered struct { + A string `json:"a"` + B string `json:"b"` + } + o := Ordered{A: "1", B: "2"} + res := toSerializable(reflect.ValueOf(o), objectYAML) + if assert.IsType(t, yaml.MapSlice{}, res) { + slice := res.(yaml.MapSlice) + assert.Equal(t, "a", slice[0].Key) + assert.Equal(t, "b", slice[1].Key) + } + }) +} + +func TestIsEmptyValue(t *testing.T) { + cases := []struct { + val any + empty bool + }{ + {0, true}, + {1, false}, + {"", true}, + {"a", false}, + {false, true}, + {true, false}, + {0.0, true}, + {1.1, false}, + {[]int{}, true}, + {[]int{1}, false}, + {map[string]int{}, true}, + {map[string]int{"a": 1}, false}, + } + for _, tc := range cases { + assert.Equal(t, tc.empty, isEmptyValue(reflect.ValueOf(tc.val)), "isEmptyValue(%v)", tc.val) + } +} + +func TestMarshalYAML(t *testing.T) { + val := map[string]any{ + "foo": "bar", + "nested": []any{1, "two", map[string]any{"x": true}}, + } + + data, err := MarshalYAML(val) + require.NoError(t, err) + + var out any + err = yaml.Unmarshal(data, &out) + require.NoError(t, err) +} + +func TestToSerializable(t *testing.T) { + t.Run("Nil", func(t *testing.T) { + assert.Nil(t, toSerializable(reflect.ValueOf(nil), objectJSON)) + var s *string + assert.Nil(t, toSerializable(reflect.ValueOf(s), objectJSON)) + }) + + t.Run("Map", func(t *testing.T) { + m := map[string]int{"a": 1, "b": 2} + res := toSerializable(reflect.ValueOf(m), objectYAML) + if assert.IsType(t, yaml.MapSlice{}, res) { + slice := res.(yaml.MapSlice) + if assert.Len(t, slice, 2) { + assert.Equal(t, "a", slice[0].Key) + assert.Equal(t, "b", slice[1].Key) + } + } + }) + + t.Run("Slice", func(t *testing.T) { + s := []string{"a", "b"} + res := toSerializable(reflect.ValueOf(s), objectJSON) + if assert.IsType(t, []any{}, res) { + slice := res.([]any) + assert.Equal(t, []any{"a", "b"}, slice) + } + }) + + t.Run("Array", func(t *testing.T) { + a := [2]string{"a", "b"} + res := toSerializable(reflect.ValueOf(a), objectJSON) + if assert.IsType(t, []any{}, res) { + array := res.([]any) + assert.Equal(t, []any{"a", "b"}, array) + } + }) +} diff --git a/openapi/config.go b/openapi/config.go index e75a875..f8ab22b 100644 --- a/openapi/config.go +++ b/openapi/config.go @@ -5,101 +5,92 @@ import ( specui "github.com/oaswrap/spec-ui" "github.com/oaswrap/spec-ui/config" - "github.com/swaggest/jsonschema-go" ) -// Config defines the root configuration for OpenAPI documentation generation. +const ( + // Version300 is OpenAPI 3.0.0. + Version300 = "3.0.0" + // Version301 is OpenAPI 3.0.1. + Version301 = "3.0.1" + // Version302 is OpenAPI 3.0.2. + Version302 = "3.0.2" + // Version303 is OpenAPI 3.0.3. + Version303 = "3.0.3" + // Version304 is OpenAPI 3.0.4. + Version304 = "3.0.4" + // Version310 is OpenAPI 3.1.0. + Version310 = "3.1.0" + // Version311 is OpenAPI 3.1.1. + Version311 = "3.1.1" + // Version312 is OpenAPI 3.1.2. + Version312 = "3.1.2" + // Version320 is OpenAPI 3.2.0. + Version320 = "3.2.0" +) + +// Config is the main configuration struct for generating an OpenAPI document. +// It contains all the necessary information and options to customize the generated document, including metadata, +// server information, security schemes, and UI configuration. type Config struct { - OpenAPIVersion string // OpenAPI version, e.g., "3.1.0". - Title string // Title of the API. - Version string // Version of the API. - Description *string // Optional description of the API. - Contact *Contact // Contact information for the API. - License *License // License information for the API. - TermsOfService *string // Terms of service URL. - Servers []Server // List of API servers. - SecuritySchemes map[string]*SecurityScheme // Security schemes available for the API. - Tags []Tag // Tags used to organize operations. - ExternalDocs *ExternalDocs // Additional external documentation. + OpenAPIVersion string + Self string + Title string + InfoSummary string + Version string + Description *string + JSONSchemaDialect string + Contact *Contact + License *License + TermsOfService *string + Servers []Server + SecuritySchemes map[string]*SecurityScheme + Security []SecurityRequirement + Tags []Tag + ExternalDocs *ExternalDocs - ReflectorConfig *ReflectorConfig // Configuration for schema reflection. + ReflectorConfig *ReflectorConfig + StripTrailingSlash bool + PathParser PathParser + DocumentCustomizers []func(*Document) - DocsPath string // Path where the documentation will be served. - SpecPath string // Path for the OpenAPI specification JSON or YAML. - CacheAge *int // Cache age for OpenAPI specification responses. - DisableDocs bool // If true, disables serving OpenAPI docs. - StripTrailingSlash bool // If true, trailing slashes are removed from all operation paths. - Logger Logger // Logger for diagnostic output. - PathParser PathParser // Path parser for framework-specific path conversions. + DocsPath string + SpecPath string + CacheAge *int + DisableDocs bool - UIProvider config.Provider // UI provider for the OpenAPI documentation. - SwaggerUIConfig *config.SwaggerUI // Configuration for embedded Swagger UI. - StoplightElementsConfig *config.StoplightElements // Configuration for Stoplight Elements. - ReDocConfig *config.ReDoc // Configuration for Redoc. - ScalarConfig *config.Scalar // Configuration for Scalar. - RapiDocConfig *config.RapiDoc // Configuration for RapiDoc. - UIOption specui.Option // Ready-to-use spec-ui option for the selected provider. + UIProvider config.Provider + SwaggerUIConfig *config.SwaggerUI + StoplightElementsConfig *config.StoplightElements + ReDocConfig *config.ReDoc + ScalarConfig *config.Scalar + RapiDocConfig *config.RapiDoc + UIOption specui.Option } -// ReflectorConfig holds advanced options for schema reflection. +// ReflectorConfig contains configuration options for the reflection process used to generate +// the OpenAPI document from Go types. It allows customization of how types are reflected, including inline references, +// stripping of definition name prefixes, and custom type mappings. type ReflectorConfig struct { - InlineRefs bool // If true, inline schema references instead of using components. - RootRef bool // If true, use a root reference for top-level schemas. - RootNullable bool // If true, allow root schemas to be nullable. - StripDefNamePrefix []string // Prefixes to strip from generated definition names. - InterceptDefNameFunc InterceptDefNameFunc // Function to customize definition names. - InterceptPropFunc InterceptPropFunc // Function to intercept property schema generation. - InterceptSchemaFunc InterceptSchemaFunc // Function to intercept full schema generation. - TypeMappings []TypeMapping // Custom type mappings for schema generation. - ParameterTagMapping map[ParameterIn]string // Custom struct tag mapping for parameters. + InlineRefs bool + StripDefNamePrefix []string + InterceptDefName func(t reflect.Type, defaultDefName string) string + DefNameCallerPkg string + TypeMappings []TypeMapping + ParameterTagMapping map[ParameterIn]string } -// TypeMapping maps a source type to a target type in schema generation. +// TypeMapping represents a mapping between a source type and a destination type. It is used in the reflection process +// to specify how certain types should be mapped when generating the OpenAPI document. The Src field represents +// the original type, while the Dst field represents the type that should be used in the generated document. type TypeMapping struct { - Src any // Source type. - Dst any // Destination type. -} - -// InterceptDefNameFunc allows customizing schema definition names. -type InterceptDefNameFunc func(t reflect.Type, defaultDefName string) string - -// InterceptPropFunc allows customizing property schemas during generation. -type InterceptPropFunc func(params InterceptPropParams) error - -// InterceptPropParams holds information for intercepting property generation. -type InterceptPropParams struct { - Context *jsonschema.ReflectContext // Reflection context. - Path []string // Path to the property. - Name string // Property name. - Field reflect.StructField // Struct field being processed. - PropertySchema *jsonschema.Schema // Generated property schema. - ParentSchema *jsonschema.Schema // Parent object schema. - Processed bool // True if the property was already processed. -} - -// InterceptSchemaFunc allows intercepting schema generation for entire types. -type InterceptSchemaFunc func(params InterceptSchemaParams) (stop bool, err error) - -// InterceptSchemaParams holds information for intercepting full schema generation. -type InterceptSchemaParams struct { - Context *jsonschema.ReflectContext // Reflection context. - Value reflect.Value // Value being reflected. - Schema *jsonschema.Schema // Generated schema. - Processed bool // True if the schema was already processed. -} - -// Logger defines an interface for logging diagnostic messages. -type Logger interface { - Printf(format string, v ...any) + Src any + Dst any } -// PathParser defines an interface for converting router paths to OpenAPI paths. -// -// Example: -// -// Input: "/users/:id" -// Output: "/users/{id}" +// PathParser is an interface that defines a method for parsing a path string and returning a modified version of it. +// This can be used to customize how paths are represented in the generated OpenAPI document, allowing +// for transformations such as converting path parameters to a specific format or applying +// any other necessary modifications to the path strings. type PathParser interface { - // Parse converts a framework-style path to OpenAPI path syntax. Parse(path string) (string, error) } diff --git a/openapi/document.go b/openapi/document.go new file mode 100644 index 0000000..b9cb5fc --- /dev/null +++ b/openapi/document.go @@ -0,0 +1,104 @@ +package openapi + +// Document represents an OpenAPI document root object. +type Document struct { + OpenAPI string `json:"openapi" yaml:"openapi"` + Self string `json:"$self,omitempty" yaml:"$self,omitempty"` + Info Info `json:"info" yaml:"info"` + JSONSchemaDialect string `json:"jsonSchemaDialect,omitempty" yaml:"jsonSchemaDialect,omitempty"` + Servers []Server `json:"servers,omitempty" yaml:"servers,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + Tags []Tag `json:"tags,omitempty" yaml:"tags,omitempty"` + Security []SecurityRequirement `json:"security,omitempty" yaml:"security,omitempty"` + Paths map[string]*PathItem `json:"paths" yaml:"paths"` + Webhooks map[string]*PathItem `json:"webhooks,omitempty" yaml:"webhooks,omitempty"` + Components *Components `json:"components,omitempty" yaml:"components,omitempty"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} + +// Info represents the OpenAPI Info Object. +type Info struct { + Title string `json:"title" yaml:"title"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + TermsOfService *string `json:"termsOfService,omitempty" yaml:"termsOfService,omitempty"` + Contact *Contact `json:"contact,omitempty" yaml:"contact,omitempty"` + License *License `json:"license,omitempty" yaml:"license,omitempty"` + Version string `json:"version" yaml:"version"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} + +// Contact represents the OpenAPI Contact Object. +type Contact struct { + Name string `json:"name,omitempty" yaml:"name,omitempty"` + URL string `json:"url,omitempty" yaml:"url,omitempty"` + Email string `json:"email,omitempty" yaml:"email,omitempty"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} + +// License represents the OpenAPI License Object. +type License struct { + Name string `json:"name" yaml:"name"` + Identifier string `json:"identifier,omitempty" yaml:"identifier,omitempty"` + URL string `json:"url,omitempty" yaml:"url,omitempty"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} + +// Tag represents the OpenAPI Tag Object. +type Tag struct { + Name string `json:"name" yaml:"name"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + Parent string `json:"parent,omitempty" yaml:"parent,omitempty"` + Kind string `json:"kind,omitempty" yaml:"kind,omitempty"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} + +// ExternalDocs represents the OpenAPI External Documentation Object. +type ExternalDocs struct { + Description string `json:"description,omitempty" yaml:"description,omitempty"` + URL string `json:"url" yaml:"url"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} + +// Server represents the OpenAPI Server Object. +type Server struct { + URL string `json:"url" yaml:"url"` + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + Variables map[string]ServerVariable `json:"variables,omitempty" yaml:"variables,omitempty"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} + +// ServerVariable represents the OpenAPI Server Variable Object. +type ServerVariable struct { + Enum []string `json:"enum,omitempty" yaml:"enum,omitempty"` + Default string `json:"default" yaml:"default"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} + +// Components represents the OpenAPI Components Object. +type Components struct { + Schemas map[string]*Schema `json:"schemas,omitempty" yaml:"schemas,omitempty"` + Responses map[string]*Response `json:"responses,omitempty" yaml:"responses,omitempty"` + Parameters map[string]*Parameter `json:"parameters,omitempty" yaml:"parameters,omitempty"` + Examples map[string]*Example `json:"examples,omitempty" yaml:"examples,omitempty"` + RequestBodies map[string]*RequestBody `json:"requestBodies,omitempty" yaml:"requestBodies,omitempty"` + Headers map[string]*Header `json:"headers,omitempty" yaml:"headers,omitempty"` + SecuritySchemes map[string]*SecurityScheme `json:"securitySchemes,omitempty" yaml:"securitySchemes,omitempty"` + Links map[string]*Link `json:"links,omitempty" yaml:"links,omitempty"` + Callbacks map[string]*Callback `json:"callbacks,omitempty" yaml:"callbacks,omitempty"` + PathItems map[string]*PathItem `json:"pathItems,omitempty" yaml:"pathItems,omitempty"` + MediaTypes map[string]*MediaType `json:"mediaTypes,omitempty" yaml:"mediaTypes,omitempty"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} diff --git a/openapi/entities.go b/openapi/entities.go deleted file mode 100644 index d520c34..0000000 --- a/openapi/entities.go +++ /dev/null @@ -1,184 +0,0 @@ -package openapi - -// ContentUnit defines the structure for OpenAPI content configuration. -type ContentUnit struct { - Structure any - HTTPStatus int - - ContentType string // ContentType specifies the MIME type of the content. - - IsDefault bool // IsDefault indicates if this content unit is the default response. - - Description string // Description provides a description for the content unit. - - Encoding map[string]string // Encoding maps property names to content types -} - -// Contact represents contact information for the API. -// Generated from "#/$defs/contact". -type Contact struct { - Name string // Contact name. - URL string // Contact URL. Format: uri. - Email string // Contact email. Format: email. - - // MapOfAnything holds vendor extensions. Keys must match `^x-`. - MapOfAnything map[string]any -} - -// License provides license information for the API. -// Generated from "#/$defs/license". -type License struct { - Name string // License name (required). - - Identifier string // SPDX identifier. - URL string // License URL. Format: uri. - - // MapOfAnything holds vendor extensions. Keys must match `^x-`. - MapOfAnything map[string]any -} - -// Tag adds metadata to an API operation. -// Generated from "#/definitions/Tag". -type Tag struct { - Name string // Tag name (required). - Description string // Tag description. - ExternalDocs *ExternalDocs // Additional external documentation. - MapOfAnything map[string]any // Vendor extensions. Keys must match `^x-`. -} - -// ExternalDocs describes external documentation for a tag or operation. -// Generated from "#/$defs/external-documentation". -type ExternalDocs struct { - Description string // Description of the documentation. - - URL string // Required. Documentation URL. Format: uri. - - MapOfAnything map[string]any // Vendor extensions. Keys must match `^x-`. -} - -// Server describes an API server. -// Generated from "#/$defs/server". -type Server struct { - URL string // Required. Server URL. Format: uri-reference. - - Description *string // Optional server description. - Variables map[string]ServerVariable // Server variables for URL templates. - - MapOfAnything map[string]any // Vendor extensions. Keys must match `^x-`. -} - -// ServerVariable describes a variable for server URL template substitution. -// Generated from "#/$defs/server-variable". -type ServerVariable struct { - Enum []string // Allowed values. - Default string // Required. Default value. - - Description string // Variable description. - MapOfAnything map[string]any // Vendor extensions. Keys must match `^x-`. -} - -// SecurityScheme describes a security scheme that can be used by operations. -// Generated from "#/$defs/security-scheme". -type SecurityScheme struct { - Description *string // Optional description. - APIKey *SecuritySchemeAPIKey // API key authentication scheme. - HTTPBearer *SecuritySchemeHTTPBearer // HTTP Bearer authentication scheme. - OAuth2 *SecuritySchemeOAuth2 // OAuth2 authentication scheme. - - MapOfAnything map[string]any // Vendor extensions. Keys must match `^x-`. -} - -// SecuritySchemeAPIKey defines an API key authentication scheme. -// Generated from "#/$defs/security-scheme/$defs/type-apikey". -type SecuritySchemeAPIKey struct { - Name string // Required. Name of the header, query, or cookie parameter. - In SecuritySchemeAPIKeyIn // Required. Location of the API key. -} - -// SecuritySchemeAPIKeyIn specifies where the API key is passed. -type SecuritySchemeAPIKeyIn string - -const ( - // SecuritySchemeAPIKeyInQuery specifies the key is passed in the query string. - SecuritySchemeAPIKeyInQuery = SecuritySchemeAPIKeyIn("query") - // SecuritySchemeAPIKeyInHeader specifies the key is passed in a header. - SecuritySchemeAPIKeyInHeader = SecuritySchemeAPIKeyIn("header") - // SecuritySchemeAPIKeyInCookie specifies the key is passed in a cookie. - SecuritySchemeAPIKeyInCookie = SecuritySchemeAPIKeyIn("cookie") -) - -// SecuritySchemeHTTPBearer defines HTTP Bearer authentication. -// Generated from "#/$defs/security-scheme/$defs/type-http-bearer". -type SecuritySchemeHTTPBearer struct { - Scheme string // Required. Must match pattern `^[Bb][Ee][Aa][Rr][Ee][Rr]$`. - BearerFormat *string // Optional bearer format hint. -} - -// SecuritySchemeOAuth2 defines OAuth2 flows. -// Generated from "#/$defs/security-scheme/$defs/type-oauth2". -type SecuritySchemeOAuth2 struct { - Flows OAuthFlows // Required. Supported OAuth2 flows. -} - -// OAuthFlows groups supported OAuth2 flows. -// Generated from "#/$defs/oauth-flows". -type OAuthFlows struct { - Implicit *OAuthFlowsImplicit - Password *OAuthFlowsPassword - ClientCredentials *OAuthFlowsClientCredentials - AuthorizationCode *OAuthFlowsAuthorizationCode - - MapOfAnything map[string]any // Vendor extensions. Keys must match `^x-`. -} - -// OAuthFlowsImplicit defines an OAuth2 implicit flow. -// Generated from "#/$defs/oauth-flows/$defs/implicit". -type OAuthFlowsImplicit struct { - AuthorizationURL string // Required. Format: uri. - RefreshURL *string // Optional refresh URL. Format: uri. - Scopes map[string]string // Required. Available scopes. - - MapOfAnything map[string]any // Vendor extensions. Keys must match `^x-`. -} - -// OAuthFlowsPassword defines an OAuth2 password flow. -// Generated from "#/$defs/oauth-flows/$defs/password". -type OAuthFlowsPassword struct { - TokenURL string // Required. Token URL. Format: uri. - RefreshURL *string // Optional refresh URL. Format: uri. - Scopes map[string]string // Required. Available scopes. - - MapOfAnything map[string]any // Vendor extensions. Keys must match `^x-`. -} - -// OAuthFlowsClientCredentials defines an OAuth2 client credentials flow. -// Generated from "#/$defs/oauth-flows/$defs/client-credentials". -type OAuthFlowsClientCredentials struct { - TokenURL string // Required. Token URL. Format: uri. - RefreshURL *string // Optional refresh URL. Format: uri. - Scopes map[string]string // Required. Available scopes. - - MapOfAnything map[string]any // Vendor extensions. Keys must match `^x-`. -} - -// OAuthFlowsAuthorizationCode defines an OAuth2 authorization code flow. -// Generated from "#/$defs/oauth-flows/$defs/authorization-code". -type OAuthFlowsAuthorizationCode struct { - AuthorizationURL string // Required. Format: uri. - TokenURL string // Required. Token URL. Format: uri. - RefreshURL *string // Optional refresh URL. Format: uri. - Scopes map[string]string // Required. Available scopes. - - MapOfAnything map[string]any // Vendor extensions. Keys must match `^x-`. -} - -// ParameterIn is an enum type. -type ParameterIn string - -// ParameterIn values enumeration. -const ( - ParameterInPath = ParameterIn("path") - ParameterInQuery = ParameterIn("query") - ParameterInHeader = ParameterIn("header") - ParameterInCookie = ParameterIn("cookie") -) diff --git a/openapi/media.go b/openapi/media.go new file mode 100644 index 0000000..5a57021 --- /dev/null +++ b/openapi/media.go @@ -0,0 +1,61 @@ +package openapi + +// ContentUnit is an internal content descriptor used by option builders. +type ContentUnit struct { + Structure any + HTTPStatus int + ContentType string + IsDefault bool + Description string + Encoding map[string]string + Example any + Examples map[string]*Example + Required bool + Format string +} + +// MediaType represents the OpenAPI Media Type Object. +type MediaType struct { + Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Schema *Schema `json:"schema,omitempty" yaml:"schema,omitempty"` + ItemSchema *Schema `json:"itemSchema,omitempty" yaml:"itemSchema,omitempty"` + Example any `json:"example,omitempty" yaml:"example,omitempty"` + Examples map[string]*Example `json:"examples,omitempty" yaml:"examples,omitempty"` + Encoding map[string]*Encoding `json:"encoding,omitempty" yaml:"encoding,omitempty"` + PrefixEncoding []*Encoding `json:"prefixEncoding,omitempty" yaml:"prefixEncoding,omitempty"` + ItemEncoding *Encoding `json:"itemEncoding,omitempty" yaml:"itemEncoding,omitempty"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} + +// Encoding represents the OpenAPI Encoding Object. +type Encoding struct { + ContentType string `json:"contentType,omitempty" yaml:"contentType,omitempty"` + Headers map[string]*Header `json:"headers,omitempty" yaml:"headers,omitempty"` + Style string `json:"style,omitempty" yaml:"style,omitempty"` + Explode *bool `json:"explode,omitempty" yaml:"explode,omitempty"` + AllowReserved bool `json:"allowReserved,omitempty" yaml:"allowReserved,omitempty"` + Encoding map[string]*Encoding `json:"encoding,omitempty" yaml:"encoding,omitempty"` + PrefixEncoding []*Encoding `json:"prefixEncoding,omitempty" yaml:"prefixEncoding,omitempty"` + ItemEncoding *Encoding `json:"itemEncoding,omitempty" yaml:"itemEncoding,omitempty"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} + +// Example represents the OpenAPI Example Object. +type Example struct { + Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + DataValue any `json:"dataValue,omitempty" yaml:"dataValue,omitempty"` + Value any `json:"value,omitempty" yaml:"value,omitempty"` + ExternalValue string `json:"externalValue,omitempty" yaml:"externalValue,omitempty"` + SerializedValue string `json:"serializedValue,omitempty" yaml:"serializedValue,omitempty"` + // Deprecated: OpenAPI 3.2 uses serializedValue. This field is accepted for + // source compatibility but is not serialized. + SerializedExample any `json:"-" yaml:"-"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} diff --git a/openapi/operation.go b/openapi/operation.go new file mode 100644 index 0000000..6a5c473 --- /dev/null +++ b/openapi/operation.go @@ -0,0 +1,140 @@ +package openapi + +// Operation represents the OpenAPI Operation Object. +type Operation struct { + Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + OperationID string `json:"operationId,omitempty" yaml:"operationId,omitempty"` + Parameters []*Parameter `json:"parameters,omitempty" yaml:"parameters,omitempty"` + RequestBody *RequestBody `json:"requestBody,omitempty" yaml:"requestBody,omitempty"` + Responses map[string]*Response `json:"responses" yaml:"responses"` + Callbacks map[string]*Callback `json:"callbacks,omitempty" yaml:"callbacks,omitempty"` + Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"` + Security []SecurityRequirement `json:"security,omitempty" yaml:"security,omitempty"` + Servers []Server `json:"servers,omitempty" yaml:"servers,omitempty"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} + +// RequestBody represents the OpenAPI Request Body Object. +type RequestBody struct { + Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Content map[string]MediaType `json:"content,omitempty" yaml:"content,omitempty"` + Required bool `json:"required,omitempty" yaml:"required,omitempty"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} + +// Response represents the OpenAPI Response Object. +type Response struct { + Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Headers map[string]*Header `json:"headers,omitempty" yaml:"headers,omitempty"` + Content map[string]MediaType `json:"content,omitempty" yaml:"content,omitempty"` + Links map[string]*Link `json:"links,omitempty" yaml:"links,omitempty"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} + +// Header represents the OpenAPI Header Object. +type Header struct { + Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Required bool `json:"required,omitempty" yaml:"required,omitempty"` + Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"` + AllowEmptyValue bool `json:"allowEmptyValue,omitempty" yaml:"allowEmptyValue,omitempty"` + Style string `json:"style,omitempty" yaml:"style,omitempty"` + Explode *bool `json:"explode,omitempty" yaml:"explode,omitempty"` + AllowReserved bool `json:"allowReserved,omitempty" yaml:"allowReserved,omitempty"` + Schema *Schema `json:"schema,omitempty" yaml:"schema,omitempty"` + Content map[string]*MediaType `json:"content,omitempty" yaml:"content,omitempty"` + Example any `json:"example,omitempty" yaml:"example,omitempty"` + Examples map[string]*Example `json:"examples,omitempty" yaml:"examples,omitempty"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} + +// Link represents the OpenAPI Link Object. +type Link struct { + Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + OperationRef string `json:"operationRef,omitempty" yaml:"operationRef,omitempty"` + OperationID string `json:"operationId,omitempty" yaml:"operationId,omitempty"` + Parameters map[string]any `json:"parameters,omitempty" yaml:"parameters,omitempty"` + RequestBody any `json:"requestBody,omitempty" yaml:"requestBody,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Server *Server `json:"server,omitempty" yaml:"server,omitempty"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} + +// Callback represents the OpenAPI Callback Object. +type Callback struct { + Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Expressions map[string]*PathItem `json:"-" yaml:"-"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} + +// SecurityRequirement represents one OpenAPI Security Requirement Object. +type SecurityRequirement map[string][]string + +// SecuritySchemeAPIKeyIn is the location of an API key security scheme. +type SecuritySchemeAPIKeyIn string + +const ( + // SecuritySchemeAPIKeyInQuery indicates an API key in query. + SecuritySchemeAPIKeyInQuery SecuritySchemeAPIKeyIn = "query" + // SecuritySchemeAPIKeyInHeader indicates an API key in header. + SecuritySchemeAPIKeyInHeader SecuritySchemeAPIKeyIn = "header" + // SecuritySchemeAPIKeyInCookie indicates an API key in cookie. + SecuritySchemeAPIKeyInCookie SecuritySchemeAPIKeyIn = "cookie" +) + +// SecurityScheme represents the OpenAPI Security Scheme Object. +type SecurityScheme struct { + Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Type string `json:"type,omitempty" yaml:"type,omitempty"` + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + In SecuritySchemeAPIKeyIn `json:"in,omitempty" yaml:"in,omitempty"` + Scheme string `json:"scheme,omitempty" yaml:"scheme,omitempty"` + BearerFormat *string `json:"bearerFormat,omitempty" yaml:"bearerFormat,omitempty"` + Flows *OAuthFlows `json:"flows,omitempty" yaml:"flows,omitempty"` + OpenIDConnectURL string `json:"openIdConnectUrl,omitempty" yaml:"openIdConnectUrl,omitempty"` + OAuth2MetadataURL string `json:"oauth2MetadataUrl,omitempty" yaml:"oauth2MetadataUrl,omitempty"` + Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} + +// OAuthFlows represents the OpenAPI OAuth Flows Object. +type OAuthFlows struct { + Implicit *OAuthFlow `json:"implicit,omitempty" yaml:"implicit,omitempty"` + Password *OAuthFlow `json:"password,omitempty" yaml:"password,omitempty"` + ClientCredentials *OAuthFlow `json:"clientCredentials,omitempty" yaml:"clientCredentials,omitempty"` + AuthorizationCode *OAuthFlow `json:"authorizationCode,omitempty" yaml:"authorizationCode,omitempty"` + DeviceAuthorization *OAuthFlow `json:"deviceAuthorization,omitempty" yaml:"deviceAuthorization,omitempty"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} + +// OAuthFlow represents one OpenAPI OAuth Flow Object. +type OAuthFlow struct { + AuthorizationURL string `json:"authorizationUrl,omitempty" yaml:"authorizationUrl,omitempty"` + DeviceAuthorizationURL string `json:"deviceAuthorizationUrl,omitempty" yaml:"deviceAuthorizationUrl,omitempty"` + TokenURL string `json:"tokenUrl,omitempty" yaml:"tokenUrl,omitempty"` + RefreshURL *string `json:"refreshUrl,omitempty" yaml:"refreshUrl,omitempty"` + Scopes map[string]string `json:"scopes" yaml:"scopes"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} diff --git a/openapi/path.go b/openapi/path.go new file mode 100644 index 0000000..915d225 --- /dev/null +++ b/openapi/path.go @@ -0,0 +1,59 @@ +package openapi + +// PathItem represents the OpenAPI Path Item Object. +type PathItem struct { + Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Get *Operation `json:"get,omitempty" yaml:"get,omitempty"` + Put *Operation `json:"put,omitempty" yaml:"put,omitempty"` + Post *Operation `json:"post,omitempty" yaml:"post,omitempty"` + Delete *Operation `json:"delete,omitempty" yaml:"delete,omitempty"` + Options *Operation `json:"options,omitempty" yaml:"options,omitempty"` + Head *Operation `json:"head,omitempty" yaml:"head,omitempty"` + Patch *Operation `json:"patch,omitempty" yaml:"patch,omitempty"` + Trace *Operation `json:"trace,omitempty" yaml:"trace,omitempty"` + Query *Operation `json:"query,omitempty" yaml:"query,omitempty"` + AdditionalOperations map[string]*Operation `json:"additionalOperations,omitempty" yaml:"additionalOperations,omitempty"` + Servers []Server `json:"servers,omitempty" yaml:"servers,omitempty"` + Parameters []*Parameter `json:"parameters,omitempty" yaml:"parameters,omitempty"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} + +// Parameter represents the OpenAPI Parameter Object. +type Parameter struct { + Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + In string `json:"in,omitempty" yaml:"in,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Required bool `json:"required,omitempty" yaml:"required,omitempty"` + Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"` + AllowEmptyValue bool `json:"allowEmptyValue,omitempty" yaml:"allowEmptyValue,omitempty"` + Style string `json:"style,omitempty" yaml:"style,omitempty"` + Explode *bool `json:"explode,omitempty" yaml:"explode,omitempty"` + AllowReserved bool `json:"allowReserved,omitempty" yaml:"allowReserved,omitempty"` + Schema *Schema `json:"schema,omitempty" yaml:"schema,omitempty"` + Content map[string]*MediaType `json:"content,omitempty" yaml:"content,omitempty"` + Example any `json:"example,omitempty" yaml:"example,omitempty"` + Examples map[string]*Example `json:"examples,omitempty" yaml:"examples,omitempty"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} + +// ParameterIn is the location of a parameter. +type ParameterIn string + +const ( + // ParameterInPath indicates a path parameter. + ParameterInPath ParameterIn = "path" + // ParameterInQuery indicates a query parameter. + ParameterInQuery ParameterIn = "query" + // ParameterInQueryString indicates an OpenAPI 3.2 querystring parameter. + ParameterInQueryString ParameterIn = "querystring" + // ParameterInHeader indicates a header parameter. + ParameterInHeader ParameterIn = "header" + // ParameterInCookie indicates a cookie parameter. + ParameterInCookie ParameterIn = "cookie" +) diff --git a/openapi/schema.go b/openapi/schema.go new file mode 100644 index 0000000..2c682db --- /dev/null +++ b/openapi/schema.go @@ -0,0 +1,87 @@ +package openapi + +// Schema represents the OpenAPI Schema Object. +type Schema struct { + Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` + Schema string `json:"$schema,omitempty" yaml:"$schema,omitempty"` + ID string `json:"$id,omitempty" yaml:"$id,omitempty"` + Defs map[string]*Schema `json:"$defs,omitempty" yaml:"$defs,omitempty"` + Anchor string `json:"$anchor,omitempty" yaml:"$anchor,omitempty"` + DynamicAnchor string `json:"$dynamicAnchor,omitempty" yaml:"$dynamicAnchor,omitempty"` + DynamicRef string `json:"$dynamicRef,omitempty" yaml:"$dynamicRef,omitempty"` + Vocabulary map[string]bool `json:"$vocabulary,omitempty" yaml:"$vocabulary,omitempty"` + Comment string `json:"$comment,omitempty" yaml:"$comment,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Type any `json:"type,omitempty" yaml:"type,omitempty"` + Format string `json:"format,omitempty" yaml:"format,omitempty"` + Nullable bool `json:"nullable,omitempty" yaml:"nullable,omitempty"` + Default any `json:"default,omitempty" yaml:"default,omitempty"` + Example any `json:"example,omitempty" yaml:"example,omitempty"` + Examples []any `json:"examples,omitempty" yaml:"examples,omitempty"` + Enum []any `json:"enum,omitempty" yaml:"enum,omitempty"` + Const any `json:"const,omitempty" yaml:"const,omitempty"` + MultipleOf *float64 `json:"multipleOf,omitempty" yaml:"multipleOf,omitempty"` + Maximum *float64 `json:"maximum,omitempty" yaml:"maximum,omitempty"` + ExclusiveMaximum any `json:"exclusiveMaximum,omitempty" yaml:"exclusiveMaximum,omitempty"` + Minimum *float64 `json:"minimum,omitempty" yaml:"minimum,omitempty"` + ExclusiveMinimum any `json:"exclusiveMinimum,omitempty" yaml:"exclusiveMinimum,omitempty"` + MaxLength *int `json:"maxLength,omitempty" yaml:"maxLength,omitempty"` + MinLength *int `json:"minLength,omitempty" yaml:"minLength,omitempty"` + Pattern string `json:"pattern,omitempty" yaml:"pattern,omitempty"` + MaxItems *int `json:"maxItems,omitempty" yaml:"maxItems,omitempty"` + MinItems *int `json:"minItems,omitempty" yaml:"minItems,omitempty"` + UniqueItems *bool `json:"uniqueItems,omitempty" yaml:"uniqueItems,omitempty"` + MaxProperties *int `json:"maxProperties,omitempty" yaml:"maxProperties,omitempty"` + MinProperties *int `json:"minProperties,omitempty" yaml:"minProperties,omitempty"` + Required []string `json:"required,omitempty" yaml:"required,omitempty"` + Properties map[string]*Schema `json:"properties,omitempty" yaml:"properties,omitempty"` + PatternProperties map[string]*Schema `json:"patternProperties,omitempty" yaml:"patternProperties,omitempty"` + Items *Schema `json:"items,omitempty" yaml:"items,omitempty"` + PrefixItems []*Schema `json:"prefixItems,omitempty" yaml:"prefixItems,omitempty"` + Contains *Schema `json:"contains,omitempty" yaml:"contains,omitempty"` + MaxContains *int `json:"maxContains,omitempty" yaml:"maxContains,omitempty"` + MinContains *int `json:"minContains,omitempty" yaml:"minContains,omitempty"` + AdditionalProperties any `json:"additionalProperties,omitempty" yaml:"additionalProperties,omitempty"` + UnevaluatedProperties any `json:"unevaluatedProperties,omitempty" yaml:"unevaluatedProperties,omitempty"` + PropertyNames *Schema `json:"propertyNames,omitempty" yaml:"propertyNames,omitempty"` + DependentRequired map[string][]string `json:"dependentRequired,omitempty" yaml:"dependentRequired,omitempty"` + DependentSchemas map[string]*Schema `json:"dependentSchemas,omitempty" yaml:"dependentSchemas,omitempty"` + AllOf []*Schema `json:"allOf,omitempty" yaml:"allOf,omitempty"` + AnyOf []*Schema `json:"anyOf,omitempty" yaml:"anyOf,omitempty"` + OneOf []*Schema `json:"oneOf,omitempty" yaml:"oneOf,omitempty"` + Not *Schema `json:"not,omitempty" yaml:"not,omitempty"` + If *Schema `json:"if,omitempty" yaml:"if,omitempty"` + Then *Schema `json:"then,omitempty" yaml:"then,omitempty"` + Else *Schema `json:"else,omitempty" yaml:"else,omitempty"` + Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"` + ReadOnly bool `json:"readOnly,omitempty" yaml:"readOnly,omitempty"` + WriteOnly bool `json:"writeOnly,omitempty" yaml:"writeOnly,omitempty"` + ContentEncoding string `json:"contentEncoding,omitempty" yaml:"contentEncoding,omitempty"` + ContentMediaType string `json:"contentMediaType,omitempty" yaml:"contentMediaType,omitempty"` + ContentSchema *Schema `json:"contentSchema,omitempty" yaml:"contentSchema,omitempty"` + Discriminator *Discriminator `json:"discriminator,omitempty" yaml:"discriminator,omitempty"` + XML *XML `json:"xml,omitempty" yaml:"xml,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} + +// Discriminator represents the OpenAPI Discriminator Object. +type Discriminator struct { + PropertyName string `json:"propertyName" yaml:"propertyName"` + Mapping map[string]string `json:"mapping,omitempty" yaml:"mapping,omitempty"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} + +// XML represents the OpenAPI XML Object. +type XML struct { + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"` + Prefix string `json:"prefix,omitempty" yaml:"prefix,omitempty"` + Attribute bool `json:"attribute,omitempty" yaml:"attribute,omitempty"` + Wrapped bool `json:"wrapped,omitempty" yaml:"wrapped,omitempty"` + Extensions map[string]any `json:"-" yaml:"-"` + Extra map[string]any `json:"-" yaml:"-"` +} diff --git a/operation.go b/operation.go deleted file mode 100644 index 07b8a74..0000000 --- a/operation.go +++ /dev/null @@ -1,289 +0,0 @@ -package spec - -import ( - "fmt" - "reflect" - "strings" - - "github.com/oaswrap/spec/internal/debuglog" - "github.com/oaswrap/spec/internal/mapper" - specopenapi "github.com/oaswrap/spec/openapi" - "github.com/oaswrap/spec/option" - "github.com/swaggest/jsonschema-go" - "github.com/swaggest/openapi-go" - "github.com/swaggest/openapi-go/openapi3" - "github.com/swaggest/openapi-go/openapi31" -) - -var _ operationContext = (*operationContextImpl)(nil) - -type operationContextImpl struct { - op openapi.OperationContext - cfg *option.OperationConfig - logger *debuglog.Logger - parameterTagMapping map[specopenapi.ParameterIn]string -} - -func (oc *operationContextImpl) With(opts ...option.OperationOption) operationContext { - for _, opt := range opts { - opt(oc.cfg) - } - return oc -} - -func (oc *operationContextImpl) build() openapi.OperationContext { - method := strings.ToUpper(oc.op.Method()) - path := oc.op.PathPattern() - - logger := oc.logger - - cfg := oc.cfg - if cfg == nil { - return nil - } - if cfg.Hide { - logger.LogAction("skip operation", fmt.Sprintf("%s %s", method, path)) - return nil - } - if cfg.Deprecated { - oc.op.SetIsDeprecated(true) - logger.LogOp(method, path, "set is deprecated", "true") - } - if cfg.OperationID != "" { - oc.op.SetID(cfg.OperationID) - logger.LogOp(method, path, "set operation ID", cfg.OperationID) - } - if cfg.Summary != "" { - oc.op.SetSummary(cfg.Summary) - logger.LogOp(method, path, "set summary", cfg.Summary) - } - if cfg.Description != "" { - oc.op.SetDescription(cfg.Description) - logger.LogOp(method, path, "set description", cfg.Description) - } - if len(cfg.Tags) > 0 { - oc.op.SetTags(cfg.Tags...) - logger.LogOp(method, path, "set tags", fmt.Sprintf("%v", cfg.Tags)) - } - if len(cfg.Security) > 0 { - for _, sec := range cfg.Security { - oc.op.AddSecurity(sec.Name, sec.Scopes...) - } - logger.LogOp(method, path, "set security", fmt.Sprintf("%v", cfg.Security)) - } - - for _, req := range cfg.Requests { - opts, value := oc.buildRequestOpts(req) - oc.op.AddReqStructure(oc.modifyReqStructure(req.Structure), opts...) - logger.LogOp(method, path, "add request", value) - } - - for _, resp := range mergeResponses(cfg.Responses) { - opts, value := oc.buildResponseOpts(resp) - oc.op.AddRespStructure(resp.Structure, opts...) - logger.LogOp(method, path, "add response", value) - } - - return oc.op -} - -// responseKey identifies a unique response slot by HTTP status and content type. -type responseKey struct { - httpStatus int - contentType string -} - -// mergeResponses groups responses by (HTTPStatus, ContentType) and combines -// duplicates into a single response using jsonschema.OneOf. -func mergeResponses(responses []*specopenapi.ContentUnit) []*specopenapi.ContentUnit { - if len(responses) <= 1 { - return responses - } - - type group struct { - key responseKey - items []*specopenapi.ContentUnit - } - - var order []responseKey - groups := make(map[responseKey]*group) - - for _, resp := range responses { - k := responseKey{httpStatus: resp.HTTPStatus, contentType: resp.ContentType} - g, exists := groups[k] - if !exists { - g = &group{key: k} - groups[k] = g - order = append(order, k) - } - g.items = append(g.items, resp) - } - - result := make([]*specopenapi.ContentUnit, 0, len(order)) - for _, k := range order { - g := groups[k] - if len(g.items) == 1 { - result = append(result, g.items[0]) - continue - } - - // Merge multiple structures into oneOf. - structures := make([]interface{}, 0, len(g.items)) - for _, item := range g.items { - structures = append(structures, item.Structure) - } - - merged := *g.items[0] // copy first entry for description, status, etc. - merged.Structure = jsonschema.OneOf(structures...) - result = append(result, &merged) - } - - return result -} - -func (oc *operationContextImpl) buildRequestOpts(req *specopenapi.ContentUnit) ([]openapi.ContentOption, string) { - log := fmt.Sprintf("%T", req.Structure) - var opts []openapi.ContentOption - if req.Description != "" { - opts = append(opts, func(cu *openapi.ContentUnit) { - cu.Description = req.Description - }) - log += fmt.Sprintf(" (%s)", req.Description) - } - if req.ContentType != "" { - opts = append(opts, openapi.WithContentType(req.ContentType)) - log += fmt.Sprintf(" (Content-Type: %s)", req.ContentType) - } - opts = append(opts, func(cu *openapi.ContentUnit) { - cu.Customize = func(cor openapi.ContentOrReference) { - switch v := cor.(type) { - case *openapi3.RequestBodyOrRef: - content := map[string]openapi3.MediaType{} - for k, val := range v.RequestBody.Content { - content[k] = *val.WithEncoding(mapper.StringMapToEncodingMap3(req.Encoding)) - } - v.RequestBody.WithContent(content) - case *openapi31.RequestBodyOrReference: - content := map[string]openapi31.MediaType{} - for k, val := range v.RequestBody.Content { - content[k] = *val.WithEncoding(mapper.StringMapToEncodingMap31(req.Encoding)) - } - v.RequestBody.WithContent(content) - } - } - }) - return opts, log -} - -func (oc *operationContextImpl) buildResponseOpts(resp *specopenapi.ContentUnit) ([]openapi.ContentOption, string) { - log := fmt.Sprintf("%T", resp.Structure) - var opts []openapi.ContentOption - if resp.IsDefault { - opts = append(opts, func(cu *openapi.ContentUnit) { - cu.IsDefault = true - }) - log += " (default)" - } - if resp.HTTPStatus != 0 { - opts = append(opts, openapi.WithHTTPStatus(resp.HTTPStatus)) - log += fmt.Sprintf(" (HTTP %d)", resp.HTTPStatus) - } - if resp.Description != "" { - opts = append(opts, func(cu *openapi.ContentUnit) { - cu.Description = resp.Description - }) - log += fmt.Sprintf(" (%s)", resp.Description) - } - if resp.ContentType != "" { - opts = append(opts, openapi.WithContentType(resp.ContentType)) - log += fmt.Sprintf(" (Content-Type: %s)", resp.ContentType) - } - return opts, log -} - -func (oc *operationContextImpl) modifyReqStructure(structure any) any { - if len(oc.parameterTagMapping) == 0 { - return structure - } - - t := reflect.TypeOf(structure) - if t == nil { - return structure - } - if t.Kind() == reflect.Ptr { - t = t.Elem() - } - - // Only structs are supported for parameter tag modification - if t.Kind() != reflect.Struct { - return structure - } - - fields, modified := oc.buildModifiedFields(t) - if !modified { - return structure - } - - // Create new struct type with modified fields - newType := reflect.StructOf(fields) - return reflect.New(newType).Interface() -} - -// buildModifiedFields processes struct fields and applies parameter tag mappings. -func (oc *operationContextImpl) buildModifiedFields(t reflect.Type) ([]reflect.StructField, bool) { - fields := make([]reflect.StructField, 0, t.NumField()) - modified := false - - for i := 0; i < t.NumField(); i++ { - field := t.Field(i) - originalField := field - - // Apply parameter tag mappings - for paramIn, sourceTag := range oc.parameterTagMapping { - if oc.shouldApplyMapping(field, sourceTag, string(paramIn)) { - field.Tag = oc.buildNewTag(field.Tag, sourceTag, string(paramIn)) - modified = true - } - } - - fields = append(fields, field) - - // Log if field was modified (for debugging) - if field.Tag != originalField.Tag { - oc.logger.LogAction("modified field tag", - fmt.Sprintf("field=%s, original=%q, new=%q", - field.Name, originalField.Tag, field.Tag)) - } - } - - return fields, modified -} - -// shouldApplyMapping determines if a parameter tag mapping should be applied to a field. -func (oc *operationContextImpl) shouldApplyMapping(field reflect.StructField, sourceTag, targetTag string) bool { - // Only apply if source tag exists and target tag doesn't exist - return field.Tag.Get(sourceTag) != "" && field.Tag.Get(targetTag) == "" -} - -// buildNewTag constructs a new struct tag by adding the mapped parameter tag. -func (oc *operationContextImpl) buildNewTag( - originalTag reflect.StructTag, - sourceTag, targetTag string, -) reflect.StructTag { - sourceValue := originalTag.Get(sourceTag) - if sourceValue == "" { - return originalTag - } - - // Parse existing tag string and add new tag - tagStr := string(originalTag) - if tagStr != "" && !strings.HasSuffix(tagStr, " ") { - tagStr += " " - } - - // Escape quotes in the tag value - escapedValue := strings.ReplaceAll(sourceValue, `"`, `\"`) - newTag := fmt.Sprintf(`%s%s:"%s"`, tagStr, targetTag, escapedValue) - - return reflect.StructTag(newTag) -} diff --git a/option/content.go b/option/content.go index 8c93367..d76da42 100644 --- a/option/content.go +++ b/option/content.go @@ -1,34 +1,26 @@ package option -import ( - "github.com/oaswrap/spec/openapi" - "github.com/oaswrap/spec/pkg/util" -) +import "github.com/oaswrap/spec/openapi" -// ContentOption is a function that modifies the ContentUnit. -type ContentOption func(cu *openapi.ContentUnit) +// ContentOption mutates request/response content generation. +type ContentOption func(*openapi.ContentUnit) -// ContentType sets the content type for the OpenAPI content. +// ContentType sets media type, for example `application/json`. func ContentType(contentType string) ContentOption { - return func(cu *openapi.ContentUnit) { - cu.ContentType = contentType - } + return func(cu *openapi.ContentUnit) { cu.ContentType = contentType } } -// ContentDescription sets the description for the OpenAPI content. +// ContentDescription sets request-body/response description. func ContentDescription(description string) ContentOption { - return func(cu *openapi.ContentUnit) { - cu.Description = description - } + return func(cu *openapi.ContentUnit) { cu.Description = description } } -// ContentDefault sets whether this content unit is the default response. +// ContentDefault marks a response as the `default` response. func ContentDefault(isDefault ...bool) ContentOption { - return func(cu *openapi.ContentUnit) { - cu.IsDefault = util.Optional(true, isDefault...) - } + return func(cu *openapi.ContentUnit) { cu.IsDefault = optional(true, isDefault...) } } +// ContentEncoding sets encoding metadata for a body property. func ContentEncoding(prop, enc string) ContentOption { return func(cu *openapi.ContentUnit) { if cu.Encoding == nil { @@ -37,3 +29,79 @@ func ContentEncoding(prop, enc string) ContentOption { cu.Encoding[prop] = enc } } + +// ContentExample sets the media type example value. +func ContentExample(value any) ContentOption { + return func(cu *openapi.ContentUnit) { cu.Example = value } +} + +// ExampleOption mutates an example object. +type ExampleOption func(*openapi.Example) + +// ContentNamedExample adds one named media type example. +func ContentNamedExample(name string, value any, opts ...ExampleOption) ContentOption { + return func(cu *openapi.ContentUnit) { + if cu.Examples == nil { + cu.Examples = map[string]*openapi.Example{} + } + example := &openapi.Example{Value: value} + for _, opt := range opts { + opt(example) + } + cu.Examples[name] = example + } +} + +// ContentExamples sets the media type examples map. +func ContentExamples(examples map[string]*openapi.Example) ContentOption { + return func(cu *openapi.ContentUnit) { cu.Examples = examples } +} + +// ExampleSummary sets example summary. +func ExampleSummary(summary string) ExampleOption { + return func(example *openapi.Example) { example.Summary = summary } +} + +// ExampleDescription sets example description. +func ExampleDescription(description string) ExampleOption { + return func(example *openapi.Example) { example.Description = description } +} + +// ExampleExternalValue sets example externalValue and clears value fields. +func ExampleExternalValue(url string) ExampleOption { + return func(example *openapi.Example) { + example.Value = nil + example.DataValue = nil + example.ExternalValue = url + } +} + +// ExampleDataValue sets example dataValue and clears other value fields. +// `dataValue` is only valid for OpenAPI 3.2.0. +func ExampleDataValue(value any) ExampleOption { + return func(example *openapi.Example) { + example.Value = nil + example.ExternalValue = "" + example.DataValue = value + } +} + +// ExampleSerializedValue sets example serializedValue and clears value fields. +// `serializedValue` is only valid for OpenAPI 3.2.0. +func ExampleSerializedValue(value string) ExampleOption { + return func(example *openapi.Example) { + example.Value = nil + example.DataValue = nil + example.SerializedValue = value + } +} + +// ContentRequired marks request body as required. +func ContentRequired(required ...bool) ContentOption { + return func(cu *openapi.ContentUnit) { cu.Required = optional(true, required...) } +} + +// ContentFormat sets schema format for reflected payload. +func ContentFormat(format string) ContentOption { + return func(cu *openapi.ContentUnit) { cu.Format = format } +} diff --git a/option/content_test.go b/option/content_test.go deleted file mode 100644 index 763fd3a..0000000 --- a/option/content_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package option_test - -import ( - "testing" - - "github.com/oaswrap/spec/openapi" - "github.com/oaswrap/spec/option" - "github.com/stretchr/testify/assert" -) - -func TestContentOption(t *testing.T) { - tests := []struct { - name string - httpStatus int - opts []option.ContentOption - expected openapi.ContentUnit - }{ - { - name: "empty options", - httpStatus: 0, - opts: []option.ContentOption{}, - expected: openapi.ContentUnit{ - HTTPStatus: 0, - }, - }, - { - name: "with content type", - httpStatus: 200, - opts: []option.ContentOption{ - option.ContentType("application/json"), - }, - expected: openapi.ContentUnit{ - HTTPStatus: 200, - ContentType: "application/json", - }, - }, - { - name: "with description", - httpStatus: 200, - opts: []option.ContentOption{ - option.ContentDescription("This is a response"), - }, - expected: openapi.ContentUnit{ - HTTPStatus: 200, - Description: "This is a response", - }, - }, - { - name: "with default flag", - httpStatus: 200, - opts: []option.ContentOption{ - option.ContentDefault(true), - }, - expected: openapi.ContentUnit{ - HTTPStatus: 200, - IsDefault: true, - }, - }, - { - name: "with multiple options", - httpStatus: 200, - opts: []option.ContentOption{ - option.ContentType("application/json"), - option.ContentDescription("This is a response"), - option.ContentDefault(true), - }, - expected: openapi.ContentUnit{ - HTTPStatus: 200, - ContentType: "application/json", - Description: "This is a response", - IsDefault: true, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config := &openapi.ContentUnit{ - HTTPStatus: tt.httpStatus, - } - for _, opt := range tt.opts { - opt(config) - } - assert.Equal(t, tt.expected, *config) - }) - } -} diff --git a/option/doc.go b/option/doc.go deleted file mode 100644 index 2ddf180..0000000 --- a/option/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package option provides functional options for configuring OpenAPI generation, -// including server setup, group settings, operation options, and reflector behavior. -package option diff --git a/option/group.go b/option/group.go index 4254b77..ad92d60 100644 --- a/option/group.go +++ b/option/group.go @@ -1,53 +1,34 @@ package option -import "github.com/oaswrap/spec/pkg/util" - -// GroupConfig defines configuration options for a group of routes in an OpenAPI specification. +// GroupConfig stores effective group-level settings. type GroupConfig struct { + Hide bool + Deprecated bool Tags []string Security []OperationSecurityConfig - Deprecated bool - Hide bool } -// GroupOption applies a configuration option to a GroupConfig. +// GroupOption mutates route-group behavior. type GroupOption func(*GroupConfig) -// GroupTags sets one or more tags for the group. -// -// These tags will be added to all routes in the sub-router. -func GroupTags(tags ...string) GroupOption { - return func(cfg *GroupConfig) { - cfg.Tags = append(cfg.Tags, tags...) - } +// GroupHidden skips emitting all operations within the group scope. +func GroupHidden(hide ...bool) GroupOption { + return func(cfg *GroupConfig) { cfg.Hide = optional(true, hide...) } } -// GroupSecurity adds a security scheme to the group. -// -// The security scheme will apply to all routes in the sub-router. -func GroupSecurity(securityName string, scopes ...string) GroupOption { - return func(cfg *GroupConfig) { - cfg.Security = append(cfg.Security, OperationSecurityConfig{ - Name: securityName, - Scopes: scopes, - }) - } +// GroupDeprecated marks all operations in the group deprecated. +func GroupDeprecated(deprecated ...bool) GroupOption { + return func(cfg *GroupConfig) { cfg.Deprecated = optional(true, deprecated...) } } -// GroupHidden sets whether the group should be hidden. -// -// If true, the group and its routes will be excluded from the OpenAPI output. -func GroupHidden(hidden ...bool) GroupOption { - return func(cfg *GroupConfig) { - cfg.Hide = util.Optional(true, hidden...) - } +// GroupTags appends tags to all operations in the group. +func GroupTags(tags ...string) GroupOption { + return func(cfg *GroupConfig) { cfg.Tags = append(cfg.Tags, tags...) } } -// GroupDeprecated sets whether the group is deprecated. -// -// If true, all routes in the group will be marked as deprecated in the OpenAPI output. -func GroupDeprecated(deprecated ...bool) GroupOption { +// GroupSecurity appends one security requirement to all operations in the group. +func GroupSecurity(name string, scopes ...string) GroupOption { return func(cfg *GroupConfig) { - cfg.Deprecated = util.Optional(true, deprecated...) + cfg.Security = append(cfg.Security, OperationSecurityConfig{Name: name, Scopes: scopes}) } } diff --git a/option/group_test.go b/option/group_test.go deleted file mode 100644 index fd45985..0000000 --- a/option/group_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package option_test - -import ( - "testing" - - "github.com/oaswrap/spec/option" - "github.com/stretchr/testify/assert" -) - -func TestGroupTags(t *testing.T) { - t.Run("adds single tag", func(t *testing.T) { - cfg := &option.GroupConfig{} - opt := option.GroupTags("auth") - opt(cfg) - - assert.Equal(t, []string{"auth"}, cfg.Tags) - }) - - t.Run("adds multiple tags", func(t *testing.T) { - cfg := &option.GroupConfig{} - opt := option.GroupTags("auth", "user", "admin") - opt(cfg) - - assert.Equal(t, []string{"auth", "user", "admin"}, cfg.Tags) - }) - - t.Run("appends to existing tags", func(t *testing.T) { - cfg := &option.GroupConfig{Tags: []string{"existing"}} - opt := option.GroupTags("new") - opt(cfg) - - assert.Equal(t, []string{"existing", "new"}, cfg.Tags) - }) -} - -func TestGroupSecurity(t *testing.T) { - t.Run("adds security without scopes", func(t *testing.T) { - cfg := &option.GroupConfig{} - opt := option.GroupSecurity("oauth2") - opt(cfg) - - expected := []option.OperationSecurityConfig{ - {Name: "oauth2"}, - } - assert.Equal(t, expected, cfg.Security) - }) - - t.Run("adds security with scopes", func(t *testing.T) { - cfg := &option.GroupConfig{} - opt := option.GroupSecurity("oauth2", "read", "write") - opt(cfg) - - expected := []option.OperationSecurityConfig{ - {Name: "oauth2", Scopes: []string{"read", "write"}}, - } - assert.Equal(t, expected, cfg.Security) - }) - - t.Run("appends to existing security", func(t *testing.T) { - cfg := &option.GroupConfig{ - Security: []option.OperationSecurityConfig{ - {Name: "existing", Scopes: []string{"scope1"}}, - }, - } - opt := option.GroupSecurity("oauth2", "read") - opt(cfg) - - expected := []option.OperationSecurityConfig{ - {Name: "existing", Scopes: []string{"scope1"}}, - {Name: "oauth2", Scopes: []string{"read"}}, - } - assert.Equal(t, expected, cfg.Security) - }) -} - -func TestGroupHidden(t *testing.T) { - t.Run("hides route by default", func(t *testing.T) { - cfg := &option.GroupConfig{} - opt := option.GroupHidden() - opt(cfg) - - assert.True(t, cfg.Hide) - }) - - t.Run("hides route when true", func(t *testing.T) { - cfg := &option.GroupConfig{} - opt := option.GroupHidden(true) - opt(cfg) - - assert.True(t, cfg.Hide) - }) - - t.Run("shows route when false", func(t *testing.T) { - cfg := &option.GroupConfig{} - opt := option.GroupHidden(false) - opt(cfg) - - assert.False(t, cfg.Hide) - }) - - t.Run("uses first value when multiple provided", func(t *testing.T) { - cfg := &option.GroupConfig{} - opt := option.GroupHidden(false, true, false) - opt(cfg) - - assert.False(t, cfg.Hide) - }) -} - -func TestGroupDeprecated(t *testing.T) { - t.Run("deprecated route by default", func(t *testing.T) { - cfg := &option.GroupConfig{} - opt := option.GroupDeprecated() - opt(cfg) - - assert.True(t, cfg.Deprecated) - }) - - t.Run("deprecated route when true", func(t *testing.T) { - cfg := &option.GroupConfig{} - opt := option.GroupDeprecated(true) - opt(cfg) - - assert.True(t, cfg.Deprecated) - }) - - t.Run("not deprecated route when false", func(t *testing.T) { - cfg := &option.GroupConfig{} - opt := option.GroupDeprecated(false) - opt(cfg) - - assert.False(t, cfg.Deprecated) - }) - - t.Run("uses first value when multiple provided", func(t *testing.T) { - cfg := &option.GroupConfig{} - opt := option.GroupDeprecated(false, true, false) - opt(cfg) - - assert.False(t, cfg.Deprecated) - }) -} diff --git a/option/openapi.go b/option/openapi.go index 874f31d..538f236 100644 --- a/option/openapi.go +++ b/option/openapi.go @@ -1,8 +1,6 @@ package option import ( - "log" //nolint:depguard // Use standard log package for simplicity. - specui "github.com/oaswrap/spec-ui" "github.com/oaswrap/spec-ui/config" "github.com/oaswrap/spec-ui/rapidoc" @@ -11,96 +9,142 @@ import ( "github.com/oaswrap/spec-ui/stoplight" "github.com/oaswrap/spec-ui/swaggerui" "github.com/oaswrap/spec/openapi" - "github.com/oaswrap/spec/pkg/util" ) -// OpenAPIOption defines a function that applies configuration to an OpenAPI Config. +// OpenAPIOption mutates root generator configuration. type OpenAPIOption func(*openapi.Config) -// WithOpenAPIConfig creates a new OpenAPI configuration with the provided options. -// It initializes the configuration with default values and applies the provided options. +// WithOpenAPIConfig builds config with defaults and applies options in order. +// +// Example: +// +// cfg := option.WithOpenAPIConfig( +// option.WithOpenAPIVersion(openapi.Version312), +// option.WithTitle("Payments API"), +// option.WithVersion("1.2.0"), +// ) func WithOpenAPIConfig(opts ...OpenAPIOption) *openapi.Config { cfg := &openapi.Config{ - OpenAPIVersion: "3.0.3", + OpenAPIVersion: openapi.Version304, Title: "API Documentation", - Description: nil, - Logger: &noopLogger{}, + Version: "1.0.0", DocsPath: "/docs", SpecPath: "/docs/openapi.yaml", } - for _, opt := range opts { opt(cfg) } - return cfg } -// WithOpenAPIVersion sets the OpenAPI version for the documentation. -// -// The default version is "3.0.3". -// Supported versions are "3.0.3" and "3.1.0". +// WithOpenAPIVersion sets the OpenAPI document version. func WithOpenAPIVersion(version string) OpenAPIOption { - return func(c *openapi.Config) { - c.OpenAPIVersion = version - } + return func(c *openapi.Config) { c.OpenAPIVersion = version } +} + +// WithSelf sets the OpenAPI 3.2.0 `$self` URI reference. +// It is only valid when `openapi` is `3.2.0`. +func WithSelf(self string) OpenAPIOption { + return func(c *openapi.Config) { c.Self = self } +} + +// WithJSONSchemaDialect sets the root `jsonSchemaDialect`. +// It is only valid for OpenAPI 3.1.x and 3.2.0. +func WithJSONSchemaDialect(uri string) OpenAPIOption { + return func(c *openapi.Config) { c.JSONSchemaDialect = uri } } -// WithTitle sets the title for the OpenAPI documentation. +// WithTitle sets `info.title`. func WithTitle(title string) OpenAPIOption { - return func(c *openapi.Config) { - c.Title = title - } + return func(c *openapi.Config) { c.Title = title } +} + +// WithInfoSummary sets `info.summary`. +func WithInfoSummary(summary string) OpenAPIOption { + return func(c *openapi.Config) { c.InfoSummary = summary } } -// WithVersion sets the version for the OpenAPI documentation. +// WithVersion sets `info.version`. func WithVersion(version string) OpenAPIOption { - return func(c *openapi.Config) { - c.Version = version - } + return func(c *openapi.Config) { c.Version = version } } -// WithDescription sets the description for the OpenAPI documentation. +// WithDescription sets `info.description`. func WithDescription(description string) OpenAPIOption { - return func(c *openapi.Config) { - c.Description = &description - } + return func(c *openapi.Config) { c.Description = &description } } -// WithContact sets the contact information for the OpenAPI documentation. +// WithContact sets `info.contact`. func WithContact(contact openapi.Contact) OpenAPIOption { - return func(c *openapi.Config) { - c.Contact = &contact - } + return func(c *openapi.Config) { c.Contact = &contact } } -// WithLicense sets the license information for the OpenAPI documentation. +// WithLicense sets `info.license`. func WithLicense(license openapi.License) OpenAPIOption { - return func(c *openapi.Config) { - c.License = &license - } + return func(c *openapi.Config) { c.License = &license } } -// WithTermsOfService sets the terms of service URL for the OpenAPI documentation. +// WithTermsOfService sets `info.termsOfService`. func WithTermsOfService(terms string) OpenAPIOption { - return func(c *openapi.Config) { - c.TermsOfService = &terms - } + return func(c *openapi.Config) { c.TermsOfService = &terms } } -// WithTags adds tags to the OpenAPI documentation. +// WithTags appends root-level tags. func WithTags(tags ...openapi.Tag) OpenAPIOption { + return func(c *openapi.Config) { c.Tags = append(c.Tags, tags...) } +} + +// TagOption mutates a root-level tag. +type TagOption func(*openapi.Tag) + +// WithTag appends one root-level tag. +func WithTag(name string, opts ...TagOption) OpenAPIOption { return func(c *openapi.Config) { - c.Tags = append(c.Tags, tags...) + tag := openapi.Tag{Name: name} + for _, opt := range opts { + opt(&tag) + } + c.Tags = append(c.Tags, tag) } } -// WithServer adds a server to the OpenAPI documentation. +// TagSummary sets tag summary. +func TagSummary(summary string) TagOption { + return func(tag *openapi.Tag) { tag.Summary = summary } +} + +// TagDescription sets tag description. +func TagDescription(description string) TagOption { + return func(tag *openapi.Tag) { tag.Description = description } +} + +// TagExternalDocs sets tag external documentation. +func TagExternalDocs(url string, description ...string) TagOption { + return func(tag *openapi.Tag) { + docs := &openapi.ExternalDocs{URL: url} + if len(description) > 0 { + docs.Description = description[0] + } + tag.ExternalDocs = docs + } +} + +// TagParent sets the OpenAPI 3.2.0 tag parent. +// It is only valid when `openapi` is `3.2.0`. +func TagParent(parent string) TagOption { + return func(tag *openapi.Tag) { tag.Parent = parent } +} + +// TagKind sets the OpenAPI 3.2.0 tag kind. +// It is only valid when `openapi` is `3.2.0`. +func TagKind(kind string) TagOption { + return func(tag *openapi.Tag) { tag.Kind = kind } +} + +// WithServer appends a root server and applies server options. func WithServer(url string, opts ...ServerOption) OpenAPIOption { return func(c *openapi.Config) { - server := openapi.Server{ - URL: url, - } + server := openapi.Server{URL: url} for _, opt := range opts { opt(&server) } @@ -108,53 +152,64 @@ func WithServer(url string, opts ...ServerOption) OpenAPIOption { } } -// WithExternalDocs sets the external documentation for the OpenAPI documentation. +// WithExternalDocs sets root `externalDocs`. func WithExternalDocs(url string, description ...string) OpenAPIOption { return func(c *openapi.Config) { - externalDocs := &openapi.ExternalDocs{ - URL: url, - } + docs := &openapi.ExternalDocs{URL: url} if len(description) > 0 { - externalDocs.Description = description[0] + docs.Description = description[0] } - c.ExternalDocs = externalDocs + c.ExternalDocs = docs } } -// WithSecurity adds a security scheme to the OpenAPI documentation. +// WithSecurity registers a reusable named security scheme. // -// It can be used to define API key or HTTP Bearer authentication schemes. +// Example: +// +// r := spec.NewRouter( +// option.WithSecurity( +// "bearerAuth", +// option.SecurityHTTPBearer("bearer"), +// ), +// option.WithGlobalSecurity("bearerAuth"), +// ) func WithSecurity(name string, opts ...SecurityOption) OpenAPIOption { return func(c *openapi.Config) { - securityConfig := &securityConfig{} + s := &securityConfig{} for _, opt := range opts { - opt(securityConfig) + opt(s) } if c.SecuritySchemes == nil { - c.SecuritySchemes = make(map[string]*openapi.SecurityScheme) + c.SecuritySchemes = map[string]*openapi.SecurityScheme{} + } + scheme := s.scheme + if scheme != nil { + c.SecuritySchemes[name] = scheme } + } +} - switch { - case securityConfig.APIKey != nil: - c.SecuritySchemes[name] = &openapi.SecurityScheme{ - Description: securityConfig.Description, - APIKey: securityConfig.APIKey, - } - case securityConfig.HTTPBearer != nil: - c.SecuritySchemes[name] = &openapi.SecurityScheme{ - Description: securityConfig.Description, - HTTPBearer: securityConfig.HTTPBearer, - } - case securityConfig.Oauth2 != nil: - c.SecuritySchemes[name] = &openapi.SecurityScheme{ - Description: securityConfig.Description, - OAuth2: securityConfig.Oauth2, - } +// WithGlobalSecurity appends root security requirements. +func WithGlobalSecurity(name string, scopes ...string) OpenAPIOption { + return func(c *openapi.Config) { + if scopes == nil { + scopes = []string{} } + c.Security = append(c.Security, openapi.SecurityRequirement{name: scopes}) } } -// WithReflectorConfig applies custom configurations to the OpenAPI reflector. +// WithReflectorConfig mutates schema reflection settings. +// +// Example: +// +// r := spec.NewRouter( +// option.WithReflectorConfig( +// option.InlineRefs(), +// option.ParameterTagMapping(openapi.ParameterInPath, "uri"), +// ), +// ) func WithReflectorConfig(opts ...ReflectorOption) OpenAPIOption { return func(c *openapi.Config) { if c.ReflectorConfig == nil { @@ -166,31 +221,169 @@ func WithReflectorConfig(opts ...ReflectorOption) OpenAPIOption { } } -// WithDisableDocs disables the OpenAPI documentation. +// WithStripTrailingSlash toggles trimming of trailing slashes in route paths. +func WithStripTrailingSlash(strip ...bool) OpenAPIOption { + return func(c *openapi.Config) { c.StripTrailingSlash = optional(true, strip...) } +} + +// WithPathParser sets a custom route path parser. +func WithPathParser(parser openapi.PathParser) OpenAPIOption { + return func(c *openapi.Config) { c.PathParser = parser } +} + +// WithDocument applies a low-level mutation after routes and reflected schemas +// have been added and before validation/serialization. // -// If set to true, the OpenAPI documentation will not be served at the specified path. -// By default, this is false, meaning the documentation is enabled. -func WithDisableDocs(disable ...bool) OpenAPIOption { +// Example: +// +// r := spec.NewRouter( +// option.WithDocument(func(doc *openapi.Document) { +// doc.Extensions = map[string]any{"x-service": "billing"} +// }), +// ) +func WithDocument(fn func(*openapi.Document)) OpenAPIOption { return func(c *openapi.Config) { - c.DisableDocs = util.Optional(true, disable...) + if fn != nil { + c.DocumentCustomizers = append(c.DocumentCustomizers, fn) + } } } -// WithStripTrailingSlash removes trailing slashes from all registered operation paths -// before they are written to the spec. -// -// For example, "/pet/" becomes "/pet". -// The root path "/" is left unchanged. -func WithStripTrailingSlash(strip ...bool) OpenAPIOption { +// WithComponentSchema registers a reusable schema component. +func WithComponentSchema(name string, schema *openapi.Schema) OpenAPIOption { + return WithDocument(func(doc *openapi.Document) { + components := ensureComponents(doc) + if components.Schemas == nil { + components.Schemas = map[string]*openapi.Schema{} + } + components.Schemas[name] = schema + }) +} + +// WithComponentResponse registers a reusable response component. +func WithComponentResponse(name string, response *openapi.Response) OpenAPIOption { + return WithDocument(func(doc *openapi.Document) { + components := ensureComponents(doc) + if components.Responses == nil { + components.Responses = map[string]*openapi.Response{} + } + components.Responses[name] = response + }) +} + +// WithComponentParameter registers a reusable parameter component. +func WithComponentParameter(name string, parameter *openapi.Parameter) OpenAPIOption { + return WithDocument(func(doc *openapi.Document) { + components := ensureComponents(doc) + if components.Parameters == nil { + components.Parameters = map[string]*openapi.Parameter{} + } + components.Parameters[name] = parameter + }) +} + +// WithComponentExample registers a reusable example component. +func WithComponentExample(name string, example *openapi.Example) OpenAPIOption { + return WithDocument(func(doc *openapi.Document) { + components := ensureComponents(doc) + if components.Examples == nil { + components.Examples = map[string]*openapi.Example{} + } + components.Examples[name] = example + }) +} + +// WithComponentRequestBody registers a reusable request body component. +func WithComponentRequestBody(name string, requestBody *openapi.RequestBody) OpenAPIOption { + return WithDocument(func(doc *openapi.Document) { + components := ensureComponents(doc) + if components.RequestBodies == nil { + components.RequestBodies = map[string]*openapi.RequestBody{} + } + components.RequestBodies[name] = requestBody + }) +} + +// WithComponentHeader registers a reusable header component. +func WithComponentHeader(name string, header *openapi.Header) OpenAPIOption { + return WithDocument(func(doc *openapi.Document) { + components := ensureComponents(doc) + if components.Headers == nil { + components.Headers = map[string]*openapi.Header{} + } + components.Headers[name] = header + }) +} + +// WithComponentSecurityScheme registers a reusable security scheme component. +func WithComponentSecurityScheme(name string, scheme *openapi.SecurityScheme) OpenAPIOption { + return WithDocument(func(doc *openapi.Document) { + components := ensureComponents(doc) + if components.SecuritySchemes == nil { + components.SecuritySchemes = map[string]*openapi.SecurityScheme{} + } + components.SecuritySchemes[name] = scheme + }) +} + +// WithComponentLink registers a reusable link component. +func WithComponentLink(name string, link *openapi.Link) OpenAPIOption { + return WithDocument(func(doc *openapi.Document) { + components := ensureComponents(doc) + if components.Links == nil { + components.Links = map[string]*openapi.Link{} + } + components.Links[name] = link + }) +} + +// WithComponentCallback registers a reusable callback component. +func WithComponentCallback(name string, callback *openapi.Callback) OpenAPIOption { + return WithDocument(func(doc *openapi.Document) { + components := ensureComponents(doc) + if components.Callbacks == nil { + components.Callbacks = map[string]*openapi.Callback{} + } + components.Callbacks[name] = callback + }) +} + +// WithComponentPathItem registers a reusable path item component. +func WithComponentPathItem(name string, pathItem *openapi.PathItem) OpenAPIOption { + return WithDocument(func(doc *openapi.Document) { + components := ensureComponents(doc) + if components.PathItems == nil { + components.PathItems = map[string]*openapi.PathItem{} + } + components.PathItems[name] = pathItem + }) +} + +// WithComponentMediaType registers a reusable media type component. +// Media type components are only valid for OpenAPI 3.2.0. +func WithComponentMediaType(name string, mediaType *openapi.MediaType) OpenAPIOption { + return WithDocument(func(doc *openapi.Document) { + components := ensureComponents(doc) + if components.MediaTypes == nil { + components.MediaTypes = map[string]*openapi.MediaType{} + } + components.MediaTypes[name] = mediaType + }) +} + +func ensureComponents(doc *openapi.Document) *openapi.Components { + if doc.Components == nil { + doc.Components = &openapi.Components{} + } + return doc.Components +} + +func WithDisableDocs(disable ...bool) OpenAPIOption { return func(c *openapi.Config) { - c.StripTrailingSlash = util.Optional(true, strip...) + c.DisableDocs = optional(true, disable...) } } -// WithDocsPath sets the path for the OpenAPI documentation. -// -// This is the path where the OpenAPI documentation will be served. -// The default path is "/docs". func WithDocsPath(path string) OpenAPIOption { return func(c *openapi.Config) { c.DocsPath = path @@ -288,53 +481,3 @@ func WithRapiDoc(cfg ...config.RapiDoc) OpenAPIOption { c.UIOption = rapidoc.WithUI(uiCfg) } } - -// WithDebug enables or disables debug logging for OpenAPI operations. -// -// If debug is true, debug logging is enabled, otherwise it is disabled. -// By default, debug logging is disabled. -func WithDebug(debug ...bool) OpenAPIOption { - return func(c *openapi.Config) { - if util.Optional(true, debug...) { - c.Logger = log.Default() - } else { - c.Logger = &noopLogger{} - } - } -} - -// WithPathParser sets a custom path parser for the OpenAPI documentation. -// -// The parser must convert framework-style paths to OpenAPI-style parameter syntax. -// For example, a path like "/users/:id" should be converted to "/users/{id}". -// -// Example: -// -// // myCustomParser implements PathParser and converts ":param" to "{param}". -// type myCustomParser struct { -// re *regexp.Regexp -// } -// -// // newMyCustomParser creates an instance with a regexp for colon-prefixed params. -// func newMyCustomParser() *myCustomParser { -// return &myCustomParser{ -// re: regexp.MustCompile(`:([a-zA-Z_][a-zA-Z0-9_]*)`), -// } -// } -// -// // Parse replaces ":param" with "{param}" to match OpenAPI path syntax. -// func (p *myCustomParser) Parse(path string) (string, error) { -// return p.re.ReplaceAllString(path, "{$1}"), nil -// } -// -// // Example usage: -// opt := option.WithPathParser(newMyCustomParser()) -func WithPathParser(parser openapi.PathParser) OpenAPIOption { - return func(c *openapi.Config) { - c.PathParser = parser - } -} - -type noopLogger struct{} - -func (l noopLogger) Printf(_ string, _ ...any) {} diff --git a/option/openapi_test.go b/option/openapi_test.go deleted file mode 100644 index f75b18c..0000000 --- a/option/openapi_test.go +++ /dev/null @@ -1,855 +0,0 @@ -package option_test - -import ( - "testing" - - "github.com/oaswrap/spec-ui/config" - "github.com/oaswrap/spec-ui/swaggeruiemb" - "github.com/oaswrap/spec/openapi" - "github.com/oaswrap/spec/option" - "github.com/oaswrap/spec/pkg/util" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestWithOpenAPIConfig(t *testing.T) { - tests := []struct { - name string - opts []option.OpenAPIOption - validate func(t *testing.T, config *openapi.Config) - }{ - { - name: "default configuration", - opts: []option.OpenAPIOption{}, - validate: func(t *testing.T, config *openapi.Config) { - assert.Equal(t, "3.0.3", config.OpenAPIVersion) - assert.Equal(t, "API Documentation", config.Title) - assert.Equal(t, "/docs", config.DocsPath) - assert.Equal(t, "/docs/openapi.yaml", config.SpecPath) - assert.Nil(t, config.Description) - assert.NotNil(t, config.Logger) - assert.Empty(t, config.Version) - assert.Nil(t, config.Contact) - assert.Nil(t, config.License) - assert.Empty(t, config.TermsOfService) - assert.Empty(t, config.Tags) - assert.Empty(t, config.Servers) - assert.Nil(t, config.ExternalDocs) - assert.Nil(t, config.SecuritySchemes) - assert.Nil(t, config.ReflectorConfig) - assert.False(t, config.DisableDocs) - assert.Nil(t, config.SwaggerUIConfig) - assert.Nil(t, config.StoplightElementsConfig) - assert.Nil(t, config.ReDocConfig) - assert.Nil(t, config.ScalarConfig) - assert.Nil(t, config.RapiDocConfig) - assert.Nil(t, config.PathParser) - }, - }, - { - name: "single option", - opts: []option.OpenAPIOption{ - option.WithTitle("My Custom API"), - }, - validate: func(t *testing.T, config *openapi.Config) { - assert.Equal(t, "3.0.3", config.OpenAPIVersion) - assert.Equal(t, "My Custom API", config.Title) - assert.Nil(t, config.Description) - assert.NotNil(t, config.Logger) - }, - }, - { - name: "multiple options", - opts: []option.OpenAPIOption{ - option.WithOpenAPIVersion("3.1.0"), - option.WithTitle("Advanced API"), - option.WithVersion("2.0.0"), - option.WithDescription("A comprehensive API"), - option.WithCacheAge(86400), - }, - validate: func(t *testing.T, config *openapi.Config) { - assert.Equal(t, "3.1.0", config.OpenAPIVersion) - assert.Equal(t, "Advanced API", config.Title) - assert.Equal(t, "2.0.0", config.Version) - require.NotNil(t, config.Description) - assert.Equal(t, "A comprehensive API", *config.Description) - assert.Equal(t, 86400, *config.CacheAge) - }, - }, - { - name: "complex configuration", - opts: []option.OpenAPIOption{ - option.WithTitle("Complete API"), - option.WithVersion("1.0.0"), - option.WithDescription("Full-featured API"), - option.WithContact(openapi.Contact{ - Name: "Support", - Email: "support@example.com", - }), - option.WithLicense(openapi.License{ - Name: "MIT", - URL: "https://opensource.org/licenses/MIT", - }), - option.WithServer("https://api.example.com"), - option.WithTags(openapi.Tag{ - Name: "users", - Description: "User operations", - }), - option.WithTermsOfService("https://example.com/terms"), - option.WithDebug(true), - option.WithDisableDocs(false), - }, - validate: func(t *testing.T, config *openapi.Config) { - assert.Equal(t, "Complete API", config.Title) - assert.Equal(t, "1.0.0", config.Version) - require.NotNil(t, config.Description) - assert.Equal(t, "Full-featured API", *config.Description) - require.NotNil(t, config.Contact) - assert.Equal(t, "Support", config.Contact.Name) - assert.Equal(t, "support@example.com", config.Contact.Email) - require.NotNil(t, config.License) - assert.Equal(t, "MIT", config.License.Name) - assert.Equal(t, "https://opensource.org/licenses/MIT", config.License.URL) - require.Len(t, config.Servers, 1) - assert.Equal(t, "https://api.example.com", config.Servers[0].URL) - require.Len(t, config.Tags, 1) - assert.Equal(t, "users", config.Tags[0].Name) - assert.Equal(t, "User operations", config.Tags[0].Description) - assert.Equal(t, "https://example.com/terms", *config.TermsOfService) - assert.NotNil(t, config.Logger) - assert.False(t, config.DisableDocs) - }, - }, - { - name: "overriding defaults", - opts: []option.OpenAPIOption{ - option.WithOpenAPIVersion("3.1.0"), - option.WithTitle("Override Title"), - option.WithDescription("Override Description"), - option.WithDebug(false), - }, - validate: func(t *testing.T, config *openapi.Config) { - assert.Equal(t, "3.1.0", config.OpenAPIVersion) - assert.Equal(t, "Override Title", config.Title) - require.NotNil(t, config.Description) - assert.Equal(t, "Override Description", *config.Description) - assert.NotNil(t, config.Logger) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config := option.WithOpenAPIConfig(tt.opts...) - require.NotNil(t, config) - tt.validate(t, config) - }) - } -} - -func TestWithOpenAPIVersion(t *testing.T) { - config := &openapi.Config{} - opt := option.WithOpenAPIVersion("3.0.0") - opt(config) - - assert.Equal(t, "3.0.0", config.OpenAPIVersion) -} - -func TestWithDisableDocs(t *testing.T) { - tests := []struct { - name string - disable []bool - expected bool - }{ - {"default true", []bool{}, true}, - {"explicit true", []bool{true}, true}, - {"explicit false", []bool{false}, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config := &openapi.Config{} - opt := option.WithDisableDocs(tt.disable...) - opt(config) - - assert.Equal(t, tt.expected, config.DisableDocs) - }) - } -} - -func TestWithTitle(t *testing.T) { - config := &openapi.Config{} - opt := option.WithTitle("My API") - opt(config) - - assert.Equal(t, "My API", config.Title) -} - -func TestWithVersion(t *testing.T) { - config := &openapi.Config{} - opt := option.WithVersion("1.0.0") - opt(config) - - assert.Equal(t, "1.0.0", config.Version) -} - -func TestWithDescription(t *testing.T) { - config := &openapi.Config{} - opt := option.WithDescription("API description") - opt(config) - - require.NotNil(t, config.Description) - assert.Equal(t, "API description", *config.Description) -} - -func TestWithServer(t *testing.T) { - tests := []struct { - name string - url string - opts []option.ServerOption - expected openapi.Server - }{ - { - name: "without description", - url: "https://api.example.com", - expected: openapi.Server{ - URL: "https://api.example.com", - }, - }, - { - name: "with description", - url: "https://api.example.com", - opts: []option.ServerOption{option.ServerDescription("Production server")}, - expected: openapi.Server{ - URL: "https://api.example.com", - Description: util.PtrOf("Production server"), - }, - }, - { - name: "with variables", - url: "https://api.example.com", - opts: []option.ServerOption{ - option.ServerVariables(map[string]openapi.ServerVariable{ - "version": { - Default: "v1", - Description: "API version", - Enum: []string{"v1", "v2"}, - }, - }), - }, - expected: openapi.Server{ - URL: "https://api.example.com", - Variables: map[string]openapi.ServerVariable{ - "version": { - Default: "v1", - Description: "API version", - Enum: []string{"v1", "v2"}, - }, - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config := &openapi.Config{} - opt := option.WithServer(tt.url, tt.opts...) - opt(config) - - require.Len(t, config.Servers, 1) - assert.Equal(t, tt.expected.URL, config.Servers[0].URL) - if tt.expected.Description != nil { - require.NotNil(t, config.Servers[0].Description) - assert.Equal(t, *tt.expected.Description, *config.Servers[0].Description) - } else { - assert.Nil(t, config.Servers[0].Description) - } - }) - } -} - -func TestWithDocsPath(t *testing.T) { - config := &openapi.Config{} - opt := option.WithDocsPath("/docs") - opt(config) - - assert.Equal(t, "/docs", config.DocsPath) -} - -func TestWithSecurity(t *testing.T) { - tests := []struct { - name string - scheme string - opts []option.SecurityOption - expected *openapi.SecurityScheme - }{ - { - name: "API Key Scheme", - scheme: "apiKey", - opts: []option.SecurityOption{ - option.SecurityAPIKey("x-api-key", "header"), - option.SecurityDescription("API key for authentication"), - }, - expected: &openapi.SecurityScheme{ - Description: util.PtrOf("API key for authentication"), - APIKey: &openapi.SecuritySchemeAPIKey{ - Name: "x-api-key", - In: "header", - }, - }, - }, - { - name: "HTTP Bearer Scheme", - scheme: "bearerAuth", - opts: []option.SecurityOption{ - option.SecurityHTTPBearer("Bearer"), - option.SecurityDescription(""), - }, - expected: &openapi.SecurityScheme{ - HTTPBearer: &openapi.SecuritySchemeHTTPBearer{ - Scheme: "Bearer", - }, - }, - }, - { - name: "OAuth2 Scheme", - scheme: "oauth2", - opts: []option.SecurityOption{ - option.SecurityOAuth2(openapi.OAuthFlows{ - Implicit: &openapi.OAuthFlowsImplicit{ - AuthorizationURL: "https://auth.example.com/authorize", - Scopes: map[string]string{ - "read": "Read access", - "write": "Write access", - }, - }, - }), - }, - expected: &openapi.SecurityScheme{ - OAuth2: &openapi.SecuritySchemeOAuth2{ - Flows: openapi.OAuthFlows{ - Implicit: &openapi.OAuthFlowsImplicit{ - AuthorizationURL: "https://auth.example.com/authorize", - Scopes: map[string]string{ - "read": "Read access", - "write": "Write access", - }, - }, - }, - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config := &openapi.Config{} - opt := option.WithSecurity(tt.scheme, tt.opts...) - opt(config) - - require.NotNil(t, config.SecuritySchemes) - require.Len(t, config.SecuritySchemes, 1) - assert.Equal(t, tt.expected, config.SecuritySchemes[tt.scheme]) - }) - } -} - -func TestWithSwaggerUI(t *testing.T) { - tests := []struct { - name string - cfgs []config.SwaggerUI - expected *config.SwaggerUI - }{ - { - name: "no config", - cfgs: []config.SwaggerUI{}, - expected: &config.SwaggerUI{}, - }, - { - name: "empty config", - cfgs: []config.SwaggerUI{{}}, - expected: &config.SwaggerUI{}, - }, - { - name: "valid config", - cfgs: []config.SwaggerUI{ - { - HideCurl: false, - }, - }, - expected: &config.SwaggerUI{ - HideCurl: false, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config := &openapi.Config{} - opt := option.WithSwaggerUI(tt.cfgs...) - opt(config) - - assert.Equal(t, tt.expected, config.SwaggerUIConfig) - }) - } -} - -func TestWithStoplightElements(t *testing.T) { - tests := []struct { - name string - cfgs []config.StoplightElements - expected *config.StoplightElements - }{ - { - name: "no config", - cfgs: []config.StoplightElements{}, - expected: &config.StoplightElements{}, - }, - { - name: "empty config", - cfgs: []config.StoplightElements{{}}, - expected: &config.StoplightElements{}, - }, - { - name: "valid config", - cfgs: []config.StoplightElements{ - { - HideExport: true, - HideSchemas: true, - Logo: "https://example.com/logo.png", - Layout: "sidebar", - Router: "hash", - }, - }, - expected: &config.StoplightElements{ - HideExport: true, - HideSchemas: true, - Logo: "https://example.com/logo.png", - Layout: "sidebar", - Router: "hash", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config := &openapi.Config{} - opt := option.WithStoplightElements(tt.cfgs...) - opt(config) - - assert.Equal(t, tt.expected, config.StoplightElementsConfig) - }) - } -} - -func TestWithRedoc(t *testing.T) { - tests := []struct { - name string - cfgs []config.ReDoc - expected *config.ReDoc - }{ - { - name: "no config", - cfgs: []config.ReDoc{}, - expected: &config.ReDoc{}, - }, - { - name: "empty config", - cfgs: []config.ReDoc{}, - expected: &config.ReDoc{}, - }, - { - name: "valid config", - cfgs: []config.ReDoc{ - { - HideSearch: true, - HideDownloadButtons: true, - HideSchemaTitles: true, - }, - }, - expected: &config.ReDoc{ - HideSearch: true, - HideDownloadButtons: true, - HideSchemaTitles: true, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config := &openapi.Config{} - opt := option.WithReDoc(tt.cfgs...) - opt(config) - - assert.Equal(t, tt.expected, config.ReDocConfig) - }) - } -} - -func TestWithScalar(t *testing.T) { - tests := []struct { - name string - cfgs []config.Scalar - expected *config.Scalar - }{ - { - name: "no config", - cfgs: []config.Scalar{}, - expected: &config.Scalar{}, - }, - { - name: "empty config", - cfgs: []config.Scalar{{}}, - expected: &config.Scalar{}, - }, - { - name: "valid config", - cfgs: []config.Scalar{ - { - HideSidebar: true, - HideModels: true, - }, - }, - expected: &config.Scalar{ - HideSidebar: true, - HideModels: true, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config := &openapi.Config{} - opt := option.WithScalar(tt.cfgs...) - opt(config) - - assert.Equal(t, tt.expected, config.ScalarConfig) - }) - } -} - -func TestWithRapiDoc(t *testing.T) { - tests := []struct { - name string - cfgs []config.RapiDoc - expected *config.RapiDoc - }{ - { - name: "no config", - cfgs: []config.RapiDoc{}, - expected: &config.RapiDoc{}, - }, - { - name: "empty config", - cfgs: []config.RapiDoc{{}}, - expected: &config.RapiDoc{}, - }, - { - name: "valid config", - cfgs: []config.RapiDoc{ - { - Theme: "dark", - HideTryIt: true, - }, - }, - expected: &config.RapiDoc{ - Theme: "dark", - HideTryIt: true, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config := &openapi.Config{} - opt := option.WithRapiDoc(tt.cfgs...) - opt(config) - - assert.Equal(t, tt.expected, config.RapiDocConfig) - }) - } -} - -func TestWithContact(t *testing.T) { - tests := []struct { - name string - contact openapi.Contact - expected openapi.Contact - }{ - { - name: "full contact info", - contact: openapi.Contact{ - Name: "API Support", - URL: "https://example.com/support", - Email: "support@example.com", - }, - expected: openapi.Contact{ - Name: "API Support", - URL: "https://example.com/support", - Email: "support@example.com", - }, - }, - { - name: "minimal contact info", - contact: openapi.Contact{ - Name: "Support Team", - }, - expected: openapi.Contact{ - Name: "Support Team", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config := &openapi.Config{} - opt := option.WithContact(tt.contact) - opt(config) - - require.NotNil(t, config.Contact) - assert.Equal(t, tt.expected, *config.Contact) - }) - } -} - -func TestWithLicense(t *testing.T) { - tests := []struct { - name string - license openapi.License - expected openapi.License - }{ - { - name: "license with URL", - license: openapi.License{ - Name: "MIT", - URL: "https://opensource.org/licenses/MIT", - }, - expected: openapi.License{ - Name: "MIT", - URL: "https://opensource.org/licenses/MIT", - }, - }, - { - name: "license without URL", - license: openapi.License{ - Name: "Apache 2.0", - }, - expected: openapi.License{ - Name: "Apache 2.0", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config := &openapi.Config{} - opt := option.WithLicense(tt.license) - opt(config) - - require.NotNil(t, config.License) - assert.Equal(t, tt.expected, *config.License) - }) - } -} - -func TestWithExternalDocs(t *testing.T) { - tests := []struct { - name string - url string - desc []string - expected *openapi.ExternalDocs - }{ - { - name: "with description", - url: "https://example.com/docs", - desc: []string{"External documentation"}, - expected: &openapi.ExternalDocs{ - URL: "https://example.com/docs", - Description: "External documentation", - }, - }, - { - name: "without description", - url: "https://example.com/docs", - desc: []string{}, - expected: &openapi.ExternalDocs{ - URL: "https://example.com/docs", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config := &openapi.Config{} - opt := option.WithExternalDocs(tt.url, tt.desc...) - opt(config) - - require.NotNil(t, config.ExternalDocs) - assert.Equal(t, tt.expected.URL, config.ExternalDocs.URL) - assert.Equal(t, tt.expected.Description, config.ExternalDocs.Description) - }) - } -} - -func TestWithTags(t *testing.T) { - tags := []openapi.Tag{ - { - Name: "users", - Description: "User management", - }, - { - Name: "orders", - Description: "Order management", - }, - } - - config := &openapi.Config{} - opt := option.WithTags(tags...) - opt(config) - - require.Len(t, config.Tags, 2) - assert.Equal(t, "users", config.Tags[0].Name) - assert.Equal(t, "User management", config.Tags[0].Description) - assert.Equal(t, "orders", config.Tags[1].Name) - assert.Equal(t, "Order management", config.Tags[1].Description) -} - -func TestWithReflectorConfig(t *testing.T) { - tests := []struct { - name string - opts []option.ReflectorOption - validate func(t *testing.T, config *openapi.Config) - }{ - { - name: "creates new reflector config when nil", - opts: []option.ReflectorOption{}, - validate: func(t *testing.T, config *openapi.Config) { - require.NotNil(t, config.ReflectorConfig) - }, - }, - { - name: "applies single option", - opts: []option.ReflectorOption{ - option.InlineRefs(), - }, - validate: func(t *testing.T, config *openapi.Config) { - require.NotNil(t, config.ReflectorConfig) - assert.True(t, config.ReflectorConfig.InlineRefs) - }, - }, - { - name: "applies multiple options", - opts: []option.ReflectorOption{ - option.RequiredPropByValidateTag("validate", ","), - option.StripDefNamePrefix("MyPrefix"), - }, - validate: func(t *testing.T, config *openapi.Config) { - require.NotNil(t, config.ReflectorConfig) - assert.NotNil(t, config.ReflectorConfig.InterceptPropFunc) - assert.NotEmpty(t, config.ReflectorConfig.StripDefNamePrefix) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config := &openapi.Config{} - - // For the "preserves existing reflector config" test, pre-populate the config - if tt.name == "preserves existing reflector config" { - config.ReflectorConfig = &openapi.ReflectorConfig{} - } - - opt := option.WithReflectorConfig(tt.opts...) - opt(config) - - tt.validate(t, config) - }) - } -} - -func TestWithDebug(t *testing.T) { - config := &openapi.Config{} - opt := option.WithDebug(true) - opt(config) - - assert.NotNil(t, config.Logger) - - config = &openapi.Config{} - opt = option.WithDebug(false) - opt(config) - assert.NotNil(t, config.Logger) -} - -func TestWithPathParser(t *testing.T) { - // Mock path parser for testing - mockParser := &mockPathParser{} - - config := &openapi.Config{} - opt := option.WithPathParser(mockParser) - opt(config) - - assert.Equal(t, mockParser, config.PathParser) - assert.NotNil(t, config.PathParser) -} - -// mockPathParser is a test implementation of openapi.PathParser. -type mockPathParser struct{} - -func (m *mockPathParser) Parse(path string) (string, error) { - // Simple mock implementation that converts :param to {param} - return path, nil -} - -func TestOpenAPIWithUIOption(t *testing.T) { - config := &openapi.Config{} - opt := option.WithUIOption( - swaggeruiemb.WithUI(), - ) - opt(config) - - assert.NotNil(t, config.UIOption) - assert.Nil(t, config.SwaggerUIConfig) -} - -func TestOpnenAPIWithStripTrailingSlash(t *testing.T) { - config := &openapi.Config{} - opt := option.WithStripTrailingSlash(true) - opt(config) - - assert.True(t, config.StripTrailingSlash) -} - -func TestOpenAPIWithSpecPath(t *testing.T) { - config := &openapi.Config{} - opt := option.WithSpecPath("/openapi.yaml") - opt(config) - - assert.Equal(t, "/openapi.yaml", config.SpecPath) -} - -func TestOpenAPIConfigDefaults(t *testing.T) { - config := &openapi.Config{} - - // Test that default values are properly set - assert.Empty(t, config.OpenAPIVersion) - assert.False(t, config.DisableDocs) - assert.Empty(t, config.Title) - assert.Empty(t, config.Version) - assert.Nil(t, config.Description) - assert.Empty(t, config.Servers) - assert.Empty(t, config.DocsPath) - assert.Empty(t, config.SpecPath) - assert.Nil(t, config.SecuritySchemes) - assert.Nil(t, config.SwaggerUIConfig) - assert.Nil(t, config.StoplightElementsConfig) - assert.Nil(t, config.ReDocConfig) - assert.Nil(t, config.ScalarConfig) - assert.Nil(t, config.RapiDocConfig) - assert.Nil(t, config.Logger) - assert.Nil(t, config.Contact) - assert.Nil(t, config.License) - assert.Empty(t, config.Tags) - assert.Empty(t, config.TermsOfService) - assert.Nil(t, config.PathParser) -} diff --git a/option/operation.go b/option/operation.go index b6cc08e..ae975a6 100644 --- a/option/operation.go +++ b/option/operation.go @@ -1,59 +1,58 @@ package option -import ( - "github.com/oaswrap/spec/openapi" - "github.com/oaswrap/spec/pkg/util" -) +import "github.com/oaswrap/spec/openapi" -// OperationConfig holds configuration for an OpenAPI operation. +// OperationConfig stores effective operation-level settings. type OperationConfig struct { - Hide bool - OperationID string - Description string - Summary string - Deprecated bool - Tags []string - Security []OperationSecurityConfig - - Requests []*openapi.ContentUnit - Responses []*openapi.ContentUnit + Hide bool + OperationID string + Description string + Summary string + ExternalDocs *openapi.ExternalDocs + Deprecated bool + Tags []string + Security []OperationSecurityConfig + Requests []*openapi.ContentUnit + Responses []*openapi.ContentUnit + Customizers []func(*openapi.Operation) } -// OperationSecurityConfig defines a security requirement for an operation. +// OperationSecurityConfig describes one operation security requirement entry. type OperationSecurityConfig struct { Name string Scopes []string } -// OperationOption applies configuration to an OpenAPI operation. +// OperationOption mutates operation generation behavior. type OperationOption func(*OperationConfig) -// Hidden marks the operation as hidden in the OpenAPI documentation. -// -// This is useful for internal or non-public endpoints. +// Hidden skips emitting this operation. func Hidden(hide ...bool) OperationOption { - return func(cfg *OperationConfig) { - cfg.Hide = util.Optional(true, hide...) - } + return func(cfg *OperationConfig) { cfg.Hide = optional(true, hide...) } } -// OperationID sets the unique operation ID for the OpenAPI operation. +// OperationID sets `operationId`. func OperationID(id string) OperationOption { - return func(cfg *OperationConfig) { - cfg.OperationID = id - } + return func(cfg *OperationConfig) { cfg.OperationID = id } } -// Description sets the detailed description for the OpenAPI operation. +// Description sets operation description. func Description(description string) OperationOption { + return func(cfg *OperationConfig) { cfg.Description = description } +} + +// ExternalDocs sets operation external documentation. +func ExternalDocs(url string, description ...string) OperationOption { return func(cfg *OperationConfig) { - cfg.Description = description + docs := &openapi.ExternalDocs{URL: url} + if len(description) > 0 { + docs.Description = description[0] + } + cfg.ExternalDocs = docs } } -// Summary sets a short summary for the OpenAPI operation. -// -// If no description is set, the summary is also used as the description. +// Summary sets operation summary and, when empty, description. func Summary(summary string) OperationOption { return func(cfg *OperationConfig) { cfg.Summary = summary @@ -64,64 +63,62 @@ func Summary(summary string) OperationOption { } // Deprecated marks the operation as deprecated. -// -// Deprecated operations should not be used by clients. func Deprecated(deprecated ...bool) OperationOption { - return func(cfg *OperationConfig) { - cfg.Deprecated = util.Optional(true, deprecated...) - } + return func(cfg *OperationConfig) { cfg.Deprecated = optional(true, deprecated...) } } -// Tags adds tags to the OpenAPI operation. -// -// Tags help organize operations in the generated documentation. +// Tags appends operation tags. func Tags(tags ...string) OperationOption { + return func(cfg *OperationConfig) { cfg.Tags = append(cfg.Tags, tags...) } +} + +// Security appends one operation security requirement. +func Security(name string, scopes ...string) OperationOption { return func(cfg *OperationConfig) { - cfg.Tags = append(cfg.Tags, tags...) + cfg.Security = append(cfg.Security, OperationSecurityConfig{Name: name, Scopes: scopes}) } } -// Security adds a security requirement to the OpenAPI operation. +// Request appends one request content unit. // // Example: // -// r.Get("/me", -// option.Security("bearerAuth"), +// r.Post("/users", +// option.Request(new(CreateUserRequest)), // ) -func Security(securityName string, scopes ...string) OperationOption { - return func(cfg *OperationConfig) { - cfg.Security = append(cfg.Security, OperationSecurityConfig{ - Name: securityName, - Scopes: scopes, - }) - } -} - -// Request adds a request body or parameter structure to the OpenAPI operation. -func Request(structure any, options ...ContentOption) OperationOption { +func Request(structure any, opts ...ContentOption) OperationOption { return func(cfg *OperationConfig) { - cu := &openapi.ContentUnit{ - Structure: structure, - } - for _, opt := range options { + cu := &openapi.ContentUnit{Structure: structure} + for _, opt := range opts { opt(cu) } cfg.Requests = append(cfg.Requests, cu) } } -// Response adds a response for the OpenAPI operation. +// Response appends one response content unit. +// +// Example: // -// The HTTP status code defines which response is described. -func Response(httpStatus int, structure any, options ...ContentOption) OperationOption { +// r.Get("/users/{id}", +// option.Response(200, new(User)), +// option.Response(404, nil, option.ContentDescription("Not Found")), +// ) +func Response(httpStatus int, structure any, opts ...ContentOption) OperationOption { return func(cfg *OperationConfig) { - cu := &openapi.ContentUnit{ - HTTPStatus: httpStatus, - Structure: structure, - } - for _, opt := range options { + cu := &openapi.ContentUnit{HTTPStatus: httpStatus, Structure: structure} + for _, opt := range opts { opt(cu) } cfg.Responses = append(cfg.Responses, cu) } } + +// CustomizeOperation applies a low-level mutation to the generated operation. +func CustomizeOperation(fn func(*openapi.Operation)) OperationOption { + return func(cfg *OperationConfig) { + if fn != nil { + cfg.Customizers = append(cfg.Customizers, fn) + } + } +} diff --git a/option/operation_test.go b/option/operation_test.go deleted file mode 100644 index d844ca4..0000000 --- a/option/operation_test.go +++ /dev/null @@ -1,202 +0,0 @@ -package option_test - -import ( - "testing" - - "github.com/oaswrap/spec/option" - "github.com/stretchr/testify/assert" -) - -func TestHidden(t *testing.T) { - t.Run("default hidden true", func(t *testing.T) { - cfg := &option.OperationConfig{} - option.Hidden()(cfg) - assert.True(t, cfg.Hide) - }) - - t.Run("explicit hidden true", func(t *testing.T) { - cfg := &option.OperationConfig{} - option.Hidden(true)(cfg) - assert.True(t, cfg.Hide) - }) - - t.Run("explicit hidden false", func(t *testing.T) { - cfg := &option.OperationConfig{} - option.Hidden(false)(cfg) - assert.False(t, cfg.Hide) - }) -} - -func TestOperationID(t *testing.T) { - cfg := &option.OperationConfig{} - option.OperationID("test-operation")(cfg) - assert.Equal(t, "test-operation", cfg.OperationID) -} - -func TestDescription(t *testing.T) { - cfg := &option.OperationConfig{} - option.Description("Test description")(cfg) - assert.Equal(t, "Test description", cfg.Description) -} - -func TestSummary(t *testing.T) { - t.Run("summary only", func(t *testing.T) { - cfg := &option.OperationConfig{} - option.Summary("Test summary")(cfg) - assert.Equal(t, "Test summary", cfg.Summary) - assert.Equal(t, "Test summary", cfg.Description) - }) - - t.Run("summary with existing description", func(t *testing.T) { - cfg := &option.OperationConfig{Description: "Existing description"} - option.Summary("Test summary")(cfg) - assert.Equal(t, "Test summary", cfg.Summary) - assert.Equal(t, "Existing description", cfg.Description) - }) -} - -func TestDeprecated(t *testing.T) { - t.Run("default deprecated true", func(t *testing.T) { - cfg := &option.OperationConfig{} - option.Deprecated()(cfg) - assert.True(t, cfg.Deprecated) - }) - - t.Run("explicit deprecated true", func(t *testing.T) { - cfg := &option.OperationConfig{} - option.Deprecated(true)(cfg) - assert.True(t, cfg.Deprecated) - }) - - t.Run("explicit deprecated false", func(t *testing.T) { - cfg := &option.OperationConfig{} - option.Deprecated(false)(cfg) - assert.False(t, cfg.Deprecated) - }) -} - -func TestTags(t *testing.T) { - t.Run("single tag", func(t *testing.T) { - cfg := &option.OperationConfig{} - option.Tags("auth")(cfg) - assert.Equal(t, []string{"auth"}, cfg.Tags) - }) - - t.Run("multiple tags", func(t *testing.T) { - cfg := &option.OperationConfig{} - option.Tags("auth", "users", "admin")(cfg) - assert.Equal(t, []string{"auth", "users", "admin"}, cfg.Tags) - }) - - t.Run("append tags", func(t *testing.T) { - cfg := &option.OperationConfig{Tags: []string{"existing"}} - option.Tags("new")(cfg) - assert.Equal(t, []string{"existing", "new"}, cfg.Tags) - }) -} - -func TestSecurity(t *testing.T) { - t.Run("security without scopes", func(t *testing.T) { - cfg := &option.OperationConfig{} - option.Security("bearer")(cfg) - assert.Len(t, cfg.Security, 1) - assert.Equal(t, "bearer", cfg.Security[0].Name) - assert.Empty(t, cfg.Security[0].Scopes) - }) - - t.Run("security with scopes", func(t *testing.T) { - cfg := &option.OperationConfig{} - option.Security("oauth2", "read", "write")(cfg) - assert.Len(t, cfg.Security, 1) - assert.Equal(t, "oauth2", cfg.Security[0].Name) - assert.Equal(t, []string{"read", "write"}, cfg.Security[0].Scopes) - }) - - t.Run("multiple security configs", func(t *testing.T) { - cfg := &option.OperationConfig{} - option.Security("bearer")(cfg) - option.Security("oauth2", "read")(cfg) - assert.Len(t, cfg.Security, 2) - assert.Equal(t, "bearer", cfg.Security[0].Name) - assert.Equal(t, "oauth2", cfg.Security[1].Name) - }) -} - -func TestRequest(t *testing.T) { - type TestStruct struct { - ID int `json:"id"` - Name string `json:"name"` - } - - t.Run("request without options", func(t *testing.T) { - cfg := &option.OperationConfig{} - option.Request(TestStruct{})(cfg) - assert.Len(t, cfg.Requests, 1) - assert.Equal(t, TestStruct{}, cfg.Requests[0].Structure) - }) - - t.Run("multiple requests", func(t *testing.T) { - cfg := &option.OperationConfig{} - option.Request(TestStruct{})(cfg) - option.Request("string")(cfg) - assert.Len(t, cfg.Requests, 2) - assert.Equal(t, TestStruct{}, cfg.Requests[0].Structure) - assert.Equal(t, "string", cfg.Requests[1].Structure) - }) - - t.Run("request with content options", func(t *testing.T) { - cfg := &option.OperationConfig{} - option.Request(TestStruct{}, option.ContentType("application/json"))(cfg) - assert.Len(t, cfg.Requests, 1) - assert.Equal(t, "application/json", cfg.Requests[0].ContentType) - assert.Equal(t, TestStruct{}, cfg.Requests[0].Structure) - }) -} - -func TestResponse(t *testing.T) { - type TestStruct struct { - ID int `json:"id"` - Name string `json:"name"` - } - - t.Run("response without options", func(t *testing.T) { - cfg := &option.OperationConfig{} - option.Response(200, TestStruct{})(cfg) - assert.Len(t, cfg.Responses, 1) - assert.Equal(t, 200, cfg.Responses[0].HTTPStatus) - assert.Equal(t, TestStruct{}, cfg.Responses[0].Structure) - }) - - t.Run("multiple responses", func(t *testing.T) { - cfg := &option.OperationConfig{} - option.Response(200, TestStruct{})(cfg) - option.Response(400, "error")(cfg) - assert.Len(t, cfg.Responses, 2) - assert.Equal(t, 200, cfg.Responses[0].HTTPStatus) - assert.Equal(t, 400, cfg.Responses[1].HTTPStatus) - }) - - t.Run("response with content options", func(t *testing.T) { - cfg := &option.OperationConfig{} - option.Response(200, TestStruct{}, option.ContentType("application/json"))(cfg) - assert.Len(t, cfg.Responses, 1) - assert.Equal(t, 200, cfg.Responses[0].HTTPStatus) - assert.Equal(t, "application/json", cfg.Responses[0].ContentType) - assert.Equal(t, TestStruct{}, cfg.Responses[0].Structure) - }) -} - -func TestOperationConfig(t *testing.T) { - t.Run("default values", func(t *testing.T) { - cfg := &option.OperationConfig{} - assert.False(t, cfg.Hide) - assert.Empty(t, cfg.OperationID) - assert.Empty(t, cfg.Description) - assert.Empty(t, cfg.Summary) - assert.False(t, cfg.Deprecated) - assert.Nil(t, cfg.Tags) - assert.Nil(t, cfg.Security) - assert.Nil(t, cfg.Requests) - assert.Nil(t, cfg.Responses) - }) -} diff --git a/option/option_test.go b/option/option_test.go new file mode 100644 index 0000000..e0b4405 --- /dev/null +++ b/option/option_test.go @@ -0,0 +1,486 @@ +package option + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/oaswrap/spec-ui/config" + "github.com/oaswrap/spec/openapi" +) + +func TestWithOpenAPIConfig(t *testing.T) { + cfg := WithOpenAPIConfig( + WithOpenAPIVersion(openapi.Version312), + WithSelf("https://api.test.com/openapi.yaml"), + WithJSONSchemaDialect("https://json-schema.org/draft/2020-12/schema"), + WithTitle("Test API"), + WithInfoSummary("Short API summary"), + WithVersion("2.0.0"), + WithDescription("Test description"), + WithTermsOfService("https://example.com/terms"), + WithContact(openapi.Contact{Name: "Test Contact"}), + WithLicense(openapi.License{Name: "MIT"}), + WithTags(openapi.Tag{Name: "test"}), + WithServer("https://api.test.com", ServerDescription("Test server")), + WithExternalDocs("https://docs.test.com", "Test docs"), + WithSecurity("apiKey", SecurityAPIKey("X-API-Key", "header")), + WithGlobalSecurity("apiKey", "read", "write"), + WithStripTrailingSlash(true), + ) + + assert.Equal(t, openapi.Version312, cfg.OpenAPIVersion) + assert.Equal(t, "https://api.test.com/openapi.yaml", cfg.Self) + assert.Equal(t, "https://json-schema.org/draft/2020-12/schema", cfg.JSONSchemaDialect) + assert.Equal(t, "Test API", cfg.Title) + assert.Equal(t, "Short API summary", cfg.InfoSummary) + assert.Equal(t, "2.0.0", cfg.Version) + assert.Equal(t, "Test description", *cfg.Description) + assert.Equal(t, "https://example.com/terms", *cfg.TermsOfService) + assert.Equal(t, "Test Contact", cfg.Contact.Name) + assert.Equal(t, "MIT", cfg.License.Name) + + if assert.Len(t, cfg.Tags, 1) { + assert.Equal(t, "test", cfg.Tags[0].Name) + } + if assert.Len(t, cfg.Servers, 1) { + assert.Equal(t, "https://api.test.com", cfg.Servers[0].URL) + assert.Equal(t, "Test server", *cfg.Servers[0].Description) + } + assert.Equal(t, "https://docs.test.com", cfg.ExternalDocs.URL) + assert.Equal(t, "Test docs", cfg.ExternalDocs.Description) + assert.Equal(t, "apiKey", cfg.SecuritySchemes["apiKey"].Type) + if assert.Len(t, cfg.Security, 1) { + assert.Equal(t, "read", cfg.Security[0]["apiKey"][0]) + } + assert.True(t, cfg.StripTrailingSlash) + + t.Run("ReflectorConfig", func(t *testing.T) { + c := WithOpenAPIConfig(WithReflectorConfig(InlineRefs(true))) + assert.True(t, c.ReflectorConfig.InlineRefs) + }) + + t.Run("PathParser", func(t *testing.T) { + c := WithOpenAPIConfig(WithPathParser(nil)) + assert.Nil(t, c.PathParser) + }) + + t.Run("GlobalSecurityWithoutScopes", func(t *testing.T) { + c := WithOpenAPIConfig(WithGlobalSecurity("apiKey")) + if assert.Len(t, c.Security, 1) { + assert.Empty(t, c.Security[0]["apiKey"]) + } + }) + + t.Run("WithDocument", func(t *testing.T) { + called := false + c := WithOpenAPIConfig(WithDocument(func(_ *openapi.Document) { called = true })) + if assert.Len(t, c.DocumentCustomizers, 1) { + c.DocumentCustomizers[0](nil) + assert.True(t, called) + } + }) +} + +func TestTagOptions(t *testing.T) { + cfg := WithOpenAPIConfig( + WithTag("payments", + TagSummary("Payments"), + TagDescription("Payment operations"), + TagExternalDocs("https://docs.test/payments", "Payment docs"), + TagParent("commerce"), + TagKind("nav"), + ), + ) + + if assert.Len(t, cfg.Tags, 1) { + tag := cfg.Tags[0] + assert.Equal(t, "payments", tag.Name) + assert.Equal(t, "Payments", tag.Summary) + assert.Equal(t, "Payment operations", tag.Description) + assert.Equal(t, "https://docs.test/payments", tag.ExternalDocs.URL) + assert.Equal(t, "Payment docs", tag.ExternalDocs.Description) + assert.Equal(t, "commerce", tag.Parent) + assert.Equal(t, "nav", tag.Kind) + } +} + +func TestComponentOptions(t *testing.T) { + doc := &openapi.Document{} + cfg := WithOpenAPIConfig( + WithComponentSchema("User", &openapi.Schema{Type: "object"}), + WithComponentResponse("OK", &openapi.Response{Description: "OK"}), + WithComponentParameter("TraceID", &openapi.Parameter{Name: "X-Trace-ID", In: "header"}), + WithComponentExample("UserExample", &openapi.Example{Value: map[string]any{"id": "123"}}), + WithComponentRequestBody("CreateUser", &openapi.RequestBody{Content: map[string]openapi.MediaType{}}), + WithComponentHeader("RateLimit", &openapi.Header{Description: "Rate limit"}), + WithComponentSecurityScheme("ApiKey", &openapi.SecurityScheme{Type: "apiKey", Name: "X-API-Key", In: "header"}), + WithComponentLink("UserLink", &openapi.Link{OperationID: "getUser"}), + WithComponentCallback("Event", &openapi.Callback{}), + WithComponentPathItem("Users", &openapi.PathItem{}), + WithComponentMediaType("JSON", &openapi.MediaType{Schema: &openapi.Schema{Type: "object"}}), + ) + + for _, customize := range cfg.DocumentCustomizers { + customize(doc) + } + + assert.Equal(t, "object", doc.Components.Schemas["User"].Type) + assert.Equal(t, "OK", doc.Components.Responses["OK"].Description) + assert.Equal(t, "X-Trace-ID", doc.Components.Parameters["TraceID"].Name) + assert.NotNil(t, doc.Components.Examples["UserExample"]) + assert.NotNil(t, doc.Components.RequestBodies["CreateUser"]) + assert.Equal(t, "Rate limit", doc.Components.Headers["RateLimit"].Description) + assert.Equal(t, "apiKey", doc.Components.SecuritySchemes["ApiKey"].Type) + assert.Equal(t, "getUser", doc.Components.Links["UserLink"].OperationID) + assert.NotNil(t, doc.Components.Callbacks["Event"]) + assert.NotNil(t, doc.Components.PathItems["Users"]) + assert.NotNil(t, doc.Components.MediaTypes["JSON"]) +} + +func TestOperationOptions(t *testing.T) { + cfg := &OperationConfig{} + opts := []OperationOption{ + Hidden(true), + OperationID("testOp"), + Summary("Test Summary"), + Description("Test Description"), + ExternalDocs("https://docs.test/operation", "Operation docs"), + Deprecated(true), + Tags("tag1", "tag2"), + Security("auth", "scope1"), + Request(struct{}{}, ContentType("application/json")), + Response(200, struct{}{}, ContentDescription("OK")), + CustomizeOperation(func(op *openapi.Operation) { op.Summary = "Custom" }), + } + + for _, opt := range opts { + opt(cfg) + } + + assert.True(t, cfg.Hide) + assert.Equal(t, "testOp", cfg.OperationID) + assert.Equal(t, "Test Summary", cfg.Summary) + assert.Equal(t, "Test Description", cfg.Description) + assert.Equal(t, "https://docs.test/operation", cfg.ExternalDocs.URL) + assert.Equal(t, "Operation docs", cfg.ExternalDocs.Description) + assert.True(t, cfg.Deprecated) + assert.Equal(t, []string{"tag1", "tag2"}, cfg.Tags) + if assert.Len(t, cfg.Security, 1) { + assert.Equal(t, "auth", cfg.Security[0].Name) + assert.Equal(t, "scope1", cfg.Security[0].Scopes[0]) + } + if assert.Len(t, cfg.Requests, 1) { + assert.Equal(t, "application/json", cfg.Requests[0].ContentType) + } + if assert.Len(t, cfg.Responses, 1) { + assert.Equal(t, "OK", cfg.Responses[0].Description) + } + assert.Len(t, cfg.Customizers, 1) +} + +func TestGroupOptions(t *testing.T) { + cfg := &GroupConfig{} + opts := []GroupOption{ + GroupHidden(true), + GroupDeprecated(true), + GroupTags("g1", "g2"), + GroupSecurity("gauth", "gscope"), + } + + for _, opt := range opts { + opt(cfg) + } + + assert.True(t, cfg.Hide) + assert.True(t, cfg.Deprecated) + assert.Equal(t, []string{"g1", "g2"}, cfg.Tags) + if assert.Len(t, cfg.Security, 1) { + assert.Equal(t, "gauth", cfg.Security[0].Name) + assert.Equal(t, "gscope", cfg.Security[0].Scopes[0]) + } +} + +func TestReflectorOptions(t *testing.T) { + cfg := &openapi.ReflectorConfig{} + opts := []ReflectorOption{ + InlineRefs(true), + StripDefNamePrefix("Pre"), + InterceptDefName(func(_ reflect.Type, _ string) string { return "Intercepted" }), + TypeMapping(1, "one"), + ParameterTagMapping(openapi.ParameterInQuery, "q"), + } + + for _, opt := range opts { + opt(cfg) + } + + assert.True(t, cfg.InlineRefs) + if assert.Len(t, cfg.StripDefNamePrefix, 1) { + assert.Equal(t, "Pre", cfg.StripDefNamePrefix[0]) + } + assert.Equal(t, "Intercepted", cfg.InterceptDefName(nil, "")) + if assert.Len(t, cfg.TypeMappings, 1) { + assert.Equal(t, 1, cfg.TypeMappings[0].Src) + assert.Equal(t, "one", cfg.TypeMappings[0].Dst) + } + assert.Equal(t, "q", cfg.ParameterTagMapping[openapi.ParameterInQuery]) +} + +func TestSecurityOptions(t *testing.T) { + t.Run("APIKey", func(t *testing.T) { + cfg := &securityConfig{} + SecurityAPIKey("api_key", "query")(cfg) + assert.Equal(t, "apiKey", cfg.scheme.Type) + assert.Equal(t, "api_key", cfg.scheme.Name) + assert.Equal(t, openapi.SecuritySchemeAPIKeyInQuery, cfg.scheme.In) + }) + + t.Run("HTTPBearer", func(t *testing.T) { + cfg := &securityConfig{} + SecurityHTTPBearer("bearer", "JWT")(cfg) + assert.Equal(t, "http", cfg.scheme.Type) + assert.Equal(t, "bearer", cfg.scheme.Scheme) + assert.Equal(t, "JWT", *cfg.scheme.BearerFormat) + }) + + t.Run("OAuth2", func(t *testing.T) { + cfg := &securityConfig{} + flows := openapi.OAuthFlows{Implicit: &openapi.OAuthFlow{AuthorizationURL: "https://auth.com"}} + SecurityOAuth2(flows)(cfg) + assert.Equal(t, "oauth2", cfg.scheme.Type) + assert.Equal(t, "https://auth.com", cfg.scheme.Flows.Implicit.AuthorizationURL) + }) + + t.Run("OAuth2FlowHelpers", func(t *testing.T) { + scopes := map[string]string{"read": "Read access"} + + cfg := &securityConfig{} + SecurityOAuth2Implicit("https://auth.com/authorize", scopes, OAuthRefreshURL("https://auth.com/refresh"))(cfg) + assert.Equal(t, "https://auth.com/authorize", cfg.scheme.Flows.Implicit.AuthorizationURL) + assert.Equal(t, "https://auth.com/refresh", *cfg.scheme.Flows.Implicit.RefreshURL) + + cfg = &securityConfig{} + SecurityOAuth2Password("https://auth.com/token", nil)(cfg) + assert.Equal(t, "https://auth.com/token", cfg.scheme.Flows.Password.TokenURL) + assert.Empty(t, cfg.scheme.Flows.Password.Scopes) + + cfg = &securityConfig{} + SecurityOAuth2ClientCredentials("https://auth.com/token", scopes)(cfg) + assert.Equal(t, "https://auth.com/token", cfg.scheme.Flows.ClientCredentials.TokenURL) + + cfg = &securityConfig{} + SecurityOAuth2AuthorizationCode("https://auth.com/authorize", "https://auth.com/token", scopes)(cfg) + assert.Equal(t, "https://auth.com/authorize", cfg.scheme.Flows.AuthorizationCode.AuthorizationURL) + assert.Equal(t, "https://auth.com/token", cfg.scheme.Flows.AuthorizationCode.TokenURL) + + cfg = &securityConfig{} + SecurityOAuth2DeviceAuthorization("https://auth.com/device", "https://auth.com/token", scopes)(cfg) + assert.Equal(t, "https://auth.com/device", cfg.scheme.Flows.DeviceAuthorization.DeviceAuthorizationURL) + assert.Equal(t, "https://auth.com/token", cfg.scheme.Flows.DeviceAuthorization.TokenURL) + }) + + t.Run("MutualTLS", func(t *testing.T) { + cfg := &securityConfig{} + SecurityMutualTLS()(cfg) + assert.Equal(t, "mutualTLS", cfg.scheme.Type) + }) + + t.Run("OpenIDConnect", func(t *testing.T) { + cfg := &securityConfig{} + SecurityOpenIDConnect("https://oidc.com")(cfg) + assert.Equal(t, "openIdConnect", cfg.scheme.Type) + assert.Equal(t, "https://oidc.com", cfg.scheme.OpenIDConnectURL) + }) + + t.Run("Description", func(t *testing.T) { + cfg := &securityConfig{} + SecurityDescription("desc")(cfg) + SecurityAPIKey("k", "h")(cfg) + assert.Equal(t, "desc", *cfg.scheme.Description) + }) + + t.Run("OAuth2MetadataURL", func(t *testing.T) { + cfg := &securityConfig{} + SecurityOAuth2MetadataURL("https://issuer.test/.well-known/oauth-authorization-server")(cfg) + SecurityHTTPBearer("bearer")(cfg) + assert.Equal(t, "https://issuer.test/.well-known/oauth-authorization-server", cfg.scheme.OAuth2MetadataURL) + }) + + t.Run("Deprecated", func(t *testing.T) { + cfg := &securityConfig{} + SecurityDeprecated()(cfg) + assert.True(t, cfg.scheme.Deprecated) + SecurityDeprecated(false)(cfg) + assert.False(t, cfg.scheme.Deprecated) + }) +} + +func TestContentOptions(t *testing.T) { + cu := &openapi.ContentUnit{} + opts := []ContentOption{ + ContentType("text/plain"), + ContentDescription("Text"), + ContentDefault(true), + ContentEncoding("prop", "enc"), + ContentExample(map[string]any{"id": "123"}), + ContentNamedExample("named", map[string]any{"id": "456"}, ExampleSummary("Named")), + ContentRequired(true), + ContentFormat("binary"), + } + + for _, opt := range opts { + opt(cu) + } + + assert.Equal(t, "text/plain", cu.ContentType) + assert.Equal(t, "Text", cu.Description) + assert.True(t, cu.IsDefault) + assert.Equal(t, "enc", cu.Encoding["prop"]) + assert.Equal(t, map[string]any{"id": "123"}, cu.Example) + assert.Equal(t, "Named", cu.Examples["named"].Summary) + assert.Equal(t, map[string]any{"id": "456"}, cu.Examples["named"].Value) + assert.True(t, cu.Required) + assert.Equal(t, "binary", cu.Format) + + t.Run("ContentExamples", func(t *testing.T) { + content := &openapi.ContentUnit{} + examples := map[string]*openapi.Example{"external": {}} + ContentExamples(examples)(content) + assert.Same(t, examples["external"], content.Examples["external"]) + }) + + t.Run("ExampleExternalValue", func(t *testing.T) { + content := &openapi.ContentUnit{} + ContentNamedExample("external", "ignored", ExampleExternalValue("https://example.com/user.json"))(content) + assert.Nil(t, content.Examples["external"].Value) + assert.Equal(t, "https://example.com/user.json", content.Examples["external"].ExternalValue) + }) + + t.Run("ExampleSerializedValue", func(t *testing.T) { + content := &openapi.ContentUnit{} + ContentNamedExample("serialized", "ignored", ExampleSerializedValue(`{"id":"123"}`))(content) + assert.Nil(t, content.Examples["serialized"].Value) + assert.JSONEq(t, `{"id":"123"}`, content.Examples["serialized"].SerializedValue) + }) + + t.Run("ExampleDescriptionAndDataValue", func(t *testing.T) { + example := &openapi.Example{Value: "keep"} + ExampleDescription("Example desc")(example) + ExampleDataValue(map[string]any{"id": "123"})(example) + assert.Equal(t, "Example desc", example.Description) + assert.Nil(t, example.Value) + assert.Equal(t, map[string]any{"id": "123"}, example.DataValue) + assert.Empty(t, example.ExternalValue) + }) +} + +func TestServerOptions(t *testing.T) { + s := &openapi.Server{} + opts := []ServerOption{ + ServerDescription("Desc"), + ServerVariables(map[string]openapi.ServerVariable{"v": {Default: "d"}}), + } + + for _, opt := range opts { + opt(s) + } + + assert.Equal(t, "Desc", *s.Description) + assert.Equal(t, "d", s.Variables["v"].Default) +} + +func TestConfigAndUIOptions(t *testing.T) { + t.Run("Basic config setters", func(t *testing.T) { + cfg := &openapi.Config{} + + WithDisableDocs()(cfg) + assert.True(t, cfg.DisableDocs) + + WithDisableDocs(false)(cfg) + assert.False(t, cfg.DisableDocs) + + WithDocsPath("/custom/docs")(cfg) + assert.Equal(t, "/custom/docs", cfg.DocsPath) + + WithSpecPath("/custom/openapi.yaml")(cfg) + assert.Equal(t, "/custom/openapi.yaml", cfg.SpecPath) + + WithCacheAge(42)(cfg) + if assert.NotNil(t, cfg.CacheAge) { + assert.Equal(t, 42, *cfg.CacheAge) + } + }) + + t.Run("WithUIOption", func(t *testing.T) { + cfg := &openapi.Config{} + called := false + opt := func(*config.SpecUI) { called = true } + + WithUIOption(opt)(cfg) + if assert.NotNil(t, cfg.UIOption) { + cfg.UIOption(&config.SpecUI{}) + } + assert.True(t, called) + }) + + t.Run("Provider helpers", func(t *testing.T) { + t.Run("SwaggerUI", func(t *testing.T) { + cfg := &openapi.Config{} + WithSwaggerUI(config.SwaggerUI{HideCurl: true})(cfg) + assert.Equal(t, config.ProviderSwaggerUI, cfg.UIProvider) + if assert.NotNil(t, cfg.SwaggerUIConfig) { + assert.True(t, cfg.SwaggerUIConfig.HideCurl) + } + assert.NotNil(t, cfg.UIOption) + }) + + t.Run("StoplightElements", func(t *testing.T) { + cfg := &openapi.Config{} + WithStoplightElements(config.StoplightElements{HideTryIt: true})(cfg) + assert.Equal(t, config.ProviderStoplightElements, cfg.UIProvider) + if assert.NotNil(t, cfg.StoplightElementsConfig) { + assert.True(t, cfg.StoplightElementsConfig.HideTryIt) + } + assert.NotNil(t, cfg.UIOption) + }) + + t.Run("ReDoc", func(t *testing.T) { + cfg := &openapi.Config{} + WithReDoc(config.ReDoc{HideSearch: true})(cfg) + assert.Equal(t, config.ProviderReDoc, cfg.UIProvider) + if assert.NotNil(t, cfg.ReDocConfig) { + assert.True(t, cfg.ReDocConfig.HideSearch) + } + assert.NotNil(t, cfg.UIOption) + }) + + t.Run("Scalar", func(t *testing.T) { + cfg := &openapi.Config{} + WithScalar(config.Scalar{DarkMode: true})(cfg) + assert.Equal(t, config.ProviderScalar, cfg.UIProvider) + if assert.NotNil(t, cfg.ScalarConfig) { + assert.True(t, cfg.ScalarConfig.DarkMode) + } + assert.NotNil(t, cfg.UIOption) + }) + + t.Run("RapiDoc", func(t *testing.T) { + cfg := &openapi.Config{} + WithRapiDoc(config.RapiDoc{HideHeader: true})(cfg) + assert.Equal(t, config.ProviderRapiDoc, cfg.UIProvider) + if assert.NotNil(t, cfg.RapiDocConfig) { + assert.True(t, cfg.RapiDocConfig.HideHeader) + } + assert.NotNil(t, cfg.UIOption) + }) + }) +} + +func TestOptional(t *testing.T) { + assert.Equal(t, 1, optional(1)) + assert.Equal(t, 2, optional(1, 2)) + assert.Equal(t, "b", optional("a", "b")) + assert.False(t, optional(true, false)) +} diff --git a/option/reflector.go b/option/reflector.go index e163fc9..587f2dd 100644 --- a/option/reflector.go +++ b/option/reflector.go @@ -1,142 +1,44 @@ package option import ( - "strings" + "reflect" "github.com/oaswrap/spec/openapi" ) -// ReflectorOption defines a function that modifies the OpenAPI reflector configuration. +// ReflectorOption mutates schema reflection behavior. type ReflectorOption func(*openapi.ReflectorConfig) -// InlineRefs sets references to be inlined in the OpenAPI documentation. -// -// When enabled, references will be inlined instead of defined in the components section. -func InlineRefs() ReflectorOption { - return func(c *openapi.ReflectorConfig) { - c.InlineRefs = true - } -} - -// RootRef sets whether to use a root reference in the OpenAPI documentation. -// -// When enabled, the root schema will be used as a shared reference for all schemas. -func RootRef() ReflectorOption { - return func(c *openapi.ReflectorConfig) { - c.RootRef = true - } -} - -// RootNullable sets whether root schemas are allowed to be nullable. -// -// When enabled, root schemas can be nullable in the OpenAPI documentation. -func RootNullable() ReflectorOption { - return func(c *openapi.ReflectorConfig) { - c.RootNullable = true - } +// InlineRefs toggles inlining referenced schemas. +func InlineRefs(inline ...bool) ReflectorOption { + return func(cfg *openapi.ReflectorConfig) { cfg.InlineRefs = optional(true, inline...) } } -// StripDefNamePrefix specifies one or more prefixes to strip from schema definition names. +// StripDefNamePrefix appends prefixes removed from reflected definition names. func StripDefNamePrefix(prefixes ...string) ReflectorOption { - return func(c *openapi.ReflectorConfig) { - c.StripDefNamePrefix = append(c.StripDefNamePrefix, prefixes...) - } -} - -// InterceptDefNameFunc sets a custom function for generating schema definition names. -// -// The provided function is called with the type and the default definition name, -// and should return the desired name. -func InterceptDefNameFunc(fn openapi.InterceptDefNameFunc) ReflectorOption { - return func(c *openapi.ReflectorConfig) { - c.InterceptDefNameFunc = fn + return func(cfg *openapi.ReflectorConfig) { + cfg.StripDefNamePrefix = append(cfg.StripDefNamePrefix, prefixes...) } } -// InterceptPropFunc sets a custom function for generating property schemas. -// -// The provided function is called with the parameters for property schema generation. -func InterceptPropFunc(fn openapi.InterceptPropFunc) ReflectorOption { - return func(c *openapi.ReflectorConfig) { - c.InterceptPropFunc = fn - } -} - -// RequiredPropByValidateTag marks properties as required based on a struct validation tag. -// -// By default, it uses the `validate` tag and looks for the "required" keyword. -// -// You can override the tag name and separator by providing them: -// - First argument: tag name (default "validate") -// - Second argument: separator (default ",") -// -// Example: -// -// option.WithReflectorConfig(option.RequiredPropByValidateTag()) -func RequiredPropByValidateTag(tags ...string) ReflectorOption { - return InterceptPropFunc(func(params openapi.InterceptPropParams) error { - if !params.Processed { - return nil - } - validateTag := "validate" - sep := "," - if len(tags) > 0 { - validateTag = tags[0] - } - if len(tags) > 1 { - sep = tags[1] - } - if v, ok := params.Field.Tag.Lookup(validateTag); ok { - parts := strings.Split(v, sep) - for _, part := range parts { - if strings.TrimSpace(part) == "required" { - params.ParentSchema.Required = append(params.ParentSchema.Required, params.Name) - break - } - } - } - return nil - }) -} - -// InterceptSchemaFunc sets a custom function for intercepting schema generation. -// -// The provided function is called with the schema generation parameters. -// You can use it to modify schemas before they are added to the OpenAPI output. -func InterceptSchemaFunc(fn openapi.InterceptSchemaFunc) ReflectorOption { - return func(c *openapi.ReflectorConfig) { - c.InterceptSchemaFunc = fn - } +// InterceptDefName sets callback to customize reflected definition names. +func InterceptDefName(fn func(t reflect.Type, defaultDefName string) string) ReflectorOption { + return func(cfg *openapi.ReflectorConfig) { cfg.InterceptDefName = fn } } -// TypeMapping defines a custom type mapping for OpenAPI generation. -// -// Example: -// -// type NullString struct { -// sql.NullString -// } -// -// option.WithReflectorConfig(option.TypeMapping(NullString{}, new(string))) +// TypeMapping maps source type to destination type during reflection. func TypeMapping(src, dst any) ReflectorOption { - return func(c *openapi.ReflectorConfig) { - c.TypeMappings = append(c.TypeMappings, openapi.TypeMapping{ - Src: src, - Dst: dst, - }) + return func(cfg *openapi.ReflectorConfig) { + cfg.TypeMappings = append(cfg.TypeMappings, openapi.TypeMapping{Src: src, Dst: dst}) } } -// ParameterTagMapping sets a custom struct tag mapping for parameters of a specific location. -// -// Example: -// -// option.WithReflectorConfig(option.ParameterTagMapping(openapi.ParameterInPath, "param")) -func ParameterTagMapping(paramIn openapi.ParameterIn, tagName string) ReflectorOption { - return func(c *openapi.ReflectorConfig) { - if c.ParameterTagMapping == nil { - c.ParameterTagMapping = make(map[openapi.ParameterIn]string) +// ParameterTagMapping overrides tag source for a specific parameter location. +func ParameterTagMapping(in openapi.ParameterIn, sourceTag string) ReflectorOption { + return func(cfg *openapi.ReflectorConfig) { + if cfg.ParameterTagMapping == nil { + cfg.ParameterTagMapping = map[openapi.ParameterIn]string{} } - c.ParameterTagMapping[paramIn] = tagName + cfg.ParameterTagMapping[in] = sourceTag } } diff --git a/option/reflector_test.go b/option/reflector_test.go deleted file mode 100644 index 2c8171a..0000000 --- a/option/reflector_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package option_test - -import ( - "reflect" - "testing" - - "github.com/oaswrap/spec/openapi" - "github.com/oaswrap/spec/option" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/swaggest/jsonschema-go" -) - -func TestInlineRefs(t *testing.T) { - config := &openapi.ReflectorConfig{} - opt := option.InlineRefs() - opt(config) - - assert.True(t, config.InlineRefs) -} - -func TestRootRef(t *testing.T) { - config := &openapi.ReflectorConfig{} - opt := option.RootRef() - opt(config) - - assert.True(t, config.RootRef) -} - -func TestRootNullable(t *testing.T) { - config := &openapi.ReflectorConfig{} - opt := option.RootNullable() - opt(config) - - assert.True(t, config.RootNullable) -} - -func TestStripDefNamePrefix(t *testing.T) { - config := &openapi.ReflectorConfig{} - prefixes := []string{"Test", "Mock"} - opt := option.StripDefNamePrefix(prefixes...) - opt(config) - - assert.Equal(t, prefixes, config.StripDefNamePrefix) -} - -func TestInterceptDefNameFunc(t *testing.T) { - config := &openapi.ReflectorConfig{} - mockFunc := func(_ reflect.Type, _ string) string { - return "CustomName" - } - opt := option.InterceptDefNameFunc(mockFunc) - opt(config) - - assert.NotNil(t, config.InterceptDefNameFunc) -} - -func TestInterceptPropFunc(t *testing.T) { - config := &openapi.ReflectorConfig{} - mockFunc := func(_ openapi.InterceptPropParams) error { - return nil - } - opt := option.InterceptPropFunc(mockFunc) - opt(config) - - assert.NotNil(t, config.InterceptPropFunc) -} - -func TestRequiredPropByValidateTag(t *testing.T) { - config := &openapi.ReflectorConfig{} - opt := option.RequiredPropByValidateTag() - opt(config) - - assert.NotNil(t, config.InterceptPropFunc) - - params := openapi.InterceptPropParams{ - Name: "Field1", - Field: reflect.StructField{ - Tag: reflect.StructTag(`validate:"required"`), - }, - ParentSchema: &jsonschema.Schema{ - Required: []string{}, - }, - Processed: true, - } - err := config.InterceptPropFunc(params) - require.NoError(t, err) - assert.Contains(t, params.ParentSchema.Required, "Field1") -} - -func TestInterceptSchemaFunc(t *testing.T) { - config := &openapi.ReflectorConfig{} - mockFunc := func(_ openapi.InterceptSchemaParams) (bool, error) { - return false, nil - } - opt := option.InterceptSchemaFunc(mockFunc) - opt(config) - - assert.NotNil(t, config.InterceptSchemaFunc) -} - -func TestTypeMapping(t *testing.T) { - config := &openapi.ReflectorConfig{} - src := "source" - dst := "destination" - opt := option.TypeMapping(src, dst) - opt(config) - - assert.Len(t, config.TypeMappings, 1) - - mapping := config.TypeMappings[0] - assert.Equal(t, src, mapping.Src) - assert.Equal(t, dst, mapping.Dst) -} - -func TestParameterTagMapping(t *testing.T) { - config := &openapi.ReflectorConfig{} - opt := option.ParameterTagMapping(openapi.ParameterInPath, "param") - opt(config) - - assert.Equal(t, "param", config.ParameterTagMapping[openapi.ParameterInPath]) -} diff --git a/option/security.go b/option/security.go index 8d5f0af..a4256f2 100644 --- a/option/security.go +++ b/option/security.go @@ -2,66 +2,200 @@ package option import "github.com/oaswrap/spec/openapi" -// securityConfig holds configuration for defining a security scheme. +// SecurityOption mutates a reusable security scheme definition. +type SecurityOption func(*securityConfig) + type securityConfig struct { - Description *string - APIKey *openapi.SecuritySchemeAPIKey - HTTPBearer *openapi.SecuritySchemeHTTPBearer - Oauth2 *openapi.SecuritySchemeOAuth2 + scheme *openapi.SecurityScheme } -// SecurityOption applies configuration to a securityConfig. -type SecurityOption func(*securityConfig) - -// SecurityDescription sets the description for the security scheme. -// -// If the description is empty, it clears any existing description. +// SecurityDescription sets security scheme description. func SecurityDescription(description string) SecurityOption { return func(cfg *securityConfig) { - if description != "" { - cfg.Description = &description - } else { - cfg.Description = nil + if cfg.scheme == nil { + cfg.scheme = &openapi.SecurityScheme{} + } + cfg.scheme.Description = &description + } +} + +// SecurityOAuth2MetadataURL sets oauth2 metadata discovery URL. +// It is only valid for OpenAPI 3.2.0 and for `oauth2` security schemes. +func SecurityOAuth2MetadataURL(url string) SecurityOption { + return func(cfg *securityConfig) { + if cfg.scheme == nil { + cfg.scheme = &openapi.SecurityScheme{} } + cfg.scheme.OAuth2MetadataURL = url } } -// SecurityAPIKey defines an API key security scheme. +// SecurityDeprecated marks a security scheme deprecated. +// It is only valid for OpenAPI 3.2.0. +func SecurityDeprecated(deprecated ...bool) SecurityOption { + return func(cfg *securityConfig) { + if cfg.scheme == nil { + cfg.scheme = &openapi.SecurityScheme{} + } + cfg.scheme.Deprecated = optional(true, deprecated...) + } +} + +// SecurityAPIKey configures an `apiKey` security scheme. // // Example: // -// option.WithSecurity("apiKey", -// option.SecurityAPIKey("x-api-key", openapi.SecuritySchemeAPIKeyInHeader), +// option.WithSecurity( +// "apiKeyAuth", +// option.SecurityAPIKey("X-API-Key", openapi.SecuritySchemeAPIKeyInHeader), // ) func SecurityAPIKey(name string, in openapi.SecuritySchemeAPIKeyIn) SecurityOption { return func(cfg *securityConfig) { - cfg.APIKey = &openapi.SecuritySchemeAPIKey{ - Name: name, - In: in, - } + current := currentSecurityScheme(cfg) + cfg.scheme = &openapi.SecurityScheme{Type: "apiKey", Name: name, In: in} + applySecurityCommon(cfg.scheme, current) } } -// SecurityHTTPBearer defines an HTTP Bearer security scheme. -// -// Optionally, you can provide a bearer format. +// SecurityHTTPBearer configures an `http` security scheme. func SecurityHTTPBearer(scheme string, bearerFormat ...string) SecurityOption { return func(cfg *securityConfig) { - httpBearer := &openapi.SecuritySchemeHTTPBearer{ - Scheme: scheme, - } + current := currentSecurityScheme(cfg) + cfg.scheme = &openapi.SecurityScheme{Type: "http", Scheme: scheme} + applySecurityCommon(cfg.scheme, current) if len(bearerFormat) > 0 { - httpBearer.BearerFormat = &bearerFormat[0] + cfg.scheme.BearerFormat = &bearerFormat[0] } - cfg.HTTPBearer = httpBearer } } -// SecurityOAuth2 defines an OAuth 2.0 security scheme. +// SecurityOAuth2 configures an `oauth2` security scheme. func SecurityOAuth2(flows openapi.OAuthFlows) SecurityOption { return func(cfg *securityConfig) { - cfg.Oauth2 = &openapi.SecuritySchemeOAuth2{ - Flows: flows, - } + current := currentSecurityScheme(cfg) + cfg.scheme = &openapi.SecurityScheme{Type: "oauth2", Flows: &flows} + applySecurityCommon(cfg.scheme, current) + } +} + +// SecurityOAuth2Implicit configures an OAuth2 implicit flow. +func SecurityOAuth2Implicit(authorizationURL string, scopes map[string]string, opts ...OAuthFlowOption) SecurityOption { + return SecurityOAuth2(openapi.OAuthFlows{ + Implicit: newOAuthFlow(openapi.OAuthFlow{AuthorizationURL: authorizationURL, Scopes: scopes}, opts...), + }) +} + +// SecurityOAuth2Password configures an OAuth2 password flow. +func SecurityOAuth2Password(tokenURL string, scopes map[string]string, opts ...OAuthFlowOption) SecurityOption { + return SecurityOAuth2(openapi.OAuthFlows{ + Password: newOAuthFlow(openapi.OAuthFlow{TokenURL: tokenURL, Scopes: scopes}, opts...), + }) +} + +// SecurityOAuth2ClientCredentials configures an OAuth2 client credentials flow. +func SecurityOAuth2ClientCredentials( + tokenURL string, + scopes map[string]string, + opts ...OAuthFlowOption, +) SecurityOption { + return SecurityOAuth2(openapi.OAuthFlows{ + ClientCredentials: newOAuthFlow(openapi.OAuthFlow{TokenURL: tokenURL, Scopes: scopes}, opts...), + }) +} + +// SecurityOAuth2AuthorizationCode configures an OAuth2 authorization code flow. +// +// Example: +// +// option.WithSecurity( +// "oauth2", +// option.SecurityOAuth2AuthorizationCode( +// "https://auth.example.com/oauth/authorize", +// "https://auth.example.com/oauth/token", +// map[string]string{"read": "Read access"}, +// ), +// ) +func SecurityOAuth2AuthorizationCode( + authorizationURL string, + tokenURL string, + scopes map[string]string, + opts ...OAuthFlowOption, +) SecurityOption { + return SecurityOAuth2(openapi.OAuthFlows{ + AuthorizationCode: newOAuthFlow(openapi.OAuthFlow{ + AuthorizationURL: authorizationURL, + TokenURL: tokenURL, + Scopes: scopes, + }, opts...), + }) +} + +// SecurityOAuth2DeviceAuthorization configures an OAuth2 device authorization flow. +// It is only valid for OpenAPI 3.2.0. +func SecurityOAuth2DeviceAuthorization( + deviceAuthorizationURL string, + tokenURL string, + scopes map[string]string, + opts ...OAuthFlowOption, +) SecurityOption { + return SecurityOAuth2(openapi.OAuthFlows{ + DeviceAuthorization: newOAuthFlow(openapi.OAuthFlow{ + DeviceAuthorizationURL: deviceAuthorizationURL, + TokenURL: tokenURL, + Scopes: scopes, + }, opts...), + }) +} + +// OAuthFlowOption mutates an OAuth2 flow. +type OAuthFlowOption func(*openapi.OAuthFlow) + +// OAuthRefreshURL sets OAuth2 flow refreshUrl. +func OAuthRefreshURL(url string) OAuthFlowOption { + return func(flow *openapi.OAuthFlow) { flow.RefreshURL = &url } +} + +// SecurityMutualTLS configures a `mutualTLS` security scheme. +func SecurityMutualTLS() SecurityOption { + return func(cfg *securityConfig) { + current := currentSecurityScheme(cfg) + cfg.scheme = &openapi.SecurityScheme{Type: "mutualTLS"} + applySecurityCommon(cfg.scheme, current) + } +} + +func newOAuthFlow(flow openapi.OAuthFlow, opts ...OAuthFlowOption) *openapi.OAuthFlow { + if flow.Scopes == nil { + flow.Scopes = map[string]string{} + } + for _, opt := range opts { + opt(&flow) + } + return &flow +} + +// SecurityOpenIDConnect configures an `openIdConnect` security scheme. +func SecurityOpenIDConnect(url string) SecurityOption { + return func(cfg *securityConfig) { + current := currentSecurityScheme(cfg) + cfg.scheme = &openapi.SecurityScheme{Type: "openIdConnect", OpenIDConnectURL: url} + applySecurityCommon(cfg.scheme, current) + } +} + +func currentSecurityScheme(cfg *securityConfig) *openapi.SecurityScheme { + if cfg.scheme == nil { + return nil + } + current := *cfg.scheme + return ¤t +} + +func applySecurityCommon(scheme, current *openapi.SecurityScheme) { + if current == nil { + return } + scheme.Description = current.Description + scheme.OAuth2MetadataURL = current.OAuth2MetadataURL + scheme.Deprecated = current.Deprecated } diff --git a/option/server.go b/option/server.go index 45d6279..5466f43 100644 --- a/option/server.go +++ b/option/server.go @@ -2,37 +2,15 @@ package option import "github.com/oaswrap/spec/openapi" -// ServerOption applies configuration to an OpenAPI server. +// ServerOption mutates a server entry. type ServerOption func(*openapi.Server) -// ServerDescription sets the description for an OpenAPI server. -// -// Example: -// -// option.WithServer("https://api.example.com", -// option.ServerDescription("Production server"), -// ) +// ServerDescription sets server description. func ServerDescription(description string) ServerOption { - return func(s *openapi.Server) { - s.Description = &description - } + return func(server *openapi.Server) { server.Description = &description } } -// ServerVariables sets one or more variables for an OpenAPI server. -// -// Example: -// -// option.WithServer("https://api.example.com/{version}", -// option.ServerVariables(map[string]openapi.ServerVariable{ -// "version": { -// Default: "v1", -// Description: "API version", -// Enum: []string{"v1", "v2"}, -// }, -// }), -// ) +// ServerVariables sets server variables map. func ServerVariables(variables map[string]openapi.ServerVariable) ServerOption { - return func(s *openapi.Server) { - s.Variables = variables - } + return func(server *openapi.Server) { server.Variables = variables } } diff --git a/option/util.go b/option/util.go new file mode 100644 index 0000000..ad4ed2f --- /dev/null +++ b/option/util.go @@ -0,0 +1,8 @@ +package option + +func optional[T any](fallback T, values ...T) T { + if len(values) == 0 { + return fallback + } + return values[0] +} diff --git a/petstore_test.go b/petstore_test.go new file mode 100644 index 0000000..1b3eed4 --- /dev/null +++ b/petstore_test.go @@ -0,0 +1,390 @@ +package spec_test + +import ( + "reflect" + "strings" + "time" + + "github.com/oaswrap/spec" + "github.com/oaswrap/spec/openapi" + "github.com/oaswrap/spec/option" +) + +type PetstorePet struct { + ID int64 `json:"id" example:"10"` + Name string `json:"name" example:"doggie" required:"true"` + Category *PetstoreCategory `json:"category"` + PhotoURLs []string `json:"photoUrls" required:"true" xmlName:"photoUrl" xmlWrapped:"true"` + Tags []PetstoreTag `json:"tags" xmlWrapped:"true"` + Status string `json:"status" description:"pet status in the store" enum:"available,pending,sold"` +} + +type PetstoreCategory struct { + ID int64 `json:"id" example:"1"` + Name string `json:"name" example:"Dogs"` +} + +type PetstoreTag struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +type PetstoreOrder struct { + ID int64 `json:"id" example:"10"` + PetID int64 `json:"petId" example:"198772"` + Quantity int `json:"quantity" example:"7"` + ShipDate time.Time `json:"shipDate"` + Status string `json:"status" example:"approved" description:"Order Status" enum:"placed,approved,delivered"` + Complete bool `json:"complete"` +} + +type PetstoreUser struct { + ID int64 `json:"id" example:"10"` + Username string `json:"username" example:"theUser"` + FirstName string `json:"firstName" example:"John"` + LastName string `json:"lastName" example:"James"` + Email string `json:"email" example:"john@email.com"` + Password string `json:"password" example:"12345"` + Phone string `json:"phone" example:"12345"` + UserStatus int `json:"userStatus" example:"1" description:"User Status"` +} + +type PetstoreAPIResponse struct { + Code int `json:"code"` + Type string `json:"type"` + Message string `json:"message"` +} + +type PetstoreFindPetsByStatusRequest struct { + Status string `query:"status" required:"true" description:"Status values that need to be considered for filter" default:"available" enum:"available,pending,sold"` +} + +type PetstoreFindPetsByTagsRequest struct { + Tags []string `query:"tags" required:"true" description:"Tags to filter by"` +} + +type PetstorePetByIDRequest struct { + PetID int64 `path:"petId" required:"true" description:"ID of pet to return"` +} + +type PetstoreUpdatePetWithFormRequest struct { + PetID int64 `path:"petId" required:"true" description:"ID of pet that needs to be updated"` + Name string `description:"Name of pet that needs to be updated" query:"name"` + Status string `description:"Status of pet that needs to be updated" query:"status"` +} + +type PetstoreDeletePetRequest struct { + APIKey string `header:"api_key"` + PetID int64 `path:"petId" required:"true" description:"Pet id to delete"` +} + +type PetstoreUploadImageRequest struct { + PetID int64 `path:"petId" required:"true" description:"ID of pet to update"` + AdditionalMetadata string `description:"Additional Metadata" query:"additionalMetadata"` +} + +type PetstoreOrderByIDRequest struct { + OrderID int64 `path:"orderId" required:"true" description:"ID of order that needs to be fetched"` +} + +type PetstoreDeleteOrderRequest struct { + OrderID int64 `path:"orderId" required:"true" description:"ID of the order that needs to be deleted"` +} + +type PetstoreUserByNameRequest struct { + Username string `path:"username" required:"true" description:"The name that needs to be fetched. Use user1 for testing"` +} + +type PetstoreLoginRequest struct { + Username string `query:"username" description:"The user name for login"` + Password string `query:"password" description:"The password for login in clear text"` +} + +func newPetstoreRouter(opts ...option.OpenAPIOption) spec.Generator { + baseOpts := []option.OpenAPIOption{ + option.WithTitle("Swagger Petstore - OpenAPI 3.0"), + option.WithVersion("1.0.27"), + option.WithDescription( + "This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about\nSwagger at [https://swagger.io](https://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!\nYou can now help us improve the API whether it's by making changes to the definition itself or to the code.\nThat way, with time, we can improve the API in general, and expose some of the new features in OAS3.\n\nSome useful links:\n- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)\n- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)", + ), + option.WithTermsOfService("https://swagger.io/terms/"), + option.WithContact(openapi.Contact{Email: "apiteam@swagger.io"}), + option.WithLicense( + openapi.License{Name: "Apache 2.0", URL: "https://www.apache.org/licenses/LICENSE-2.0.html"}, + ), + option.WithExternalDocs("https://swagger.io", "Find out more about Swagger"), + option.WithServer("https://petstore3.swagger.io/api/v3"), + option.WithTags( + openapi.Tag{ + Name: "pet", + Description: "Everything about your Pets", + ExternalDocs: &openapi.ExternalDocs{URL: "https://swagger.io", Description: "Find out more"}, + }, + openapi.Tag{ + Name: "store", + Description: "Access to Petstore orders", + ExternalDocs: &openapi.ExternalDocs{ + URL: "https://swagger.io", + Description: "Find out more about our store", + }, + }, + openapi.Tag{Name: "user", Description: "Operations about user"}, + ), + option.WithSecurity("petstore_auth", option.SecurityOAuth2(openapi.OAuthFlows{ + Implicit: &openapi.OAuthFlow{ + AuthorizationURL: "https://petstore3.swagger.io/oauth/authorize", + Scopes: map[string]string{ + "write:pets": "modify pets in your account", + "read:pets": "read your pets", + }, + }, + })), + option.WithSecurity("api_key", option.SecurityAPIKey("api_key", "header")), + option.WithReflectorConfig( + option.InterceptDefName(func(_ reflect.Type, defaultName string) string { + return strings.TrimPrefix(defaultName, "Petstore") + }), + ), + option.WithDocument(func(doc *openapi.Document) { + if doc.Components.Schemas == nil { + return + } + if s, ok := doc.Components.Schemas["Pet"]; ok { + s.XML = &openapi.XML{Name: "pet"} + } + if s, ok := doc.Components.Schemas["Category"]; ok { + s.XML = &openapi.XML{Name: "category"} + } + if s, ok := doc.Components.Schemas["Tag"]; ok { + s.XML = &openapi.XML{Name: "tag"} + } + if s, ok := doc.Components.Schemas["Order"]; ok { + s.XML = &openapi.XML{Name: "order"} + } + if s, ok := doc.Components.Schemas["User"]; ok { + s.XML = &openapi.XML{Name: "user"} + } + }), + } + r := spec.NewRouter(append(baseOpts, opts...)...) + + // Pet + pet := r.Group("/pet", option.GroupTags("pet")) + + pet.Put("/", + option.OperationID("updatePet"), + option.Summary("Update an existing pet."), + option.Description("Update an existing pet by Id."), + option.Security("petstore_auth", "write:pets", "read:pets"), + option.Request(new(PetstorePet), option.ContentDescription("Update an existent pet in the store")), + option.Response(200, new(PetstorePet), option.ContentDescription("Successful operation")), + option.Response(400, nil, option.ContentDescription("Invalid ID supplied")), + option.Response(404, nil, option.ContentDescription("Pet not found")), + option.Response(422, nil, option.ContentDescription("Validation exception")), + ) + + pet.Post("/", + option.OperationID("addPet"), + option.Summary("Add a new pet to the store."), + option.Description("Add a new pet to the store."), + option.Security("petstore_auth", "write:pets", "read:pets"), + option.Request(new(PetstorePet), option.ContentDescription("Create a new pet in the store")), + option.Response(200, new(PetstorePet), option.ContentDescription("Successful operation")), + option.Response(405, nil, option.ContentDescription("Invalid input")), + option.Response(422, nil, option.ContentDescription("Validation exception")), + ) + + pet.Get("/findByStatus", + option.OperationID("findPetsByStatus"), + option.Summary("Finds Pets by status."), + option.Description("Multiple status values can be provided with comma separated strings."), + option.Security("petstore_auth", "write:pets", "read:pets"), + option.Request(new(PetstoreFindPetsByStatusRequest)), + option.Response(200, new([]PetstorePet), option.ContentDescription("successful operation")), + option.Response(400, nil, option.ContentDescription("Invalid status value")), + ) + + pet.Get( + "/findByTags", + option.OperationID("findPetsByTags"), + option.Summary("Finds Pets by tags."), + option.Description( + "Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.", + ), + option.Security("petstore_auth", "write:pets", "read:pets"), + option.Request(new(PetstoreFindPetsByTagsRequest)), + option.Response(200, new([]PetstorePet), option.ContentDescription("successful operation")), + option.Response(400, nil, option.ContentDescription("Invalid tag value")), + ) + + pet.Get("/{petId}", + option.OperationID("getPetById"), + option.Summary("Find pet by ID."), + option.Description("Returns a single pet."), + option.Security("api_key"), + option.Security("petstore_auth", "write:pets", "read:pets"), + option.Request(new(PetstorePetByIDRequest)), + option.Response(200, new(PetstorePet), option.ContentDescription("successful operation")), + option.Response(400, nil, option.ContentDescription("Invalid ID supplied")), + option.Response(404, nil, option.ContentDescription("Pet not found")), + ) + + pet.Post("/{petId}", + option.OperationID("updatePetWithForm"), + option.Summary("Updates a pet in the store with form data."), + option.Description("Updates a pet resource based on the form data."), + option.Security("petstore_auth", "write:pets", "read:pets"), + option.Request(new(PetstoreUpdatePetWithFormRequest)), + option.Response(200, new(PetstorePet), option.ContentDescription("successful operation")), + option.Response(405, nil, option.ContentDescription("Invalid input")), + ) + + pet.Delete("/{petId}", + option.OperationID("deletePet"), + option.Summary("Deletes a pet."), + option.Description("Delete a pet."), + option.Security("petstore_auth", "write:pets", "read:pets"), + option.Request(new(PetstoreDeletePetRequest)), + option.Response(200, nil, option.ContentDescription("Pet deleted")), + option.Response(400, nil, option.ContentDescription("Invalid pet value")), + ) + + pet.Post("/{petId}/uploadImage", + option.OperationID("uploadFile"), + option.Summary("Uploads an image."), + option.Description("Upload image of the pet."), + option.Security("petstore_auth", "write:pets", "read:pets"), + option.Request(new(PetstoreUploadImageRequest)), + option.Request(nil, option.ContentType("application/octet-stream"), option.ContentFormat("binary")), + option.Response(200, new(PetstoreAPIResponse), option.ContentDescription("successful operation")), + ) + + // Store + store := r.Group("/store", option.GroupTags("store")) + + store.Get("/inventory", + option.OperationID("getInventory"), + option.Summary("Returns pet inventories by status."), + option.Description("Returns a map of status codes to quantities."), + option.Security("api_key"), + option.Response(200, new(map[string]int32), option.ContentDescription("successful operation")), + ) + + store.Post("/order", + option.OperationID("placeOrder"), + option.Summary("Place an order for a pet."), + option.Description("Place a new order in the store."), + option.Request(new(PetstoreOrder)), + option.Response(200, new(PetstoreOrder), option.ContentDescription("successful operation")), + option.Response(400, nil, option.ContentDescription("Invalid input")), + option.Response(422, nil, option.ContentDescription("Validation exception")), + ) + + store.Get( + "/order/{orderId}", + option.OperationID("getOrderById"), + option.Summary("Find purchase order by ID."), + option.Description( + "For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.", + ), + option.Request(new(PetstoreOrderByIDRequest)), + option.Response(200, new(PetstoreOrder), option.ContentDescription("successful operation")), + option.Response(400, nil, option.ContentDescription("Invalid ID supplied")), + option.Response(404, nil, option.ContentDescription("Order not found")), + ) + + store.Delete( + "/order/{orderId}", + option.OperationID("deleteOrder"), + option.Summary("Delete purchase order by identifier."), + option.Description( + "For valid response try integer IDs with value < 1000. Anything above 1000 or non-integers will generate API errors.", + ), + option.Request(new(PetstoreDeleteOrderRequest)), + option.Response(200, nil, option.ContentDescription("order deleted")), + option.Response(400, nil, option.ContentDescription("Invalid ID supplied")), + option.Response(404, nil, option.ContentDescription("Order not found")), + ) + + // User + user := r.Group("/user", option.GroupTags("user")) + + user.Post("/", + option.OperationID("createUser"), + option.Summary("Create user."), + option.Description("This can only be done by the logged in user."), + option.Request(new(PetstoreUser), option.ContentDescription("Created user object")), + option.Response(200, new(PetstoreUser), option.ContentDescription("successful operation")), + ) + + user.Post("/createWithList", + option.OperationID("createUsersWithListInput"), + option.Summary("Creates list of users with given input array."), + option.Description("Creates list of users with given input array."), + option.Request(new([]PetstoreUser)), + option.Response(200, new(PetstoreUser), option.ContentDescription("Successful operation")), + ) + + user.Get("/login", + option.OperationID("loginUser"), + option.Summary("Logs user into the system."), + option.Description("Log into the system."), + option.Request(new(PetstoreLoginRequest)), + option.Response(200, "string", option.ContentDescription("successful operation")), + option.Response(400, nil, option.ContentDescription("Invalid username/password supplied")), + option.CustomizeOperation(func(op *openapi.Operation) { + resp := op.Responses["200"] + if resp.Headers == nil { + resp.Headers = map[string]*openapi.Header{} + } + resp.Headers["X-Rate-Limit"] = &openapi.Header{ + Description: "calls per hour allowed by the user", + Schema: &openapi.Schema{Type: "integer", Format: "int32"}, + } + resp.Headers["X-Expires-After"] = &openapi.Header{ + Description: "date in UTC when token expires", + Schema: &openapi.Schema{Type: "string", Format: "date-time"}, + } + }), + ) + + user.Get("/logout", + option.OperationID("logoutUser"), + option.Summary("Logs out current logged in user session."), + option.Description("Log user out of the system."), + option.Response(200, nil, option.ContentDescription("successful operation")), + ) + + user.Get("/{username}", + option.OperationID("getUserByName"), + option.Summary("Get user by user name."), + option.Description("Get user detail based on username."), + option.Request(new(PetstoreUserByNameRequest)), + option.Response(200, new(PetstoreUser), option.ContentDescription("successful operation")), + option.Response(400, nil, option.ContentDescription("Invalid username supplied")), + option.Response(404, nil, option.ContentDescription("User not found")), + ) + + user.Put("/{username}", + option.OperationID("updateUser"), + option.Summary("Update user resource."), + option.Description("This can only be done by the logged in user."), + option.Request(new(PetstoreUserByNameRequest)), + option.Request(new(PetstoreUser), option.ContentDescription("Update an existent user in the store")), + option.Response(200, nil, option.ContentDescription("successful operation")), + option.Response(400, nil, option.ContentDescription("bad request")), + option.Response(404, nil, option.ContentDescription("user not found")), + ) + + user.Delete("/{username}", + option.OperationID("deleteUser"), + option.Summary("Delete user resource."), + option.Description("This can only be done by the logged in user."), + option.Request(new(PetstoreUserByNameRequest)), + option.Response(200, nil, option.ContentDescription("User deleted")), + option.Response(400, nil, option.ContentDescription("Invalid username supplied")), + option.Response(404, nil, option.ContentDescription("User not found")), + ) + + return r +} diff --git a/pkg/mapper/specui_test.go b/pkg/mapper/specui_test.go index 5580087..234087e 100644 --- a/pkg/mapper/specui_test.go +++ b/pkg/mapper/specui_test.go @@ -3,11 +3,12 @@ package mapper_test import ( "testing" + "github.com/stretchr/testify/assert" + "github.com/oaswrap/spec" "github.com/oaswrap/spec-ui/config" "github.com/oaswrap/spec/option" "github.com/oaswrap/spec/pkg/mapper" - "github.com/stretchr/testify/assert" ) func TestSpecUIOpts(t *testing.T) { diff --git a/pkg/parser/colon_param_parser.go b/pkg/parser/colon_param_parser.go index fdfab5f..c631d9d 100644 --- a/pkg/parser/colon_param_parser.go +++ b/pkg/parser/colon_param_parser.go @@ -6,10 +6,11 @@ import ( "github.com/oaswrap/spec/openapi" ) -// ColonParamParser is a parser that converts paths with colon-prefixed parameters -// (e.g., "/users/:id") to OpenAPI-style parameters (e.g., "/users/{id}"). +// ColonParamParser converts framework parameters such as "/users/:id" and +// named catch-all parameters such as "/files/*filepath" to OpenAPI templates. type ColonParamParser struct { - re *regexp.Regexp + colonRe *regexp.Regexp + wildcardRe *regexp.Regexp } var _ openapi.PathParser = &ColonParamParser{} @@ -17,11 +18,14 @@ var _ openapi.PathParser = &ColonParamParser{} // NewColonParamParser creates a new ColonParamParser instance. func NewColonParamParser() *ColonParamParser { return &ColonParamParser{ - re: regexp.MustCompile(`:([a-zA-Z_][a-zA-Z0-9_]*)`), + colonRe: regexp.MustCompile(`:([a-zA-Z_][a-zA-Z0-9_]*)`), + wildcardRe: regexp.MustCompile(`\*([a-zA-Z_][a-zA-Z0-9_]*)`), } } -// Parse converts a path with colon-prefixed parameters to OpenAPI-style parameters. +// Parse converts supported framework parameters to OpenAPI-style parameters. func (p *ColonParamParser) Parse(colonParam string) (string, error) { - return p.re.ReplaceAllString(colonParam, "{$1}"), nil + parsed := p.colonRe.ReplaceAllString(colonParam, "{$1}") + parsed = p.wildcardRe.ReplaceAllString(parsed, "{$1}") + return parsed, nil } diff --git a/pkg/parser/colon_param_parser_test.go b/pkg/parser/colon_param_parser_test.go index 9508d77..3327dc2 100644 --- a/pkg/parser/colon_param_parser_test.go +++ b/pkg/parser/colon_param_parser_test.go @@ -3,9 +3,10 @@ package parser_test import ( "testing" - "github.com/oaswrap/spec/pkg/parser" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/oaswrap/spec/pkg/parser" ) func TestNewColonParamParser(t *testing.T) { @@ -66,6 +67,16 @@ func TestColonParamParser_Parse(t *testing.T) { input: "/users/:_id", expected: "/users/{_id}", }, + { + name: "named wildcard parameter", + input: "/files/*filepath", + expected: "/files/{filepath}", + }, + { + name: "bare wildcard is left unchanged", + input: "/files/*", + expected: "/files/*", + }, } for _, tt := range tests { diff --git a/pkg/testutil/yaml.go b/pkg/testutil/yaml.go deleted file mode 100644 index 8ecbc2a..0000000 --- a/pkg/testutil/yaml.go +++ /dev/null @@ -1,32 +0,0 @@ -package testutil - -import ( - "bytes" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/stretchr/testify/assert" - "gopkg.in/yaml.v3" -) - -// YAMLToInterface parses a YAML blob into an interface{}. -// Use it before comparing. -func YAMLToInterface(t *testing.T, data []byte) interface{} { - t.Helper() - var v interface{} - dec := yaml.NewDecoder(bytes.NewReader(data)) - err := dec.Decode(&v) - assert.NoError(t, err) - return v -} - -// EqualYAML asserts that two YAML documents are semantically equal. -// It returns a cmp.Diff if they are not. -func EqualYAML(t *testing.T, want []byte, got []byte) { - wantObj := YAMLToInterface(t, want) - gotObj := YAMLToInterface(t, got) - - if diff := cmp.Diff(wantObj, gotObj); diff != "" { - t.Errorf("YAML mismatch (-want +got):\n%s", diff) - } -} diff --git a/pkg/testutil/yaml_test.go b/pkg/testutil/yaml_test.go deleted file mode 100644 index 7ec3c39..0000000 --- a/pkg/testutil/yaml_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package testutil_test - -import ( - "testing" - - "github.com/oaswrap/spec/pkg/testutil" -) - -func TestEqualYAML(t *testing.T) { - tests := []struct { - name string - want []byte - got []byte - shouldErr bool - }{ - { - name: "identical YAML", - want: []byte("key: value\nother: 123"), - got: []byte("key: value\nother: 123"), - shouldErr: false, - }, - { - name: "semantically equal YAML different formatting", - want: []byte("key: value\nother: 123"), - got: []byte("other: 123\nkey: value"), - shouldErr: false, - }, - { - name: "different values", - want: []byte("key: value1"), - got: []byte("key: value2"), - shouldErr: true, - }, - { - name: "different keys", - want: []byte("key1: value"), - got: []byte("key2: value"), - shouldErr: true, - }, - { - name: "nested objects equal", - want: []byte("parent:\n child: value\n num: 42"), - got: []byte("parent:\n num: 42\n child: value"), - shouldErr: false, - }, - { - name: "arrays equal", - want: []byte("items:\n - one\n - two\n - three"), - got: []byte("items:\n - one\n - two\n - three"), - shouldErr: false, - }, - { - name: "arrays different order", - want: []byte("items:\n - one\n - two"), - got: []byte("items:\n - two\n - one"), - shouldErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockT := &testing.T{} - testutil.EqualYAML(mockT, tt.want, tt.got) - - if tt.shouldErr && !mockT.Failed() { - t.Error("Expected test to fail but it passed") - } - if !tt.shouldErr && mockT.Failed() { - t.Error("Expected test to pass but it failed") - } - }) - } -} diff --git a/pkg/util/util.go b/pkg/util/util.go deleted file mode 100644 index 23284e5..0000000 --- a/pkg/util/util.go +++ /dev/null @@ -1,31 +0,0 @@ -package util - -import ( - "path" -) - -// Optional returns the first value from the provided values or the default value if no values are provided. -func Optional[T any](defaultValue T, value ...T) T { - if len(value) > 0 { - return value[0] - } - return defaultValue -} - -// PtrOf returns a pointer to the provided value. -func PtrOf[T any](value T) *T { - return &value -} - -// JoinPath joins multiple path segments into a single path, ensuring proper formatting. -func JoinPath(paths ...string) string { - if len(paths) == 0 { - return "" - } - - lastElement := paths[len(paths)-1] - if len(lastElement) > 0 && lastElement[len(lastElement)-1] == '/' { - return path.Join(paths...) + "/" - } - return path.Join(paths...) -} diff --git a/pkg/util/util_test.go b/pkg/util/util_test.go deleted file mode 100644 index 5a46694..0000000 --- a/pkg/util/util_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package util_test - -import ( - "testing" - - "github.com/oaswrap/spec/pkg/util" - "github.com/stretchr/testify/assert" -) - -func TestOptional(t *testing.T) { - t.Run("returns default value when no optional value provided", func(t *testing.T) { - result := util.Optional("default") - assert.Equal(t, "default", result) - }) - - t.Run("returns first optional value when provided", func(t *testing.T) { - result := util.Optional("default", "provided") - assert.Equal(t, "provided", result) - }) - - t.Run("returns first optional value when multiple values provided", func(t *testing.T) { - result := util.Optional("default", "first", "second", "third") - assert.Equal(t, "first", result) - }) - - t.Run("returns default when empty slice provided", func(t *testing.T) { - var values []string - result := util.Optional("default", values...) - assert.Equal(t, "default", result) - }) -} - -func TestPtrOf(t *testing.T) { - t.Run("returns pointer to string value", func(t *testing.T) { - value := "test" - result := util.PtrOf(value) - assert.NotNil(t, result) - assert.Equal(t, value, *result) - }) - - t.Run("returns pointer to int value", func(t *testing.T) { - value := 42 - result := util.PtrOf(value) - assert.NotNil(t, result) - assert.Equal(t, value, *result) - }) - - t.Run("returns pointer to bool value", func(t *testing.T) { - value := true - result := util.PtrOf(value) - assert.NotNil(t, result) - assert.Equal(t, value, *result) - }) - - t.Run("returns pointer to zero value", func(t *testing.T) { - value := 0 - result := util.PtrOf(value) - assert.NotNil(t, result) - assert.Equal(t, value, *result) - }) - - t.Run("returns pointer to struct", func(t *testing.T) { - type testStruct struct { - Field string - } - value := testStruct{Field: "test"} - result := util.PtrOf(value) - assert.NotNil(t, result) - assert.Equal(t, value, *result) - }) -} - -func TestJoinPath(t *testing.T) { - tests := []struct { - name string - paths []string - expected string - }{ - { - name: "empty paths", - paths: []string{}, - expected: "", - }, - { - name: "single path without trailing slash", - paths: []string{"api"}, - expected: "api", - }, - { - name: "single path with trailing slash", - paths: []string{"api/"}, - expected: "api/", - }, - { - name: "two paths without trailing slash", - paths: []string{"api", "v1"}, - expected: "api/v1", - }, - { - name: "two paths with trailing slash on last", - paths: []string{"api", "v1/"}, - expected: "api/v1/", - }, - { - name: "multiple paths without trailing slash", - paths: []string{"api", "v1", "users"}, - expected: "api/v1/users", - }, - { - name: "multiple paths with trailing slash on last", - paths: []string{"api", "v1", "users/"}, - expected: "api/v1/users/", - }, - { - name: "absolute paths", - paths: []string{"/api", "v1"}, - expected: "/api/v1", - }, - { - name: "absolute paths with trailing slash", - paths: []string{"/api", "v1/"}, - expected: "/api/v1/", - }, - { - name: "normalize double slashes", - paths: []string{"api/", "/v1"}, - expected: "api/v1", - }, - { - name: "normalize double slashes with trailing slash", - paths: []string{"api/", "/v1/"}, - expected: "api/v1/", - }, - { - name: "empty string in middle paths", - paths: []string{"api", "", "v1"}, - expected: "api/v1", - }, - { - name: "empty string on last path", - paths: []string{"api", "v1", ""}, - expected: "api/v1", - }, - { - name: "many path segments", - paths: []string{"api", "v1", "users", "123", "profile"}, - expected: "api/v1/users/123/profile", - }, - { - name: "many path segments with trailing slash", - paths: []string{"api", "v1", "users", "123", "profile/"}, - expected: "api/v1/users/123/profile/", - }, - { - name: "dot paths", - paths: []string{".", "api", "v1"}, - expected: "api/v1", - }, - { - name: "parent directory paths", - paths: []string{"api", "..", "v1"}, - expected: "v1", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := util.JoinPath(tt.paths...) - assert.Equal(t, tt.expected, result) - }) - } -} diff --git a/reflector.go b/reflector.go deleted file mode 100644 index b0b1c1a..0000000 --- a/reflector.go +++ /dev/null @@ -1,69 +0,0 @@ -package spec - -import ( - "fmt" - "regexp" - - "github.com/oaswrap/spec/internal/debuglog" - "github.com/oaswrap/spec/internal/errs" - "github.com/oaswrap/spec/openapi" - "github.com/oaswrap/spec/option" -) - -var ( - re3 = regexp.MustCompile(`^3\.0\.\d(-.+)?$`) - re31 = regexp.MustCompile(`^3\.1\.\d+(-.+)?$`) -) - -func newReflector(cfg *openapi.Config) reflector { - logger := debuglog.NewLogger("spec", cfg.Logger) - - if re3.MatchString(cfg.OpenAPIVersion) { - return newReflector3(cfg, logger) - } else if re31.MatchString(cfg.OpenAPIVersion) { - return newReflector31(cfg, logger) - } - - logger.Printf("Unsupported OpenAPI version: %s", cfg.OpenAPIVersion) - return newInvalidReflector(fmt.Errorf("unsupported OpenAPI version: %s", cfg.OpenAPIVersion)) -} - -type invalidReflector struct { - spec *noopSpec - errors *errs.SpecError -} - -func newInvalidReflector(err error) reflector { - e := &errs.SpecError{} - e.Add(err) - - return &invalidReflector{ - errors: e, - spec: &noopSpec{}, - } -} - -var _ reflector = (*invalidReflector)(nil) - -func (r *invalidReflector) Spec() spec { - return r.spec -} - -func (r *invalidReflector) Add(_, _ string, _ ...option.OperationOption) {} - -func (r *invalidReflector) Validate() error { - if r.errors.HasErrors() { - return r.errors - } - return nil -} - -type noopSpec struct{} - -func (s *noopSpec) MarshalYAML() ([]byte, error) { - return nil, nil -} - -func (s *noopSpec) MarshalJSON() ([]byte, error) { - return nil, nil -} diff --git a/reflector3.go b/reflector3.go deleted file mode 100644 index ed51dbe..0000000 --- a/reflector3.go +++ /dev/null @@ -1,172 +0,0 @@ -package spec - -import ( - "fmt" - "strings" - - "github.com/oaswrap/spec/internal/debuglog" - "github.com/oaswrap/spec/internal/errs" - "github.com/oaswrap/spec/internal/mapper" - "github.com/oaswrap/spec/openapi" - "github.com/oaswrap/spec/option" - "github.com/swaggest/openapi-go/openapi3" -) - -type reflector3 struct { - reflector *openapi3.Reflector - logger *debuglog.Logger - errors *errs.SpecError - pathParser openapi.PathParser - parameterTagMapping map[openapi.ParameterIn]string -} - -func newReflector3(cfg *openapi.Config, logger *debuglog.Logger) reflector { - reflector := openapi3.NewReflector() - logger.LogAction("Using OpenAPI 3.0 reflector for version", cfg.OpenAPIVersion) - spec := reflector.Spec - - spec.Info.Title = cfg.Title - logger.LogAction("set title", cfg.Title) - - spec.Info.Version = cfg.Version - logger.LogAction("set version", cfg.Version) - - spec.Info.Description = cfg.Description - if cfg.Description != nil { - logger.LogAction("set description", *cfg.Description) - } - - spec.Info.TermsOfService = cfg.TermsOfService - if cfg.TermsOfService != nil { - logger.LogAction("set terms of service", *cfg.TermsOfService) - } - - spec.Info.Contact = mapper.OAS3Contact(cfg.Contact) - if cfg.Contact != nil { - logger.LogContact(cfg.Contact) - } - - spec.Info.License = mapper.OAS3License(cfg.License) - if cfg.License != nil { - logger.LogLicense(cfg.License) - } - - spec.ExternalDocs = mapper.OAS3ExternalDocs(cfg.ExternalDocs) - if cfg.ExternalDocs != nil { - logger.LogExternalDocs(cfg.ExternalDocs) - } - - spec.Servers = mapper.OAS3Servers(cfg.Servers) - for _, server := range cfg.Servers { - logger.LogServer(server) - } - - spec.Tags = mapper.OAS3Tags(cfg.Tags) - for _, tag := range cfg.Tags { - logger.LogTag(tag) - } - - if len(cfg.SecuritySchemes) > 0 { - spec.Components = &openapi3.Components{} - securitySchemes := &openapi3.ComponentsSecuritySchemes{ - MapOfSecuritySchemeOrRefValues: make(map[string]openapi3.SecuritySchemeOrRef), - } - for name, scheme := range cfg.SecuritySchemes { - openapiScheme := mapper.OAS3SecurityScheme(scheme) - if openapiScheme == nil { - continue // Skip invalid security schemes - } - securitySchemes.MapOfSecuritySchemeOrRefValues[name] = openapi3.SecuritySchemeOrRef{ - SecurityScheme: openapiScheme, - } - } - spec.Components.SecuritySchemes = securitySchemes - - for name, scheme := range cfg.SecuritySchemes { - logger.LogSecurityScheme(name, scheme) - } - } - - var parameterTagMapping map[openapi.ParameterIn]string - - jsonSchemaOpts := getJSONSchemaOpts(cfg.ReflectorConfig, logger) - if len(jsonSchemaOpts) > 0 { - reflector.DefaultOptions = append(reflector.DefaultOptions, jsonSchemaOpts...) - } - - if cfg.ReflectorConfig != nil { - for _, opt := range cfg.ReflectorConfig.TypeMappings { - reflector.AddTypeMapping(opt.Src, opt.Dst) - logger.LogAction("add type mapping", fmt.Sprintf("%T -> %T", opt.Src, opt.Dst)) - } - - parameterTagMapping = cfg.ReflectorConfig.ParameterTagMapping - } - - return &reflector3{ - reflector: reflector, - logger: logger, - errors: &errs.SpecError{}, - pathParser: cfg.PathParser, - parameterTagMapping: parameterTagMapping, - } -} - -func (r *reflector3) Spec() spec { - return r.reflector.Spec -} - -func (r *reflector3) Add(method, path string, opts ...option.OperationOption) { - if r.pathParser != nil { - parsedPath, err := r.pathParser.Parse(path) - if err != nil { - r.errors.Add(fmt.Errorf("failed to parse path %q: %w", path, err)) - return - } - path = parsedPath - } - op, err := r.newOperationContext(method, path) - if err != nil { - r.errors.Add(err) - return - } - - op.With(opts...) - - method = strings.ToUpper(method) - - if err = r.addOperation(op); err != nil { - r.logger.LogOp(method, path, "add operation", "failed") - r.errors.Add(err) - return - } - r.logger.LogOp(method, path, "add operation", "successfully registered") -} - -func (r *reflector3) Validate() error { - if r.errors.HasErrors() { - return r.errors - } - return nil -} - -func (r *reflector3) addOperation(oc operationContext) error { - openapiOC := oc.build() - if openapiOC == nil { - return nil - } - return r.reflector.AddOperation(openapiOC) -} - -func (r *reflector3) newOperationContext(method, path string) (operationContext, error) { - op, err := r.reflector.NewOperationContext(method, path) - if err != nil { - return nil, err - } - return &operationContextImpl{ - op: op, - logger: r.logger, - cfg: &option.OperationConfig{}, - parameterTagMapping: r.parameterTagMapping, - }, nil -} diff --git a/reflector31.go b/reflector31.go deleted file mode 100644 index b76d9c5..0000000 --- a/reflector31.go +++ /dev/null @@ -1,169 +0,0 @@ -package spec - -import ( - "fmt" - "strings" - - "github.com/oaswrap/spec/internal/debuglog" - "github.com/oaswrap/spec/internal/errs" - "github.com/oaswrap/spec/internal/mapper" - "github.com/oaswrap/spec/openapi" - "github.com/oaswrap/spec/option" - "github.com/swaggest/openapi-go/openapi31" -) - -type reflector31 struct { - reflector *openapi31.Reflector - logger *debuglog.Logger - pathParser openapi.PathParser - parameterTagMapping map[openapi.ParameterIn]string - errors *errs.SpecError -} - -func newReflector31(cfg *openapi.Config, logger *debuglog.Logger) reflector { - reflector := openapi31.NewReflector() - logger.LogAction("Using OpenAPI 3.1 reflector for version", cfg.OpenAPIVersion) - spec := reflector.Spec - - spec.Info.Title = cfg.Title - logger.LogAction("set title", cfg.Title) - - spec.Info.Version = cfg.Version - logger.LogAction("set version", cfg.Version) - - spec.Info.Description = cfg.Description - if cfg.Description != nil { - logger.LogAction("set description", *cfg.Description) - } - - spec.Info.TermsOfService = cfg.TermsOfService - if cfg.TermsOfService != nil { - logger.LogAction("set terms of service", *cfg.TermsOfService) - } - - spec.Info.Contact = mapper.OAS31Contact(cfg.Contact) - if cfg.Contact != nil { - logger.LogContact(cfg.Contact) - } - - spec.Info.License = mapper.OAS31License(cfg.License) - if cfg.License != nil { - logger.LogLicense(cfg.License) - } - - spec.ExternalDocs = mapper.OAS31ExternalDocs(cfg.ExternalDocs) - if cfg.ExternalDocs != nil { - logger.LogExternalDocs(cfg.ExternalDocs) - } - - spec.Servers = mapper.OAS31Servers(cfg.Servers) - for _, server := range cfg.Servers { - logger.LogServer(server) - } - - spec.Tags = mapper.OAS31Tags(cfg.Tags) - for _, tag := range cfg.Tags { - logger.LogTag(tag) - } - - if len(cfg.SecuritySchemes) > 0 { - spec.Components = &openapi31.Components{} - securitySchemes := make(map[string]openapi31.SecuritySchemeOrReference) - for name, scheme := range cfg.SecuritySchemes { - openapiScheme := mapper.OAS31SecurityScheme(scheme) - if openapiScheme == nil { - continue // Skip invalid security schemes - } - securitySchemes[name] = openapi31.SecuritySchemeOrReference{ - SecurityScheme: openapiScheme, - } - } - spec.Components.SecuritySchemes = securitySchemes - for name, scheme := range cfg.SecuritySchemes { - logger.LogSecurityScheme(name, scheme) - } - } - - var parameterTagMapping map[openapi.ParameterIn]string - - jsonSchemaOpts := getJSONSchemaOpts(cfg.ReflectorConfig, logger) - if len(jsonSchemaOpts) > 0 { - reflector.DefaultOptions = append(reflector.DefaultOptions, jsonSchemaOpts...) - } - - if cfg.ReflectorConfig != nil { - for _, opt := range cfg.ReflectorConfig.TypeMappings { - reflector.AddTypeMapping(opt.Src, opt.Dst) - logger.LogAction("add type mapping", fmt.Sprintf("%T -> %T", opt.Src, opt.Dst)) - } - - parameterTagMapping = cfg.ReflectorConfig.ParameterTagMapping - } - - return &reflector31{ - reflector: reflector, - logger: logger, - errors: &errs.SpecError{}, - pathParser: cfg.PathParser, - parameterTagMapping: parameterTagMapping, - } -} - -func (r *reflector31) Add(method, path string, opts ...option.OperationOption) { - if r.pathParser != nil { - parsedPath, err := r.pathParser.Parse(path) - if err != nil { - r.errors.Add(err) - return - } - path = parsedPath - } - op, err := r.newOperationContext(method, path) - if err != nil { - r.errors.Add(err) - return - } - - op.With(opts...) - - method = strings.ToUpper(method) - - if err = r.addOperation(op); err != nil { - r.logger.LogOp(method, path, "add operation", "failed") - r.errors.Add(err) - return - } - r.logger.LogOp(method, path, "add operation", "successfully registered") -} - -func (r *reflector31) Spec() spec { - return r.reflector.Spec -} - -func (r *reflector31) Validate() error { - if r.errors.HasErrors() { - return r.errors - } - return nil -} - -func (r *reflector31) addOperation(oc operationContext) error { - openapiOC := oc.build() - if openapiOC == nil { - return nil - } - return r.reflector.AddOperation(openapiOC) -} - -func (r *reflector31) newOperationContext(method, path string) (operationContext, error) { - op, err := r.reflector.NewOperationContext(method, path) - if err != nil { - return nil, err - } - return &operationContextImpl{ - op: op, - logger: r.logger, - cfg: &option.OperationConfig{}, - parameterTagMapping: r.parameterTagMapping, - }, nil -} diff --git a/router.go b/router.go index 4342387..d2ccee2 100644 --- a/router.go +++ b/router.go @@ -6,304 +6,480 @@ import ( "fmt" "net/http" "os" + "runtime" "slices" "strings" "sync" + "github.com/oaswrap/spec/internal/builder" + spec_reflect "github.com/oaswrap/spec/internal/reflect" + "github.com/oaswrap/spec/internal/validate" "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" - "github.com/oaswrap/spec/pkg/util" ) -// generator implements the Generator interface for creating OpenAPI specifications. type generator struct { - reflector reflector - spec spec - cfg *openapi.Config - + cfg *openapi.Config prefix string groups []*generator routes []*route opts []option.GroupOption - once sync.Once + state *sharedState } var _ Generator = (*generator)(nil) -// NewRouter returns a new Router instance using the given OpenAPI options. +type sharedState struct { + mu sync.Mutex + root *generator + doc *openapi.Document + builder *builder.Builder + errs []error +} + +// NewRouter creates a new OpenAPI generator. +// It is an alias of NewGenerator for naming compatibility. // -// It is equivalent to NewGenerator. +// Example: // -// See also: NewGenerator. +// g := NewRouter( +// option.WithTitle("My API"), +// option.WithVersion("1.0.0"), +// option.WithDescription("This is my API"), +// option.WithServer("https://api.example.com"), +// ) +// g.Get("/users", option.Summary("Get all users")) +// g.Post("/users", option.Summary("Create a new user")) +// schema, err := g.GenerateSchema("yaml") +// if err != nil { +// log.Fatal(err) +// } +// fmt.Println(string(schema)) func NewRouter(opts ...option.OpenAPIOption) Generator { return NewGenerator(opts...) } -// NewGenerator returns a new Generator instance using the given OpenAPI options. +// NewGenerator creates a new OpenAPI generator with the provided options. +// +// Example: // -// It initializes the OpenAPI reflector and configuration. +// g := NewGenerator( +// option.WithTitle("My API"), +// option.WithVersion("1.0.0"), +// option.WithDescription("This is my API"), +// option.WithServer("https://api.example.com"), +// ) +// g.Get("/users", option.Summary("Get all users")) +// g.Post("/users", option.Summary("Create a new user")) +// schema, err := g.GenerateSchema("yaml") +// if err != nil { +// log.Fatal(err) +// } +// fmt.Println(string(schema)) func NewGenerator(opts ...option.OpenAPIOption) Generator { cfg := option.WithOpenAPIConfig(opts...) + ensureCallerPkgForDefName(cfg, callerPackagePath()) + g := &generator{ + cfg: cfg, + } + g.state = &sharedState{root: g} + return g +} - reflector := newReflector(cfg) +func ensureCallerPkgForDefName(cfg *openapi.Config, callerPkgPath string) { + if cfg.ReflectorConfig == nil { + cfg.ReflectorConfig = &openapi.ReflectorConfig{} + } + cfg.ReflectorConfig.DefNameCallerPkg = callerPkgPath +} + +func callerPackagePath() string { + pcs := make([]uintptr, 16) + n := runtime.Callers(2, pcs) + frames := runtime.CallersFrames(pcs[:n]) - generator := &generator{ - reflector: reflector, - spec: reflector.Spec(), - cfg: cfg, + for { + frame, more := frames.Next() + pkgPath := packagePathFromFunc(frame.Function) + if pkgPath != "" && pkgPath != "github.com/oaswrap/spec" { + return pkgPath + } + if !more { + break + } } - return generator + return "" +} + +func packagePathFromFunc(funcName string) string { + if funcName == "" { + return "" + } + idx := strings.LastIndex(funcName, "/") + if idx == -1 { + idx = 0 + } else { + idx++ + } + dot := strings.IndexByte(funcName[idx:], '.') + if dot == -1 { + return "" + } + return funcName[:idx+dot] } -// Config returns the OpenAPI configuration used by the Generator. func (g *generator) Config() *openapi.Config { return g.cfg } -// Get registers a GET operation for the given path and options. +func (g *generator) Document() *openapi.Document { + g.build() + g.state.mu.Lock() + defer g.state.mu.Unlock() + return g.state.doc +} + func (g *generator) Get(path string, opts ...option.OperationOption) Route { return g.Add(http.MethodGet, path, opts...) } -// Post registers a POST operation for the given path and options. func (g *generator) Post(path string, opts ...option.OperationOption) Route { return g.Add(http.MethodPost, path, opts...) } -// Put registers a PUT operation for the given path and options. func (g *generator) Put(path string, opts ...option.OperationOption) Route { return g.Add(http.MethodPut, path, opts...) } -// Delete registers a DELETE operation for the given path and options. func (g *generator) Delete(path string, opts ...option.OperationOption) Route { return g.Add(http.MethodDelete, path, opts...) } -// Patch registers a PATCH operation for the given path and options. func (g *generator) Patch(path string, opts ...option.OperationOption) Route { return g.Add(http.MethodPatch, path, opts...) } -// Options registers an OPTIONS operation for the given path and options. func (g *generator) Options(path string, opts ...option.OperationOption) Route { return g.Add(http.MethodOptions, path, opts...) } -// Trace registers a TRACE operation for the given path and options. +func (g *generator) Head(path string, opts ...option.OperationOption) Route { + return g.Add(http.MethodHead, path, opts...) +} + func (g *generator) Trace(path string, opts ...option.OperationOption) Route { return g.Add(http.MethodTrace, path, opts...) } -// Head registers a HEAD operation for the given path and options. -func (g *generator) Head(path string, opts ...option.OperationOption) Route { - return g.Add(http.MethodHead, path, opts...) +func (g *generator) Query(path string, opts ...option.OperationOption) Route { + return g.Add("QUERY", path, opts...) } -// Add registers an operation for the given HTTP method, path, and options. func (g *generator) Add(method, path string, opts ...option.OperationOption) Route { - if g.prefix != "" { - path = util.JoinPath(g.prefix, path) - } - route := &route{ - prefix: g.prefix, - method: method, - path: path, - opts: opts, - } - g.routes = append(g.routes, route) + g.state.mu.Lock() + defer g.state.mu.Unlock() + path = joinPath(g.prefix, path) + r := &route{prefix: g.prefix, method: method, path: path, opts: opts, isWebhook: false, state: g.state} + g.routes = append(g.routes, r) + return r +} - return route +func (g *generator) Webhook(name string, opts ...option.OperationOption) Route { + return g.AddWebhook(http.MethodPost, name, opts...) } -// NewRoute creates a new route with the given options. -func (g *generator) NewRoute(opts ...option.OperationOption) Route { - route := &route{ - prefix: g.prefix, - opts: opts, - } - g.routes = append(g.routes, route) +func (g *generator) AddWebhook(method, name string, opts ...option.OperationOption) Route { + g.state.mu.Lock() + defer g.state.mu.Unlock() + r := &route{prefix: g.prefix, method: method, path: name, opts: opts, isWebhook: true, state: g.state} + g.routes = append(g.routes, r) + return r +} - return route +func (g *generator) NewRoute(opts ...option.OperationOption) Route { + g.state.mu.Lock() + defer g.state.mu.Unlock() + r := &route{prefix: g.prefix, opts: opts, state: g.state} + g.routes = append(g.routes, r) + return r } -// Route registers a nested route under the given pattern. func (g *generator) Route(pattern string, fn func(router Router), opts ...option.GroupOption) Router { - subGroup := g.Group(pattern, opts...) - fn(subGroup) - return subGroup + group := g.Group(pattern, opts...) + fn(group) + return group } -// Group creates a new sub-router with the given path prefix and group options. func (g *generator) Group(pattern string, opts ...option.GroupOption) Router { + g.state.mu.Lock() + defer g.state.mu.Unlock() group := &generator{ - prefix: util.JoinPath(g.prefix, pattern), - reflector: g.reflector, - cfg: g.cfg, - opts: opts, + cfg: g.cfg, + prefix: joinPath(g.prefix, pattern), + opts: opts, + state: g.state, } g.groups = append(g.groups, group) return group } -// With applies one or more group options to the router. func (g *generator) With(opts ...option.GroupOption) Router { + g.state.mu.Lock() + defer g.state.mu.Unlock() g.opts = append(g.opts, opts...) return g } -// MarshalYAML and MarshalJSON implement the YAML and JSON serialization for the OpenAPI specification. +func (g *generator) GenerateSchema(formats ...string) ([]byte, error) { + format := optional("yaml", formats...) + if !slices.Contains([]string{"json", "yaml", "yml"}, format) { + return nil, fmt.Errorf("unsupported format: %s, expected one of json, yaml, yml", format) + } + if format == "json" { + return g.MarshalJSON() + } + return g.MarshalYAML() +} + func (g *generator) MarshalYAML() ([]byte, error) { if err := g.Validate(); err != nil { return nil, err } - return g.spec.MarshalYAML() + g.state.mu.Lock() + doc := g.state.doc + g.state.mu.Unlock() + return openapi.MarshalYAML(doc) } -// MarshalJSON implements the JSON serialization for the OpenAPI specification. func (g *generator) MarshalJSON() ([]byte, error) { if err := g.Validate(); err != nil { return nil, err } - schema, err := g.spec.MarshalJSON() + g.state.mu.Lock() + doc := g.state.doc + g.state.mu.Unlock() + raw, err := openapi.MarshalJSON(doc) if err != nil { return nil, err } - - var buffer bytes.Buffer - if err = json.Indent(&buffer, schema, "", " "); err != nil { - return nil, fmt.Errorf("failed to indent OpenAPI JSON schema: %w", err) - } - - return buffer.Bytes(), nil -} - -// GenerateSchema generates the OpenAPI schema in the specified format (JSON or YAML). -func (g *generator) GenerateSchema(formats ...string) ([]byte, error) { - format := util.Optional("yaml", formats...) - supportedFormats := []string{"json", "yaml", "yml"} - if !slices.Contains(supportedFormats, format) { - return nil, fmt.Errorf( - "unsupported format: %s, expected one of %s", - format, - strings.Join(supportedFormats, ", "), - ) - } - - if format == "yaml" || format == "yml" { - return g.MarshalYAML() + var out bytes.Buffer + if err = json.Indent(&out, raw, "", " "); err != nil { + return nil, err } - - return g.MarshalJSON() + out.WriteByte('\n') + return out.Bytes(), nil } -// WriteSchemaTo writes the OpenAPI schema to a file. func (g *generator) WriteSchemaTo(path string) error { format := "yaml" if strings.HasSuffix(path, ".json") { format = "json" } else if !strings.HasSuffix(path, ".yaml") && !strings.HasSuffix(path, ".yml") { - return fmt.Errorf("unsupported file extension: %s, expected '.json' or '.yaml' or '.yml'", path) + return fmt.Errorf("unsupported file extension: %s, expected .json, .yaml, or .yml", path) } schema, err := g.GenerateSchema(format) if err != nil { return err } - return os.WriteFile(path, schema, 0600) + return os.WriteFile(path, schema, 0o600) } -// Validate checks whether the OpenAPI specification is valid. func (g *generator) Validate() error { - g.buildOnce() - - return g.reflector.Validate() + g.build() + g.state.mu.Lock() + defer g.state.mu.Unlock() + return joinErrors(append([]error(nil), g.state.errs...)) } -func (g *generator) buildOnce() { - g.once.Do(func() { - for _, r := range g.build() { - path := r.path - if g.cfg.StripTrailingSlash && len(path) > 1 { - path = strings.TrimRight(path, "/") - } - g.reflector.Add(r.method, path, r.opts...) - } - }) -} +func (g *generator) build() { + g.state.mu.Lock() + defer g.state.mu.Unlock() -func (g *generator) build() []*route { - var routes []*route - for _, r := range g.routes { - if r.method == "" || r.path == "" { - continue // Skip incomplete routes - } + g.state.errs = nil + g.state.doc = newDocument(g.cfg) + g.state.builder = builder.NewBuilder(g.cfg, g.state.doc) - opts, ok := g.buildRouteGroupOpts() - if !ok { + if !supportedVersion(g.cfg.OpenAPIVersion) { + g.state.errs = append(g.state.errs, fmt.Errorf("unsupported OpenAPI version: %s", g.cfg.OpenAPIVersion)) + return + } + routes := g.state.root.collectRoutes(nil) + for _, item := range routes { + if item.method == "" || item.path == "" { + continue + } + if item.isWebhook { + if err := g.state.builder.AddWebhookOperation(item.method, item.path, item.opts); err != nil { + g.state.errs = append(g.state.errs, err) + } continue } - if len(r.opts) > 0 { - r.opts = append(r.opts, opts...) + path := item.path + if g.cfg.PathParser != nil { + parsed, err := g.cfg.PathParser.Parse(path) + if err != nil { + g.state.errs = append(g.state.errs, fmt.Errorf("failed to parse path %q: %w", path, err)) + continue + } + path = parsed + } + if g.cfg.StripTrailingSlash && len(path) > 1 { + path = strings.TrimRight(path, "/") + } + if err := g.state.builder.AddOperation(item.method, path, item.opts); err != nil { + g.state.errs = append(g.state.errs, err) } - routes = append(routes, r) } - - for _, group := range g.groups { - group.opts = append(g.opts, group.opts...) - routes = append(routes, group.build()...) + g.state.builder.Finish() + if g.state.doc.Components == nil { + g.state.doc.Components = &openapi.Components{} + } + for _, customize := range g.cfg.DocumentCustomizers { + customize(g.state.doc) } - return routes + if builder.ComponentsEmpty(g.state.doc.Components) { + g.state.doc.Components = nil + } + g.state.errs = append(g.state.errs, validate.ValidateDocument(g.state.doc, g.cfg.OpenAPIVersion)...) } -func (g *generator) buildRouteGroupOpts() ([]option.OperationOption, bool) { - var opts []option.OperationOption - if len(g.opts) > 0 { - cfg := &option.GroupConfig{} - for _, opt := range g.opts { - opt(cfg) - } - if cfg.Hide { - return nil, false - } - if cfg.Deprecated { - opts = append(opts, option.Deprecated(true)) - } - if len(cfg.Tags) > 0 { - opts = append(opts, option.Tags(cfg.Tags...)) - } - if len(cfg.Security) > 0 { - for _, sec := range cfg.Security { - opts = append(opts, option.Security(sec.Name, sec.Scopes...)) - } +type routeItem struct { + method string + path string + opts []option.OperationOption + isWebhook bool +} + +func (g *generator) collectRoutes(parent []option.GroupOption) []routeItem { + groupOpts := append(append([]option.GroupOption{}, parent...), g.opts...) + var result []routeItem + opOpts, hidden := groupOperationOptions(groupOpts) + if !hidden { + for _, r := range g.routes { + opts := append([]option.OperationOption{}, opOpts...) + opts = append(opts, r.opts...) + result = append(result, routeItem{method: r.method, path: r.path, opts: opts, isWebhook: r.isWebhook}) } } - return opts, true + for _, group := range g.groups { + result = append(result, group.collectRoutes(groupOpts)...) + } + return result } -type route struct { - prefix string // Path prefix for the route - method string - path string - opts []option.OperationOption +func groupOperationOptions(opts []option.GroupOption) ([]option.OperationOption, bool) { + cfg := &option.GroupConfig{} + for _, opt := range opts { + opt(cfg) + } + if cfg.Hide { + return nil, true + } + var out []option.OperationOption + if cfg.Deprecated { + out = append(out, option.Deprecated()) + } + if len(cfg.Tags) > 0 { + out = append(out, option.Tags(cfg.Tags...)) + } + for _, sec := range cfg.Security { + out = append(out, option.Security(sec.Name, sec.Scopes...)) + } + return out, false } -var _ Route = (*route)(nil) - -func (r *route) With(opts ...option.OperationOption) Route { - r.opts = append(r.opts, opts...) - return r +type route struct { + prefix string + method string + path string + opts []option.OperationOption + isWebhook bool + state *sharedState } func (r *route) Method(method string) Route { + r.state.mu.Lock() + defer r.state.mu.Unlock() r.method = method return r } func (r *route) Path(path string) Route { - if r.prefix != "" { - path = util.JoinPath(r.prefix, path) + r.state.mu.Lock() + defer r.state.mu.Unlock() + if !r.isWebhook && r.prefix != "" { + path = joinPath(r.prefix, path) } r.path = path return r } + +func (r *route) With(opts ...option.OperationOption) Route { + r.state.mu.Lock() + defer r.state.mu.Unlock() + r.opts = append(r.opts, opts...) + return r +} + +func newDocument(cfg *openapi.Config) *openapi.Document { + doc := &openapi.Document{ + OpenAPI: cfg.OpenAPIVersion, + Self: cfg.Self, + Info: openapi.Info{ + Title: cfg.Title, + Summary: cfg.InfoSummary, + Description: cfg.Description, + TermsOfService: cfg.TermsOfService, + Contact: cfg.Contact, + License: cfg.License, + Version: cfg.Version, + }, + Servers: cfg.Servers, + Paths: map[string]*openapi.PathItem{}, + Security: cfg.Security, + Tags: cfg.Tags, + ExternalDocs: cfg.ExternalDocs, + } + if cfg.JSONSchemaDialect != "" { + doc.JSONSchemaDialect = cfg.JSONSchemaDialect + } + if len(cfg.SecuritySchemes) > 0 { + doc.Components = &openapi.Components{SecuritySchemes: cfg.SecuritySchemes} + } + return doc +} + +func supportedVersion(version string) bool { + return spec_reflect.IsOpenAPI30(version) || validate.IsOpenAPI31(version) || validate.IsOpenAPI32(version) +} + +func joinPath(prefix, path string) string { + if prefix == "" { + if path == "" { + return "/" + } + return ensureLeadingSlash(path) + } + if path == "" || path == "/" { + return ensureLeadingSlash(prefix) + } + return strings.TrimRight(ensureLeadingSlash(prefix), "/") + "/" + strings.TrimLeft(path, "/") +} + +func ensureLeadingSlash(path string) string { + if path == "" { + return "/" + } + if strings.HasPrefix(path, "/") { + return path + } + return "/" + path +} + +func optional[T any](fallback T, values ...T) T { + if len(values) == 0 { + return fallback + } + return values[0] +} diff --git a/router_internal_test.go b/router_internal_test.go new file mode 100644 index 0000000..2ebf062 --- /dev/null +++ b/router_internal_test.go @@ -0,0 +1,68 @@ +package spec + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/oaswrap/spec/option" +) + +func TestJoinPath(t *testing.T) { + tests := []struct { + name string + prefix string + path string + want string + }{ + {name: "empty both", prefix: "", path: "", want: "/"}, + {name: "empty prefix", prefix: "", path: "users", want: "/users"}, + {name: "empty path", prefix: "/api", path: "", want: "/api"}, + {name: "slash path", prefix: "/api/", path: "/", want: "/api/"}, + {name: "normal join", prefix: "/api/", path: "/users", want: "/api/users"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, joinPath(tt.prefix, tt.path)) + }) + } +} + +func TestEnsureLeadingSlashAndOptional(t *testing.T) { + assert.Equal(t, "/", ensureLeadingSlash("")) + assert.Equal(t, "/x", ensureLeadingSlash("x")) + assert.Equal(t, "/x", ensureLeadingSlash("/x")) + + assert.Equal(t, "fallback", optional("fallback")) + assert.Equal(t, "value", optional("fallback", "value")) +} + +func TestGroupOperationOptions(t *testing.T) { + t.Run("hidden group", func(t *testing.T) { + opts, hidden := groupOperationOptions([]option.GroupOption{option.GroupHidden()}) + assert.Nil(t, opts) + assert.True(t, hidden) + }) + + t.Run("maps group options to op options", func(t *testing.T) { + opts, hidden := groupOperationOptions([]option.GroupOption{ + option.GroupDeprecated(), + option.GroupTags("g1", "g2"), + option.GroupSecurity("apiKey", "read"), + }) + assert.False(t, hidden) + assert.Len(t, opts, 3) + }) +} + +func TestRoutePathRespectsPrefixForNonWebhook(t *testing.T) { + r := &route{prefix: "/v1", isWebhook: false, state: &sharedState{mu: sync.Mutex{}}} + r.Path("users") + assert.Equal(t, "/v1/users", r.path) + + webhook := &route{prefix: "/v1", isWebhook: true, state: &sharedState{mu: sync.Mutex{}}} + webhook.Path("user.created") + assert.Equal(t, "user.created", webhook.path) +} diff --git a/router_test.go b/router_test.go index 62c6dc6..ec3b8e2 100644 --- a/router_test.go +++ b/router_test.go @@ -2,1016 +2,514 @@ package spec_test import ( "encoding/json" - "flag" - "fmt" - "os" + "net/http" "path/filepath" - "reflect" - "regexp" - "strings" "testing" - "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/oaswrap/spec" "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" - "github.com/oaswrap/spec/pkg/dto" - "github.com/oaswrap/spec/pkg/testutil" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -//nolint:gochecknoglobals // test flag for golden file updates -var update = flag.Bool("update", false, "update golden files") - -type AllBasicDataTypes struct { - Int int `json:"int"` - Int8 int8 `json:"int8"` - Int16 int16 `json:"int16"` - Int32 int32 `json:"int32"` - Int64 int64 `json:"int64"` - Uint uint `json:"uint"` - Uint8 uint8 `json:"uint8"` - Uint16 uint16 `json:"uint16"` - Uint32 uint32 `json:"uint32"` - Uint64 uint64 `json:"uint64"` - Float32 float32 `json:"float32"` - Float64 float64 `json:"float64"` - Byte byte `json:"byte"` - Rune rune `json:"rune"` - String string `json:"string"` - Bool bool `json:"bool"` +type LoginRequest struct { + Username string `json:"username" required:"true" minLength:"3"` + Password string `json:"password" required:"true" writeOnly:"true"` } -type AllBasicDataTypesPointers struct { - Int *int `json:"int"` - Int8 *int8 `json:"int8"` - Int16 *int16 `json:"int16"` - Int32 *int32 `json:"int32"` - Int64 *int64 `json:"int64"` - Uint *uint `json:"uint"` - Uint8 *uint8 `json:"uint8"` - Uint16 *uint16 `json:"uint16"` - Uint32 *uint32 `json:"uint32"` - Uint64 *uint64 `json:"uint64"` - Float32 *float32 `json:"float32"` - Float64 *float64 `json:"float64"` - Byte *byte `json:"byte"` - Rune *rune `json:"rune"` - String *string `json:"string"` - Bool *bool `json:"bool"` +type LoginResponse struct { + Token string `json:"token" required:"true"` } -type LoginRequest struct { - Username string `json:"username" example:"john_doe" validate:"required"` - Password string `json:"password" example:"password123" validate:"required"` +type GetUserRequest struct { + ID string `path:"id" required:"true" description:"User identifier"` } -type Response[T any] struct { - Status int `json:"status" example:"200"` - Data T `json:"data"` +type User struct { + ID string `json:"id" required:"true"` + Name string `json:"name"` } -type Token struct { - Token string `json:"token" example:"abc123"` -} +func TestRouter_GenerateSchema_OpenAPI304(t *testing.T) { + r := spec.NewRouter( + option.WithTitle("Users API"), + option.WithVersion("1.2.3"), + option.WithServer("https://api.example.com"), + option.WithSecurity("bearerAuth", option.SecurityHTTPBearer("bearer")), + ) + + v1 := r.Group("/api/v1", option.GroupTags("v1")) + v1.Post("/login", + option.Summary("Login"), + option.Request(new(LoginRequest)), + option.Response(200, new(LoginResponse)), + ) + v1.Get("/users/{id}", + option.Summary("Get user"), + option.Request(new(GetUserRequest)), + option.Response(200, new(User)), + ) + + raw, err := r.GenerateSchema("json") + require.NoError(t, err) + + var doc openapi.Document + err = json.Unmarshal(raw, &doc) + require.NoError(t, err, "unmarshal generated JSON:\n%s", raw) + + assert.Equal(t, openapi.Version304, doc.OpenAPI) + assert.Equal(t, "Users API", doc.Info.Title) + assert.Equal(t, "1.2.3", doc.Info.Version) + assert.NotNil(t, doc.Paths["/api/v1/login"].Post) + + get := doc.Paths["/api/v1/users/{id}"].Get + require.NotNil(t, get) + if assert.Len(t, get.Parameters, 1) { + assert.Equal(t, "id", get.Parameters[0].Name) + assert.True(t, get.Parameters[0].Required) + } -type NullString struct { - String string - Valid bool + assert.NotContains(t, doc.Components.Schemas, "user", "component names should use exported Go type names") + assert.NotNil(t, doc.Components.Schemas["User"]) + assert.NotNil(t, doc.Components.Schemas["LoginRequest"]) + assert.Equal(t, "http", doc.Components.SecuritySchemes["bearerAuth"].Type) } -type NullTime struct { - Time time.Time - Valid bool + +func TestRouter_OpenAPI320_Features(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version320), + option.WithTitle("Search API"), + option.WithVersion("1.0.0"), + ) + r.Query("/search", option.Response(200, new([]User))) + r.Add("PURGE", "/cache", option.Response(204, nil)) + r.Add(http.MethodConnect, "/tunnel", option.Response(204, nil)) + + doc := r.Document() + require.NoError(t, r.Validate()) + assert.NotNil(t, doc.Paths["/search"].Query) + assert.NotNil(t, doc.Paths["/cache"].AdditionalOperations["PURGE"]) + assert.NotNil(t, doc.Paths["/tunnel"].AdditionalOperations[http.MethodConnect]) } -type User struct { - ID int `json:"id"` - Username string `json:"username"` - Email NullString `json:"email"` - Age *int `json:"age,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt NullTime `json:"updated_at"` +func TestRouter_Webhooks_OpenAPI312(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version312), + option.WithTitle("Webhook API"), + option.WithVersion("1.0.0"), + ) + r.Webhook("user.created", option.Response(202, nil)) + r.AddWebhook("POST", "cache.invalidate", option.Response(204, nil)) + + doc := r.Document() + require.NoError(t, r.Validate()) + require.NotNil(t, doc.Webhooks["user.created"].Post) + require.NotNil(t, doc.Webhooks["cache.invalidate"].Post) + assert.NotNil(t, doc.Webhooks["cache.invalidate"].Post.Responses["204"]) } -type CustomParser struct { - re *regexp.Regexp +func TestRouter_Webhooks_OpenAPI304_Rejection(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version304), + option.WithTitle("Webhook API"), + option.WithVersion("1.0.0"), + ) + r.Webhook("user.created", option.Response(202, nil)) + + err := r.Validate() + require.Error(t, err) + assert.ErrorContains(t, err, "webhooks require OpenAPI 3.1.x or 3.2.0") } -func NewCustomParser() *CustomParser { - return &CustomParser{ - re: regexp.MustCompile(`:([a-zA-Z_][a-zA-Z0-9_]*)`), +func TestRouter_MergeResponses(t *testing.T) { + type conflictA struct { + Code string `json:"code"` + } + type conflictB struct { + Message string `json:"message"` } + + r := spec.NewRouter(option.WithTitle("Conflicts"), option.WithVersion("1.0.0")) + r.Get("/items", + option.Response(409, new(conflictA)), + option.Response(409, new(conflictB)), + ) + + doc := r.Document() + require.NoError(t, r.Validate()) + schema := doc.Paths["/items"].Get.Responses["409"].Content["application/json"].Schema + assert.Len(t, schema.OneOf, 2) } -func (p *CustomParser) Parse(path string) (string, error) { - return p.re.ReplaceAllString(path, "{$1}"), nil +func TestMissingPathParamIsAutoAdded(t *testing.T) { + r := spec.NewRouter(option.WithTitle("Invalid"), option.WithVersion("1.0.0")) + r.Get("/users/{id}", option.Response(200, new(User))) + + require.NoError(t, r.Validate()) + doc := r.Document() + params := doc.Paths["/users/{id}"].Get.Parameters + require.Len(t, params, 1) + assert.Equal(t, "id", params[0].Name) + assert.Equal(t, "path", params[0].In) + assert.True(t, params[0].Required) } -type ErrorCustomParser struct{} +func TestUnsupportedVersionFailsValidation(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion("3.3.0"), + option.WithTitle("Invalid"), + option.WithVersion("1.0.0"), + ) + r.Get("/ping", option.Response(204, nil)) -func (p *ErrorCustomParser) Parse(path string) (string, error) { - return "", fmt.Errorf("failed to parse path: %s", path) + err := r.Validate() + assert.ErrorContains(t, err, "unsupported OpenAPI version") } -func TestRouter(t *testing.T) { - tests := []struct { - name string - golden string - opts []option.OpenAPIOption - setup func(r spec.Router) - shouldError bool - }{ - { - name: "Basic Data Types", - golden: "basic_data_types", - setup: func(r spec.Router) { - r.Post("/basic-data-types", - option.OperationID("getBasicDataTypes"), - option.Summary("Get Basic Data Types"), - option.Description("This operation returns all basic data types."), - option.Request(new(AllBasicDataTypes)), - option.Response(200, new(AllBasicDataTypes)), - ) - }, - }, - { - name: "Basic Data Types Pointers", - golden: "basic_data_types_pointers", - setup: func(r spec.Router) { - r.Put("/basic-data-types-pointers", - option.OperationID("getBasicDataTypesPointers"), - option.Summary("Get Basic Data Types Pointers"), - option.Description("This operation returns all basic data types as pointers."), - option.Request(new(AllBasicDataTypesPointers)), - option.Response(200, new(AllBasicDataTypesPointers)), - ) - }, - }, - { - name: "All methods", - golden: "all_methods", - setup: func(r spec.Router) { - type UserDetailRequest struct { - ID int `path:"id" validate:"required"` - } - r.Get("/user", option.OperationID("getUser"), option.Summary("Get User")) - r.Post( - "/user", - option.OperationID("createUser"), - option.Summary("Create User"), - option.Response(201, new(string), option.ContentType("plain/text")), - ) - r.Put( - "/user/{id}", - option.OperationID("updateUser"), - option.Summary("Update User"), - option.Request(new(UserDetailRequest)), - ) - r.Patch( - "/user/{id}", - option.OperationID("patchUser"), - option.Summary("Patch User"), - option.Request(new(UserDetailRequest)), - ) - r.Delete( - "/user/{id}", - option.OperationID("deleteUser"), - option.Summary("Delete User"), - option.Request(new(UserDetailRequest)), - ) - r.Head( - "/user/{id}", - option.OperationID("headUser"), - option.Summary("Head User"), - option.Request(new(UserDetailRequest)), - ) - r.Options("/user", option.OperationID("optionsUser"), option.Summary("Options User")) - r.Trace( - "/user/{id}", - option.OperationID("traceUser"), - option.Summary("Trace User"), - option.Request(new(UserDetailRequest)), - ) - }, - }, - { - name: "Generic Response", - golden: "generic_response", - opts: []option.OpenAPIOption{ - option.WithTags(openapi.Tag{ - Name: "Authentication", - Description: "Operations related to user authentication", - }), - option.WithReflectorConfig( - option.TypeMapping(NullString{}, new(string)), - option.TypeMapping(NullTime{}, new(time.Time)), - ), - option.WithSecurity("bearerAuth", option.SecurityHTTPBearer("Bearer")), - }, - setup: func(r spec.Router) { - r.Post("/login", - option.OperationID("login"), - option.Summary("User Login"), - option.Description("This operation allows users to log in."), - option.Tags("Authentication"), - option.Request(new(LoginRequest)), - option.Response(200, new(Response[Token])), - ) - r.Get("/user", - option.OperationID("getUserProfile"), - option.Summary("Get User Profile"), - option.Description("This operation retrieves the authenticated user's profile."), - option.Tags("Authentication"), - option.Security("bearerAuth"), - option.Response(200, new(Response[User])), - ) - r.Get("/users", - option.OperationID("getUsers"), - option.Summary("Get Users"), - option.Description("This operation retrieves a list of users."), - option.Tags("Authentication"), - option.Security("bearerAuth"), - option.Response(200, new(Response[[]User])), - ) - r.Get("/nested-generic-user", - option.OperationID("getNestedGenericUser"), - option.Summary("Get Nested Generic User"), - option.Description("This operation retrieves a nested generic user."), - option.Tags("Authentication"), - option.Security("bearerAuth"), - option.Response(200, new(Response[Response[User]])), - ) - r.Get("/nested-generic-users", - option.OperationID("getNestedGenericUsers"), - option.Summary("Get Nested Generic Users"), - option.Description("This operation retrieves a nested generic users."), - option.Tags("Authentication"), - option.Security("bearerAuth"), - option.Response(200, new(Response[Response[[]User]])), - ) - }, - }, - { - name: "Custom Type Mapping", - golden: "custom_type_mapping", - opts: []option.OpenAPIOption{ - option.WithSecurity("bearerAuth", option.SecurityHTTPBearer("Bearer")), - option.WithReflectorConfig( - option.TypeMapping(NullString{}, new(string)), - option.TypeMapping(NullTime{}, new(time.Time)), - ), - }, - setup: func(r spec.Router) { - r.Get("/auth/me", - option.OperationID("getUserProfile"), - option.Summary("Get User Profile"), - option.Description("This operation retrieves the authenticated user's profile."), - option.Security("bearerAuth"), - option.Request(new(User)), - option.Response(200, new(User)), - ) - }, - }, - { - name: "Custom Parameter Mapping", - golden: "custom_parameter_mapping", - opts: []option.OpenAPIOption{ - option.WithReflectorConfig( - option.ParameterTagMapping(openapi.ParameterInPath, "param"), - option.ParameterTagMapping(openapi.ParameterInQuery, "query2"), - ), - }, - setup: func(r spec.Router) { - type GetUserByIDRequest struct { - ID int `param:"id"` - ExtraParam string ` query2:"extra_param" required:"true"` - } - r.Get("/user/{id}", - option.OperationID("getUserById"), - option.Summary("Get User by ID"), - option.Description("This operation retrieves a user by ID."), - option.Request(new(GetUserByIDRequest)), - option.Response(200, new(User)), - ) - }, - }, - { - name: "Pet Store", - golden: "petstore", - opts: []option.OpenAPIOption{option.WithTitle("Petstore API"), - option.WithDescription("This is a sample Petstore server."), +func TestSupportedPatchVersions(t *testing.T) { + versions := []string{ + openapi.Version300, + openapi.Version301, + openapi.Version302, + openapi.Version303, + openapi.Version304, + openapi.Version310, + openapi.Version311, + openapi.Version312, + openapi.Version320, + } + + for _, version := range versions { + t.Run(version, func(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(version), + option.WithTitle("Versioned"), option.WithVersion("1.0.0"), - option.WithTermsOfService("https://swagger.io/terms/"), - option.WithContact(openapi.Contact{ - Email: "apiteam@swagger.io", - }), - option.WithLicense(openapi.License{ - Name: "Apache 2.0", - URL: "https://www.apache.org/licenses/LICENSE-2.0.html", - }), - option.WithExternalDocs("https://swagger.io", "Find more info here about swagger"), - option.WithServer("https://petstore3.swagger.io/api/v3"), - option.WithTags( - openapi.Tag{ - Name: "pet", - Description: "Everything about your Pets", - ExternalDocs: &openapi.ExternalDocs{ - Description: "Find out more about our Pets", - URL: "https://swagger.io", - }, - }, - openapi.Tag{ - Name: "store", - Description: "Access to Petstore orders", - ExternalDocs: &openapi.ExternalDocs{ - Description: "Find out more about our Store", - URL: "https://swagger.io", - }, - }, - openapi.Tag{ - Name: "user", - Description: "Operations about user", - }, - ), - option.WithSecurity("petstore_auth", option.SecurityOAuth2( - openapi.OAuthFlows{ - Implicit: &openapi.OAuthFlowsImplicit{ - AuthorizationURL: "https://petstore3.swagger.io/oauth/authorize", - Scopes: map[string]string{ - "write:pets": "modify pets in your account", - "read:pets": "read your pets", - }, - }, - }), - ), - option.WithSecurity("apiKey", option.SecurityAPIKey("api_key", openapi.SecuritySchemeAPIKeyInHeader)), - }, - setup: func(r spec.Router) { - pet := r.Group("/pet", - option.GroupTags("pet"), - option.GroupSecurity("petstore_auth", "write:pets", "read:pets"), - ) - pet.Put("/", - option.OperationID("updatePet"), - option.Summary("Update an existing pet"), - option.Description("Update the details of an existing pet in the store."), - option.Request(new(dto.Pet)), - option.Response(200, new(dto.Pet)), - ) - pet.Post("/", - option.OperationID("addPet"), - option.Summary("Add a new pet"), - option.Description("Add a new pet to the store."), - option.Request(new(dto.Pet)), - option.Response(201, new(dto.Pet)), - ) - pet.Get( - "/findByStatus", - option.OperationID("findPetsByStatus"), - option.Summary("Find pets by status"), - option.Description( - "Finds Pets by status. Multiple status values can be provided with comma separated strings.", - ), - option.Request(new(struct { - Status string `query:"status" enum:"available,pending,sold"` - })), - option.Response(200, new([]dto.Pet)), - ) - pet.Get( - "/findByTags", - option.OperationID("findPetsByTags"), - option.Summary("Find pets by tags"), - option.Description( - "Finds Pets by tags. Multiple tags can be provided with comma separated strings.", - ), - option.Request(new(struct { - Tags []string `query:"tags"` - })), - option.Response(200, new([]dto.Pet)), - ) - pet.Post("/{petId}/uploadImage", - option.OperationID("uploadFile"), - option.Summary("Upload an image for a pet"), - option.Description("Uploads an image for a pet."), - option.Request(new(dto.UploadImageRequest)), - option.Response(200, new(dto.APIResponse)), - ) - pet.Get("/{petId}", - option.OperationID("getPetById"), - option.Summary("Get pet by ID"), - option.Description("Retrieve a pet by its ID."), - option.Request(new(struct { - ID int `path:"petId" required:"true"` - })), - option.Response(200, new(dto.Pet)), - ) - pet.Post("/{petId}", - option.OperationID("updatePetWithForm"), - option.Summary("Update pet with form"), - option.Description("Updates a pet in the store with form data."), - option.Request(new(dto.UpdatePetWithFormRequest)), - option.Response(200, nil), - ) - pet.Delete("/{petId}", - option.OperationID("deletePet"), - option.Summary("Delete a pet"), - option.Description("Delete a pet from the store by its ID."), - option.Request(new(dto.DeletePetRequest)), - option.Response(204, nil), - ) - store := r.Group("/store", - option.GroupTags("store"), - ) - store.Post("/order", - option.OperationID("placeOrder"), - option.Summary("Place an order"), - option.Description("Place a new order for a pet."), - option.Request(new(dto.Order)), - option.Response(201, new(dto.Order)), - ) - store.Get("/order/{orderId}", - option.OperationID("getOrderById"), - option.Summary("Get order by ID"), - option.Description("Retrieve an order by its ID."), - option.Request(new(struct { - ID int `path:"orderId" required:"true"` - })), - option.Response(200, new(dto.Order)), - option.Response(404, nil), - ) - store.Delete("/order/{orderId}", - option.OperationID("deleteOrder"), - option.Summary("Delete an order"), - option.Description("Delete an order by its ID."), - option.Request(new(struct { - ID int `path:"orderId" required:"true"` - })), - option.Response(204, nil), - ) - - user := r.Group("/user", - option.GroupTags("user"), - ) - user.Post("/createWithList", - option.OperationID("createUsersWithList"), - option.Summary("Create users with list"), - option.Description("Create multiple users in the store with a list."), - option.Request(new([]dto.PetUser)), - option.Response(201, nil), - ) - user.Post("/", - option.OperationID("createUser"), - option.Summary("Create a new user"), - option.Description("Create a new user in the store."), - option.Request(new(dto.PetUser)), - option.Response(201, new(dto.PetUser)), - ) - user.Get("/{username}", - option.OperationID("getUserByName"), - option.Summary("Get user by username"), - option.Description("Retrieve a user by their username."), - option.Request(new(struct { - Username string `path:"username" required:"true"` - })), - option.Response(200, new(dto.PetUser)), - option.Response(404, nil), - ) - user.Put("/{username}", - option.OperationID("updateUser"), - option.Summary("Update an existing user"), - option.Description("Update the details of an existing user."), - option.Request(new(struct { - dto.PetUser - - Username string `path:"username" required:"true"` - })), - option.Response(200, new(dto.PetUser)), - option.Response(404, nil), - ) - user.Delete("/{username}", - option.OperationID("deleteUser"), - option.Summary("Delete a user"), - option.Description("Delete a user from the store by their username."), - option.Request(new(struct { - Username string `path:"username" required:"true"` - })), - option.Response(204, nil), - ) - }, - }, - { - name: "All Operation Options", - golden: "all_operation_options", - opts: []option.OpenAPIOption{ - option.WithSecurity("apiKey", option.SecurityAPIKey("x-api-key", "header")), - }, - setup: func(r spec.Router) { - r.Post("/operation/options", - option.OperationID("postOperationOptions"), - option.Summary("Post Operation Options"), - option.Description("This operation retrieves all operation options."), - option.Security("apiKey"), - option.Tags("Operation Options"), - option.Deprecated(), - option.Request(new(LoginRequest), - option.ContentType("application/json"), - option.ContentDescription("Request body for operation options"), - ), - option.Response(200, new(Response[User]), - option.ContentType("application/json"), - option.ContentDescription("Response body for operation options"), - option.ContentDefault(true), - ), - ) - }, - }, - { - name: "Hide Operation", - golden: "hide_operation", - setup: func(r spec.Router) { - r.Get("/hidden/operation", - option.OperationID("hiddenOperation"), - option.Summary("Hidden Operation"), - option.Description("This operation is hidden and should not appear in the spec."), - option.Hidden(), - option.Request(new(LoginRequest)), - option.Response(200, new(Response[User])), - ) - }, - }, - { - name: "All Reflector Options", - golden: "all_reflector_options", - opts: []option.OpenAPIOption{ - option.WithReflectorConfig( - option.InlineRefs(), - option.RootRef(), - option.RootNullable(), - option.StripDefNamePrefix("Test", "Mock"), - option.InterceptDefNameFunc(func(_ reflect.Type, defaultDefName string) string { - return defaultDefName + "_Custom" - }), - option.InterceptPropFunc(func(_ openapi.InterceptPropParams) error { - return nil - }), - option.RequiredPropByValidateTag(), - option.InterceptSchemaFunc(func(_ openapi.InterceptSchemaParams) (bool, error) { - return false, nil - }), - option.TypeMapping(NullString{}, new(string)), - option.TypeMapping(NullTime{}, new(time.Time)), - ), - }, - setup: func(r spec.Router) { - r.Get("/reflector/options", - option.OperationID("getReflectorOptions"), - option.Summary("Get Reflector Options"), - option.Description("This operation retrieves the OpenAPI reflector options."), - option.Request(new(LoginRequest)), - option.Response(200, new(Response[User])), - ) - }, - }, - { - name: "Group Routes", - golden: "group_routes", - opts: []option.OpenAPIOption{ - option.WithSecurity("bearerAuth", option.SecurityHTTPBearer("Bearer")), - option.WithReflectorConfig( - option.TypeMapping(NullString{}, new(string)), - option.TypeMapping(NullTime{}, new(time.Time)), - ), - }, - setup: func(r spec.Router) { - api := r.Group("/api") - v1 := api.Group("/v1") - v1.Route("/auth", func(r spec.Router) { - r.Post("/login", - option.Summary("User Login v1"), - option.Request(new(LoginRequest)), - option.Response(200, new(Token)), - ) - r.Get("/me", - option.Summary("Get Profile v1"), - option.Response(200, new(User)), - ).With(option.Security("bearerAuth")) - }, option.GroupDeprecated(), option.GroupTags("Authentication")) - v1.Route("/profile", func(r spec.Router) { - r.Get("/", - option.Summary("Get Profile v1"), - option.Response(200, new(User)), - ) - }, option.GroupHidden()) - v2 := api.Group("/v2") - v2.Route("/auth", func(r spec.Router) { - r.Post("/login", - option.Summary("User Login v2"), - option.Request(new(LoginRequest)), - option.Response(200, new(Token)), - ) - auth := r.Group("/", option.GroupSecurity("bearerAuth")) - auth.Get("/me", - option.Summary("Get Profile v2"), - option.Response(200, new(User)), - ).With(option.Tags("Profile")) - }, option.GroupTags("Authentication")) - v2.Route("/profile", func(r spec.Router) { - r.Put("/", - option.Summary("Update Profile v2"), - option.Request(new(User)), - option.Response(200, new(User)), - ) - }, option.GroupSecurity("bearerAuth")).With(option.GroupTags("Profile")) - }, - }, - { - name: "Custom Path Parser", - golden: "custom_path_parser", - opts: []option.OpenAPIOption{ - option.WithPathParser(NewCustomParser()), - }, - setup: func(r spec.Router) { - r.Get("/user/:id", - option.OperationID("getUserById"), - option.Summary("Get User by ID"), - option.Description("This operation retrieves a user by ID."), - option.Request(new(struct { - ID int `path:"id"` - })), - option.Response(200, new(User)), - ) - }, - }, - { - name: "Duplicate Status Code Responses", - golden: "duplicate_status_code_responses", - setup: func(r spec.Router) { - type SuccessA struct { - Message string `json:"message"` - } - type SuccessB struct { - Count int `json:"count"` - } - r.Get("/mixed", - option.OperationID("getMixed"), - option.Summary("Get Mixed Responses"), - option.Description("Returns one of two possible response shapes."), - option.Response(200, new(SuccessA)), - option.Response(200, new(SuccessB)), - ) - }, - }, - { - name: "Strip Trailing Slashes", - golden: "strip_trailing_slashes", - opts: []option.OpenAPIOption{ - option.WithStripTrailingSlash(), - }, - setup: func(r spec.Router) { - r.Get("/path/with/trailing/slash/", - option.OperationID("getPathWithTrailingSlash"), - option.Summary("Get Path With Trailing Slash"), - option.Description("This operation tests paths with trailing slashes."), - option.Response(200, new(User)), - ) - r.Route("/api/v1", func(r spec.Router) { - r.Route("/users", func(r spec.Router) { - r.Get("/", - option.Summary("Get Users"), - option.Response(200, new([]User)), - option.Tags("Users"), - ) - }) - }) - }, - }, - { - name: "Server Variables", - golden: "server_variables", - opts: []option.OpenAPIOption{ - option.WithServer("https://api.example.com/{version}", - option.ServerDescription("Production Server"), - option.ServerVariables(map[string]openapi.ServerVariable{ - "version": { - Default: "v1", - Enum: []string{"v1", "v2"}, - Description: "API version", - }, - }), - ), - option.WithServer("https://api.example.dev/{version}", - option.ServerDescription("Development Server"), - option.ServerVariables(map[string]openapi.ServerVariable{ - "version": { - Default: "v1", - Enum: []string{"v1", "v2"}, - Description: "API version", - }, - }), - ), - }, - }, - { - name: "Spec Information", - golden: "spec_information", - opts: []option.OpenAPIOption{ - option.WithContact(openapi.Contact{ - Name: "Support Team", - URL: "https://support.example.com", - Email: "support@example.com", - }), - option.WithLicense(openapi.License{ - Name: "MIT License", - URL: "https://opensource.org/licenses/MIT", - }), - option.WithExternalDocs("https://docs.example.com", "API Documentation"), - }, - }, - { - name: "Mux Route", - golden: "mux_route", - opts: []option.OpenAPIOption{ - option.WithSecurity("bearerAuth", option.SecurityHTTPBearer("Bearer")), - }, - setup: func(r spec.Router) { - api := r.Group("api") - v1 := api.Group("v1") - v1.NewRoute().Method("POST").Path("/login").With( - option.Summary("User Login v1"), - option.Request(new(LoginRequest)), - option.Response(200, new(Token)), - ) - auth := v1.Group("/", option.GroupSecurity("bearerAuth")) - auth.NewRoute().Method("GET").Path("/profile").With( - option.Summary("Get Profile v1"), - option.Response(200, new(User)), - ) - }, - }, - { - name: "Invalid OpenAPI Version", - opts: []option.OpenAPIOption{ - option.WithOpenAPIVersion("2.0.0"), // Invalid version for OpenAPI 3.x - }, - shouldError: true, - }, - { - name: "Invalid URL Path Parameter", - setup: func(r spec.Router) { - r.Get("/user/{id}", - option.OperationID("getUserById"), - option.Summary("Get User by ID"), - option.Description("This operation retrieves a user by ID."), - option.Request(new(struct { - ID int `params:"id"` - })), - ) - }, - shouldError: true, // Invalid path parameter without a proper tag - }, - { - name: "Error Custom Path Parser", - opts: []option.OpenAPIOption{ - option.WithPathParser(&ErrorCustomParser{}), - }, - setup: func(r spec.Router) { - r.Get("/user/:id", - option.OperationID("getUserById"), - option.Summary("Get User by ID"), - option.Description("This operation retrieves a user by ID."), - option.Request(new(struct { - ID int `path:"id"` - })), - option.Response(200, new(User)), - ) - }, - shouldError: true, // Custom parser should fail - }, + ) + r.Get("/ping", option.Response(200, nil)) + require.NoError(t, r.Validate()) + assert.Equal(t, version, r.Document().OpenAPI) + }) } +} - versions := map[string]string{ - "3.0.0": "3", - "3.1.0": "31", +func TestSchemaSkipsJSONIgnoredFields(t *testing.T) { + type Payload struct { + Public string `json:"public"` + Secret string `json:"-"` } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - for version, alias := range versions { - opts := []option.OpenAPIOption{ - option.WithTitle("API Doc: " + tt.name), - option.WithDescription("This is the API documentation for " + tt.name), - option.WithOpenAPIVersion(version), - option.WithVersion("1.0.0"), - option.WithReflectorConfig(option.RequiredPropByValidateTag()), - } - if len(tt.opts) > 0 { - opts = append(opts, tt.opts...) - } - r := spec.NewRouter(opts...) - - if tt.setup != nil { - tt.setup(r) - } - - if tt.shouldError { - require.Error(t, r.Validate(), "Expected router to fail validation") - continue - } - require.NoError(t, r.Validate(), "Router validation failed") - - schema, err := r.GenerateSchema("yaml") - require.NoError(t, err) - - golden := fmt.Sprintf("%s_%s.yaml", tt.golden, alias) - - goldenFile := filepath.Join("testdata", golden) - - if *update { - err = os.WriteFile(goldenFile, schema, 0644) - require.NoError(t, err, "failed to write golden file") - t.Logf("Updated golden file: %s", goldenFile) - } - - want, err := os.ReadFile(goldenFile) - require.NoError(t, err, "failed to read golden file %s", goldenFile) - - testutil.EqualYAML(t, want, schema) - } - }) + r := spec.NewRouter(option.WithTitle("Ignored"), option.WithVersion("1.0.0")) + r.Post("/payload", option.Request(new(Payload)), option.Response(204, nil)) + + doc := r.Document() + require.NoError(t, r.Validate()) + payload := doc.Components.Schemas["Payload"] + assert.NotNil(t, payload.Properties["public"]) + assert.NotContains(t, payload.Properties, "Secret") + assert.NotContains(t, payload.Properties, "secret") +} + +func TestEmptySecurityScopesRenderAsArray(t *testing.T) { + r := spec.NewRouter( + option.WithTitle("Secure API"), + option.WithVersion("1.0.0"), + option.WithSecurity("apiKey", option.SecurityAPIKey("X-API-Key", "header")), + ) + r.Get("/me", option.Security("apiKey"), option.Response(204, nil)) + + raw, err := r.GenerateSchema("yaml") + require.NoError(t, err) + assert.NotContains(t, string(raw), "apiKey: null") + assert.Contains(t, string(raw), "apiKey: []") +} + +func TestAllowMutationAfterBuild(t *testing.T) { + r := spec.NewRouter(option.WithTitle("Built"), option.WithVersion("1.0.0")) + r.Get("/before", option.Response(200, nil)) + + require.NoError(t, r.Validate()) + require.NotNil(t, r.Document().Paths["/before"]) + + // Add more routes after first build/validate + r.Get("/after", option.Response(200, nil)) + route := r.NewRoute() + route.Method("POST").Path("/after-route").With(option.Response(201, nil)) + + // Validate again, should succeed and include new routes + require.NoError(t, r.Validate()) + doc := r.Document() + require.NotNil(t, doc.Paths["/before"]) + assert.NotNil(t, doc.Paths["/after"]) + assert.NotNil(t, doc.Paths["/after-route"]) +} + +func TestRouterAdditionalMethods(t *testing.T) { + r := spec.NewRouter() + r.Patch("/patch", option.Response(204, nil)) + r.Options("/options", option.Response(204, nil)) + r.Head("/head", option.Response(204, nil)) + r.Trace("/trace", option.Response(204, nil)) + + doc := r.Document() + methods := []string{"patch", "options", "head", "trace"} + for _, m := range methods { + path := "/" + m + assert.NotNil(t, doc.Paths[path], "expected path %s", path) } } -func TestRouter_GenerateSchema(t *testing.T) { - tests := []struct { - name string - formats []string - expectError bool - errorMsg string - }{ - { - name: "Default format (YAML)", - formats: nil, - }, - { - name: "Explicit YAML format", - formats: []string{"yaml"}, - }, - { - name: "JSON format", - formats: []string{"json"}, - }, - { - name: "Unsupported format", - formats: []string{"xml"}, - expectError: true, - errorMsg: "unsupported format: xml, expected one of json, yaml, yml", - }, - { - name: "Empty string format", - formats: []string{""}, - expectError: true, - errorMsg: "unsupported format: , expected one of json, yaml, yml", - }, - { - name: "Invalid format", - formats: []string{"invalid"}, - expectError: true, - errorMsg: "unsupported format: invalid, expected one of json, yaml, ym", - }, +func TestRouteFluentAPI(t *testing.T) { + r := spec.NewRouter() + r.NewRoute(). + Method(http.MethodPost). + Path("/fluent"). + With(option.OperationID("fluentOp"), option.Response(201, nil)) + + doc := r.Document() + require.NotNil(t, doc.Paths["/fluent"]) + require.NotNil(t, doc.Paths["/fluent"].Post) + assert.Equal(t, "fluentOp", doc.Paths["/fluent"].Post.OperationID) +} + +func TestRouterGroupWith(t *testing.T) { + r := spec.NewRouter() + g := r.Group("/api") + g.With(option.GroupTags("api")).Get("/ping", option.Response(204, nil)) + + doc := r.Document() + op := doc.Paths["/api/ping"].Get + if assert.Len(t, op.Tags, 1) { + assert.Equal(t, "api", op.Tags[0]) } +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := spec.NewRouter( - option.WithOpenAPIVersion("3.1.0"), - option.WithTitle("Test API"), - option.WithVersion("1.0.0"), - ) +func TestRouterGenerateSchemaErrors(t *testing.T) { + r := spec.NewRouter() + r.Get("/ping", option.Security("missing"), option.Response(204, nil)) + _, err := r.GenerateSchema("yaml") + require.Error(t, err) - // Add a simple operation to ensure we have some content - r.Add("GET", "/test", - option.OperationID("test"), - option.Summary("Test operation"), - option.Description("This is a test operation."), - ) + r = spec.NewRouter() + r.Get("/ping", option.Response(204, nil)) + _, err = r.GenerateSchema("invalid") + require.Error(t, err) +} - schema, err := r.GenerateSchema(tt.formats...) +func TestRouterAutoAddsPathParameterWithoutRequestStruct(t *testing.T) { + r := spec.NewRouter() + r.Get("/users/{id}", option.Response(200, nil)) + + require.NoError(t, r.Validate()) + doc := r.Document() + params := doc.Paths["/users/{id}"].Get.Parameters + require.Len(t, params, 1) + assert.Equal(t, "id", params[0].Name) + assert.Equal(t, "path", params[0].In) + assert.True(t, params[0].Required) + require.NotNil(t, params[0].Schema) + assert.Equal(t, "string", params[0].Schema.Type) +} - if tt.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.errorMsg) - assert.Nil(t, schema) - return - } +func TestRouterMarshal(t *testing.T) { + r := spec.NewRouter(option.WithTitle("Test")) + r.Get("/ping", option.Response(204, nil)) - require.NoError(t, err) - assert.NotNil(t, schema) - assert.NotEmpty(t, schema) - - // Verify format-specific content - if len(tt.formats) == 0 || tt.formats[0] == "yaml" { - // YAML format should contain YAML-specific syntax - assert.Contains(t, string(schema), "openapi:") - assert.Contains(t, string(schema), "info:") - } else if tt.formats[0] == "json" { - // JSON format should be valid JSON with proper indentation - assert.True(t, json.Valid(schema), "Generated JSON should be valid") - assert.Contains(t, string(schema), "{\n \"openapi\":") - assert.Contains(t, string(schema), "\"info\":") - } - }) + data, err := json.Marshal(r) + require.NoError(t, err) + + var out map[string]any + err = json.Unmarshal(data, &out) + require.NoError(t, err) + + info := out["info"].(map[string]any) + assert.Equal(t, "Test", info["title"]) +} + +func TestRouterUsabilityOptions(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version320), + option.WithJSONSchemaDialect("https://json-schema.org/draft/2020-12/schema"), + option.WithInfoSummary("Short summary"), + option.WithTag("commerce", option.TagSummary("Commerce"), option.TagKind("nav")), + option.WithTag("payments", option.TagParent("commerce"), option.TagDescription("Payment operations")), + option.WithComponentExample("UserExample", &openapi.Example{Value: map[string]any{"id": "123"}}), + option.WithSecurity("oauth2", + option.SecurityOAuth2ClientCredentials( + "https://auth.example.com/token", + map[string]string{"payments:read": "Read payments"}, + ), + option.SecurityOAuth2MetadataURL("https://auth.example.com/.well-known/oauth-authorization-server"), + ), + ) + r.Get("/users", + option.ExternalDocs("https://docs.example.com/users/get", "Get user docs"), + option.Response(200, new(User), + option.ContentNamedExample("sample", map[string]any{"id": "123"}, option.ExampleSummary("Sample user")), + ), + ) + + require.NoError(t, r.Validate()) + doc := r.Document() + assert.Equal(t, "https://json-schema.org/draft/2020-12/schema", doc.JSONSchemaDialect) + assert.Equal(t, "Short summary", doc.Info.Summary) + assert.Equal(t, "commerce", doc.Tags[1].Parent) + assert.Equal( + t, + "https://auth.example.com/token", + doc.Components.SecuritySchemes["oauth2"].Flows.ClientCredentials.TokenURL, + ) + + op := doc.Paths["/users"].Get + assert.Equal(t, "https://docs.example.com/users/get", op.ExternalDocs.URL) + example := op.Responses["200"].Content["application/json"].Examples["sample"] + assert.Equal(t, "Sample user", example.Summary) + assert.Equal(t, map[string]any{"id": "123"}, example.Value) +} + +func TestRouterWith(t *testing.T) { + r := spec.NewRouter() + r.With(option.GroupTags("global")).Get("/ping", option.Response(204, nil)) + doc := r.Document() + op := doc.Paths["/ping"].Get + if assert.Len(t, op.Tags, 1) { + assert.Equal(t, "global", op.Tags[0]) } } func TestRouter_WriteSchemaTo(t *testing.T) { - tests := []struct { - name string - path string - expectError bool - expectJSON bool - }{ - { - name: "Write YAML file", - path: "test_schema.yaml", - expectJSON: false, - }, - { - name: "Write JSON file", - path: "test_schema.json", - expectJSON: true, - }, - { - name: "Write file without extension (defaults to YAML)", - path: "test_schema", - expectError: true, - }, - { - name: "Write file with .yml extension (YAML)", - path: "test_schema.yml", - expectJSON: false, - }, - { - name: "Write to invalid path", - path: "/invalid/path/that/does/not/exist/test.yaml", - expectError: true, - }, - } + tmp := t.TempDir() + + t.Run("SuccessYAML", func(t *testing.T) { + r := spec.NewRouter() + r.Get("/ping", option.Response(204, nil)) + err := r.WriteSchemaTo(filepath.Join(tmp, "test.yaml")) + require.NoError(t, err) + }) + + t.Run("SuccessJSON", func(t *testing.T) { + r := spec.NewRouter() + r.Get("/ping", option.Response(204, nil)) + err := r.WriteSchemaTo(filepath.Join(tmp, "test.json")) + require.NoError(t, err) + }) + + t.Run("InvalidExtension", func(t *testing.T) { + r := spec.NewRouter() + err := r.WriteSchemaTo(filepath.Join(tmp, "test.txt")) + require.Error(t, err) + }) +} +func TestRouter_PathHelpers(t *testing.T) { + r := spec.NewRouter() + r.Get("ping", option.Response(204, nil)) // should ensure leading slash + doc := r.Document() + assert.Contains(t, doc.Paths, "/ping") +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create router with test configuration - r := spec.NewRouter( - option.WithOpenAPIVersion("3.1.0"), - option.WithTitle("Test API"), - option.WithVersion("1.0.0"), - ) +func TestRouter_StripTrailingSlash(t *testing.T) { + r := spec.NewRouter(option.WithStripTrailingSlash(true)) + r.Get("/ping/", option.Response(204, nil)) + doc := r.Document() + assert.Contains(t, doc.Paths, "/ping") +} - // Add a simple operation to ensure we have content - r.Add("GET", "/test", - option.OperationID("test"), - option.Summary("Test operation"), - option.Description("This is a test operation."), - ) +func TestRouterConfigAndRoute(t *testing.T) { + r := spec.NewRouter(option.WithTitle("Configured"), option.WithVersion("2.0.0")) + cfg := r.Config() + assert.Equal(t, "Configured", cfg.Title) + assert.Equal(t, "2.0.0", cfg.Version) - // Construct full path - var fullPath string - if tt.expectError { - fullPath = tt.path // Use invalid path as-is - } else { - fullPath = filepath.Join(t.TempDir(), tt.path) - } + r.Route("/api", func(router spec.Router) { + router.Get("/ping", option.Response(204, nil)) + }, option.GroupTags("v1")) - // Write schema to file - err := r.WriteSchemaTo(fullPath) + doc := r.Document() + require.Contains(t, doc.Paths, "/api/ping") + if assert.NotNil(t, doc.Paths["/api/ping"].Get) { + assert.Equal(t, []string{"v1"}, doc.Paths["/api/ping"].Get.Tags) + } +} - if tt.expectError { - assert.Error(t, err) - return +func TestRouter_EscapeHatches(t *testing.T) { + r := spec.NewRouter( + option.WithOpenAPIVersion(openapi.Version320), + option.WithTitle("Official Surface"), + option.WithVersion("1.0.0"), + option.WithSecurity("mtls", option.SecurityMutualTLS()), + option.WithDocument(func(doc *openapi.Document) { + doc.Extensions = map[string]any{"x-root": "ok"} + doc.Webhooks = map[string]*openapi.PathItem{ + "user.created": { + Post: &openapi.Operation{ + Responses: map[string]*openapi.Response{ + "202": {Description: "Accepted"}, + }, + }, + }, } - - require.NoError(t, err) - - // Verify file was created - assert.FileExists(t, fullPath) - - // Read and verify file content - content, err := os.ReadFile(fullPath) - require.NoError(t, err) - assert.NotEmpty(t, content) - - if tt.expectJSON { - // Verify JSON format - assert.True(t, json.Valid(content), "File content should be valid JSON") - assert.Contains(t, string(content), "{\n \"openapi\":") - assert.Contains(t, string(content), "\"info\":") - } else { - // Verify YAML format - assert.Contains(t, string(content), "openapi:") - assert.Contains(t, string(content), "info:") - // Ensure it's not JSON format - assert.False(t, strings.HasPrefix(string(content), "{")) + doc.Components.MediaTypes = map[string]*openapi.MediaType{ + "json-seq": { + ItemSchema: &openapi.Schema{Ref: "#/components/schemas/User"}, + ItemEncoding: &openapi.Encoding{ + ContentType: "application/json", + Extensions: map[string]any{"x-encoding": true}, + }, + }, } - }) + doc.Components.Parameters = map[string]*openapi.Parameter{ + "TraceID": { + Name: "X-Trace-ID", + In: "header", + Required: false, + Schema: &openapi.Schema{Type: "string"}, + }, + } + }), + ) + r.Get("/users/{id}", + option.Request(new(GetUserRequest)), + option.Response(200, new(User)), + option.CustomizeOperation(func(op *openapi.Operation) { + op.Extensions = map[string]any{"x-operation": "ok"} + op.Parameters = append(op.Parameters, &openapi.Parameter{Ref: "#/components/parameters/TraceID"}) + }), + ) + + raw, err := r.GenerateSchema("json") + require.NoError(t, err) + + var doc map[string]any + err = json.Unmarshal(raw, &doc) + require.NoError(t, err, "unmarshal generated JSON:\n%s", raw) + + assert.Equal(t, "ok", doc["x-root"]) + + components := doc["components"].(map[string]any) + security := components["securitySchemes"].(map[string]any) + mtls := security["mtls"].(map[string]any) + assert.Equal(t, "mutualTLS", mtls["type"]) + + assert.Contains(t, doc["webhooks"].(map[string]any), "user.created") + + mediaTypes := components["mediaTypes"].(map[string]any) + assert.Contains(t, mediaTypes, "json-seq") + + paths := doc["paths"].(map[string]any) + get := paths["/users/{id}"].(map[string]any)["get"].(map[string]any) + assert.Equal(t, "ok", get["x-operation"]) +} + +func TestRouter_Errors_Unwrap(t *testing.T) { + r := spec.NewRouter() + r.Get("/ping", option.Security("missing"), option.Response(204, nil)) + err := r.Validate() + require.Error(t, err) + + if assert.Implements(t, (*interface{ Unwrap() []error })(nil), err) { + u := err.(interface{ Unwrap() []error }) + assert.NotEmpty(t, u.Unwrap()) } } diff --git a/testdata/all_methods_3.yaml b/testdata/all_methods_3.yaml deleted file mode 100644 index 8cdf481..0000000 --- a/testdata/all_methods_3.yaml +++ /dev/null @@ -1,98 +0,0 @@ -openapi: 3.0.3 -info: - description: This is the API documentation for All methods - title: 'API Doc: All methods' - version: 1.0.0 -paths: - /user: - get: - description: Get User - operationId: getUser - responses: - "204": - description: No Content - summary: Get User - options: - description: Options User - operationId: optionsUser - responses: - "204": - description: No Content - summary: Options User - post: - description: Create User - operationId: createUser - responses: - "201": - content: - plain/text: - schema: - type: string - description: Created - summary: Create User - /user/{id}: - delete: - description: Delete User - operationId: deleteUser - parameters: - - in: path - name: id - required: true - schema: - type: integer - responses: - "204": - description: No Content - summary: Delete User - head: - description: Head User - operationId: headUser - parameters: - - in: path - name: id - required: true - schema: - type: integer - responses: - "204": - description: No Content - summary: Head User - patch: - description: Patch User - operationId: patchUser - parameters: - - in: path - name: id - required: true - schema: - type: integer - responses: - "204": - description: No Content - summary: Patch User - put: - description: Update User - operationId: updateUser - parameters: - - in: path - name: id - required: true - schema: - type: integer - responses: - "204": - description: No Content - summary: Update User - trace: - description: Trace User - operationId: traceUser - parameters: - - in: path - name: id - required: true - schema: - type: integer - responses: - "204": - description: No Content - summary: Trace User diff --git a/testdata/all_methods_31.yaml b/testdata/all_methods_31.yaml deleted file mode 100644 index f5d6a47..0000000 --- a/testdata/all_methods_31.yaml +++ /dev/null @@ -1,100 +0,0 @@ -openapi: 3.1.0 -info: - description: This is the API documentation for All methods - title: 'API Doc: All methods' - version: 1.0.0 -paths: - /user: - get: - description: Get User - operationId: getUser - responses: - "204": - description: No Content - summary: Get User - options: - description: Options User - operationId: optionsUser - responses: - "204": - description: No Content - summary: Options User - post: - description: Create User - operationId: createUser - responses: - "201": - content: - plain/text: - schema: - type: - - "null" - - string - description: Created - summary: Create User - /user/{id}: - delete: - description: Delete User - operationId: deleteUser - parameters: - - in: path - name: id - required: true - schema: - type: integer - responses: - "204": - description: No Content - summary: Delete User - head: - description: Head User - operationId: headUser - parameters: - - in: path - name: id - required: true - schema: - type: integer - responses: - "204": - description: No Content - summary: Head User - patch: - description: Patch User - operationId: patchUser - parameters: - - in: path - name: id - required: true - schema: - type: integer - responses: - "204": - description: No Content - summary: Patch User - put: - description: Update User - operationId: updateUser - parameters: - - in: path - name: id - required: true - schema: - type: integer - responses: - "204": - description: No Content - summary: Update User - trace: - description: Trace User - operationId: traceUser - parameters: - - in: path - name: id - required: true - schema: - type: integer - responses: - "204": - description: No Content - summary: Trace User diff --git a/testdata/all_operation_options_3.yaml b/testdata/all_operation_options_3.yaml deleted file mode 100644 index 9d7703d..0000000 --- a/testdata/all_operation_options_3.yaml +++ /dev/null @@ -1,77 +0,0 @@ -openapi: 3.0.3 -info: - description: This is the API documentation for All Operation Options - title: 'API Doc: All Operation Options' - version: 1.0.0 -paths: - /operation/options: - post: - deprecated: true - description: This operation retrieves all operation options. - operationId: postOperationOptions - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestLoginRequest' - description: Request body for operation options - responses: - default: - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestResponseUser' - description: Response body for operation options - security: - - apiKey: [] - summary: Post Operation Options - tags: - - Operation Options -components: - schemas: - SpecTestLoginRequest: - properties: - password: - example: password123 - type: string - username: - example: john_doe - type: string - required: - - username - - password - type: object - SpecTestNullString: - type: object - SpecTestNullTime: - type: object - SpecTestResponseUser: - properties: - data: - $ref: '#/components/schemas/SpecTestUser' - status: - example: 200 - type: integer - type: object - SpecTestUser: - properties: - age: - nullable: true - type: integer - created_at: - format: date-time - type: string - email: - $ref: '#/components/schemas/SpecTestNullString' - id: - type: integer - updated_at: - $ref: '#/components/schemas/SpecTestNullTime' - username: - type: string - type: object - securitySchemes: - apiKey: - in: header - name: x-api-key - type: apiKey diff --git a/testdata/all_operation_options_31.yaml b/testdata/all_operation_options_31.yaml deleted file mode 100644 index 56c405c..0000000 --- a/testdata/all_operation_options_31.yaml +++ /dev/null @@ -1,81 +0,0 @@ -openapi: 3.1.0 -info: - description: This is the API documentation for All Operation Options - title: 'API Doc: All Operation Options' - version: 1.0.0 -paths: - /operation/options: - post: - deprecated: true - description: This operation retrieves all operation options. - operationId: postOperationOptions - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestLoginRequest' - description: Request body for operation options - responses: - default: - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestResponseUser' - description: Response body for operation options - security: - - apiKey: [] - summary: Post Operation Options - tags: - - Operation Options -components: - schemas: - SpecTestLoginRequest: - properties: - password: - examples: - - password123 - type: string - username: - examples: - - john_doe - type: string - required: - - username - - password - type: object - SpecTestNullString: - type: object - SpecTestNullTime: - type: object - SpecTestResponseUser: - properties: - data: - $ref: '#/components/schemas/SpecTestUser' - status: - examples: - - 200 - type: integer - type: object - SpecTestUser: - properties: - age: - type: - - "null" - - integer - created_at: - format: date-time - type: string - email: - $ref: '#/components/schemas/SpecTestNullString' - id: - type: integer - updated_at: - $ref: '#/components/schemas/SpecTestNullTime' - username: - type: string - type: object - securitySchemes: - apiKey: - in: header - name: x-api-key - type: apiKey diff --git a/testdata/all_reflector_options_3.yaml b/testdata/all_reflector_options_3.yaml deleted file mode 100644 index 1e620bf..0000000 --- a/testdata/all_reflector_options_3.yaml +++ /dev/null @@ -1,40 +0,0 @@ -openapi: 3.0.3 -info: - description: This is the API documentation for All Reflector Options - title: 'API Doc: All Reflector Options' - version: 1.0.0 -paths: - /reflector/options: - get: - description: This operation retrieves the OpenAPI reflector options. - operationId: getReflectorOptions - responses: - "200": - content: - application/json: - schema: - properties: - data: - properties: - age: - nullable: true - type: integer - created_at: - format: date-time - type: string - email: - type: string - id: - type: integer - updated_at: - format: date-time - type: string - username: - type: string - type: object - status: - example: 200 - type: integer - type: object - description: OK - summary: Get Reflector Options diff --git a/testdata/all_reflector_options_31.yaml b/testdata/all_reflector_options_31.yaml deleted file mode 100644 index df9ffb3..0000000 --- a/testdata/all_reflector_options_31.yaml +++ /dev/null @@ -1,44 +0,0 @@ -openapi: 3.1.0 -info: - description: This is the API documentation for All Reflector Options - title: 'API Doc: All Reflector Options' - version: 1.0.0 -paths: - /reflector/options: - get: - description: This operation retrieves the OpenAPI reflector options. - operationId: getReflectorOptions - responses: - "200": - content: - application/json: - schema: - properties: - data: - properties: - age: - type: - - "null" - - integer - created_at: - format: date-time - type: string - email: - type: string - id: - type: integer - updated_at: - format: date-time - type: string - username: - type: string - type: object - status: - examples: - - 200 - type: integer - type: - - object - - "null" - description: OK - summary: Get Reflector Options diff --git a/testdata/anonymous_structs.v30.yaml b/testdata/anonymous_structs.v30.yaml new file mode 100644 index 0000000..18195e4 --- /dev/null +++ b/testdata/anonymous_structs.v30.yaml @@ -0,0 +1,25 @@ +openapi: 3.0.4 +info: + title: API Documentation + version: 1.0.0 +paths: + /anonymous: + post: + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/AnonymousStructRequest" + responses: + "204": + description: No Content +components: + schemas: + AnonymousStructRequest: + type: object + properties: + foo: + type: object + properties: + bar: + type: string diff --git a/testdata/anonymous_structs.v31.yaml b/testdata/anonymous_structs.v31.yaml new file mode 100644 index 0000000..ec99dc9 --- /dev/null +++ b/testdata/anonymous_structs.v31.yaml @@ -0,0 +1,25 @@ +openapi: 3.1.2 +info: + title: API Documentation + version: 1.0.0 +paths: + /anonymous: + post: + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/AnonymousStructRequest" + responses: + "204": + description: No Content +components: + schemas: + AnonymousStructRequest: + type: object + properties: + foo: + type: object + properties: + bar: + type: string diff --git a/testdata/anonymous_structs.v32.yaml b/testdata/anonymous_structs.v32.yaml new file mode 100644 index 0000000..690becd --- /dev/null +++ b/testdata/anonymous_structs.v32.yaml @@ -0,0 +1,25 @@ +openapi: 3.2.0 +info: + title: API Documentation + version: 1.0.0 +paths: + /anonymous: + post: + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/AnonymousStructRequest" + responses: + "204": + description: No Content +components: + schemas: + AnonymousStructRequest: + type: object + properties: + foo: + type: object + properties: + bar: + type: string diff --git a/testdata/basic_data_types_3.yaml b/testdata/basic_data_types_3.yaml deleted file mode 100644 index 57b39ca..0000000 --- a/testdata/basic_data_types_3.yaml +++ /dev/null @@ -1,71 +0,0 @@ -openapi: 3.0.3 -info: - description: This is the API documentation for Basic Data Types - title: 'API Doc: Basic Data Types' - version: 1.0.0 -paths: - /basic-data-types: - post: - description: This operation returns all basic data types. - operationId: getBasicDataTypes - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestAllBasicDataTypes' - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestAllBasicDataTypes' - description: OK - summary: Get Basic Data Types -components: - schemas: - SpecTestAllBasicDataTypes: - properties: - bool: - type: boolean - byte: - minimum: 0 - type: integer - float32: - format: float - type: number - float64: - format: double - type: number - int: - type: integer - int8: - type: integer - int16: - type: integer - int32: - format: int32 - type: integer - int64: - format: int64 - type: integer - rune: - format: int32 - type: integer - string: - type: string - uint: - minimum: 0 - type: integer - uint8: - minimum: 0 - type: integer - uint16: - minimum: 0 - type: integer - uint32: - minimum: 0 - type: integer - uint64: - minimum: 0 - type: integer - type: object diff --git a/testdata/basic_data_types_31.yaml b/testdata/basic_data_types_31.yaml deleted file mode 100644 index 40fc62b..0000000 --- a/testdata/basic_data_types_31.yaml +++ /dev/null @@ -1,71 +0,0 @@ -openapi: 3.1.0 -info: - description: This is the API documentation for Basic Data Types - title: 'API Doc: Basic Data Types' - version: 1.0.0 -paths: - /basic-data-types: - post: - description: This operation returns all basic data types. - operationId: getBasicDataTypes - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestAllBasicDataTypes' - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestAllBasicDataTypes' - description: OK - summary: Get Basic Data Types -components: - schemas: - SpecTestAllBasicDataTypes: - properties: - bool: - type: boolean - byte: - minimum: 0 - type: integer - float32: - format: float - type: number - float64: - format: double - type: number - int: - type: integer - int8: - type: integer - int16: - type: integer - int32: - format: int32 - type: integer - int64: - format: int64 - type: integer - rune: - format: int32 - type: integer - string: - type: string - uint: - minimum: 0 - type: integer - uint8: - minimum: 0 - type: integer - uint16: - minimum: 0 - type: integer - uint32: - minimum: 0 - type: integer - uint64: - minimum: 0 - type: integer - type: object diff --git a/testdata/basic_data_types_pointers_3.yaml b/testdata/basic_data_types_pointers_3.yaml deleted file mode 100644 index 5bfa37a..0000000 --- a/testdata/basic_data_types_pointers_3.yaml +++ /dev/null @@ -1,82 +0,0 @@ -openapi: 3.0.3 -info: - description: This is the API documentation for Basic Data Types Pointers - title: 'API Doc: Basic Data Types Pointers' - version: 1.0.0 -paths: - /basic-data-types-pointers: - put: - description: This operation returns all basic data types as pointers. - operationId: getBasicDataTypesPointers - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestAllBasicDataTypesPointers' - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestAllBasicDataTypesPointers' - description: OK - summary: Get Basic Data Types Pointers -components: - schemas: - SpecTestAllBasicDataTypesPointers: - properties: - bool: - nullable: true - type: boolean - byte: - minimum: 0 - nullable: true - type: integer - float32: - nullable: true - type: number - float64: - nullable: true - type: number - int: - nullable: true - type: integer - int8: - nullable: true - type: integer - int16: - nullable: true - type: integer - int32: - nullable: true - type: integer - int64: - nullable: true - type: integer - rune: - nullable: true - type: integer - string: - nullable: true - type: string - uint: - minimum: 0 - nullable: true - type: integer - uint8: - minimum: 0 - nullable: true - type: integer - uint16: - minimum: 0 - nullable: true - type: integer - uint32: - minimum: 0 - nullable: true - type: integer - uint64: - minimum: 0 - nullable: true - type: integer - type: object diff --git a/testdata/basic_data_types_pointers_31.yaml b/testdata/basic_data_types_pointers_31.yaml deleted file mode 100644 index 1d3b5b8..0000000 --- a/testdata/basic_data_types_pointers_31.yaml +++ /dev/null @@ -1,98 +0,0 @@ -openapi: 3.1.0 -info: - description: This is the API documentation for Basic Data Types Pointers - title: 'API Doc: Basic Data Types Pointers' - version: 1.0.0 -paths: - /basic-data-types-pointers: - put: - description: This operation returns all basic data types as pointers. - operationId: getBasicDataTypesPointers - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestAllBasicDataTypesPointers' - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestAllBasicDataTypesPointers' - description: OK - summary: Get Basic Data Types Pointers -components: - schemas: - SpecTestAllBasicDataTypesPointers: - properties: - bool: - type: - - "null" - - boolean - byte: - minimum: 0 - type: - - "null" - - integer - float32: - type: - - "null" - - number - float64: - type: - - "null" - - number - int: - type: - - "null" - - integer - int8: - type: - - "null" - - integer - int16: - type: - - "null" - - integer - int32: - type: - - "null" - - integer - int64: - type: - - "null" - - integer - rune: - type: - - "null" - - integer - string: - type: - - "null" - - string - uint: - minimum: 0 - type: - - "null" - - integer - uint8: - minimum: 0 - type: - - "null" - - integer - uint16: - minimum: 0 - type: - - "null" - - integer - uint32: - minimum: 0 - type: - - "null" - - integer - uint64: - minimum: 0 - type: - - "null" - - integer - type: object diff --git a/testdata/compatibility_extensions.v30.yaml b/testdata/compatibility_extensions.v30.yaml new file mode 100644 index 0000000..fdb6827 --- /dev/null +++ b/testdata/compatibility_extensions.v30.yaml @@ -0,0 +1,38 @@ +openapi: 3.0.4 +info: + title: Compatibility API + version: 1.0.0 +paths: + /users/{id}: + get: + parameters: + - name: id + in: path + description: User identifier + required: true + schema: + description: User identifier + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/User" + x-operation: ok +components: + schemas: + User: + type: object + required: + - id + properties: + id: + type: string + name: + type: string + securitySchemes: + mtls: + type: mutualTLS +x-root: ok diff --git a/testdata/compatibility_extensions.v31.yaml b/testdata/compatibility_extensions.v31.yaml new file mode 100644 index 0000000..f555f44 --- /dev/null +++ b/testdata/compatibility_extensions.v31.yaml @@ -0,0 +1,44 @@ +openapi: 3.1.2 +info: + title: Compatibility API + version: 1.0.0 +paths: + /users/{id}: + get: + parameters: + - name: id + in: path + description: User identifier + required: true + schema: + description: User identifier + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/User" + x-operation: ok +webhooks: + user.created: + post: + responses: + "202": + description: Accepted +components: + schemas: + User: + type: object + required: + - id + properties: + id: + type: string + name: + type: string + securitySchemes: + mtls: + type: mutualTLS +x-root: ok diff --git a/testdata/complex_types.v30.yaml b/testdata/complex_types.v30.yaml new file mode 100644 index 0000000..905f7ad --- /dev/null +++ b/testdata/complex_types.v30.yaml @@ -0,0 +1,128 @@ +openapi: 3.0.4 +info: + title: Complex API + version: 1.0.0 +paths: + /complex: + post: + operationId: complex + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ComplexRequest" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ComplexResponse" +components: + schemas: + ComplexRequest: + type: object + required: + - string + properties: + any: {} + array: + type: array + maxItems: 5 + minItems: 1 + uniqueItems: true + items: + type: string + bool: + type: boolean + enum: + type: string + enum: + - a + - b + - c + int: + type: integer + format: int32 + maximum: 100.0 + minimum: 1.0 + map: + type: object + additionalProperties: + type: integer + format: int32 + nullable: + type: string + nullable: true + number: + type: number + format: double + multipleOf: 0.5 + object: + type: object + nullable: true + properties: + foo: + type: string + string: + type: string + maxLength: 10 + minLength: 1 + pattern: ^[a-z]+$ + ComplexResponse: + type: object + properties: + data: + type: object + required: + - string + properties: + any: {} + array: + type: array + maxItems: 5 + minItems: 1 + uniqueItems: true + items: + type: string + bool: + type: boolean + enum: + type: string + enum: + - a + - b + - c + int: + type: integer + format: int32 + maximum: 100.0 + minimum: 1.0 + map: + type: object + additionalProperties: + type: integer + format: int32 + nullable: + type: string + nullable: true + number: + type: number + format: double + multipleOf: 0.5 + object: + type: object + nullable: true + properties: + foo: + type: string + string: + type: string + maxLength: 10 + minLength: 1 + pattern: ^[a-z]+$ + message: + type: string + deprecated: true + status: + type: string diff --git a/testdata/complex_types.v31.yaml b/testdata/complex_types.v31.yaml new file mode 100644 index 0000000..16f73e2 --- /dev/null +++ b/testdata/complex_types.v31.yaml @@ -0,0 +1,132 @@ +openapi: 3.1.2 +info: + title: Complex API + version: 1.0.0 +paths: + /complex: + post: + operationId: complex + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ComplexRequest" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ComplexResponse" +components: + schemas: + ComplexRequest: + type: object + required: + - string + properties: + any: {} + array: + type: array + maxItems: 5 + minItems: 1 + uniqueItems: true + items: + type: string + bool: + type: boolean + enum: + type: string + enum: + - a + - b + - c + int: + type: integer + format: int32 + maximum: 100.0 + minimum: 1.0 + map: + type: object + additionalProperties: + type: integer + format: int32 + nullable: + type: + - string + - "null" + number: + type: number + format: double + multipleOf: 0.5 + object: + type: + - object + - "null" + properties: + foo: + type: string + string: + type: string + maxLength: 10 + minLength: 1 + pattern: ^[a-z]+$ + ComplexResponse: + type: object + properties: + data: + type: object + required: + - string + properties: + any: {} + array: + type: array + maxItems: 5 + minItems: 1 + uniqueItems: true + items: + type: string + bool: + type: boolean + enum: + type: string + enum: + - a + - b + - c + int: + type: integer + format: int32 + maximum: 100.0 + minimum: 1.0 + map: + type: object + additionalProperties: + type: integer + format: int32 + nullable: + type: + - string + - "null" + number: + type: number + format: double + multipleOf: 0.5 + object: + type: + - object + - "null" + properties: + foo: + type: string + string: + type: string + maxLength: 10 + minLength: 1 + pattern: ^[a-z]+$ + message: + type: string + deprecated: true + status: + type: string diff --git a/testdata/complex_types.v32.yaml b/testdata/complex_types.v32.yaml new file mode 100644 index 0000000..c24fb18 --- /dev/null +++ b/testdata/complex_types.v32.yaml @@ -0,0 +1,132 @@ +openapi: 3.2.0 +info: + title: Complex API + version: 1.0.0 +paths: + /complex: + post: + operationId: complex + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ComplexRequest" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ComplexResponse" +components: + schemas: + ComplexRequest: + type: object + required: + - string + properties: + any: {} + array: + type: array + maxItems: 5 + minItems: 1 + uniqueItems: true + items: + type: string + bool: + type: boolean + enum: + type: string + enum: + - a + - b + - c + int: + type: integer + format: int32 + maximum: 100.0 + minimum: 1.0 + map: + type: object + additionalProperties: + type: integer + format: int32 + nullable: + type: + - string + - "null" + number: + type: number + format: double + multipleOf: 0.5 + object: + type: + - object + - "null" + properties: + foo: + type: string + string: + type: string + maxLength: 10 + minLength: 1 + pattern: ^[a-z]+$ + ComplexResponse: + type: object + properties: + data: + type: object + required: + - string + properties: + any: {} + array: + type: array + maxItems: 5 + minItems: 1 + uniqueItems: true + items: + type: string + bool: + type: boolean + enum: + type: string + enum: + - a + - b + - c + int: + type: integer + format: int32 + maximum: 100.0 + minimum: 1.0 + map: + type: object + additionalProperties: + type: integer + format: int32 + nullable: + type: + - string + - "null" + number: + type: number + format: double + multipleOf: 0.5 + object: + type: + - object + - "null" + properties: + foo: + type: string + string: + type: string + maxLength: 10 + minLength: 1 + pattern: ^[a-z]+$ + message: + type: string + deprecated: true + status: + type: string diff --git a/testdata/composition.v30.yaml b/testdata/composition.v30.yaml new file mode 100644 index 0000000..b94a24a --- /dev/null +++ b/testdata/composition.v30.yaml @@ -0,0 +1,27 @@ +openapi: 3.0.4 +info: + title: Composition API + version: 1.0.0 +paths: + /oneof: + post: + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/OneOfRequest" + responses: + "200": + description: OK + content: + application/json: + schema: + oneOf: + - type: string + - type: string +components: + schemas: + OneOfRequest: + type: object + properties: + value: {} diff --git a/testdata/composition.v31.yaml b/testdata/composition.v31.yaml new file mode 100644 index 0000000..ce3d2ec --- /dev/null +++ b/testdata/composition.v31.yaml @@ -0,0 +1,27 @@ +openapi: 3.1.2 +info: + title: Composition API + version: 1.0.0 +paths: + /oneof: + post: + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/OneOfRequest" + responses: + "200": + description: OK + content: + application/json: + schema: + oneOf: + - type: string + - type: string +components: + schemas: + OneOfRequest: + type: object + properties: + value: {} diff --git a/testdata/composition.v32.yaml b/testdata/composition.v32.yaml new file mode 100644 index 0000000..d538312 --- /dev/null +++ b/testdata/composition.v32.yaml @@ -0,0 +1,27 @@ +openapi: 3.2.0 +info: + title: Composition API + version: 1.0.0 +paths: + /oneof: + post: + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/OneOfRequest" + responses: + "200": + description: OK + content: + application/json: + schema: + oneOf: + - type: string + - type: string +components: + schemas: + OneOfRequest: + type: object + properties: + value: {} diff --git a/testdata/custom_parameter_mapping_3.yaml b/testdata/custom_parameter_mapping_3.yaml deleted file mode 100644 index 04d3108..0000000 --- a/testdata/custom_parameter_mapping_3.yaml +++ /dev/null @@ -1,52 +0,0 @@ -openapi: 3.0.3 -info: - description: This is the API documentation for Custom Parameter Mapping - title: 'API Doc: Custom Parameter Mapping' - version: 1.0.0 -paths: - /user/{id}: - get: - description: This operation retrieves a user by ID. - operationId: getUserById - parameters: - - in: query - name: extra_param - required: true - schema: - type: string - - in: path - name: id - required: true - schema: - type: integer - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestUser' - description: OK - summary: Get User by ID -components: - schemas: - SpecTestNullString: - type: object - SpecTestNullTime: - type: object - SpecTestUser: - properties: - age: - nullable: true - type: integer - created_at: - format: date-time - type: string - email: - $ref: '#/components/schemas/SpecTestNullString' - id: - type: integer - updated_at: - $ref: '#/components/schemas/SpecTestNullTime' - username: - type: string - type: object diff --git a/testdata/custom_parameter_mapping_31.yaml b/testdata/custom_parameter_mapping_31.yaml deleted file mode 100644 index f276826..0000000 --- a/testdata/custom_parameter_mapping_31.yaml +++ /dev/null @@ -1,53 +0,0 @@ -openapi: 3.1.0 -info: - description: This is the API documentation for Custom Parameter Mapping - title: 'API Doc: Custom Parameter Mapping' - version: 1.0.0 -paths: - /user/{id}: - get: - description: This operation retrieves a user by ID. - operationId: getUserById - parameters: - - in: query - name: extra_param - required: true - schema: - type: string - - in: path - name: id - required: true - schema: - type: integer - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestUser' - description: OK - summary: Get User by ID -components: - schemas: - SpecTestNullString: - type: object - SpecTestNullTime: - type: object - SpecTestUser: - properties: - age: - type: - - "null" - - integer - created_at: - format: date-time - type: string - email: - $ref: '#/components/schemas/SpecTestNullString' - id: - type: integer - updated_at: - $ref: '#/components/schemas/SpecTestNullTime' - username: - type: string - type: object diff --git a/testdata/custom_path_parser.v30.yaml b/testdata/custom_path_parser.v30.yaml new file mode 100644 index 0000000..b920077 --- /dev/null +++ b/testdata/custom_path_parser.v30.yaml @@ -0,0 +1,33 @@ +openapi: 3.0.4 +info: + title: API Documentation + version: 1.0.0 +paths: + /users/{id}: + get: + parameters: + - name: id + in: path + description: User identifier + required: true + schema: + description: User identifier + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/User" +components: + schemas: + User: + type: object + required: + - id + properties: + id: + type: string + name: + type: string diff --git a/testdata/custom_path_parser.v31.yaml b/testdata/custom_path_parser.v31.yaml new file mode 100644 index 0000000..cf75476 --- /dev/null +++ b/testdata/custom_path_parser.v31.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.2 +info: + title: API Documentation + version: 1.0.0 +paths: + /users/{id}: + get: + parameters: + - name: id + in: path + description: User identifier + required: true + schema: + description: User identifier + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/User" +components: + schemas: + User: + type: object + required: + - id + properties: + id: + type: string + name: + type: string diff --git a/testdata/custom_path_parser.v32.yaml b/testdata/custom_path_parser.v32.yaml new file mode 100644 index 0000000..337a69b --- /dev/null +++ b/testdata/custom_path_parser.v32.yaml @@ -0,0 +1,33 @@ +openapi: 3.2.0 +info: + title: API Documentation + version: 1.0.0 +paths: + /users/{id}: + get: + parameters: + - name: id + in: path + description: User identifier + required: true + schema: + description: User identifier + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/User" +components: + schemas: + User: + type: object + required: + - id + properties: + id: + type: string + name: + type: string diff --git a/testdata/custom_path_parser_3.yaml b/testdata/custom_path_parser_3.yaml deleted file mode 100644 index b96865e..0000000 --- a/testdata/custom_path_parser_3.yaml +++ /dev/null @@ -1,47 +0,0 @@ -openapi: 3.0.3 -info: - description: This is the API documentation for Custom Path Parser - title: 'API Doc: Custom Path Parser' - version: 1.0.0 -paths: - /user/{id}: - get: - description: This operation retrieves a user by ID. - operationId: getUserById - parameters: - - in: path - name: id - required: true - schema: - type: integer - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestUser' - description: OK - summary: Get User by ID -components: - schemas: - SpecTestNullString: - type: object - SpecTestNullTime: - type: object - SpecTestUser: - properties: - age: - nullable: true - type: integer - created_at: - format: date-time - type: string - email: - $ref: '#/components/schemas/SpecTestNullString' - id: - type: integer - updated_at: - $ref: '#/components/schemas/SpecTestNullTime' - username: - type: string - type: object diff --git a/testdata/custom_path_parser_31.yaml b/testdata/custom_path_parser_31.yaml deleted file mode 100644 index 012e891..0000000 --- a/testdata/custom_path_parser_31.yaml +++ /dev/null @@ -1,48 +0,0 @@ -openapi: 3.1.0 -info: - description: This is the API documentation for Custom Path Parser - title: 'API Doc: Custom Path Parser' - version: 1.0.0 -paths: - /user/{id}: - get: - description: This operation retrieves a user by ID. - operationId: getUserById - parameters: - - in: path - name: id - required: true - schema: - type: integer - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestUser' - description: OK - summary: Get User by ID -components: - schemas: - SpecTestNullString: - type: object - SpecTestNullTime: - type: object - SpecTestUser: - properties: - age: - type: - - "null" - - integer - created_at: - format: date-time - type: string - email: - $ref: '#/components/schemas/SpecTestNullString' - id: - type: integer - updated_at: - $ref: '#/components/schemas/SpecTestNullTime' - username: - type: string - type: object diff --git a/testdata/custom_type_mapping_3.yaml b/testdata/custom_type_mapping_3.yaml deleted file mode 100644 index 81f1ff7..0000000 --- a/testdata/custom_type_mapping_3.yaml +++ /dev/null @@ -1,44 +0,0 @@ -openapi: 3.0.3 -info: - description: This is the API documentation for Custom Type Mapping - title: 'API Doc: Custom Type Mapping' - version: 1.0.0 -paths: - /auth/me: - get: - description: This operation retrieves the authenticated user's profile. - operationId: getUserProfile - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestUser' - description: OK - security: - - bearerAuth: [] - summary: Get User Profile -components: - schemas: - SpecTestUser: - properties: - age: - nullable: true - type: integer - created_at: - format: date-time - type: string - email: - type: string - id: - type: integer - updated_at: - format: date-time - type: string - username: - type: string - type: object - securitySchemes: - bearerAuth: - scheme: Bearer - type: http diff --git a/testdata/custom_type_mapping_31.yaml b/testdata/custom_type_mapping_31.yaml deleted file mode 100644 index d5cdbe5..0000000 --- a/testdata/custom_type_mapping_31.yaml +++ /dev/null @@ -1,45 +0,0 @@ -openapi: 3.1.0 -info: - description: This is the API documentation for Custom Type Mapping - title: 'API Doc: Custom Type Mapping' - version: 1.0.0 -paths: - /auth/me: - get: - description: This operation retrieves the authenticated user's profile. - operationId: getUserProfile - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestUser' - description: OK - security: - - bearerAuth: [] - summary: Get User Profile -components: - schemas: - SpecTestUser: - properties: - age: - type: - - "null" - - integer - created_at: - format: date-time - type: string - email: - type: string - id: - type: integer - updated_at: - format: date-time - type: string - username: - type: string - type: object - securitySchemes: - bearerAuth: - scheme: Bearer - type: http diff --git a/testdata/duplicate_status_code_responses_3.yaml b/testdata/duplicate_status_code_responses_3.yaml deleted file mode 100644 index 8ca12d2..0000000 --- a/testdata/duplicate_status_code_responses_3.yaml +++ /dev/null @@ -1,32 +0,0 @@ -openapi: 3.0.3 -info: - description: This is the API documentation for Duplicate Status Code Responses - title: 'API Doc: Duplicate Status Code Responses' - version: 1.0.0 -paths: - /mixed: - get: - description: Returns one of two possible response shapes. - operationId: getMixed - responses: - "200": - content: - application/json: - schema: - oneOf: - - $ref: '#/components/schemas/SpecTestSuccessA' - - $ref: '#/components/schemas/SpecTestSuccessB' - description: OK - summary: Get Mixed Responses -components: - schemas: - SpecTestSuccessA: - properties: - message: - type: string - type: object - SpecTestSuccessB: - properties: - count: - type: integer - type: object diff --git a/testdata/duplicate_status_code_responses_31.yaml b/testdata/duplicate_status_code_responses_31.yaml deleted file mode 100644 index 24c7670..0000000 --- a/testdata/duplicate_status_code_responses_31.yaml +++ /dev/null @@ -1,32 +0,0 @@ -openapi: 3.1.0 -info: - description: This is the API documentation for Duplicate Status Code Responses - title: 'API Doc: Duplicate Status Code Responses' - version: 1.0.0 -paths: - /mixed: - get: - description: Returns one of two possible response shapes. - operationId: getMixed - responses: - "200": - content: - application/json: - schema: - oneOf: - - $ref: '#/components/schemas/SpecTestSuccessA' - - $ref: '#/components/schemas/SpecTestSuccessB' - description: OK - summary: Get Mixed Responses -components: - schemas: - SpecTestSuccessA: - properties: - message: - type: string - type: object - SpecTestSuccessB: - properties: - count: - type: integer - type: object diff --git a/testdata/generic_response_3.yaml b/testdata/generic_response_3.yaml deleted file mode 100644 index d83757d..0000000 --- a/testdata/generic_response_3.yaml +++ /dev/null @@ -1,177 +0,0 @@ -openapi: 3.0.3 -info: - description: This is the API documentation for Generic Response - title: 'API Doc: Generic Response' - version: 1.0.0 -tags: -- description: Operations related to user authentication - name: Authentication -paths: - /login: - post: - description: This operation allows users to log in. - operationId: login - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestLoginRequest' - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestResponseToken' - description: OK - summary: User Login - tags: - - Authentication - /nested-generic-user: - get: - description: This operation retrieves a nested generic user. - operationId: getNestedGenericUser - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestResponseUserType2' - description: OK - security: - - bearerAuth: [] - summary: Get Nested Generic User - tags: - - Authentication - /nested-generic-users: - get: - description: This operation retrieves a nested generic users. - operationId: getNestedGenericUsers - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestResponseUserType3' - description: OK - security: - - bearerAuth: [] - summary: Get Nested Generic Users - tags: - - Authentication - /user: - get: - description: This operation retrieves the authenticated user's profile. - operationId: getUserProfile - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestResponseUser' - description: OK - security: - - bearerAuth: [] - summary: Get User Profile - tags: - - Authentication - /users: - get: - description: This operation retrieves a list of users. - operationId: getUsers - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestResponseUserList' - description: OK - security: - - bearerAuth: [] - summary: Get Users - tags: - - Authentication -components: - schemas: - SpecTestLoginRequest: - properties: - password: - example: password123 - type: string - username: - example: john_doe - type: string - required: - - username - - password - type: object - SpecTestResponseToken: - properties: - data: - $ref: '#/components/schemas/SpecTestToken' - status: - example: 200 - type: integer - type: object - SpecTestResponseUser: - properties: - data: - $ref: '#/components/schemas/SpecTestUser' - status: - example: 200 - type: integer - type: object - SpecTestResponseUserList: - properties: - data: - items: - $ref: '#/components/schemas/SpecTestUser' - nullable: true - type: array - status: - example: 200 - type: integer - type: object - SpecTestResponseUserType2: - properties: - data: - $ref: '#/components/schemas/SpecTestResponseUser' - status: - example: 200 - type: integer - type: object - SpecTestResponseUserType3: - properties: - data: - $ref: '#/components/schemas/SpecTestResponseUserList' - status: - example: 200 - type: integer - type: object - SpecTestToken: - properties: - token: - example: abc123 - type: string - type: object - SpecTestUser: - properties: - age: - nullable: true - type: integer - created_at: - format: date-time - type: string - email: - type: string - id: - type: integer - updated_at: - format: date-time - type: string - username: - type: string - type: object - securitySchemes: - bearerAuth: - scheme: Bearer - type: http diff --git a/testdata/generic_response_31.yaml b/testdata/generic_response_31.yaml deleted file mode 100644 index 55d5603..0000000 --- a/testdata/generic_response_31.yaml +++ /dev/null @@ -1,187 +0,0 @@ -openapi: 3.1.0 -info: - description: This is the API documentation for Generic Response - title: 'API Doc: Generic Response' - version: 1.0.0 -paths: - /login: - post: - description: This operation allows users to log in. - operationId: login - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestLoginRequest' - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestResponseToken' - description: OK - summary: User Login - tags: - - Authentication - /nested-generic-user: - get: - description: This operation retrieves a nested generic user. - operationId: getNestedGenericUser - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestResponseUserType2' - description: OK - security: - - bearerAuth: [] - summary: Get Nested Generic User - tags: - - Authentication - /nested-generic-users: - get: - description: This operation retrieves a nested generic users. - operationId: getNestedGenericUsers - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestResponseUserType3' - description: OK - security: - - bearerAuth: [] - summary: Get Nested Generic Users - tags: - - Authentication - /user: - get: - description: This operation retrieves the authenticated user's profile. - operationId: getUserProfile - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestResponseUser' - description: OK - security: - - bearerAuth: [] - summary: Get User Profile - tags: - - Authentication - /users: - get: - description: This operation retrieves a list of users. - operationId: getUsers - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestResponseUserList' - description: OK - security: - - bearerAuth: [] - summary: Get Users - tags: - - Authentication -components: - schemas: - SpecTestLoginRequest: - properties: - password: - examples: - - password123 - type: string - username: - examples: - - john_doe - type: string - required: - - username - - password - type: object - SpecTestResponseToken: - properties: - data: - $ref: '#/components/schemas/SpecTestToken' - status: - examples: - - 200 - type: integer - type: object - SpecTestResponseUser: - properties: - data: - $ref: '#/components/schemas/SpecTestUser' - status: - examples: - - 200 - type: integer - type: object - SpecTestResponseUserList: - properties: - data: - items: - $ref: '#/components/schemas/SpecTestUser' - type: - - array - - "null" - status: - examples: - - 200 - type: integer - type: object - SpecTestResponseUserType2: - properties: - data: - $ref: '#/components/schemas/SpecTestResponseUser' - status: - examples: - - 200 - type: integer - type: object - SpecTestResponseUserType3: - properties: - data: - $ref: '#/components/schemas/SpecTestResponseUserList' - status: - examples: - - 200 - type: integer - type: object - SpecTestToken: - properties: - token: - examples: - - abc123 - type: string - type: object - SpecTestUser: - properties: - age: - type: - - "null" - - integer - created_at: - format: date-time - type: string - email: - type: string - id: - type: integer - updated_at: - format: date-time - type: string - username: - type: string - type: object - securitySchemes: - bearerAuth: - scheme: Bearer - type: http -tags: -- description: Operations related to user authentication - name: Authentication diff --git a/testdata/generics.v30.yaml b/testdata/generics.v30.yaml new file mode 100644 index 0000000..c108914 --- /dev/null +++ b/testdata/generics.v30.yaml @@ -0,0 +1,86 @@ +openapi: 3.0.4 +info: + title: Generics API + version: 1.0.0 +paths: + /profile: + get: + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ProfileResponse" + /user: + get: + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/BaseResponseUser" + /users: + post: + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: "#/components/schemas/BaseResponseUserList" +components: + schemas: + BaseResponseUser: + type: object + properties: + data: + type: object + required: + - id + properties: + id: + type: string + name: + type: string + message: + type: string + success: + type: boolean + BaseResponseUserList: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/User" + message: + type: string + success: + type: boolean + ProfileResponse: + type: object + properties: + data: + type: object + required: + - id + properties: + id: + type: string + name: + type: string + message: + type: string + success: + type: boolean + User: + type: object + required: + - id + properties: + id: + type: string + name: + type: string diff --git a/testdata/generics.v31.yaml b/testdata/generics.v31.yaml new file mode 100644 index 0000000..93e1712 --- /dev/null +++ b/testdata/generics.v31.yaml @@ -0,0 +1,86 @@ +openapi: 3.1.2 +info: + title: Generics API + version: 1.0.0 +paths: + /profile: + get: + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ProfileResponse" + /user: + get: + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/BaseResponseUser" + /users: + post: + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: "#/components/schemas/BaseResponseUserList" +components: + schemas: + BaseResponseUser: + type: object + properties: + data: + type: object + required: + - id + properties: + id: + type: string + name: + type: string + message: + type: string + success: + type: boolean + BaseResponseUserList: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/User" + message: + type: string + success: + type: boolean + ProfileResponse: + type: object + properties: + data: + type: object + required: + - id + properties: + id: + type: string + name: + type: string + message: + type: string + success: + type: boolean + User: + type: object + required: + - id + properties: + id: + type: string + name: + type: string diff --git a/testdata/generics.v32.yaml b/testdata/generics.v32.yaml new file mode 100644 index 0000000..4469789 --- /dev/null +++ b/testdata/generics.v32.yaml @@ -0,0 +1,86 @@ +openapi: 3.2.0 +info: + title: Generics API + version: 1.0.0 +paths: + /profile: + get: + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ProfileResponse" + /user: + get: + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/BaseResponseUser" + /users: + post: + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: "#/components/schemas/BaseResponseUserList" +components: + schemas: + BaseResponseUser: + type: object + properties: + data: + type: object + required: + - id + properties: + id: + type: string + name: + type: string + message: + type: string + success: + type: boolean + BaseResponseUserList: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/User" + message: + type: string + success: + type: boolean + ProfileResponse: + type: object + properties: + data: + type: object + required: + - id + properties: + id: + type: string + name: + type: string + message: + type: string + success: + type: boolean + User: + type: object + required: + - id + properties: + id: + type: string + name: + type: string diff --git a/testdata/group_routes_3.yaml b/testdata/group_routes_3.yaml deleted file mode 100644 index 630e136..0000000 --- a/testdata/group_routes_3.yaml +++ /dev/null @@ -1,137 +0,0 @@ -openapi: 3.0.3 -info: - description: This is the API documentation for Group Routes - title: 'API Doc: Group Routes' - version: 1.0.0 -paths: - /api/v1/auth/login: - post: - deprecated: true - description: User Login v1 - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestLoginRequest' - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestToken' - description: OK - summary: User Login v1 - tags: - - Authentication - /api/v1/auth/me: - get: - deprecated: true - description: Get Profile v1 - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestUser' - description: OK - security: - - bearerAuth: [] - summary: Get Profile v1 - tags: - - Authentication - /api/v2/auth/login: - post: - description: User Login v2 - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestLoginRequest' - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestToken' - description: OK - summary: User Login v2 - tags: - - Authentication - /api/v2/auth/me: - get: - description: Get Profile v2 - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestUser' - description: OK - security: - - bearerAuth: [] - summary: Get Profile v2 - tags: - - Profile - - Authentication - /api/v2/profile/: - put: - description: Update Profile v2 - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestUser' - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestUser' - description: OK - security: - - bearerAuth: [] - summary: Update Profile v2 - tags: - - Profile -components: - schemas: - SpecTestLoginRequest: - properties: - password: - example: password123 - type: string - username: - example: john_doe - type: string - required: - - username - - password - type: object - SpecTestToken: - properties: - token: - example: abc123 - type: string - type: object - SpecTestUser: - properties: - age: - nullable: true - type: integer - created_at: - format: date-time - type: string - email: - type: string - id: - type: integer - updated_at: - format: date-time - type: string - username: - type: string - type: object - securitySchemes: - bearerAuth: - scheme: Bearer - type: http diff --git a/testdata/group_routes_31.yaml b/testdata/group_routes_31.yaml deleted file mode 100644 index 20db389..0000000 --- a/testdata/group_routes_31.yaml +++ /dev/null @@ -1,141 +0,0 @@ -openapi: 3.1.0 -info: - description: This is the API documentation for Group Routes - title: 'API Doc: Group Routes' - version: 1.0.0 -paths: - /api/v1/auth/login: - post: - deprecated: true - description: User Login v1 - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestLoginRequest' - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestToken' - description: OK - summary: User Login v1 - tags: - - Authentication - /api/v1/auth/me: - get: - deprecated: true - description: Get Profile v1 - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestUser' - description: OK - security: - - bearerAuth: [] - summary: Get Profile v1 - tags: - - Authentication - /api/v2/auth/login: - post: - description: User Login v2 - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestLoginRequest' - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestToken' - description: OK - summary: User Login v2 - tags: - - Authentication - /api/v2/auth/me: - get: - description: Get Profile v2 - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestUser' - description: OK - security: - - bearerAuth: [] - summary: Get Profile v2 - tags: - - Profile - - Authentication - /api/v2/profile/: - put: - description: Update Profile v2 - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestUser' - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestUser' - description: OK - security: - - bearerAuth: [] - summary: Update Profile v2 - tags: - - Profile -components: - schemas: - SpecTestLoginRequest: - properties: - password: - examples: - - password123 - type: string - username: - examples: - - john_doe - type: string - required: - - username - - password - type: object - SpecTestToken: - properties: - token: - examples: - - abc123 - type: string - type: object - SpecTestUser: - properties: - age: - type: - - "null" - - integer - created_at: - format: date-time - type: string - email: - type: string - id: - type: integer - updated_at: - format: date-time - type: string - username: - type: string - type: object - securitySchemes: - bearerAuth: - scheme: Bearer - type: http diff --git a/testdata/hide_operation_3.yaml b/testdata/hide_operation_3.yaml deleted file mode 100644 index fc369f8..0000000 --- a/testdata/hide_operation_3.yaml +++ /dev/null @@ -1,6 +0,0 @@ -openapi: 3.0.3 -info: - description: This is the API documentation for Hide Operation - title: 'API Doc: Hide Operation' - version: 1.0.0 -paths: {} diff --git a/testdata/hide_operation_31.yaml b/testdata/hide_operation_31.yaml deleted file mode 100644 index 644d5d0..0000000 --- a/testdata/hide_operation_31.yaml +++ /dev/null @@ -1,6 +0,0 @@ -openapi: 3.1.0 -info: - description: This is the API documentation for Hide Operation - title: 'API Doc: Hide Operation' - version: 1.0.0 -paths: {} diff --git a/testdata/multipart_binary.v30.yaml b/testdata/multipart_binary.v30.yaml new file mode 100644 index 0000000..80eb835 --- /dev/null +++ b/testdata/multipart_binary.v30.yaml @@ -0,0 +1,54 @@ +openapi: 3.0.4 +info: + title: Binary API + version: 1.0.0 +paths: + /download: + get: + operationId: downloadFile + responses: + "200": + description: The image file + content: + image/png: + schema: + type: string + format: binary + /upload: + post: + operationId: uploadFile + requestBody: + content: + multipart/form-data: + schema: + $ref: "#/components/schemas/UploadRequest" + encoding: + file: + contentType: image/png + responses: + "201": + description: File uploaded + /upload-raw: + post: + operationId: uploadRaw + requestBody: + content: + image/png: + schema: + type: string + format: binary + responses: + "204": + description: No Content +components: + schemas: + UploadRequest: + type: object + properties: + file: + description: The file to upload + type: string + format: binary + fileName: + description: Optional file name + type: string diff --git a/testdata/multipart_binary.v31.yaml b/testdata/multipart_binary.v31.yaml new file mode 100644 index 0000000..8eddcea --- /dev/null +++ b/testdata/multipart_binary.v31.yaml @@ -0,0 +1,55 @@ +openapi: 3.1.2 +info: + title: Binary API + version: 1.0.0 +paths: + /download: + get: + operationId: downloadFile + responses: + "200": + description: The image file + content: + image/png: + schema: + type: string + format: binary + /upload: + post: + operationId: uploadFile + requestBody: + content: + multipart/form-data: + schema: + $ref: "#/components/schemas/UploadRequest" + encoding: + file: + contentType: image/png + responses: + "201": + description: File uploaded + /upload-raw: + post: + operationId: uploadRaw + requestBody: + content: + image/png: + schema: + type: string + format: binary + responses: + "204": + description: No Content +components: + schemas: + UploadRequest: + type: object + properties: + file: + description: The file to upload + type: string + format: binary + contentEncoding: base64 + fileName: + description: Optional file name + type: string diff --git a/testdata/multipart_binary.v32.yaml b/testdata/multipart_binary.v32.yaml new file mode 100644 index 0000000..898be2f --- /dev/null +++ b/testdata/multipart_binary.v32.yaml @@ -0,0 +1,55 @@ +openapi: 3.2.0 +info: + title: Binary API + version: 1.0.0 +paths: + /download: + get: + operationId: downloadFile + responses: + "200": + description: The image file + content: + image/png: + schema: + type: string + format: binary + /upload: + post: + operationId: uploadFile + requestBody: + content: + multipart/form-data: + schema: + $ref: "#/components/schemas/UploadRequest" + encoding: + file: + contentType: image/png + responses: + "201": + description: File uploaded + /upload-raw: + post: + operationId: uploadRaw + requestBody: + content: + image/png: + schema: + type: string + format: binary + responses: + "204": + description: No Content +components: + schemas: + UploadRequest: + type: object + properties: + file: + description: The file to upload + type: string + format: binary + contentEncoding: base64 + fileName: + description: Optional file name + type: string diff --git a/testdata/multiple_content_types.v30.yaml b/testdata/multiple_content_types.v30.yaml new file mode 100644 index 0000000..38c429a --- /dev/null +++ b/testdata/multiple_content_types.v30.yaml @@ -0,0 +1,47 @@ +openapi: 3.0.4 +info: + title: Multi-content API + version: 1.0.0 +paths: + /multi: + post: + operationId: multi + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/LoginRequest" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/LoginResponse" + application/xml: + schema: + $ref: "#/components/schemas/LoginResponse" +components: + schemas: + LoginRequest: + type: object + required: + - username + - password + properties: + password: + type: string + writeOnly: true + username: + type: string + minLength: 3 + LoginResponse: + type: object + required: + - token + properties: + token: + type: string diff --git a/testdata/multiple_content_types.v31.yaml b/testdata/multiple_content_types.v31.yaml new file mode 100644 index 0000000..72d637b --- /dev/null +++ b/testdata/multiple_content_types.v31.yaml @@ -0,0 +1,47 @@ +openapi: 3.1.2 +info: + title: Multi-content API + version: 1.0.0 +paths: + /multi: + post: + operationId: multi + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/LoginRequest" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/LoginResponse" + application/xml: + schema: + $ref: "#/components/schemas/LoginResponse" +components: + schemas: + LoginRequest: + type: object + required: + - username + - password + properties: + password: + type: string + writeOnly: true + username: + type: string + minLength: 3 + LoginResponse: + type: object + required: + - token + properties: + token: + type: string diff --git a/testdata/multiple_content_types.v32.yaml b/testdata/multiple_content_types.v32.yaml new file mode 100644 index 0000000..3c2df3d --- /dev/null +++ b/testdata/multiple_content_types.v32.yaml @@ -0,0 +1,47 @@ +openapi: 3.2.0 +info: + title: Multi-content API + version: 1.0.0 +paths: + /multi: + post: + operationId: multi + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/LoginRequest" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/LoginResponse" + application/xml: + schema: + $ref: "#/components/schemas/LoginResponse" +components: + schemas: + LoginRequest: + type: object + required: + - username + - password + properties: + password: + type: string + writeOnly: true + username: + type: string + minLength: 3 + LoginResponse: + type: object + required: + - token + properties: + token: + type: string diff --git a/testdata/mux_route_3.yaml b/testdata/mux_route_3.yaml deleted file mode 100644 index c82d531..0000000 --- a/testdata/mux_route_3.yaml +++ /dev/null @@ -1,80 +0,0 @@ -openapi: 3.0.3 -info: - description: This is the API documentation for Mux Route - title: 'API Doc: Mux Route' - version: 1.0.0 -paths: - api/v1/login: - post: - description: User Login v1 - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestLoginRequest' - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestToken' - description: OK - summary: User Login v1 - api/v1/profile: - get: - description: Get Profile v1 - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestUser' - description: OK - security: - - bearerAuth: [] - summary: Get Profile v1 -components: - schemas: - SpecTestLoginRequest: - properties: - password: - example: password123 - type: string - username: - example: john_doe - type: string - required: - - username - - password - type: object - SpecTestNullString: - type: object - SpecTestNullTime: - type: object - SpecTestToken: - properties: - token: - example: abc123 - type: string - type: object - SpecTestUser: - properties: - age: - nullable: true - type: integer - created_at: - format: date-time - type: string - email: - $ref: '#/components/schemas/SpecTestNullString' - id: - type: integer - updated_at: - $ref: '#/components/schemas/SpecTestNullTime' - username: - type: string - type: object - securitySchemes: - bearerAuth: - scheme: Bearer - type: http diff --git a/testdata/mux_route_31.yaml b/testdata/mux_route_31.yaml deleted file mode 100644 index d075936..0000000 --- a/testdata/mux_route_31.yaml +++ /dev/null @@ -1,84 +0,0 @@ -openapi: 3.1.0 -info: - description: This is the API documentation for Mux Route - title: 'API Doc: Mux Route' - version: 1.0.0 -paths: - api/v1/login: - post: - description: User Login v1 - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestLoginRequest' - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestToken' - description: OK - summary: User Login v1 - api/v1/profile: - get: - description: Get Profile v1 - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestUser' - description: OK - security: - - bearerAuth: [] - summary: Get Profile v1 -components: - schemas: - SpecTestLoginRequest: - properties: - password: - examples: - - password123 - type: string - username: - examples: - - john_doe - type: string - required: - - username - - password - type: object - SpecTestNullString: - type: object - SpecTestNullTime: - type: object - SpecTestToken: - properties: - token: - examples: - - abc123 - type: string - type: object - SpecTestUser: - properties: - age: - type: - - "null" - - integer - created_at: - format: date-time - type: string - email: - $ref: '#/components/schemas/SpecTestNullString' - id: - type: integer - updated_at: - $ref: '#/components/schemas/SpecTestNullTime' - username: - type: string - type: object - securitySchemes: - bearerAuth: - scheme: Bearer - type: http diff --git a/testdata/nested_structures.v30.yaml b/testdata/nested_structures.v30.yaml new file mode 100644 index 0000000..f6b51c4 --- /dev/null +++ b/testdata/nested_structures.v30.yaml @@ -0,0 +1,28 @@ +openapi: 3.0.4 +info: + title: API Documentation + version: 1.0.0 +paths: + /nested: + post: + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/NestedRequest" + responses: + "204": + description: No Content +components: + schemas: + NestedRequest: + type: object + properties: + level1: + type: object + properties: + level2: + type: object + properties: + level3: + type: string diff --git a/testdata/nested_structures.v31.yaml b/testdata/nested_structures.v31.yaml new file mode 100644 index 0000000..d9c3da2 --- /dev/null +++ b/testdata/nested_structures.v31.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.2 +info: + title: API Documentation + version: 1.0.0 +paths: + /nested: + post: + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/NestedRequest" + responses: + "204": + description: No Content +components: + schemas: + NestedRequest: + type: object + properties: + level1: + type: object + properties: + level2: + type: object + properties: + level3: + type: string diff --git a/testdata/nested_structures.v32.yaml b/testdata/nested_structures.v32.yaml new file mode 100644 index 0000000..cd3b8b1 --- /dev/null +++ b/testdata/nested_structures.v32.yaml @@ -0,0 +1,28 @@ +openapi: 3.2.0 +info: + title: API Documentation + version: 1.0.0 +paths: + /nested: + post: + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/NestedRequest" + responses: + "204": + description: No Content +components: + schemas: + NestedRequest: + type: object + properties: + level1: + type: object + properties: + level2: + type: object + properties: + level3: + type: string diff --git a/testdata/openapi_312_reference_descriptions.v31.yaml b/testdata/openapi_312_reference_descriptions.v31.yaml new file mode 100644 index 0000000..a75a91f --- /dev/null +++ b/testdata/openapi_312_reference_descriptions.v31.yaml @@ -0,0 +1,70 @@ +openapi: 3.1.2 +info: + title: Reference Descriptions API + version: 1.0.0 +paths: + /users/{id}: + get: + operationId: findUser + parameters: + - name: id + in: path + description: User identifier + required: true + schema: + description: User identifier + type: string + - $ref: "#/components/parameters/TraceID" + description: Request trace identifier. + requestBody: + $ref: "#/components/requestBodies/UserBody" + description: Reusable user payload. + responses: + "200": + $ref: "#/components/responses/UserResponse" + description: Reusable user response. + "204": + description: No content + links: + findUser: + $ref: "#/components/links/FindUser" + description: Reusable follow-up link. +components: + schemas: + User: + type: object + required: + - id + properties: + id: + type: string + name: + type: string + responses: + UserResponse: + description: User response + content: + application/json: + schema: + $ref: "#/components/schemas/User" + parameters: + TraceID: + name: traceId + in: header + required: true + schema: + type: string + examples: + UserExample: + value: + id: user-123 + name: Ada + requestBodies: + UserBody: + content: + application/json: + schema: + $ref: "#/components/schemas/User" + links: + FindUser: + operationId: findUser diff --git a/testdata/openapi_320_features.v32.yaml b/testdata/openapi_320_features.v32.yaml new file mode 100644 index 0000000..4406935 --- /dev/null +++ b/testdata/openapi_320_features.v32.yaml @@ -0,0 +1,67 @@ +openapi: 3.2.0 +$self: https://api.example.com/openapi.yaml +info: + title: OpenAPI 3.2 Features API + version: 1.0.0 +tags: + - name: commerce + summary: Commerce + description: Commerce APIs. + kind: nav + - name: payments + summary: Payments + description: Payment operations. + parent: commerce + kind: nav +security: + - deviceAuth: + - payments:read +paths: + /payments/{id}: + get: + tags: + - payments + parameters: + - name: id + in: path + description: User identifier + required: true + schema: + description: User identifier + type: string + responses: + "200": + summary: Payment found + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/User" + examples: + encoded-id: + summary: Encoded identifier + dataValue: + id: pay_123 + serializedValue: "{\"id\":\"pay_123\"}" +components: + schemas: + User: + type: object + required: + - id + properties: + id: + type: string + name: + type: string + securitySchemes: + deviceAuth: + type: oauth2 + flows: + deviceAuthorization: + deviceAuthorizationUrl: https://auth.example.com/device + tokenUrl: https://auth.example.com/token + scopes: + payments:read: Read payments + oauth2MetadataUrl: https://auth.example.com/.well-known/oauth-authorization-server + deprecated: true diff --git a/testdata/openapi_320_operations.v32.yaml b/testdata/openapi_320_operations.v32.yaml new file mode 100644 index 0000000..599b905 --- /dev/null +++ b/testdata/openapi_320_operations.v32.yaml @@ -0,0 +1,33 @@ +openapi: 3.2.0 +info: + title: Search API + version: 1.0.0 +paths: + /cache: + additionalOperations: + PURGE: + responses: + "204": + description: No Content + /search: + query: + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" +components: + schemas: + User: + type: object + required: + - id + properties: + id: + type: string + name: + type: string diff --git a/testdata/path_parameters.v30.yaml b/testdata/path_parameters.v30.yaml new file mode 100644 index 0000000..313b39a --- /dev/null +++ b/testdata/path_parameters.v30.yaml @@ -0,0 +1,36 @@ +openapi: 3.0.4 +info: + title: Users API + version: 1.0.0 +paths: + /users/{id}: + get: + summary: Get user + description: Get user + operationId: getUser + parameters: + - name: id + in: path + description: User identifier + required: true + schema: + description: User identifier + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/User" +components: + schemas: + User: + type: object + required: + - id + properties: + id: + type: string + name: + type: string diff --git a/testdata/path_parameters.v31.yaml b/testdata/path_parameters.v31.yaml new file mode 100644 index 0000000..2d1d23c --- /dev/null +++ b/testdata/path_parameters.v31.yaml @@ -0,0 +1,36 @@ +openapi: 3.1.2 +info: + title: Users API + version: 1.0.0 +paths: + /users/{id}: + get: + summary: Get user + description: Get user + operationId: getUser + parameters: + - name: id + in: path + description: User identifier + required: true + schema: + description: User identifier + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/User" +components: + schemas: + User: + type: object + required: + - id + properties: + id: + type: string + name: + type: string diff --git a/testdata/path_parameters.v32.yaml b/testdata/path_parameters.v32.yaml new file mode 100644 index 0000000..aeb568d --- /dev/null +++ b/testdata/path_parameters.v32.yaml @@ -0,0 +1,36 @@ +openapi: 3.2.0 +info: + title: Users API + version: 1.0.0 +paths: + /users/{id}: + get: + summary: Get user + description: Get user + operationId: getUser + parameters: + - name: id + in: path + description: User identifier + required: true + schema: + description: User identifier + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/User" +components: + schemas: + User: + type: object + required: + - id + properties: + id: + type: string + name: + type: string diff --git a/testdata/petstore.v30.yaml b/testdata/petstore.v30.yaml new file mode 100644 index 0000000..a29b0e3 --- /dev/null +++ b/testdata/petstore.v30.yaml @@ -0,0 +1,692 @@ +openapi: 3.0.4 +info: + title: "Swagger Petstore - OpenAPI 3.0" + description: "This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about\nSwagger at [https://swagger.io](https://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!\nYou can now help us improve the API whether it's by making changes to the definition itself or to the code.\nThat way, with time, we can improve the API in general, and expose some of the new features in OAS3.\n\nSome useful links:\n- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)\n- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)" + termsOfService: https://swagger.io/terms/ + contact: + email: apiteam@swagger.io + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + version: 1.0.27 +servers: + - url: https://petstore3.swagger.io/api/v3 +externalDocs: + description: Find out more about Swagger + url: https://swagger.io +tags: + - name: pet + description: Everything about your Pets + externalDocs: + description: Find out more + url: https://swagger.io + - name: store + description: Access to Petstore orders + externalDocs: + description: Find out more about our store + url: https://swagger.io + - name: user + description: Operations about user +paths: + /pet: + put: + tags: + - pet + summary: Update an existing pet. + description: Update an existing pet by Id. + operationId: updatePet + requestBody: + description: Update an existent pet in the store + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid ID supplied + "404": + description: Pet not found + "422": + description: Validation exception + security: + - petstore_auth: + - write:pets + - read:pets + post: + tags: + - pet + summary: Add a new pet to the store. + description: Add a new pet to the store. + operationId: addPet + requestBody: + description: Create a new pet in the store + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + "405": + description: Invalid input + "422": + description: Validation exception + security: + - petstore_auth: + - write:pets + - read:pets + /pet/findByStatus: + get: + tags: + - pet + summary: Finds Pets by status. + description: Multiple status values can be provided with comma separated strings. + operationId: findPetsByStatus + parameters: + - name: status + in: query + description: Status values that need to be considered for filter + required: true + schema: + description: Status values that need to be considered for filter + type: string + default: available + enum: + - available + - pending + - sold + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid status value + security: + - petstore_auth: + - write:pets + - read:pets + /pet/findByTags: + get: + tags: + - pet + summary: Finds Pets by tags. + description: Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. + operationId: findPetsByTags + parameters: + - name: tags + in: query + description: Tags to filter by + required: true + schema: + description: Tags to filter by + type: array + items: + type: string + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid tag value + security: + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}: + get: + tags: + - pet + summary: Find pet by ID. + description: Returns a single pet. + operationId: getPetById + parameters: + - name: petId + in: path + description: ID of pet to return + required: true + schema: + description: ID of pet to return + type: integer + format: int64 + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid ID supplied + "404": + description: Pet not found + security: + - api_key: [] + - petstore_auth: + - write:pets + - read:pets + post: + tags: + - pet + summary: Updates a pet in the store with form data. + description: Updates a pet resource based on the form data. + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + description: ID of pet that needs to be updated + type: integer + format: int64 + - name: name + in: query + description: Name of pet that needs to be updated + schema: + description: Name of pet that needs to be updated + type: string + - name: status + in: query + description: Status of pet that needs to be updated + schema: + description: Status of pet that needs to be updated + type: string + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + "405": + description: Invalid input + security: + - petstore_auth: + - write:pets + - read:pets + delete: + tags: + - pet + summary: Deletes a pet. + description: Delete a pet. + operationId: deletePet + parameters: + - name: api_key + in: header + schema: + type: string + - name: petId + in: path + description: Pet id to delete + required: true + schema: + description: Pet id to delete + type: integer + format: int64 + responses: + "200": + description: Pet deleted + "400": + description: Invalid pet value + security: + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}/uploadImage: + post: + tags: + - pet + summary: Uploads an image. + description: Upload image of the pet. + operationId: uploadFile + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + schema: + description: ID of pet to update + type: integer + format: int64 + - name: additionalMetadata + in: query + description: Additional Metadata + schema: + description: Additional Metadata + type: string + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/APIResponse" + security: + - petstore_auth: + - write:pets + - read:pets + /store/inventory: + get: + tags: + - store + summary: Returns pet inventories by status. + description: Returns a map of status codes to quantities. + operationId: getInventory + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: object + additionalProperties: + type: integer + format: int32 + security: + - api_key: [] + /store/order: + post: + tags: + - store + summary: Place an order for a pet. + description: Place a new order in the store. + operationId: placeOrder + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + "400": + description: Invalid input + "422": + description: Validation exception + /store/order/{orderId}: + get: + tags: + - store + summary: Find purchase order by ID. + description: For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions. + operationId: getOrderById + parameters: + - name: orderId + in: path + description: ID of order that needs to be fetched + required: true + schema: + description: ID of order that needs to be fetched + type: integer + format: int64 + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + "400": + description: Invalid ID supplied + "404": + description: Order not found + delete: + tags: + - store + summary: Delete purchase order by identifier. + description: For valid response try integer IDs with value < 1000. Anything above 1000 or non-integers will generate API errors. + operationId: deleteOrder + parameters: + - name: orderId + in: path + description: ID of the order that needs to be deleted + required: true + schema: + description: ID of the order that needs to be deleted + type: integer + format: int64 + responses: + "200": + description: order deleted + "400": + description: Invalid ID supplied + "404": + description: Order not found + /user: + post: + tags: + - user + summary: Create user. + description: This can only be done by the logged in user. + operationId: createUser + requestBody: + description: Created user object + content: + application/json: + schema: + $ref: "#/components/schemas/User" + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/User" + /user/createWithList: + post: + tags: + - user + summary: Creates list of users with given input array. + description: Creates list of users with given input array. + operationId: createUsersWithListInput + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/User" + /user/login: + get: + tags: + - user + summary: Logs user into the system. + description: Log into the system. + operationId: loginUser + parameters: + - name: username + in: query + description: The user name for login + schema: + description: The user name for login + type: string + - name: password + in: query + description: The password for login in clear text + schema: + description: The password for login in clear text + type: string + responses: + "200": + description: successful operation + headers: + X-Expires-After: + description: date in UTC when token expires + schema: + type: string + format: date-time + X-Rate-Limit: + description: calls per hour allowed by the user + schema: + type: integer + format: int32 + content: + application/json: + schema: + type: string + "400": + description: Invalid username/password supplied + /user/logout: + get: + tags: + - user + summary: Logs out current logged in user session. + description: Log user out of the system. + operationId: logoutUser + responses: + "200": + description: successful operation + /user/{username}: + get: + tags: + - user + summary: Get user by user name. + description: Get user detail based on username. + operationId: getUserByName + parameters: + - name: username + in: path + description: The name that needs to be fetched. Use user1 for testing + required: true + schema: + description: The name that needs to be fetched. Use user1 for testing + type: string + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/User" + "400": + description: Invalid username supplied + "404": + description: User not found + put: + tags: + - user + summary: Update user resource. + description: This can only be done by the logged in user. + operationId: updateUser + parameters: + - name: username + in: path + description: The name that needs to be fetched. Use user1 for testing + required: true + schema: + description: The name that needs to be fetched. Use user1 for testing + type: string + requestBody: + description: Update an existent user in the store + content: + application/json: + schema: + $ref: "#/components/schemas/User" + responses: + "200": + description: successful operation + "400": + description: bad request + "404": + description: user not found + delete: + tags: + - user + summary: Delete user resource. + description: This can only be done by the logged in user. + operationId: deleteUser + parameters: + - name: username + in: path + description: The name that needs to be fetched. Use user1 for testing + required: true + schema: + description: The name that needs to be fetched. Use user1 for testing + type: string + responses: + "200": + description: User deleted + "400": + description: Invalid username supplied + "404": + description: User not found +components: + schemas: + APIResponse: + type: object + properties: + code: + type: integer + format: int32 + message: + type: string + type: + type: string + Order: + type: object + properties: + complete: + type: boolean + id: + type: integer + format: int64 + example: 10 + petId: + type: integer + format: int64 + example: 198772 + quantity: + type: integer + format: int32 + example: 7 + shipDate: + type: string + format: date-time + status: + description: Order Status + type: string + example: approved + enum: + - placed + - approved + - delivered + xml: + name: order + Pet: + type: object + required: + - name + - photoUrls + properties: + category: + type: object + nullable: true + properties: + id: + type: integer + format: int64 + example: 1 + name: + type: string + example: Dogs + id: + type: integer + format: int64 + example: 10 + name: + type: string + example: doggie + photoUrls: + type: array + items: + type: string + xml: + name: photoUrl + wrapped: true + status: + description: pet status in the store + type: string + enum: + - available + - pending + - sold + tags: + type: array + items: + $ref: "#/components/schemas/Tag" + xml: + wrapped: true + xml: + name: pet + Tag: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: tag + User: + type: object + properties: + email: + type: string + example: john@email.com + firstName: + type: string + example: John + id: + type: integer + format: int64 + example: 10 + lastName: + type: string + example: James + password: + type: string + example: 12345 + phone: + type: string + example: 12345 + userStatus: + description: User Status + type: integer + format: int32 + example: 1 + username: + type: string + example: theUser + xml: + name: user + securitySchemes: + api_key: + type: apiKey + name: api_key + in: header + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: https://petstore3.swagger.io/oauth/authorize + scopes: + read:pets: read your pets + write:pets: modify pets in your account diff --git a/testdata/petstore.v31.yaml b/testdata/petstore.v31.yaml new file mode 100644 index 0000000..6fed2a8 --- /dev/null +++ b/testdata/petstore.v31.yaml @@ -0,0 +1,693 @@ +openapi: 3.1.2 +info: + title: "Swagger Petstore - OpenAPI 3.0" + description: "This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about\nSwagger at [https://swagger.io](https://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!\nYou can now help us improve the API whether it's by making changes to the definition itself or to the code.\nThat way, with time, we can improve the API in general, and expose some of the new features in OAS3.\n\nSome useful links:\n- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)\n- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)" + termsOfService: https://swagger.io/terms/ + contact: + email: apiteam@swagger.io + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + version: 1.0.27 +servers: + - url: https://petstore3.swagger.io/api/v3 +externalDocs: + description: Find out more about Swagger + url: https://swagger.io +tags: + - name: pet + description: Everything about your Pets + externalDocs: + description: Find out more + url: https://swagger.io + - name: store + description: Access to Petstore orders + externalDocs: + description: Find out more about our store + url: https://swagger.io + - name: user + description: Operations about user +paths: + /pet: + put: + tags: + - pet + summary: Update an existing pet. + description: Update an existing pet by Id. + operationId: updatePet + requestBody: + description: Update an existent pet in the store + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid ID supplied + "404": + description: Pet not found + "422": + description: Validation exception + security: + - petstore_auth: + - write:pets + - read:pets + post: + tags: + - pet + summary: Add a new pet to the store. + description: Add a new pet to the store. + operationId: addPet + requestBody: + description: Create a new pet in the store + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + "405": + description: Invalid input + "422": + description: Validation exception + security: + - petstore_auth: + - write:pets + - read:pets + /pet/findByStatus: + get: + tags: + - pet + summary: Finds Pets by status. + description: Multiple status values can be provided with comma separated strings. + operationId: findPetsByStatus + parameters: + - name: status + in: query + description: Status values that need to be considered for filter + required: true + schema: + description: Status values that need to be considered for filter + type: string + default: available + enum: + - available + - pending + - sold + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid status value + security: + - petstore_auth: + - write:pets + - read:pets + /pet/findByTags: + get: + tags: + - pet + summary: Finds Pets by tags. + description: Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. + operationId: findPetsByTags + parameters: + - name: tags + in: query + description: Tags to filter by + required: true + schema: + description: Tags to filter by + type: array + items: + type: string + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid tag value + security: + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}: + get: + tags: + - pet + summary: Find pet by ID. + description: Returns a single pet. + operationId: getPetById + parameters: + - name: petId + in: path + description: ID of pet to return + required: true + schema: + description: ID of pet to return + type: integer + format: int64 + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid ID supplied + "404": + description: Pet not found + security: + - api_key: [] + - petstore_auth: + - write:pets + - read:pets + post: + tags: + - pet + summary: Updates a pet in the store with form data. + description: Updates a pet resource based on the form data. + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + description: ID of pet that needs to be updated + type: integer + format: int64 + - name: name + in: query + description: Name of pet that needs to be updated + schema: + description: Name of pet that needs to be updated + type: string + - name: status + in: query + description: Status of pet that needs to be updated + schema: + description: Status of pet that needs to be updated + type: string + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + "405": + description: Invalid input + security: + - petstore_auth: + - write:pets + - read:pets + delete: + tags: + - pet + summary: Deletes a pet. + description: Delete a pet. + operationId: deletePet + parameters: + - name: api_key + in: header + schema: + type: string + - name: petId + in: path + description: Pet id to delete + required: true + schema: + description: Pet id to delete + type: integer + format: int64 + responses: + "200": + description: Pet deleted + "400": + description: Invalid pet value + security: + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}/uploadImage: + post: + tags: + - pet + summary: Uploads an image. + description: Upload image of the pet. + operationId: uploadFile + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + schema: + description: ID of pet to update + type: integer + format: int64 + - name: additionalMetadata + in: query + description: Additional Metadata + schema: + description: Additional Metadata + type: string + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/APIResponse" + security: + - petstore_auth: + - write:pets + - read:pets + /store/inventory: + get: + tags: + - store + summary: Returns pet inventories by status. + description: Returns a map of status codes to quantities. + operationId: getInventory + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: object + additionalProperties: + type: integer + format: int32 + security: + - api_key: [] + /store/order: + post: + tags: + - store + summary: Place an order for a pet. + description: Place a new order in the store. + operationId: placeOrder + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + "400": + description: Invalid input + "422": + description: Validation exception + /store/order/{orderId}: + get: + tags: + - store + summary: Find purchase order by ID. + description: For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions. + operationId: getOrderById + parameters: + - name: orderId + in: path + description: ID of order that needs to be fetched + required: true + schema: + description: ID of order that needs to be fetched + type: integer + format: int64 + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + "400": + description: Invalid ID supplied + "404": + description: Order not found + delete: + tags: + - store + summary: Delete purchase order by identifier. + description: For valid response try integer IDs with value < 1000. Anything above 1000 or non-integers will generate API errors. + operationId: deleteOrder + parameters: + - name: orderId + in: path + description: ID of the order that needs to be deleted + required: true + schema: + description: ID of the order that needs to be deleted + type: integer + format: int64 + responses: + "200": + description: order deleted + "400": + description: Invalid ID supplied + "404": + description: Order not found + /user: + post: + tags: + - user + summary: Create user. + description: This can only be done by the logged in user. + operationId: createUser + requestBody: + description: Created user object + content: + application/json: + schema: + $ref: "#/components/schemas/User" + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/User" + /user/createWithList: + post: + tags: + - user + summary: Creates list of users with given input array. + description: Creates list of users with given input array. + operationId: createUsersWithListInput + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/User" + /user/login: + get: + tags: + - user + summary: Logs user into the system. + description: Log into the system. + operationId: loginUser + parameters: + - name: username + in: query + description: The user name for login + schema: + description: The user name for login + type: string + - name: password + in: query + description: The password for login in clear text + schema: + description: The password for login in clear text + type: string + responses: + "200": + description: successful operation + headers: + X-Expires-After: + description: date in UTC when token expires + schema: + type: string + format: date-time + X-Rate-Limit: + description: calls per hour allowed by the user + schema: + type: integer + format: int32 + content: + application/json: + schema: + type: string + "400": + description: Invalid username/password supplied + /user/logout: + get: + tags: + - user + summary: Logs out current logged in user session. + description: Log user out of the system. + operationId: logoutUser + responses: + "200": + description: successful operation + /user/{username}: + get: + tags: + - user + summary: Get user by user name. + description: Get user detail based on username. + operationId: getUserByName + parameters: + - name: username + in: path + description: The name that needs to be fetched. Use user1 for testing + required: true + schema: + description: The name that needs to be fetched. Use user1 for testing + type: string + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/User" + "400": + description: Invalid username supplied + "404": + description: User not found + put: + tags: + - user + summary: Update user resource. + description: This can only be done by the logged in user. + operationId: updateUser + parameters: + - name: username + in: path + description: The name that needs to be fetched. Use user1 for testing + required: true + schema: + description: The name that needs to be fetched. Use user1 for testing + type: string + requestBody: + description: Update an existent user in the store + content: + application/json: + schema: + $ref: "#/components/schemas/User" + responses: + "200": + description: successful operation + "400": + description: bad request + "404": + description: user not found + delete: + tags: + - user + summary: Delete user resource. + description: This can only be done by the logged in user. + operationId: deleteUser + parameters: + - name: username + in: path + description: The name that needs to be fetched. Use user1 for testing + required: true + schema: + description: The name that needs to be fetched. Use user1 for testing + type: string + responses: + "200": + description: User deleted + "400": + description: Invalid username supplied + "404": + description: User not found +components: + schemas: + APIResponse: + type: object + properties: + code: + type: integer + format: int32 + message: + type: string + type: + type: string + Order: + type: object + properties: + complete: + type: boolean + id: + type: integer + format: int64 + example: 10 + petId: + type: integer + format: int64 + example: 198772 + quantity: + type: integer + format: int32 + example: 7 + shipDate: + type: string + format: date-time + status: + description: Order Status + type: string + example: approved + enum: + - placed + - approved + - delivered + xml: + name: order + Pet: + type: object + required: + - name + - photoUrls + properties: + category: + type: + - object + - "null" + properties: + id: + type: integer + format: int64 + example: 1 + name: + type: string + example: Dogs + id: + type: integer + format: int64 + example: 10 + name: + type: string + example: doggie + photoUrls: + type: array + items: + type: string + xml: + name: photoUrl + wrapped: true + status: + description: pet status in the store + type: string + enum: + - available + - pending + - sold + tags: + type: array + items: + $ref: "#/components/schemas/Tag" + xml: + wrapped: true + xml: + name: pet + Tag: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: tag + User: + type: object + properties: + email: + type: string + example: john@email.com + firstName: + type: string + example: John + id: + type: integer + format: int64 + example: 10 + lastName: + type: string + example: James + password: + type: string + example: 12345 + phone: + type: string + example: 12345 + userStatus: + description: User Status + type: integer + format: int32 + example: 1 + username: + type: string + example: theUser + xml: + name: user + securitySchemes: + api_key: + type: apiKey + name: api_key + in: header + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: https://petstore3.swagger.io/oauth/authorize + scopes: + read:pets: read your pets + write:pets: modify pets in your account diff --git a/testdata/petstore.v32.yaml b/testdata/petstore.v32.yaml new file mode 100644 index 0000000..a8d92bc --- /dev/null +++ b/testdata/petstore.v32.yaml @@ -0,0 +1,693 @@ +openapi: 3.2.0 +info: + title: "Swagger Petstore - OpenAPI 3.0" + description: "This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about\nSwagger at [https://swagger.io](https://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!\nYou can now help us improve the API whether it's by making changes to the definition itself or to the code.\nThat way, with time, we can improve the API in general, and expose some of the new features in OAS3.\n\nSome useful links:\n- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)\n- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)" + termsOfService: https://swagger.io/terms/ + contact: + email: apiteam@swagger.io + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + version: 1.0.27 +servers: + - url: https://petstore3.swagger.io/api/v3 +externalDocs: + description: Find out more about Swagger + url: https://swagger.io +tags: + - name: pet + description: Everything about your Pets + externalDocs: + description: Find out more + url: https://swagger.io + - name: store + description: Access to Petstore orders + externalDocs: + description: Find out more about our store + url: https://swagger.io + - name: user + description: Operations about user +paths: + /pet: + put: + tags: + - pet + summary: Update an existing pet. + description: Update an existing pet by Id. + operationId: updatePet + requestBody: + description: Update an existent pet in the store + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid ID supplied + "404": + description: Pet not found + "422": + description: Validation exception + security: + - petstore_auth: + - write:pets + - read:pets + post: + tags: + - pet + summary: Add a new pet to the store. + description: Add a new pet to the store. + operationId: addPet + requestBody: + description: Create a new pet in the store + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + "405": + description: Invalid input + "422": + description: Validation exception + security: + - petstore_auth: + - write:pets + - read:pets + /pet/findByStatus: + get: + tags: + - pet + summary: Finds Pets by status. + description: Multiple status values can be provided with comma separated strings. + operationId: findPetsByStatus + parameters: + - name: status + in: query + description: Status values that need to be considered for filter + required: true + schema: + description: Status values that need to be considered for filter + type: string + default: available + enum: + - available + - pending + - sold + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid status value + security: + - petstore_auth: + - write:pets + - read:pets + /pet/findByTags: + get: + tags: + - pet + summary: Finds Pets by tags. + description: Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. + operationId: findPetsByTags + parameters: + - name: tags + in: query + description: Tags to filter by + required: true + schema: + description: Tags to filter by + type: array + items: + type: string + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid tag value + security: + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}: + get: + tags: + - pet + summary: Find pet by ID. + description: Returns a single pet. + operationId: getPetById + parameters: + - name: petId + in: path + description: ID of pet to return + required: true + schema: + description: ID of pet to return + type: integer + format: int64 + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid ID supplied + "404": + description: Pet not found + security: + - api_key: [] + - petstore_auth: + - write:pets + - read:pets + post: + tags: + - pet + summary: Updates a pet in the store with form data. + description: Updates a pet resource based on the form data. + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + description: ID of pet that needs to be updated + type: integer + format: int64 + - name: name + in: query + description: Name of pet that needs to be updated + schema: + description: Name of pet that needs to be updated + type: string + - name: status + in: query + description: Status of pet that needs to be updated + schema: + description: Status of pet that needs to be updated + type: string + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + "405": + description: Invalid input + security: + - petstore_auth: + - write:pets + - read:pets + delete: + tags: + - pet + summary: Deletes a pet. + description: Delete a pet. + operationId: deletePet + parameters: + - name: api_key + in: header + schema: + type: string + - name: petId + in: path + description: Pet id to delete + required: true + schema: + description: Pet id to delete + type: integer + format: int64 + responses: + "200": + description: Pet deleted + "400": + description: Invalid pet value + security: + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}/uploadImage: + post: + tags: + - pet + summary: Uploads an image. + description: Upload image of the pet. + operationId: uploadFile + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + schema: + description: ID of pet to update + type: integer + format: int64 + - name: additionalMetadata + in: query + description: Additional Metadata + schema: + description: Additional Metadata + type: string + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/APIResponse" + security: + - petstore_auth: + - write:pets + - read:pets + /store/inventory: + get: + tags: + - store + summary: Returns pet inventories by status. + description: Returns a map of status codes to quantities. + operationId: getInventory + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: object + additionalProperties: + type: integer + format: int32 + security: + - api_key: [] + /store/order: + post: + tags: + - store + summary: Place an order for a pet. + description: Place a new order in the store. + operationId: placeOrder + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + "400": + description: Invalid input + "422": + description: Validation exception + /store/order/{orderId}: + get: + tags: + - store + summary: Find purchase order by ID. + description: For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions. + operationId: getOrderById + parameters: + - name: orderId + in: path + description: ID of order that needs to be fetched + required: true + schema: + description: ID of order that needs to be fetched + type: integer + format: int64 + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + "400": + description: Invalid ID supplied + "404": + description: Order not found + delete: + tags: + - store + summary: Delete purchase order by identifier. + description: For valid response try integer IDs with value < 1000. Anything above 1000 or non-integers will generate API errors. + operationId: deleteOrder + parameters: + - name: orderId + in: path + description: ID of the order that needs to be deleted + required: true + schema: + description: ID of the order that needs to be deleted + type: integer + format: int64 + responses: + "200": + description: order deleted + "400": + description: Invalid ID supplied + "404": + description: Order not found + /user: + post: + tags: + - user + summary: Create user. + description: This can only be done by the logged in user. + operationId: createUser + requestBody: + description: Created user object + content: + application/json: + schema: + $ref: "#/components/schemas/User" + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/User" + /user/createWithList: + post: + tags: + - user + summary: Creates list of users with given input array. + description: Creates list of users with given input array. + operationId: createUsersWithListInput + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/User" + /user/login: + get: + tags: + - user + summary: Logs user into the system. + description: Log into the system. + operationId: loginUser + parameters: + - name: username + in: query + description: The user name for login + schema: + description: The user name for login + type: string + - name: password + in: query + description: The password for login in clear text + schema: + description: The password for login in clear text + type: string + responses: + "200": + description: successful operation + headers: + X-Expires-After: + description: date in UTC when token expires + schema: + type: string + format: date-time + X-Rate-Limit: + description: calls per hour allowed by the user + schema: + type: integer + format: int32 + content: + application/json: + schema: + type: string + "400": + description: Invalid username/password supplied + /user/logout: + get: + tags: + - user + summary: Logs out current logged in user session. + description: Log user out of the system. + operationId: logoutUser + responses: + "200": + description: successful operation + /user/{username}: + get: + tags: + - user + summary: Get user by user name. + description: Get user detail based on username. + operationId: getUserByName + parameters: + - name: username + in: path + description: The name that needs to be fetched. Use user1 for testing + required: true + schema: + description: The name that needs to be fetched. Use user1 for testing + type: string + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/User" + "400": + description: Invalid username supplied + "404": + description: User not found + put: + tags: + - user + summary: Update user resource. + description: This can only be done by the logged in user. + operationId: updateUser + parameters: + - name: username + in: path + description: The name that needs to be fetched. Use user1 for testing + required: true + schema: + description: The name that needs to be fetched. Use user1 for testing + type: string + requestBody: + description: Update an existent user in the store + content: + application/json: + schema: + $ref: "#/components/schemas/User" + responses: + "200": + description: successful operation + "400": + description: bad request + "404": + description: user not found + delete: + tags: + - user + summary: Delete user resource. + description: This can only be done by the logged in user. + operationId: deleteUser + parameters: + - name: username + in: path + description: The name that needs to be fetched. Use user1 for testing + required: true + schema: + description: The name that needs to be fetched. Use user1 for testing + type: string + responses: + "200": + description: User deleted + "400": + description: Invalid username supplied + "404": + description: User not found +components: + schemas: + APIResponse: + type: object + properties: + code: + type: integer + format: int32 + message: + type: string + type: + type: string + Order: + type: object + properties: + complete: + type: boolean + id: + type: integer + format: int64 + example: 10 + petId: + type: integer + format: int64 + example: 198772 + quantity: + type: integer + format: int32 + example: 7 + shipDate: + type: string + format: date-time + status: + description: Order Status + type: string + example: approved + enum: + - placed + - approved + - delivered + xml: + name: order + Pet: + type: object + required: + - name + - photoUrls + properties: + category: + type: + - object + - "null" + properties: + id: + type: integer + format: int64 + example: 1 + name: + type: string + example: Dogs + id: + type: integer + format: int64 + example: 10 + name: + type: string + example: doggie + photoUrls: + type: array + items: + type: string + xml: + name: photoUrl + wrapped: true + status: + description: pet status in the store + type: string + enum: + - available + - pending + - sold + tags: + type: array + items: + $ref: "#/components/schemas/Tag" + xml: + wrapped: true + xml: + name: pet + Tag: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: tag + User: + type: object + properties: + email: + type: string + example: john@email.com + firstName: + type: string + example: John + id: + type: integer + format: int64 + example: 10 + lastName: + type: string + example: James + password: + type: string + example: 12345 + phone: + type: string + example: 12345 + userStatus: + description: User Status + type: integer + format: int32 + example: 1 + username: + type: string + example: theUser + xml: + name: user + securitySchemes: + api_key: + type: apiKey + name: api_key + in: header + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: https://petstore3.swagger.io/oauth/authorize + scopes: + read:pets: read your pets + write:pets: modify pets in your account diff --git a/testdata/petstore_3.yaml b/testdata/petstore_3.yaml deleted file mode 100644 index 8909455..0000000 --- a/testdata/petstore_3.yaml +++ /dev/null @@ -1,536 +0,0 @@ -openapi: 3.0.3 -info: - contact: - email: apiteam@swagger.io - description: This is a sample Petstore server. - license: - name: Apache 2.0 - url: https://www.apache.org/licenses/LICENSE-2.0.html - termsOfService: https://swagger.io/terms/ - title: Petstore API - version: 1.0.0 -externalDocs: - description: Find more info here about swagger - url: https://swagger.io -servers: -- url: https://petstore3.swagger.io/api/v3 -tags: -- description: Everything about your Pets - externalDocs: - description: Find out more about our Pets - url: https://swagger.io - name: pet -- description: Access to Petstore orders - externalDocs: - description: Find out more about our Store - url: https://swagger.io - name: store -- description: Operations about user - name: user -paths: - /pet/: - post: - description: Add a new pet to the store. - operationId: addPet - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPet' - responses: - "201": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPet' - description: Created - security: - - petstore_auth: - - write:pets - - read:pets - summary: Add a new pet - tags: - - pet - put: - description: Update the details of an existing pet in the store. - operationId: updatePet - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPet' - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPet' - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Update an existing pet - tags: - - pet - /pet/{petId}: - delete: - description: Delete a pet from the store by its ID. - operationId: deletePet - parameters: - - in: path - name: petId - required: true - schema: - type: integer - - in: header - name: api_key - schema: - type: string - responses: - "204": - description: No Content - security: - - petstore_auth: - - write:pets - - read:pets - summary: Delete a pet - tags: - - pet - get: - description: Retrieve a pet by its ID. - operationId: getPetById - parameters: - - in: path - name: petId - required: true - schema: - type: integer - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPet' - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Get pet by ID - tags: - - pet - post: - description: Updates a pet in the store with form data. - operationId: updatePetWithForm - parameters: - - in: path - name: petId - required: true - schema: - type: integer - requestBody: - content: - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/FormDataDtoUpdatePetWithFormRequest' - responses: - "200": - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Update pet with form - tags: - - pet - /pet/{petId}/uploadImage: - post: - description: Uploads an image for a pet. - operationId: uploadFile - parameters: - - in: query - name: additionalMetadata - schema: - type: string - - in: path - name: petId - required: true - schema: - format: int64 - type: integer - requestBody: - content: - application/octet-stream: - schema: - type: string - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoAPIResponse' - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Upload an image for a pet - tags: - - pet - /pet/findByStatus: - get: - description: Finds Pets by status. Multiple status values can be provided with - comma separated strings. - operationId: findPetsByStatus - parameters: - - in: query - name: status - schema: - enum: - - available - - pending - - sold - type: string - responses: - "200": - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPet' - type: array - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by status - tags: - - pet - /pet/findByTags: - get: - description: Finds Pets by tags. Multiple tags can be provided with comma separated - strings. - operationId: findPetsByTags - parameters: - - in: query - name: tags - schema: - items: - type: string - type: array - responses: - "200": - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPet' - type: array - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by tags - tags: - - pet - /store/order: - post: - description: Place a new order for a pet. - operationId: placeOrder - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoOrder' - responses: - "201": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoOrder' - description: Created - summary: Place an order - tags: - - store - /store/order/{orderId}: - delete: - description: Delete an order by its ID. - operationId: deleteOrder - parameters: - - in: path - name: orderId - required: true - schema: - type: integer - responses: - "204": - description: No Content - summary: Delete an order - tags: - - store - get: - description: Retrieve an order by its ID. - operationId: getOrderById - parameters: - - in: path - name: orderId - required: true - schema: - type: integer - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoOrder' - description: OK - "404": - description: Not Found - summary: Get order by ID - tags: - - store - /user/: - post: - description: Create a new user in the store. - operationId: createUser - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPetUser' - responses: - "201": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPetUser' - description: Created - summary: Create a new user - tags: - - user - /user/{username}: - delete: - description: Delete a user from the store by their username. - operationId: deleteUser - parameters: - - in: path - name: username - required: true - schema: - type: string - responses: - "204": - description: No Content - summary: Delete a user - tags: - - user - get: - description: Retrieve a user by their username. - operationId: getUserByName - parameters: - - in: path - name: username - required: true - schema: - type: string - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPetUser' - description: OK - "404": - description: Not Found - summary: Get user by username - tags: - - user - put: - description: Update the details of an existing user. - operationId: updateUser - parameters: - - in: path - name: username - required: true - schema: - type: string - requestBody: - content: - application/json: - schema: - properties: - email: - type: string - firstName: - type: string - id: - type: integer - lastName: - type: string - password: - type: string - phone: - type: string - userStatus: - enum: - - 0 - - 1 - - 2 - type: integer - username: - type: string - type: object - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPetUser' - description: OK - "404": - description: Not Found - summary: Update an existing user - tags: - - user - /user/createWithList: - post: - description: Create multiple users in the store with a list. - operationId: createUsersWithList - requestBody: - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPetUser' - nullable: true - type: array - responses: - "201": - description: Created - summary: Create users with list - tags: - - user -components: - schemas: - DtoAPIResponse: - properties: - code: - type: integer - message: - type: string - type: - type: string - type: object - DtoCategory: - properties: - id: - type: integer - name: - type: string - type: object - DtoOrder: - properties: - complete: - type: boolean - id: - type: integer - petId: - type: integer - quantity: - type: integer - shipDate: - format: date-time - type: string - status: - enum: - - placed - - approved - - delivered - type: string - type: object - DtoPet: - properties: - category: - $ref: '#/components/schemas/DtoCategory' - id: - type: integer - name: - type: string - photoUrls: - items: - type: string - nullable: true - type: array - status: - enum: - - available - - pending - - sold - type: string - tags: - items: - $ref: '#/components/schemas/DtoTag' - nullable: true - type: array - type: - type: string - type: object - DtoPetUser: - properties: - email: - type: string - firstName: - type: string - id: - type: integer - lastName: - type: string - password: - type: string - phone: - type: string - userStatus: - enum: - - 0 - - 1 - - 2 - type: integer - username: - type: string - type: object - DtoTag: - properties: - id: - type: integer - name: - type: string - type: object - FormDataDtoUpdatePetWithFormRequest: - properties: - name: - type: string - status: - enum: - - available - - pending - - sold - type: string - required: - - name - type: object - securitySchemes: - apiKey: - in: header - name: api_key - type: apiKey - petstore_auth: - flows: - implicit: - authorizationUrl: https://petstore3.swagger.io/oauth/authorize - scopes: - read:pets: read your pets - write:pets: modify pets in your account - type: oauth2 diff --git a/testdata/petstore_31.yaml b/testdata/petstore_31.yaml deleted file mode 100644 index bc57452..0000000 --- a/testdata/petstore_31.yaml +++ /dev/null @@ -1,543 +0,0 @@ -openapi: 3.1.0 -info: - contact: - email: apiteam@swagger.io - description: This is a sample Petstore server. - license: - name: Apache 2.0 - url: https://www.apache.org/licenses/LICENSE-2.0.html - termsOfService: https://swagger.io/terms/ - title: Petstore API - version: 1.0.0 -servers: -- url: https://petstore3.swagger.io/api/v3 -paths: - /pet/: - post: - description: Add a new pet to the store. - operationId: addPet - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPet' - responses: - "201": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPet' - description: Created - security: - - petstore_auth: - - write:pets - - read:pets - summary: Add a new pet - tags: - - pet - put: - description: Update the details of an existing pet in the store. - operationId: updatePet - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPet' - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPet' - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Update an existing pet - tags: - - pet - /pet/{petId}: - delete: - description: Delete a pet from the store by its ID. - operationId: deletePet - parameters: - - in: path - name: petId - required: true - schema: - type: integer - - in: header - name: api_key - schema: - type: string - responses: - "204": - description: No Content - security: - - petstore_auth: - - write:pets - - read:pets - summary: Delete a pet - tags: - - pet - get: - description: Retrieve a pet by its ID. - operationId: getPetById - parameters: - - in: path - name: petId - required: true - schema: - type: integer - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPet' - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Get pet by ID - tags: - - pet - post: - description: Updates a pet in the store with form data. - operationId: updatePetWithForm - parameters: - - in: path - name: petId - required: true - schema: - type: integer - requestBody: - content: - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/FormDataDtoUpdatePetWithFormRequest' - responses: - "200": - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Update pet with form - tags: - - pet - /pet/{petId}/uploadImage: - post: - description: Uploads an image for a pet. - operationId: uploadFile - parameters: - - in: query - name: additionalMetadata - schema: - type: string - - in: path - name: petId - required: true - schema: - format: int64 - type: integer - requestBody: - content: - application/octet-stream: - schema: - type: string - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoAPIResponse' - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Upload an image for a pet - tags: - - pet - /pet/findByStatus: - get: - description: Finds Pets by status. Multiple status values can be provided with - comma separated strings. - operationId: findPetsByStatus - parameters: - - in: query - name: status - schema: - enum: - - available - - pending - - sold - type: string - responses: - "200": - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPet' - type: - - "null" - - array - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by status - tags: - - pet - /pet/findByTags: - get: - description: Finds Pets by tags. Multiple tags can be provided with comma separated - strings. - operationId: findPetsByTags - parameters: - - in: query - name: tags - schema: - items: - type: string - type: array - responses: - "200": - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPet' - type: - - "null" - - array - description: OK - security: - - petstore_auth: - - write:pets - - read:pets - summary: Find pets by tags - tags: - - pet - /store/order: - post: - description: Place a new order for a pet. - operationId: placeOrder - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoOrder' - responses: - "201": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoOrder' - description: Created - summary: Place an order - tags: - - store - /store/order/{orderId}: - delete: - description: Delete an order by its ID. - operationId: deleteOrder - parameters: - - in: path - name: orderId - required: true - schema: - type: integer - responses: - "204": - description: No Content - summary: Delete an order - tags: - - store - get: - description: Retrieve an order by its ID. - operationId: getOrderById - parameters: - - in: path - name: orderId - required: true - schema: - type: integer - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoOrder' - description: OK - "404": - description: Not Found - summary: Get order by ID - tags: - - store - /user/: - post: - description: Create a new user in the store. - operationId: createUser - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPetUser' - responses: - "201": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPetUser' - description: Created - summary: Create a new user - tags: - - user - /user/{username}: - delete: - description: Delete a user from the store by their username. - operationId: deleteUser - parameters: - - in: path - name: username - required: true - schema: - type: string - responses: - "204": - description: No Content - summary: Delete a user - tags: - - user - get: - description: Retrieve a user by their username. - operationId: getUserByName - parameters: - - in: path - name: username - required: true - schema: - type: string - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPetUser' - description: OK - "404": - description: Not Found - summary: Get user by username - tags: - - user - put: - description: Update the details of an existing user. - operationId: updateUser - parameters: - - in: path - name: username - required: true - schema: - type: string - requestBody: - content: - application/json: - schema: - properties: - email: - type: string - firstName: - type: string - id: - type: integer - lastName: - type: string - password: - type: string - phone: - type: string - userStatus: - enum: - - 0 - - 1 - - 2 - type: integer - username: - type: string - type: object - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/DtoPetUser' - description: OK - "404": - description: Not Found - summary: Update an existing user - tags: - - user - /user/createWithList: - post: - description: Create multiple users in the store with a list. - operationId: createUsersWithList - requestBody: - content: - application/json: - schema: - items: - $ref: '#/components/schemas/DtoPetUser' - type: - - "null" - - array - responses: - "201": - description: Created - summary: Create users with list - tags: - - user -components: - schemas: - DtoAPIResponse: - properties: - code: - type: integer - message: - type: string - type: - type: string - type: object - DtoCategory: - properties: - id: - type: integer - name: - type: string - type: object - DtoOrder: - properties: - complete: - type: boolean - id: - type: integer - petId: - type: integer - quantity: - type: integer - shipDate: - format: date-time - type: string - status: - enum: - - placed - - approved - - delivered - type: string - type: object - DtoPet: - properties: - category: - $ref: '#/components/schemas/DtoCategory' - id: - type: integer - name: - type: string - photoUrls: - items: - type: string - type: - - array - - "null" - status: - enum: - - available - - pending - - sold - type: string - tags: - items: - $ref: '#/components/schemas/DtoTag' - type: - - array - - "null" - type: - type: string - type: object - DtoPetUser: - properties: - email: - type: string - firstName: - type: string - id: - type: integer - lastName: - type: string - password: - type: string - phone: - type: string - userStatus: - enum: - - 0 - - 1 - - 2 - type: integer - username: - type: string - type: object - DtoTag: - properties: - id: - type: integer - name: - type: string - type: object - FormDataDtoUpdatePetWithFormRequest: - properties: - name: - type: string - status: - enum: - - available - - pending - - sold - type: string - required: - - name - type: object - securitySchemes: - apiKey: - in: header - name: api_key - type: apiKey - petstore_auth: - flows: - implicit: - authorizationUrl: https://petstore3.swagger.io/oauth/authorize - scopes: - read:pets: read your pets - write:pets: modify pets in your account - type: oauth2 -tags: -- description: Everything about your Pets - externalDocs: - description: Find out more about our Pets - url: https://swagger.io - name: pet -- description: Access to Petstore orders - externalDocs: - description: Find out more about our Store - url: https://swagger.io - name: store -- description: Operations about user - name: user -externalDocs: - description: Find more info here about swagger - url: https://swagger.io diff --git a/testdata/request_response.v30.yaml b/testdata/request_response.v30.yaml new file mode 100644 index 0000000..4806788 --- /dev/null +++ b/testdata/request_response.v30.yaml @@ -0,0 +1,43 @@ +openapi: 3.0.4 +info: + title: Login API + version: 1.0.0 +paths: + /login: + post: + summary: Login + description: Login + operationId: login + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/LoginResponse" +components: + schemas: + LoginRequest: + type: object + required: + - username + - password + properties: + password: + type: string + writeOnly: true + username: + type: string + minLength: 3 + LoginResponse: + type: object + required: + - token + properties: + token: + type: string diff --git a/testdata/request_response.v31.yaml b/testdata/request_response.v31.yaml new file mode 100644 index 0000000..5f77c6a --- /dev/null +++ b/testdata/request_response.v31.yaml @@ -0,0 +1,43 @@ +openapi: 3.1.2 +info: + title: Login API + version: 1.0.0 +paths: + /login: + post: + summary: Login + description: Login + operationId: login + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/LoginResponse" +components: + schemas: + LoginRequest: + type: object + required: + - username + - password + properties: + password: + type: string + writeOnly: true + username: + type: string + minLength: 3 + LoginResponse: + type: object + required: + - token + properties: + token: + type: string diff --git a/testdata/request_response.v32.yaml b/testdata/request_response.v32.yaml new file mode 100644 index 0000000..d5595c9 --- /dev/null +++ b/testdata/request_response.v32.yaml @@ -0,0 +1,43 @@ +openapi: 3.2.0 +info: + title: Login API + version: 1.0.0 +paths: + /login: + post: + summary: Login + description: Login + operationId: login + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/LoginResponse" +components: + schemas: + LoginRequest: + type: object + required: + - username + - password + properties: + password: + type: string + writeOnly: true + username: + type: string + minLength: 3 + LoginResponse: + type: object + required: + - token + properties: + token: + type: string diff --git a/testdata/security.v30.yaml b/testdata/security.v30.yaml new file mode 100644 index 0000000..e06817d --- /dev/null +++ b/testdata/security.v30.yaml @@ -0,0 +1,35 @@ +openapi: 3.0.4 +info: + title: Secure API + version: 1.0.0 +paths: + /me: + get: + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/User" + security: + - bearerAuth: [] +components: + schemas: + User: + type: object + required: + - id + properties: + id: + type: string + name: + type: string + securitySchemes: + apiKey: + type: apiKey + name: X-API-Key + in: header + bearerAuth: + type: http + scheme: bearer diff --git a/testdata/security.v31.yaml b/testdata/security.v31.yaml new file mode 100644 index 0000000..bb45564 --- /dev/null +++ b/testdata/security.v31.yaml @@ -0,0 +1,35 @@ +openapi: 3.1.2 +info: + title: Secure API + version: 1.0.0 +paths: + /me: + get: + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/User" + security: + - bearerAuth: [] +components: + schemas: + User: + type: object + required: + - id + properties: + id: + type: string + name: + type: string + securitySchemes: + apiKey: + type: apiKey + name: X-API-Key + in: header + bearerAuth: + type: http + scheme: bearer diff --git a/testdata/security.v32.yaml b/testdata/security.v32.yaml new file mode 100644 index 0000000..ad03bf7 --- /dev/null +++ b/testdata/security.v32.yaml @@ -0,0 +1,35 @@ +openapi: 3.2.0 +info: + title: Secure API + version: 1.0.0 +paths: + /me: + get: + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/User" + security: + - bearerAuth: [] +components: + schemas: + User: + type: object + required: + - id + properties: + id: + type: string + name: + type: string + securitySchemes: + apiKey: + type: apiKey + name: X-API-Key + in: header + bearerAuth: + type: http + scheme: bearer diff --git a/testdata/server_variables.v30.yaml b/testdata/server_variables.v30.yaml new file mode 100644 index 0000000..ffd36b6 --- /dev/null +++ b/testdata/server_variables.v30.yaml @@ -0,0 +1,20 @@ +openapi: 3.0.4 +info: + title: Server Var API + version: 1.0.0 +servers: + - url: https://{environment}.example.com/v1 + variables: + environment: + enum: + - production + - staging + - dev + default: production + description: API environment +paths: + /ping: + get: + responses: + "204": + description: No Content diff --git a/testdata/server_variables.v31.yaml b/testdata/server_variables.v31.yaml new file mode 100644 index 0000000..78f06db --- /dev/null +++ b/testdata/server_variables.v31.yaml @@ -0,0 +1,20 @@ +openapi: 3.1.2 +info: + title: Server Var API + version: 1.0.0 +servers: + - url: https://{environment}.example.com/v1 + variables: + environment: + enum: + - production + - staging + - dev + default: production + description: API environment +paths: + /ping: + get: + responses: + "204": + description: No Content diff --git a/testdata/server_variables.v32.yaml b/testdata/server_variables.v32.yaml new file mode 100644 index 0000000..0403fe5 --- /dev/null +++ b/testdata/server_variables.v32.yaml @@ -0,0 +1,20 @@ +openapi: 3.2.0 +info: + title: Server Var API + version: 1.0.0 +servers: + - url: https://{environment}.example.com/v1 + variables: + environment: + enum: + - production + - staging + - dev + default: production + description: API environment +paths: + /ping: + get: + responses: + "204": + description: No Content diff --git a/testdata/server_variables_3.yaml b/testdata/server_variables_3.yaml deleted file mode 100644 index 47b6475..0000000 --- a/testdata/server_variables_3.yaml +++ /dev/null @@ -1,25 +0,0 @@ -openapi: 3.0.3 -info: - description: This is the API documentation for Server Variables - title: 'API Doc: Server Variables' - version: 1.0.0 -servers: -- description: Production Server - url: https://api.example.com/{version} - variables: - version: - default: v1 - description: API version - enum: - - v1 - - v2 -- description: Development Server - url: https://api.example.dev/{version} - variables: - version: - default: v1 - description: API version - enum: - - v1 - - v2 -paths: {} diff --git a/testdata/server_variables_31.yaml b/testdata/server_variables_31.yaml deleted file mode 100644 index ae27867..0000000 --- a/testdata/server_variables_31.yaml +++ /dev/null @@ -1,24 +0,0 @@ -openapi: 3.1.0 -info: - description: This is the API documentation for Server Variables - title: 'API Doc: Server Variables' - version: 1.0.0 -servers: -- description: Production Server - url: https://api.example.com/{version} - variables: - version: - default: v1 - description: API version - enum: - - v1 - - v2 -- description: Development Server - url: https://api.example.dev/{version} - variables: - version: - default: v1 - description: API version - enum: - - v1 - - v2 diff --git a/testdata/spec_information.v30.yaml b/testdata/spec_information.v30.yaml new file mode 100644 index 0000000..a031635 --- /dev/null +++ b/testdata/spec_information.v30.yaml @@ -0,0 +1,26 @@ +openapi: 3.0.4 +info: + title: Users API + description: User account operations. + termsOfService: https://example.com/terms + contact: + name: API Team + email: api@example.com + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + version: 1.2.3 +servers: + - url: https://api.example.com +externalDocs: + description: API documentation + url: https://docs.example.com +tags: + - name: users + description: User operations +paths: + /ping: + get: + responses: + "204": + description: No Content diff --git a/testdata/spec_information.v31.yaml b/testdata/spec_information.v31.yaml new file mode 100644 index 0000000..69c6a1c --- /dev/null +++ b/testdata/spec_information.v31.yaml @@ -0,0 +1,26 @@ +openapi: 3.1.2 +info: + title: Users API + description: User account operations. + termsOfService: https://example.com/terms + contact: + name: API Team + email: api@example.com + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + version: 1.2.3 +servers: + - url: https://api.example.com +externalDocs: + description: API documentation + url: https://docs.example.com +tags: + - name: users + description: User operations +paths: + /ping: + get: + responses: + "204": + description: No Content diff --git a/testdata/spec_information.v32.yaml b/testdata/spec_information.v32.yaml new file mode 100644 index 0000000..2a61e57 --- /dev/null +++ b/testdata/spec_information.v32.yaml @@ -0,0 +1,26 @@ +openapi: 3.2.0 +info: + title: Users API + description: User account operations. + termsOfService: https://example.com/terms + contact: + name: API Team + email: api@example.com + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + version: 1.2.3 +servers: + - url: https://api.example.com +externalDocs: + description: API documentation + url: https://docs.example.com +tags: + - name: users + description: User operations +paths: + /ping: + get: + responses: + "204": + description: No Content diff --git a/testdata/spec_information_3.yaml b/testdata/spec_information_3.yaml deleted file mode 100644 index 5b58c2e..0000000 --- a/testdata/spec_information_3.yaml +++ /dev/null @@ -1,16 +0,0 @@ -openapi: 3.0.3 -info: - contact: - email: support@example.com - name: Support Team - url: https://support.example.com - description: This is the API documentation for Spec Information - license: - name: MIT License - url: https://opensource.org/licenses/MIT - title: 'API Doc: Spec Information' - version: 1.0.0 -externalDocs: - description: API Documentation - url: https://docs.example.com -paths: {} diff --git a/testdata/spec_information_31.yaml b/testdata/spec_information_31.yaml deleted file mode 100644 index 47a89b0..0000000 --- a/testdata/spec_information_31.yaml +++ /dev/null @@ -1,15 +0,0 @@ -openapi: 3.1.0 -info: - contact: - email: support@example.com - name: Support Team - url: https://support.example.com - description: This is the API documentation for Spec Information - license: - name: MIT License - url: https://opensource.org/licenses/MIT - title: 'API Doc: Spec Information' - version: 1.0.0 -externalDocs: - description: API Documentation - url: https://docs.example.com diff --git a/testdata/strip_trailing_slashes_3.yaml b/testdata/strip_trailing_slashes_3.yaml deleted file mode 100644 index 2dfdc5b..0000000 --- a/testdata/strip_trailing_slashes_3.yaml +++ /dev/null @@ -1,56 +0,0 @@ -openapi: 3.0.3 -info: - description: This is the API documentation for Strip Trailing Slashes - title: 'API Doc: Strip Trailing Slashes' - version: 1.0.0 -paths: - /api/v1/users: - get: - description: Get Users - responses: - "200": - content: - application/json: - schema: - items: - $ref: '#/components/schemas/SpecTestUser' - type: array - description: OK - summary: Get Users - tags: - - Users - /path/with/trailing/slash: - get: - description: This operation tests paths with trailing slashes. - operationId: getPathWithTrailingSlash - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestUser' - description: OK - summary: Get Path With Trailing Slash -components: - schemas: - SpecTestNullString: - type: object - SpecTestNullTime: - type: object - SpecTestUser: - properties: - age: - nullable: true - type: integer - created_at: - format: date-time - type: string - email: - $ref: '#/components/schemas/SpecTestNullString' - id: - type: integer - updated_at: - $ref: '#/components/schemas/SpecTestNullTime' - username: - type: string - type: object diff --git a/testdata/strip_trailing_slashes_31.yaml b/testdata/strip_trailing_slashes_31.yaml deleted file mode 100644 index 96724da..0000000 --- a/testdata/strip_trailing_slashes_31.yaml +++ /dev/null @@ -1,59 +0,0 @@ -openapi: 3.1.0 -info: - description: This is the API documentation for Strip Trailing Slashes - title: 'API Doc: Strip Trailing Slashes' - version: 1.0.0 -paths: - /api/v1/users: - get: - description: Get Users - responses: - "200": - content: - application/json: - schema: - items: - $ref: '#/components/schemas/SpecTestUser' - type: - - "null" - - array - description: OK - summary: Get Users - tags: - - Users - /path/with/trailing/slash: - get: - description: This operation tests paths with trailing slashes. - operationId: getPathWithTrailingSlash - responses: - "200": - content: - application/json: - schema: - $ref: '#/components/schemas/SpecTestUser' - description: OK - summary: Get Path With Trailing Slash -components: - schemas: - SpecTestNullString: - type: object - SpecTestNullTime: - type: object - SpecTestUser: - properties: - age: - type: - - "null" - - integer - created_at: - format: date-time - type: string - email: - $ref: '#/components/schemas/SpecTestNullString' - id: - type: integer - updated_at: - $ref: '#/components/schemas/SpecTestNullTime' - username: - type: string - type: object diff --git a/testdata/trailing_slash.v30.yaml b/testdata/trailing_slash.v30.yaml new file mode 100644 index 0000000..7c33708 --- /dev/null +++ b/testdata/trailing_slash.v30.yaml @@ -0,0 +1,10 @@ +openapi: 3.0.4 +info: + title: API Documentation + version: 1.0.0 +paths: + /ping: + get: + responses: + "204": + description: No Content diff --git a/testdata/trailing_slash.v31.yaml b/testdata/trailing_slash.v31.yaml new file mode 100644 index 0000000..27d1c5d --- /dev/null +++ b/testdata/trailing_slash.v31.yaml @@ -0,0 +1,10 @@ +openapi: 3.1.2 +info: + title: API Documentation + version: 1.0.0 +paths: + /ping: + get: + responses: + "204": + description: No Content diff --git a/testdata/trailing_slash.v32.yaml b/testdata/trailing_slash.v32.yaml new file mode 100644 index 0000000..1692520 --- /dev/null +++ b/testdata/trailing_slash.v32.yaml @@ -0,0 +1,10 @@ +openapi: 3.2.0 +info: + title: API Documentation + version: 1.0.0 +paths: + /ping: + get: + responses: + "204": + description: No Content diff --git a/testdata/webhook_helpers.v31.yaml b/testdata/webhook_helpers.v31.yaml new file mode 100644 index 0000000..f884d24 --- /dev/null +++ b/testdata/webhook_helpers.v31.yaml @@ -0,0 +1,16 @@ +openapi: 3.1.2 +info: + title: Webhook API + version: 1.0.0 +paths: {} +webhooks: + cache.invalidate: + post: + responses: + "204": + description: No Content + user.created: + post: + responses: + "202": + description: Accepted diff --git a/testdata/webhook_helpers.v32.yaml b/testdata/webhook_helpers.v32.yaml new file mode 100644 index 0000000..d7f10eb --- /dev/null +++ b/testdata/webhook_helpers.v32.yaml @@ -0,0 +1,16 @@ +openapi: 3.2.0 +info: + title: Webhook API + version: 1.0.0 +paths: {} +webhooks: + cache.invalidate: + post: + responses: + "204": + description: No Content + user.created: + post: + responses: + "202": + description: Accepted diff --git a/types.go b/types.go index dd9f91b..d018c13 100644 --- a/types.go +++ b/types.go @@ -1,102 +1,127 @@ package spec import ( - specopenapi "github.com/oaswrap/spec/openapi" + "github.com/oaswrap/spec/internal/builder" + "github.com/oaswrap/spec/openapi" "github.com/oaswrap/spec/option" - "github.com/swaggest/openapi-go" ) -// Generator defines an interface for building and exporting OpenAPI specifications. -type Generator interface { - Router +// Contact is an alias of openapi.Contact. +type Contact = openapi.Contact - // Config returns the OpenAPI configuration used by the Generator. - Config() *specopenapi.Config +// License is an alias of openapi.License. +type License = openapi.License - // GenerateSchema generates the OpenAPI schema in the specified format. - // By default, it generates YAML. Pass "json" to generate JSON instead. - GenerateSchema(formats ...string) ([]byte, error) +// Tag is an alias of openapi.Tag. +type Tag = openapi.Tag - // MarshalYAML returns the OpenAPI specification marshaled as YAML. - MarshalYAML() ([]byte, error) +// ExternalDocs is an alias of openapi.ExternalDocs. +type ExternalDocs = openapi.ExternalDocs - // MarshalJSON returns the OpenAPI specification marshaled as JSON. - MarshalJSON() ([]byte, error) +// Server is an alias of openapi.Server. +type Server = openapi.Server - // Validate checks whether the OpenAPI specification is valid. - Validate() error +// ServerVariable is an alias of openapi.ServerVariable. +type ServerVariable = openapi.ServerVariable + +// SecurityScheme is an alias of openapi.SecurityScheme. +type SecurityScheme = openapi.SecurityScheme + +// OAuthFlows is an alias of openapi.OAuthFlows. +type OAuthFlows = openapi.OAuthFlows + +// OAuthFlow is an alias of openapi.OAuthFlow. +type OAuthFlow = openapi.OAuthFlow + +// Schema is an alias of openapi.Schema. +type Schema = openapi.Schema + +// Document is an alias of openapi.Document. +type Document = openapi.Document - // WriteSchemaTo writes the OpenAPI schema to a file. - // The format is inferred from the file extension: ".yaml" for YAML, ".json" for JSON. +// OneOf returns a value that represents multiple possible schemas. +func OneOf(values ...any) any { + return builder.OneOf(values...) +} + +// SchemaExposer lets custom Go types provide their own OpenAPI Schema Object. +// The selected OpenAPI version is passed so implementations can return +// version-specific schema keywords. +type SchemaExposer interface { + OpenAPISchema(version string) *openapi.Schema +} + +// StaticSchemaExposer lets custom Go types provide an OpenAPI Schema Object +// when they do not need version-specific output. +type StaticSchemaExposer interface { + OpenAPISchema() *openapi.Schema +} + +// Generator builds, validates, and serializes an OpenAPI document. +type Generator interface { + Router + // Config returns the effective OpenAPI configuration. + Config() *openapi.Config + // Document returns the built in-memory OpenAPI document. + Document() *openapi.Document + // GenerateSchema serializes the document in YAML by default, or JSON/YAML + // when explicitly requested. + GenerateSchema(formats ...string) ([]byte, error) + // MarshalYAML validates and serializes the document as YAML. + MarshalYAML() ([]byte, error) + // MarshalJSON validates and serializes the document as pretty JSON. + MarshalJSON() ([]byte, error) + // Validate builds the document and validates OpenAPI invariants. + Validate() error + // WriteSchemaTo serializes and writes the document based on file extension. WriteSchemaTo(path string) error } -// Router defines methods for registering API routes and operations -// in an OpenAPI specification. It lets you describe HTTP methods, paths, and options. +// Router registers operations and route groups that are converted to OpenAPI +// Paths and Webhooks during document generation. type Router interface { - // Get registers a GET operation for the given path and options. + // Get registers a GET operation for a path. Get(path string, opts ...option.OperationOption) Route - - // Post registers a POST operation for the given path and options. + // Post registers a POST operation for a path. Post(path string, opts ...option.OperationOption) Route - - // Put registers a PUT operation for the given path and options. + // Put registers a PUT operation for a path. Put(path string, opts ...option.OperationOption) Route - - // Delete registers a DELETE operation for the given path and options. + // Delete registers a DELETE operation for a path. Delete(path string, opts ...option.OperationOption) Route - - // Patch registers a PATCH operation for the given path and options. + // Patch registers a PATCH operation for a path. Patch(path string, opts ...option.OperationOption) Route - - // Options registers an OPTIONS operation for the given path and options. + // Options registers an OPTIONS operation for a path. Options(path string, opts ...option.OperationOption) Route - - // Head registers a HEAD operation for the given path and options. + // Head registers a HEAD operation for a path. Head(path string, opts ...option.OperationOption) Route - - // Trace registers a TRACE operation for the given path and options. + // Trace registers a TRACE operation for a path. Trace(path string, opts ...option.OperationOption) Route - - // Add registers an operation for the given HTTP method, path, and options. + // Query registers an OpenAPI 3.2 QUERY operation for a path. + Query(path string, opts ...option.OperationOption) Route + // Add registers an operation for an arbitrary HTTP method. Add(method, path string, opts ...option.OperationOption) Route - - // NewRoute creates a new route with the given options. + // Webhook registers a webhook entry using POST as the default method. + // Webhooks require OpenAPI 3.1.x or 3.2.0. + Webhook(name string, opts ...option.OperationOption) Route + // AddWebhook registers a webhook entry with an explicit HTTP method. + // Webhooks require OpenAPI 3.1.x or 3.2.0. + AddWebhook(method, name string, opts ...option.OperationOption) Route + // NewRoute creates a route that can be configured incrementally. NewRoute(opts ...option.OperationOption) Route - - // Route registers a nested route under the given pattern. - // The provided function receives a Router to define sub-routes. + // Route creates a grouped router and executes registrations in fn. Route(pattern string, fn func(router Router), opts ...option.GroupOption) Router - - // Group creates a new sub-router with the given path prefix and group options. + // Group creates a grouped router with a path prefix and group options. Group(pattern string, opts ...option.GroupOption) Router - - // With applies one or more group options to the router. + // With appends group options to the current router scope. With(opts ...option.GroupOption) Router } -// Route represents a single API route in the OpenAPI specification. +// Route lets callers set method/path/options incrementally. type Route interface { - // Method sets the HTTP method for the route. + // Method sets the HTTP method. Method(method string) Route - // Path sets the HTTP path for the route. + // Path sets the route path or webhook name. Path(path string) Route - // With applies additional operation options to the route. + // With appends operation options. With(opts ...option.OperationOption) Route } - -type reflector interface { - Add(method, path string, opts ...option.OperationOption) - Spec() spec - Validate() error -} - -type spec interface { - MarshalYAML() ([]byte, error) - MarshalJSON() ([]byte, error) -} - -type operationContext interface { - With(opts ...option.OperationOption) operationContext - build() openapi.OperationContext -}