Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
{
"name": "oastools",
"description": "15 MCP tools and 5 guided skills for working with OpenAPI specs — validate, fix, convert, diff, join, overlay, generate, and walk operations/schemas/parameters/responses/security/paths",
"version": "1.56.0",
"version": "1.57.0",
"author": {
"name": "erraggy",
"url": "https://github.com/erraggy"
Expand Down
7 changes: 4 additions & 3 deletions .claude/docs/oas-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ All OAS versions utilize **JSON Schema Specification Draft 2020-12**: https://ww

**OAS 2.0 Only:**

- `allowEmptyValue`, `collectionFormat`, single `host`/`basePath`/`schemes`
- `allowEmptyValue`, `collectionFormat`, single `host`/`basePath`/`schemes`, bare-string `discriminator`

**OAS 3.0+ Only:**

- `requestBody`, `callbacks`, `links`, cookie parameters, `servers` array, TRACE method
- `requestBody`, `callbacks`, `links`, cookie parameters, `servers` array, TRACE method, `discriminator` object with `propertyName`/`mapping`

**OAS 3.1+ Only:**

Expand Down Expand Up @@ -79,7 +79,8 @@ if typeStr, ok := schema.Type.(string); ok {
4. **Mutating source documents** - Always deep copy before modification using generated `DeepCopy()` methods (e.g., `doc.DeepCopy()`). Never use JSON marshal/unmarshal — it loses `interface{}` type distinctions and drops `json:"-"` fields
5. **Not handling operation-level consumes/produces** - Check operation-level first, then fall back to document-level
6. **Ignoring version-specific features during conversion** - Explicitly check and warn about features that don't convert
7. **Confusing Version (string) with OASVersion (enum)** - `ParseResult` has TWO version fields:
7. **Assuming `Schema.Discriminator` implies OAS 3.x** - A hand-built OAS 2.0 fixture must set `Discriminator.StringForm: true`, or the validator rejects it as the 3.0+ object form. Likewise, assuming `schema.Items` may be a `map[string]any` — every parser decode path promotes it to `*parser.Schema`
8. **Confusing Version (string) with OASVersion (enum)** - `ParseResult` has TWO version fields:
- `Version` (string): The literal version string from the document (e.g., `"3.0.3"`, `"2.0"`)
- `OASVersion` (parser.OASVersion enum): Our canonical enum for each published spec version

Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
- **Always run `go_diagnostics`** after edits—hints improve perf 5-15%
- **Favor fixing immediately** over deferring issues
- **Deep copy**: Use generated `doc.DeepCopy()` methods, **never** JSON marshal/unmarshal (loses `interface{}` types, drops `json:"-"` fields)
- **Schema-or-bool fields are always promoted**: `Items`, `AdditionalProperties`, `AdditionalItems`, `UnevaluatedItems` and `UnevaluatedProperties` are `any`, but every decode path (JSON, YAML, `decodeFromMap`) yields `*Schema`, `[]*Schema` (OAS 2.0 tuple form), or `bool`. Type-assert to `*parser.Schema`; a `map[string]any` arm is dead for parsed documents
- **Discriminator has two dialects**: OAS 2.0 spells `discriminator` as a bare string, OAS 3.0+ as an object. Both decode into `parser.Discriminator`; `StringForm bool` records which. The parser accepts either (it cannot see the document version), the validator rejects the wrong one for the version, and the converter flips the flag in both directions. `StringForm` is excluded from JSON, YAML, and `equalDiscriminator` — it is spelling, not meaning
- **`make check` before pushing** — not just `go test`; catches lint, formatting, and trailing whitespace
- **`docs/` is mixed source + generated**: Source files (`index.md`, `mcp-server.md`, `cli-reference.md`, etc.) are edited directly in `docs/`. Generated files (`docs/packages/`, `docs/examples/`) come from `{package}/deep_dive.md` and `examples/*/README.md` — see `.claude/docs/docs-website.md`
- **MCP config via env vars**: The MCP server reads `OASTOOLS_*` env vars for configuration (cache TTLs, walk limits, join strategies, etc.). The Go MCP SDK doesn't support `initializationOptions`, so env vars are used instead. MCP clients set these via their `env` field in server config.
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,7 @@ if typeStr, ok := schema.Type.(string); ok {

- `allowEmptyValue` (removed in 3.0+)
- `collectionFormat` (replaced by `style`/`explode`)
- `discriminator` as a bare string (object form in 3.0+)

**OAS 3.0+ Only:**

Expand All @@ -563,6 +564,7 @@ if typeStr, ok := schema.Type.(string); ok {
- `links` (operation relationships)
- Cookie parameters (`in: cookie`)
- TRACE HTTP method
- `discriminator` object with `propertyName` and `mapping`

**OAS 3.1+ Only:**

Expand Down
364 changes: 364 additions & 0 deletions benchmarks/benchmark-v1.57.0.txt

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions converter/deep_dive.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ fmt.Printf("\nTotal critical issues across all files: %d\n", totalCritical)
| `consumes` + body param | `requestBody.content` | Media types explicit |
| `produces` + schema | `response.content` | Media types explicit |
| `type: file` | Binary string + format | `type: string, format: binary` |
| `discriminator: petType` | `discriminator.propertyName` | Bare string promoted to the object form |
| `collectionFormat` | `style` + `explode` | Mapping varies by format |

**Server URL Construction:**
Expand Down Expand Up @@ -491,6 +492,9 @@ requestBody:
| `writeOnly`, `deprecated` | Detected | Warning issued (no OAS 2.0 equivalent) |
| `if`/`then`/`else` | Detected | Warning issued (JSON Schema 2020-12) |
| `prefixItems`, `contains`, `propertyNames` | Detected | Warning issued (JSON Schema 2020-12) |
| `discriminator` object | Bare string | `propertyName` preserved as the OAS 2.0 string form |
| `discriminator.mapping` | Dropped | Warning issued (OAS 2.0 resolves by schema name only) |
| `discriminator` extensions (`x-*`) | Dropped | Warning issued (the bare string has no object to hold them) |

**Server URL Decomposition:**

Expand Down Expand Up @@ -772,6 +776,7 @@ Understanding what information is lost during conversion is crucial for making i
| Multiple content types | First used | Ensure JSON is first if preferred |
| TRACE method | Dropped | Use custom extension if needed |
| Schema keywords (`writeOnly`, `deprecated`, `if`/`then`/`else`, `prefixItems`, `contains`, `propertyNames`) | No equivalent | Warning issued; document constraints externally |
| `discriminator.mapping` and its extensions | Complete loss | Warning issued; rename target definitions to match discriminator values |

### OAS 2.0 -> OAS 3.0 (Minimal Loss)

Expand Down
2 changes: 1 addition & 1 deletion docs/developer-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1725,7 +1725,7 @@ The walker package also supports:

- **Parent Tracking**: Access ancestor nodes with `WithParentTracking()` and helper methods like `ParentSchema()`, `ParentOperation()`
- **Post-Visit Handlers**: Process nodes after their children with `WithSchemaPostHandler()`, `WithOperationPostHandler()`, etc.
- **Reference Tracking**: Track `$ref` values with `WithRefHandler()` for reference analysis. Use `WithMapRefTracking()` to also detect refs in `map[string]any` structures within polymorphic schema fields
- **Reference Tracking**: Track `$ref` values with `WithRefHandler()` for reference analysis. Use `WithMapRefTracking()` to also detect refs in `map[string]any` structures within polymorphic schema fields — needed only for hand-constructed or externally produced documents, since the parser promotes these fields to `*parser.Schema`

## Advanced Patterns

Expand Down
37 changes: 29 additions & 8 deletions parser/deep_dive.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,29 @@ The parser automatically detects format from:
2. Content inspection (JSON starts with `{` or `[`)
3. Defaults to YAML if unknown

### Discriminator Dialects

OAS 2.0 spells a Schema Object's discriminator as a bare string naming the
property; OAS 3.0+ uses an object with `propertyName` and an optional `mapping`.
Both decode into `*Discriminator`, and `StringForm` records which spelling the
document used:

```go
pet := doc.Definitions["Pet"] // OAS 2.0: discriminator: petType
pet.Discriminator.PropertyName // "petType"
pet.Discriminator.StringForm // true
```

Marshaling reproduces the form it was given, so a 2.0 document is not silently
rewritten into the 3.x object form on a round trip. `Mapping` and any `x-*`
extensions have no representation in the string form and are dropped when
`StringForm` is set.

The parser accepts either dialect regardless of version, because a Schema Object
is decoded before the document version is known to it. Rejecting the form that is
wrong for the version is the validator's job, and re-spelling it for a target
version is the converter's.

### Reference Resolution

External `$ref` values are resolved when `ResolveRefs` is enabled:
Expand Down Expand Up @@ -166,23 +189,21 @@ For strict validation of object and array schemas:

| Keyword | Type | Description |
|---------|------|-------------|
| `unevaluatedProperties` | `any` | `*Schema`, `bool`, or `map[string]any` for uncovered properties |
| `unevaluatedItems` | `any` | `*Schema`, `bool`, or `map[string]any` for uncovered array items |
| `unevaluatedProperties` | `any` | `*Schema` or `bool` for uncovered properties |
| `unevaluatedItems` | `any` | `*Schema` or `bool` for uncovered array items |

Every decode path promotes these to `*Schema`, so a parsed document never leaves
a raw `map[string]any` here — only a hand-constructed one can. Two cases suffice:

```go
schema := doc.Components.Schemas["StrictObject"]
switch v := schema.UnevaluatedProperties.(type) {
case *parser.Schema:
// Typed schema - most common after parsing
// Typed schema
fmt.Printf("Unevaluated properties must match: %s\n", v.Ref)
case bool:
// Boolean value - false disallows, true allows any
fmt.Printf("Unevaluated properties allowed: %v\n", v)
case map[string]any:
// Raw map - when schema wasn't typed during parsing
if ref, ok := v["$ref"].(string); ok {
fmt.Printf("Raw ref to: %s\n", ref)
}
default:
// nil or unexpected type
fmt.Println("No unevaluatedProperties constraint")
Expand Down
2 changes: 1 addition & 1 deletion parser/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@
// # JSON Schema 2020-12 Keywords
//
// The parser supports JSON Schema Draft 2020-12 keywords for OAS 3.1+:
// - unevaluatedProperties/unevaluatedItems: Strict validation (can be bool, *Schema, or map)
// - unevaluatedProperties/unevaluatedItems: Strict validation (parsed as bool or *Schema)
// - contentEncoding/contentMediaType/contentSchema: Encoded content validation
// - prefixItems, contains, propertyNames, dependentSchemas, $defs: Advanced schema features
//
Expand Down
119 changes: 119 additions & 0 deletions parser/example_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package parser_test

import (
"encoding/json"
"fmt"
"log"
"net/http"
Expand Down Expand Up @@ -329,6 +330,124 @@ func ExampleSchema_Equals() {
// After modification: false
}

// ExampleDiscriminator_stringForm demonstrates how the two discriminator dialects
// are decoded and written back. OAS 2.0 spells a discriminator as a bare string
// naming the property, while OAS 3.0+ uses an object with propertyName. Both decode
// into a Discriminator; StringForm records which spelling the document used so it
// round-trips unchanged.
func ExampleDiscriminator_stringForm() {
// OAS 2.0 uses the bare-string form: `discriminator: petType`
spec := []byte(`swagger: "2.0"
info:
title: Pet Store
version: "1.0"
paths: {}
definitions:
Pet:
type: object
discriminator: petType
required:
- petType
properties:
petType:
type: string
`)

result, err := parser.ParseWithOptions(parser.WithBytes(spec))
if err != nil {
log.Fatal(err)
}

doc, ok := result.OAS2Document()
if !ok {
log.Fatal("expected OAS2 document")
}

discriminator := doc.Definitions["Pet"].Discriminator
fmt.Printf("PropertyName: %s\n", discriminator.PropertyName)
fmt.Printf("StringForm: %v\n", discriminator.StringForm)

// Marshaling reproduces the form the document used
stringForm, err := json.Marshal(discriminator)
if err != nil {
log.Fatal(err)
}
fmt.Printf("OAS 2.0 output: %s\n", stringForm)

// Targeting OAS 3.0+ means clearing the flag, which selects the object form.
// The converter package does this for you in both directions.
discriminator.StringForm = false
objectForm, err := json.Marshal(discriminator)
if err != nil {
log.Fatal(err)
}
fmt.Printf("OAS 3.x output: %s\n", objectForm)
// Output:
// PropertyName: petType
// StringForm: true
// OAS 2.0 output: "petType"
// OAS 3.x output: {"propertyName":"petType"}
}

// ExampleSchema_items demonstrates reading Schema's any-typed fields. Items,
// AdditionalProperties, AdditionalItems, UnevaluatedItems and UnevaluatedProperties
// hold either a *Schema or a bool per JSON Schema, so consumers type-assert. The
// decoded types are the same whether the source was YAML or JSON.
Comment on lines +392 to +395

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document the OAS 2.0 tuple form for Items.

Items can also decode as []*parser.Schema for the OAS 2.0 tuple form, so the stated *Schema-or-bool set is incomplete.

Proposed fix
-// AdditionalProperties, AdditionalItems, UnevaluatedItems and UnevaluatedProperties
-// hold either a *Schema or a bool per JSON Schema, so consumers type-assert.
+// AdditionalProperties, AdditionalItems, UnevaluatedItems and UnevaluatedProperties
+// are polymorphic. Items may also be []*parser.Schema for the OAS 2.0 tuple form,
+// so consumers must type-assert according to the field and OAS version.

As per coding guidelines, “Items … [decode as] *Schema, []*Schema (OAS 2.0 tuple form), or bool.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// ExampleSchema_items demonstrates reading Schema's any-typed fields. Items,
// AdditionalProperties, AdditionalItems, UnevaluatedItems and UnevaluatedProperties
// hold either a *Schema or a bool per JSON Schema, so consumers type-assert. The
// decoded types are the same whether the source was YAML or JSON.
// ExampleSchema_items demonstrates reading Schema's any-typed fields. Items,
// AdditionalProperties, AdditionalItems, UnevaluatedItems and UnevaluatedProperties
// are polymorphic. Items may also be []*parser.Schema for the OAS 2.0 tuple form,
// so consumers must type-assert according to the field and OAS version. The
// decoded types are the same whether the source was YAML or JSON.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@parser/example_test.go` around lines 392 - 395, Update the documentation
comment for ExampleSchema_items to state that Items can decode as *Schema,
[]*Schema for the OAS 2.0 tuple form, or bool; leave the documented types for
AdditionalProperties, AdditionalItems, UnevaluatedItems, and
UnevaluatedProperties unchanged.

Source: Coding guidelines

func ExampleSchema_items() {
spec := []byte(`openapi: 3.0.3
info:
title: Inventory API
version: "1.0"
paths: {}
components:
schemas:
Tags:
type: array
items:
type: string
Counters:
type: object
additionalProperties:
type: integer
Strict:
type: object
additionalProperties: false
`)

result, err := parser.ParseWithOptions(parser.WithBytes(spec))
if err != nil {
log.Fatal(err)
}

doc, ok := result.OAS3Document()
if !ok {
log.Fatal("expected OAS3 document")
}

// Items is a *parser.Schema when present
items, ok := doc.Components.Schemas["Tags"].Items.(*parser.Schema)
if !ok {
log.Fatal("expected Items to be a schema")
}
fmt.Printf("Tags items: %v\n", items.Type)

// AdditionalProperties is either a *parser.Schema or a bool
for _, name := range []string{"Counters", "Strict"} {
switch additional := doc.Components.Schemas[name].AdditionalProperties.(type) {
case *parser.Schema:
fmt.Printf("%s additionalProperties: schema of %v\n", name, additional.Type)
case bool:
fmt.Printf("%s additionalProperties: %v\n", name, additional)
default:
fmt.Printf("%s additionalProperties: not set\n", name)
}
}
// Output:
// Tags items: string
// Counters additionalProperties: schema of integer
// Strict additionalProperties: false
}

// ExampleOAS3Document_Equals demonstrates comparing two OAS 3.x documents for equality.
func ExampleOAS3Document_Equals() {
result, err := parser.ParseWithOptions(
Expand Down
2 changes: 1 addition & 1 deletion plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "oastools",
"description": "OpenAPI Specification tools — validate, fix, convert, diff, walk, and generate from OAS 2.0-3.2 documents",
"version": "1.56.0",
"version": "1.57.0",
"author": {
"name": "erraggy",
"url": "https://github.com/erraggy"
Expand Down
3 changes: 2 additions & 1 deletion validator/deep_dive.md
Original file line number Diff line number Diff line change
Expand Up @@ -625,7 +625,8 @@ Component schemas undergo structural validation.
- Valid `type` values when present
- Correct `format` for the specified `type`
- `$ref` targets must resolve to existing schemas
- Nested schema structures (allOf, oneOf, anyOf, properties)
- Nested schema structures (allOf, oneOf, anyOf, properties, items, additionalProperties)
- `discriminator` uses the form its version requires: a bare string in OAS 2.0, an object with `propertyName` in OAS 3.0+. Skipped when `ParseResult.OASVersion` is unset or invalid, since an unrecognized version says nothing about which form is correct

**Reference Validation:**

Expand Down
2 changes: 1 addition & 1 deletion walker/deep_dive.md
Original file line number Diff line number Diff line change
Expand Up @@ -918,7 +918,7 @@ walker.Walk(result,

### Map Reference Tracking

Some polymorphic schema fields (`Items`, `AdditionalItems`, `AdditionalProperties`, `UnevaluatedItems`, `UnevaluatedProperties`) can contain either `*parser.Schema` or `map[string]any`. When a document is parsed with certain configurations or when schemas aren't fully resolved, these fields may contain raw maps with `$ref` values that the standard ref tracking wouldn't detect.
Some polymorphic schema fields (`Items`, `AdditionalItems`, `AdditionalProperties`, `UnevaluatedItems`, `UnevaluatedProperties`) can contain either `*parser.Schema` or `map[string]any`. Documents parsed by oastools always carry `*parser.Schema` here; the map form appears in hand-constructed documents or documents produced by external tooling, where `$ref` values would otherwise escape standard ref tracking.

Use `WithMapRefTracking()` to enable detection of `$ref` values stored in these map structures:

Expand Down
7 changes: 4 additions & 3 deletions walker/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -612,16 +612,17 @@ func ExampleWithRefHandler() {
func ExampleWithMapRefTracking() {
// Some polymorphic schema fields (Items, AdditionalItems, AdditionalProperties,
// UnevaluatedItems, UnevaluatedProperties) can contain map[string]any instead of
// *parser.Schema in certain parsing scenarios. WithMapRefTracking enables
// detection of $ref values stored in these map structures.
// *parser.Schema in hand-constructed or externally produced documents.
// WithMapRefTracking enables detection of $ref values stored in these map
// structures.
doc := &parser.OAS3Document{
OpenAPI: "3.1.0",
Info: &parser.Info{Title: "Test", Version: "1.0.0"},
Components: &parser.Components{
Schemas: map[string]*parser.Schema{
"Container": {
Type: "array",
// Simulating Items as map[string]any with a $ref (can occur in some parsing scenarios)
// Hand-constructed Items as map[string]any with a $ref
Items: map[string]any{
"$ref": "#/components/schemas/Item",
},
Expand Down
5 changes: 3 additions & 2 deletions walker/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,9 @@ func WithParentTracking() Option {
// AdditionalProperties, UnevaluatedItems, UnevaluatedProperties) that were not parsed as *Schema.
// Implicitly enables ref tracking.
//
// Polymorphic fields may contain map[string]any instead of *Schema when:
// - Documents are parsed from raw YAML/JSON without full schema resolution
// Every parser decode path promotes these fields to *Schema, so documents
// oastools parsed never need this. Polymorphic fields may still contain
// map[string]any when:
// - Manually constructing documents with map literals (e.g., in tests)
// - Using external tooling that produces partially resolved documents
//
Expand Down