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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,15 @@ malformed input, the petstore, aliased schemas, go123-specific forms, and cross-
- `ScanModels` — also emit definitions for `swagger:model` types.
- `InputSpec` — overlay: merge discoveries on top of an existing spec.
- `BuildTags`, `Include`/`Exclude`, `IncludeTags`/`ExcludeTags`, `ExcludeDeps` — scope control.
- `RefAliases`, `TransparentAliases`, `DescWithRef` — alias handling knobs.
- `RefAliases`, `TransparentAliases`, `DescWithRef` — alias handling knobs
(`DescWithRef` is deprecated; see `EmitRefSiblings`).
- `$ref`-sibling rendering (see `internal/builders/schema/README.md#ref-override`):
- `EmitRefSiblings` — emit a `$ref`'d field's description & extensions as direct
siblings (`{$ref, description, x-*}`) instead of an `allOf` wrap; validations/
externalDocs still force a compound.
- `SkipAllOfCompounding` — never emit an `allOf` compound; validations/externalDocs
dropped (description/extensions too, unless `EmitRefSiblings` keeps them as
siblings), each with a diagnostic. For consumers (e.g. go-swagger) wanting bare refs.
- `SetXNullableForPointers` — emit `x-nullable: true` on pointer fields.
- `SkipExtensions` — suppress `x-go-*` vendor extensions.
- `Debug` — verbose logging via `internal/logger`.
Expand Down
5 changes: 4 additions & 1 deletion docs/doc-site/getting-started/usage-as-a-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ an existing spec via `Options.InputSpec`.
| `ScanModels` | Also emit definitions for `swagger:model` types. |
| `InputSpec` | Overlay: merge discoveries on top of an existing spec. |
| `BuildTags`, `Include`/`Exclude` | Scope control over what gets scanned. |
| `RefAliases`, `TransparentAliases`, `DescWithRef` | Alias-handling knobs. |
| `RefAliases`, `TransparentAliases` | Alias-handling knobs. |
| `EmitRefSiblings` | Emit a `$ref`'d field's description and extensions as direct `$ref` siblings instead of an `allOf` wrap — see [Descriptions beside a $ref]({{% relref "/shaping-the-output/descriptions-beside-a-ref" %}}). |
| `SkipAllOfCompounding` | Never wrap a `$ref`'d field in an `allOf`; emit a bare `$ref` and drop the decorations that need a compound — see [Descriptions beside a $ref]({{% relref "/shaping-the-output/descriptions-beside-a-ref" %}}). |
| `DescWithRef` | _Deprecated_ — preserve a description-only `$ref` field via a single-arm `allOf`; prefer `EmitRefSiblings`. |
| `SkipExtensions` | Suppress `x-go-*` vendor extensions. |
| `SkipEnumDescriptions` | Keep the `swagger:enum` const→value mapping out of property/parameter descriptions (it still rides `x-go-enum-desc`). |
| `EmitXGoType` | Stamp `x-go-type` (the fully-qualified Go type) on every definition — see [Vendor extensions]({{% relref "/shaping-the-output/vendor-extensions#stamping-x-go-type" %}}). |
Expand Down
119 changes: 101 additions & 18 deletions docs/doc-site/shaping-the-output/descriptions-beside-a-ref.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,117 @@
title: Descriptions beside a $ref
weight: 50
description: |
Keep a field's description when its type resolves to a $ref, by wrapping the
reference in a single-arm allOf (DescWithRef).
Control how a field's description and extensions are rendered when its type
resolves to a $ref — wrapped in an allOf, emitted as direct siblings
(EmitRefSiblings), or dropped (SkipAllOfCompounding).
---

When a struct field's only decoration is a description and its Go type resolves
to a named model (a `$ref`), JSON Schema draft 4 cannot carry a sibling
`description` next to a `$ref`. `Options.DescWithRef` decides what happens to
that description.
When a struct field's Go type resolves to a named model, the field becomes a
`$ref`. Strict JSON Schema draft 4 (the dialect OpenAPI 2.0 is built on) says a
`$ref` *replaces* its siblings — so a `description`, a validation, or an `x-*`
extension written on that field cannot simply sit next to the `$ref`.

{{< code file="shaping/descref/descref.go" lang="go" region="model" >}}
codescan's default is to preserve those decorations by wrapping the reference in
an **`allOf` compound**, which is the draft-4-correct shape. Three options tune
this behaviour. The decorations split into two classes:

By default the description is dropped (a bare `$ref`); with `DescWithRef` it is
preserved by wrapping the `$ref` in a single-arm `allOf`:
- **description & extensions** — *siblings-eligible*: modern tooling (OpenAPI
3.1 / JSON Schema 2020-12, most Swagger-UI renderers) reads them directly
beside a `$ref`.
- **validations & `externalDocs`** — *compound-only*: they have no valid
bare-`$ref` form, so they can only ride an `allOf` compound.

{{< compare left="shaping/descref/testdata/off.json" leftlabel="Default — description dropped"
right="shaping/descref/testdata/on.json" rightlabel="DescWithRef: true" >}}
{{< code file="shaping/refsiblings/refsiblings.go" lang="go" region="model" >}}

## The default — an `allOf` wrapper

With no options set, the field's description and extension are preserved by
wrapping the `$ref` as the single member of an `allOf`; the decorations ride the
outer schema:

{{< code file="shaping/refsiblings/testdata/default.json" lang="json" >}}

This is the always-correct shape and needs no configuration — see also
[Decorating a `$ref`]({{% relref "/tutorials/model-definitions" %}}) in the
Model definitions tutorial.

## Emit siblings directly — `EmitRefSiblings`

Set `Options.EmitRefSiblings` to render the description and extensions as
**direct siblings** of the `$ref`, with no `allOf` wrapper — the leaner shape
modern tools expect:

```go
codescan.Run(&codescan.Options{
Packages: []string{"./..."},
ScanModels: true,
DescWithRef: true,
Packages: []string{"./..."},
ScanModels: true,
EmitRefSiblings: true,
})
```

{{< compare left="shaping/refsiblings/testdata/default.json" leftlabel="Default — allOf wrapper"
right="shaping/refsiblings/testdata/siblings.json" rightlabel="EmitRefSiblings: true" >}}

{{% notice style="info" %}}
When the field carries **more** than a description — a validation override or a
user-authored extension — the `allOf` wrapper is emitted **regardless** of this
flag, because the override would otherwise be lost. `DescWithRef` only governs
the description-only case.
`EmitRefSiblings` only changes the cases where nothing else forces a compound.
When the field also carries a **validation** or **`externalDocs`** (which cannot
live beside a bare `$ref`), the `allOf` wrapper is still emitted and the
description / extensions ride its outer schema.
{{% /notice %}}

## Drop the compound entirely — `SkipAllOfCompounding`

Some downstream consumers — notably go-swagger's code generator — expect a field
that points at a model to be a **bare `$ref`** and do not handle the
`allOf`-compounded shape. Set `Options.SkipAllOfCompounding` to never emit an
`allOf` compound:

```go
codescan.Run(&codescan.Options{
Packages: []string{"./..."},
ScanModels: true,
SkipAllOfCompounding: true,
})
```

No compound is produced, so validations and `externalDocs` are dropped, and the
description and extension go with them — leaving a bare `$ref`:

{{< code file="shaping/refsiblings/testdata/skip.json" lang="json" >}}

Every dropped decoration is reported through `Options.OnDiagnostic` (code
`validate.dropped-ref-sibling`), so the loss is never silent. Combine it with
`EmitRefSiblings` to keep the description and extensions as siblings while still
dropping the compound-only validations:

```go
codescan.Run(&codescan.Options{
Packages: []string{"./..."},
ScanModels: true,
EmitRefSiblings: true, // keep description / x-* as $ref siblings
SkipAllOfCompounding: true, // drop validations / externalDocs, no allOf
})
```

{{% notice style="note" %}}
`required:` is never affected by any of these options. It is a property of the
*parent* object (it lands in the parent's `required` list), not a sibling of the
`$ref`, so it is always preserved.
{{% /notice %}}

## `DescWithRef` (deprecated)

`Options.DescWithRef` predates `EmitRefSiblings` and covers only the narrow
**description-only** case: a `$ref`'d field whose sole decoration is a
description. By default that description is dropped; `DescWithRef` preserves it
by wrapping the `$ref` in a single-arm `allOf`.

{{< compare left="shaping/descref/testdata/off.json" leftlabel="Default — description dropped"
right="shaping/descref/testdata/on.json" rightlabel="DescWithRef: true" >}}

{{% notice style="warning" %}}
`DescWithRef` is **deprecated** — prefer `EmitRefSiblings`, which preserves both
descriptions **and** extensions (as direct siblings). `DescWithRef` keeps its
original behaviour for compatibility and is a no-op when `EmitRefSiblings` is
set.
{{% /notice %}}
32 changes: 32 additions & 0 deletions docs/examples/shaping/refsiblings/refsiblings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: Apache-2.0

// Package refsiblings holds the annotated declarations used by the "$ref
// siblings" section of the "Descriptions beside a $ref" how-to.
// refsiblings_test.go scans it under the default, EmitRefSiblings and
// SkipAllOfCompounding options and writes the golden fragments the page renders.
package refsiblings

// snippet:model

// Address is a referenced model.
//
// swagger:model
type Address struct {
// Street is the street line.
Street string `json:"street"`
}

// Person references Address through a field decorated with a description and a
// vendor extension — both can, in principle, sit beside the $ref. How they are
// rendered depends on the options.
//
// swagger:model
type Person struct {
// Home is where the person lives.
//
// extensions:
// x-ui-order: 3
Home Address `json:"home"`
}

// endsnippet:model
72 changes: 72 additions & 0 deletions docs/examples/shaping/refsiblings/refsiblings_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: Apache-2.0

package refsiblings

import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"testing"

"github.com/go-openapi/codescan"
"github.com/go-openapi/spec"
"github.com/go-openapi/testify/v2/require"
)

func examplesRoot(t *testing.T) string {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
require.True(t, ok)
return filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
}

func scan(t *testing.T, opts codescan.Options) *spec.Swagger {
t.Helper()
opts.WorkDir = examplesRoot(t)
opts.Packages = []string{"./shaping/refsiblings"}
opts.ScanModels = true
doc, err := codescan.Run(&opts)
require.NoError(t, err)
require.NotNil(t, doc)
return doc
}

// goldenHome marshals the Person.home property and compares it to (or, under
// UPDATE_GOLDEN, rewrites) testdata/<feature>.json.
//
// Regenerate with: UPDATE_GOLDEN=1 go test ./...
func goldenHome(t *testing.T, doc *spec.Swagger, feature string) {
t.Helper()
person, ok := doc.Definitions["Person"]
require.True(t, ok, "Person definition missing")
home, ok := person.Properties["home"]
require.True(t, ok, "home property missing")
got, err := json.MarshalIndent(home, "", " ")
require.NoError(t, err)
got = append(got, '\n')

golden := filepath.Join("testdata", feature+".json")
if os.Getenv("UPDATE_GOLDEN") != "" {
require.NoError(t, os.WriteFile(golden, got, 0o600))
}
want, err := os.ReadFile(golden)
require.NoError(t, err)
require.JSONEq(t, string(want), string(got))
}

// TestRefSiblings emits the three fragments rendered by the how-to: the default
// allOf wrap, the EmitRefSiblings direct-sibling shape, and the bare $ref left
// by SkipAllOfCompounding.
func TestRefSiblings(t *testing.T) {
// Default: extension present → single-arm allOf wrap; description and x-*
// ride the outer compound.
goldenHome(t, scan(t, codescan.Options{}), "default")

// EmitRefSiblings: description and x-* ride directly beside the $ref.
goldenHome(t, scan(t, codescan.Options{EmitRefSiblings: true}), "siblings")

// SkipAllOfCompounding: no compound is produced, so the description and
// extension are dropped and a bare $ref remains.
goldenHome(t, scan(t, codescan.Options{SkipAllOfCompounding: true}), "skip")
}
10 changes: 10 additions & 0 deletions docs/examples/shaping/refsiblings/testdata/default.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"description": "Home is where the person lives.",
"allOf": [
{
"$ref": "#/definitions/Address"
}
],
"x-go-name": "Home",
"x-ui-order": 3
}
5 changes: 5 additions & 0 deletions docs/examples/shaping/refsiblings/testdata/siblings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"description": "Home is where the person lives.",
"x-ui-order": 3,
"$ref": "#/definitions/Address"
}
3 changes: 3 additions & 0 deletions docs/examples/shaping/refsiblings/testdata/skip.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"$ref": "#/definitions/Address"
}
65 changes: 48 additions & 17 deletions internal/builders/schema/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -917,23 +917,47 @@ replaces siblings. The correct shape is an **allOf compound**:
inside `allOf[1]` (go-swagger#2655). A non-ref field emits its
externalDocs via `handlers.schemaRawHandler` instead.

### The `DescWithRef` toggle and the description-only case

`scanner.Options.DescWithRef` controls how a description-only
override is emitted:

- **DescWithRef=true**: a $ref'd field whose only field-level
decoration is a description produces a single-arm allOf
compound. The description rides the outer parent so JSON-Schema
consumers see it alongside the `$ref`.
- **DescWithRef=false** (the default — matches v1 strict
behaviour): the description is dropped and a bare `$ref` is
emitted. Users who want the description preserved via the
JSON-Schema-correct compound shape opt in explicitly.

When validations or user-authored extensions are present, the
allOf wrap is mandatory regardless of the flag — the override
would be lost otherwise.
### Sibling-rendering toggles — two orthogonal axes

`$ref` siblings split into two classes by how they can be emitted:

- **description & extensions** — *siblings-eligible*: they can ride
directly beside the `$ref` (`{$ref, description, x-*}`), which strict
draft-4 ignores but OpenAPI 3.1 / JSON Schema 2020-12 / modern
Swagger-UI honour; or via the allOf wrap.
- **validations & externalDocs** — *compound-only*: they have no valid
bare-`$ref` form, so they can only ride an allOf compound (validations
on the override arm).

Three options steer the rendering. The **defaults reproduce the legacy
behaviour byte-for-byte** — both new opt-ins off:

- **`EmitRefSiblings`** (default false): when true, description and
extensions ride as **direct `$ref` siblings** (no allOf), *unless* a
validation/externalDocs already forces a compound — in which case they
ride the outer compound as before. Changes only the no-forced-compound
cases.
- **`DescWithRef`** (default false; **deprecated**, kept for
compatibility): governs only the *description-only* case in the legacy
wrap path — `true` preserves the description as a single-arm allOf
(`{description, allOf:[{$ref}]}`), `false` drops it. A no-op when
`EmitRefSiblings` is set. Prefer `EmitRefSiblings`.
- **`SkipAllOfCompounding`** (default false): when true, **no allOf
compound is ever produced**. Validations and externalDocs are
therefore **dropped**; description and extensions are dropped too
*unless* `EmitRefSiblings` keeps them as direct siblings. Each drop
raises one `CodeDroppedRefSibling` diagnostic through
`Options.OnDiagnostic`, so the loss is never silent. For downstream
consumers (e.g. go-swagger codegen) that expect a bare `$ref`.

Invariants:

- When validations or externalDocs are present, the allOf wrap is
mandatory (unless `SkipAllOfCompounding` drops them) — no toggle
promotes a validation to a bare sibling.
- **`required:` is always preserved.** It is a parent-side concern (it
lands on the enclosing object's `required` list, not as a `$ref`
sibling), applied during the collector Walk regardless of any flag.

### `refOverrideCollector` — accumulate-then-decide

Expand All @@ -952,6 +976,13 @@ Walker has finished firing. Three flags track what was collected:
true, the parsed `*ExternalDocumentation` is set on the outer
compound (sibling of the `$ref`), mirroring the extension lift.

The collector also records each collected sibling in `collected`
(keyword, source position, and `siblingKind` — validation / extension
/ externalDoc). `applyRefSiblingDrop` consumes this under
`SkipAllOfCompounding`: extension-kind siblings survive when
`EmitRefSiblings` is set, everything else is dropped with one
`CodeDroppedRefSibling` diagnostic per keyword.

Splitting the collector out of `applyToRefField` keeps the
per-shape Walker callbacks short and the orchestrator's cognitive
complexity in check. The Walker fires; the collector records; the
Expand Down
Loading
Loading