diff --git a/cmd/genspec-tui/internal/ux/model_diagmarks_test.go b/cmd/genspec-tui/internal/ux/model_diagmarks_test.go index 474917f5..c1ec2a4a 100644 --- a/cmd/genspec-tui/internal/ux/model_diagmarks_test.go +++ b/cmd/genspec-tui/internal/ux/model_diagmarks_test.go @@ -115,20 +115,27 @@ func TestE2E_DiagnosticMarksTheOffendingKeyword(t *testing.T) { "line %d: a mark past the end of the line it is on", mark.Line+1) } - // `// in: formData` is reported at the keyword; after translation it must - // still be the keyword, not the tab that precedes it. + // `// maximum: 3` is a schema keyword under a prose-only classifier body, so it is reported at + // the keyword; after translation the mark must still be the keyword, not the tab that precedes + // it. + // + // The subject used to be the `// in: formData` on the same field, which warned beside + // `swagger:file`. That warning was spurious — `in:` is a field directive the parameters builder + // reads out of band, and the line it fired on is the canonical file-upload idiom — so the subject + // moved to a keyword that is genuinely invalid there. The tab-indent property under test is + // unchanged. source := strings.Split(m.currentSource, "\n") var checked int for _, d := range m.diags { - if d.Pos.Filename != path || !strings.Contains(source[d.Pos.Line-1], "// in:") { + if d.Pos.Filename != path || !strings.Contains(source[d.Pos.Line-1], "// maximum:") { continue } col := bufferColumn(source[d.Pos.Line-1], d.Pos.Column) - assert.True(t, strings.HasPrefix(string([]rune(buffer[d.Pos.Line-1])[col-1:]), "in:"), + assert.True(t, strings.HasPrefix(string([]rune(buffer[d.Pos.Line-1])[col-1:]), "maximum:"), "line %d landed on %q", d.Pos.Line, buffer[d.Pos.Line-1]) checked++ } - require.Positive(t, checked, "the fixture must still contain a context-invalid `in:`") + require.Positive(t, checked, "the fixture must still contain a context-invalid keyword") } // The mark has to survive all the way to the screen, over the lexical class the diff --git a/docs/doc-site/annotation-index/_index.md b/docs/doc-site/annotation-index/_index.md index f3fad0b9..0aefcf48 100644 --- a/docs/doc-site/annotation-index/_index.md +++ b/docs/doc-site/annotation-index/_index.md @@ -15,7 +15,7 @@ tutorial that shows the annotation as runnable Go next to the spec it produces; | `swagger:additionalProperties` | type doc | object `additionalProperties` (open / closed / typed) | [example]({{% relref "/tutorials/maps-and-free-form-objects#open--closed-objects" %}}) | [reference]({{% relref "/maintainers/annotations/swagger-additionalproperties" %}}) | | `swagger:alias` *(deprecated)* | type alias | **no effect** — alias rendering is controlled by Go aliases + options | [how-to]({{% relref "alias-rendering" %}}) | [reference]({{% relref "/maintainers/annotations/swagger-alias" %}}) | | `swagger:allOf` | embedded field / struct | an `allOf` composition | [example]({{% relref "/tutorials/model-definitions#swaggerallof" %}}) | [reference]({{% relref "/maintainers/annotations/swagger-allof" %}}) | -| `swagger:default` | value / field doc | a default-value anchor | [example]({{% relref "/tutorials/examples-and-defaults#swaggerdefault" %}}) | [reference]({{% relref "/maintainers/annotations/swagger-default" %}}) | +| `swagger:default` *(deprecated)* | anywhere | **no effect** — use the `default:` keyword, or a `default:` response code | [how-to]({{% relref "/tutorials/examples-and-defaults" %}}) | [reference]({{% relref "/maintainers/annotations/swagger-default" %}}) | | `swagger:description` | type / field / response doc | overrides the `description` (verbatim body with `\|`) | [how-to]({{% relref "overriding-titles-and-descriptions" %}}) | [reference]({{% relref "/maintainers/annotations/swagger-description" %}}) | | `swagger:enum` | named type | an `enum` array (+ `x-go-enum-desc`) | [example]({{% relref "/tutorials/enumerations" %}}) | [reference]({{% relref "/maintainers/annotations/swagger-enum" %}}) | | `swagger:file` | param / response field | `{type: file}` | [example]({{% relref "/tutorials/routes-and-operations#swaggerfile" %}}) | [reference]({{% relref "/maintainers/annotations/swagger-file" %}}) | diff --git a/docs/doc-site/maintainers/annotations/_index.md b/docs/doc-site/maintainers/annotations/_index.md index 6d6f0163..056a3f2d 100644 --- a/docs/doc-site/maintainers/annotations/_index.md +++ b/docs/doc-site/maintainers/annotations/_index.md @@ -23,7 +23,9 @@ attach to: - **Companion declarations**: `swagger:parameters`, `swagger:response`. - **Local hints & overrides**: `swagger:ignore`, `swagger:omit`, `swagger:name`, `swagger:title`, `swagger:description`, `swagger:type`, - `swagger:file`, `swagger:default`. + `swagger:file`. +- **Deprecated no-ops**, parsed and reported but without effect: + `swagger:alias`, `swagger:default`. This section is the **author-first reference**. Each annotation has its own page covering what it produces, where it goes, its EBNF-like @@ -101,8 +103,9 @@ After the `swagger:` head, an annotation may carry positional arguments. The shapes: - **No args**: `swagger:meta`, `swagger:ignore`, `swagger:enum`, - `swagger:allOf`, `swagger:file`, `swagger:default` — bare - annotation, the surrounding decl supplies the entity name. + `swagger:allOf`, `swagger:file` — bare annotation, the surrounding + decl supplies the entity name. `swagger:default` also accepts a bare + form, but its argument is optional and unread — it is deprecated. - **One IDENT arg**: `swagger:model Pet`, `swagger:response errorResponse`, `swagger:strfmt uuid`, `swagger:name fullName`, `swagger:type integer`, `swagger:alias TimestampAlias` — the @@ -142,7 +145,7 @@ contracts, and each annotation's own page for the detail. | `swagger:additionalProperties` | — | ✅ (object schema) | — | — | — | — | — | | `swagger:patternProperties` | — | ✅ (object schema) | — | — | — | — | — | | `swagger:file` | — | — | — | — | — | — | — | -| `swagger:default` | — | — | — | — | — | — | — | +| `swagger:default` *(deprecated)* | — | — | — | — | — | — | — | A blank cell means the keyword family is not legal in that context; attempting to use it emits `CodeContextInvalid` and the keyword is diff --git a/docs/doc-site/maintainers/annotations/swagger-allof.md b/docs/doc-site/maintainers/annotations/swagger-allof.md index 977f2c8e..84502385 100644 --- a/docs/doc-site/maintainers/annotations/swagger-allof.md +++ b/docs/doc-site/maintainers/annotations/swagger-allof.md @@ -38,6 +38,41 @@ follow on the doc comment. [Schema-context keywords]({{% relref "/maintainers/keywords/schema-validations-and-decorators#schema-decorators" %}}) on the inline-object member (the second `allOf` element). +## Do not put other annotations beside it + +`swagger:allOf` takes no arguments, and no other classifier annotation belongs in +an embedded field's doc comment. `swagger:strfmt` and `swagger:type` written +there are **ignored**, and codescan reports them under +`scan.ineffective-annotation`: + +```go +type Wrong struct { + // swagger:allOf + // swagger:strfmt uuid ← ignored, and warned about + Token +} +``` + +The reason is that an embed contributes the shape of the type it embeds, and +what that shape is comes from **that type's own declaration** — never from the +site that embeds it. So the annotation belongs one level down: + +```go +// Token is rendered as a formatted string wherever it appears. +// +// swagger:strfmt uuid +type Token [16]byte + +type Right struct { + // swagger:allOf + Token +} +``` + +This is not specific to `allOf`: the same annotations are ignored on a plain +(uncomposed) embed too, and reported the same way. They are honoured on an +ordinary — non-embedded — field, which is what makes the mistake an easy one. + ## Example A struct embedding a `swagger:model` base with `swagger:allOf` on the embed diff --git a/docs/doc-site/maintainers/annotations/swagger-default.md b/docs/doc-site/maintainers/annotations/swagger-default.md index 71ea960f..a558f4af 100644 --- a/docs/doc-site/maintainers/annotations/swagger-default.md +++ b/docs/doc-site/maintainers/annotations/swagger-default.md @@ -1,49 +1,86 @@ --- title: "swagger:default" weight: 40 -description: "Classifier hint marking a value declaration as a spec default anchor." +description: "Deprecated no-op — defaults are carried by the default: keyword, or a default response code." --- + +{{% notice style="warning" %}} +**Deprecated.** `swagger:default` never emitted a `default` into the spec, in any +placement or form. It is now an empty sink that only raises a +`validate.deprecated` diagnostic. Use the +[`default:` keyword]({{% relref "/maintainers/keywords/schema-validations-and-decorators#default" %}}), +or a `default` response code in a route's `Responses:` body. +{{% /notice %}} + ## Usage ```goish -// swagger:default +// swagger:default [ VALUE ] ``` ## What it does -Marks the surrounding declaration as the spec's default value for the -corresponding shape. +Nothing. It is parsed, reported as deprecated, and ignored. + +Previously it also **suppressed** the schema of a named basic type it was placed +on: the classifier claimed the target without writing it, so the declared type +published a typeless definition and every field referencing it emitted a typeless +property, silently. That is fixed — an annotated type now emits exactly what it +would emit unannotated. + +## Why it was retired + +Every place OpenAPI 2.0 admits a `default` is already served, so the annotation +had no meaning left to implement: -Used in narrow contexts where the scanner expects an explicit anchor for a -default. This annotation is **value-only** — there's no exported entity it -publishes; it's a classifier hint the scanner consumes during discovery. +| Where a default can appear | How to write it | +|---|---| +| Schema object — a model field, or a type declaration | [`default:` keyword]({{% relref "/maintainers/keywords/schema-validations-and-decorators#default" %}}) | +| Parameter object (non-body) | `default:` keyword | +| Items object | `default:` keyword | +| Header object | `default:` keyword | +| Responses object — an operation's default response | `default:` as the response code in a `Responses:` body | + +The keyword's context set is exactly the list of OAS 2.0 objects that carry a +`default`; the response-code head closes the remainder. ## Where it goes -On a value declaration (`var`, `const`) or a struct field. +Anywhere it used to — the annotation is still recognised so existing source keeps +scanning. It has no effect wherever it appears. ## Grammar (EBNF) ```ebnf -DefaultClassifierBlock = ANN_DEFAULT , [ Title ] , [ Description ] ; +DefaultClassifierBlock = ANN_DEFAULT , [ VALUE ] , [ Title ] , [ Description ] ; ``` -Takes no argument — an optional title/description may follow on the -doc comment. +The value argument is optional and unread. It used to be mandatory, which made +the bare form this page once documented a hard parse error. ## Supported keywords -None of its own. Most spec defaults are instead carried by the -[`default:` keyword]({{% relref "/maintainers/keywords/schema-validations-and-decorators#default" %}}) on the relevant -field; this annotation has a narrow surface and is not commonly authored -directly. +None. ## Example -`swagger:default` is value-only: it produces no definition, so there is no -emitted spec to render. The source below shows the narrow classifier-hint -form — in practice most defaults come from the -[`default:` keyword]({{% relref "/maintainers/keywords/schema-validations-and-decorators#default" %}}) on a field. +Replace it with the keyword: + +```go +// Port is the listen port. +// +// swagger:model Port +// default: 8080 +type Port int +``` -{{< code file="concepts/examples/examples.go" region="swaggerdefault" lang="go" >}} +For an operation's default response, use the response code: + +```go +// swagger:route GET /things things listThings +// +// Responses: +// 200: thingList +// default: genericError +``` diff --git a/docs/doc-site/maintainers/annotations/swagger-enum.md b/docs/doc-site/maintainers/annotations/swagger-enum.md index efcdf9d6..ba60d334 100644 --- a/docs/doc-site/maintainers/annotations/swagger-enum.md +++ b/docs/doc-site/maintainers/annotations/swagger-enum.md @@ -10,6 +10,14 @@ description: "Marks a named type as an enum and collects its const values." // swagger:enum [ IDENT_NAME ] ``` +{{% notice style="note" %}} +Not to be confused with the [`enum:` keyword]({{% relref "/maintainers/keywords/schema-validations-and-decorators#enum" %}}), +which produces the same spec keyword from the opposite direction: it takes the +members you write literally, typed from the schema it sits on, whereas this +annotation collects them from a Go `const` block, typed from the declared Go +type. Side-by-side in the [enumerations tutorial]({{% relref "/tutorials/enumerations" %}}). +{{% /notice %}} + ## What it does Marks a named type over a string, integer, number or boolean as an enum @@ -27,8 +35,10 @@ A type declared over another named type keeps that type's format (`type Kind strfmt.UUID` stays `format: uuid`). Two shapes do not work: an alias to a basic type cannot host an enum (the -type-checker erases the alias, leaving nothing to collect), and a `rune` +type-checker erases the alias, leaving nothing to collect — this raises a +`parse.invalid-enum-option` warning suggesting a named type), and a `rune` or `byte` enum emits integers, which is what those types are on the wire. +An alias to a *named* enum type is fine, and is not warned about. See [Enumerations]({{% relref "/tutorials/enumerations" %}}). - **Without `swagger:model`** (the default): the values are applied diff --git a/docs/doc-site/maintainers/annotations/swagger-file.md b/docs/doc-site/maintainers/annotations/swagger-file.md index 6b76c329..3af46fcf 100644 --- a/docs/doc-site/maintainers/annotations/swagger-file.md +++ b/docs/doc-site/maintainers/annotations/swagger-file.md @@ -4,6 +4,12 @@ weight: 60 description: "Marks a parameter or response body as a binary file (`{type: file}`)." --- +{{% notice style="note" %}} +**Prefer [`swagger:type file`]({{% relref "swagger-type" %}}).** The two are exact +synonyms — same output, same location gate. `swagger:file` is expected to be +deprecated as an extraneous annotation; it is not deprecated yet and still works. +{{% /notice %}} + ## Usage ```goish diff --git a/docs/doc-site/maintainers/annotations/swagger-type.md b/docs/doc-site/maintainers/annotations/swagger-type.md index 79690476..ea8c3e67 100644 --- a/docs/doc-site/maintainers/annotations/swagger-type.md +++ b/docs/doc-site/maintainers/annotations/swagger-type.md @@ -95,5 +95,13 @@ field-level inline form above is the behaviour *without* `swagger:model`. - The `array` argument is **deprecated** — use `inline`, or `[]T` for an explicit element type. It still works, with a `validate.deprecated` warning. -- `file` as an argument is rejected with a diagnostic — use - [`swagger:file`]({{% relref "swagger-file" %}}). +- `file` used to be rejected as an argument. It is now accepted, and is the + **preferred** spelling: `file` is an OAS v2 type name like any other, so the + annotation that names types names it too. It is a synonym for + [`swagger:file`]({{% relref "swagger-file" %}}), which is expected to be + deprecated as an extraneous annotation. + + `file` is legal in exactly two places — a `formData` parameter and a response + body. Both spellings pass through the same location gate, so neither can put + `file` anywhere OAS 2.0 forbids it; elsewhere the override is refused with a + diagnostic and the Go type stands. diff --git a/docs/doc-site/maintainers/keywords/schema-validations-and-decorators.md b/docs/doc-site/maintainers/keywords/schema-validations-and-decorators.md index 9240ad17..684fc01f 100644 --- a/docs/doc-site/maintainers/keywords/schema-validations-and-decorators.md +++ b/docs/doc-site/maintainers/keywords/schema-validations-and-decorators.md @@ -176,6 +176,11 @@ more idiomatic — it picks up the constant names + godoc and produces `SkipEnumDescriptions: true` to keep the const→value mapping on `x-go-enum-desc` only, out of the description.) +Do not confuse the two: the **annotation** collects members from a Go `const` block +and types them from the declared Go type; the **keyword** takes the members you write +and types them from the schema it sits on. Side-by-side comparison in the +[enumerations tutorial]({{% relref "/tutorials/enumerations" %}}). + ### `required` Marks a field as required. Boolean. diff --git a/docs/doc-site/shaping-the-output/names-and-refs/composing-embeds-with-allof.md b/docs/doc-site/shaping-the-output/names-and-refs/composing-embeds-with-allof.md index b2400db0..c1691907 100644 --- a/docs/doc-site/shaping-the-output/names-and-refs/composing-embeds-with-allof.md +++ b/docs/doc-site/shaping-the-output/names-and-refs/composing-embeds-with-allof.md @@ -71,6 +71,34 @@ embeds should compose; reach for the option when composition is your house style for every plain embed. {{% /notice %}} +## Annotate the embedded type, not the embed + +A classifier annotation in an **embedded field's** doc comment does nothing. +`swagger:strfmt` and `swagger:type` written there are ignored — codescan reports +them under `scan.ineffective-annotation` rather than dropping them quietly: + +```go +type Wrong struct { + // swagger:strfmt uuid ← ignored, and warned about + Token +} +``` + +An embed contributes the shape of the type it embeds, and what that shape is +comes from that type's own declaration. Put the annotation there and every embed +of it composes the same way: + +```go +// swagger:strfmt uuid +type Token [16]byte +``` + +The catch is that both annotations *are* honoured on an ordinary field, so the +same line means something one field down and nothing on an embed. Only +`swagger:allOf`, [`swagger:omit`]({{% relref "/maintainers/annotations/swagger-omit" %}}), +`swagger:name`, `swagger:ignore` and a `required:` inheritance hint act on an +embed itself — everything else describes the embedded type and belongs with it. + ## When an override cannot be composed Composition has one limit worth knowing. Inlining an embed **resolves** an diff --git a/docs/doc-site/tutorials/enumerations.md b/docs/doc-site/tutorials/enumerations.md index ed13461b..6859e183 100644 --- a/docs/doc-site/tutorials/enumerations.md +++ b/docs/doc-site/tutorials/enumerations.md @@ -12,6 +12,37 @@ that pair into an `enum` array on every schema, parameter and header the type reaches. This page covers what the scanner accepts on the value side, what decides the emitted `type` / `format`, and the two shapes that do not work. +{{% notice style="note" title="`swagger:enum` and `enum:` are two different things" %}} +They produce the same spec keyword from opposite directions, and the names are +close enough to trip over: + +| | `swagger:enum` — an **annotation** | `enum:` — a **keyword** | +|---|---|---| +| Where | on the type declaration | inside any annotation block, on a field, parameter, header or declaration | +| Members come from | the Go `const` block of that type, read from the type-checker | the literal list you write after the colon | +| Type / format | the **declared Go type** | the schema the keyword sits on | +| Use it when | the values already exist as Go constants | there is no const block, or the members are not Go values at all | + +```go +// swagger:enum Kind ← annotation: members are collected from the consts +type Kind string +const ( + KindA Kind = "a" + KindB Kind = "b" +) + +type Filter struct { + // enum: asc, desc ← keyword: members are what you wrote + Order string `json:"order"` +} +``` + +The annotation is the better tool whenever the constants exist: it stays in +sync with the code, carries each member's doc comment into `x-go-enum-desc`, +and cannot drift from the Go values. The keyword is the escape hatch for +everything else. +{{% /notice %}} + Every Go snippet below comes from the test-covered [`docs/examples/concepts/enums`](https://github.com/go-openapi/codescan/tree/master/docs/examples/concepts/enums) package, and every JSON pane is a golden file a test regenerates. diff --git a/docs/doc-site/tutorials/examples-and-defaults.md b/docs/doc-site/tutorials/examples-and-defaults.md index d2762d16..28a92f73 100644 --- a/docs/doc-site/tutorials/examples-and-defaults.md +++ b/docs/doc-site/tutorials/examples-and-defaults.md @@ -47,14 +47,20 @@ is a number, `false` a boolean, `auto` a string. {{< example go="concepts/examples/examples.go" goregion="default" json="concepts/examples/testdata/default.json" jsonlabel="#/definitions/Settings" >}} -## swagger:default +## swagger:default (deprecated) -`swagger:default` is a narrow, value-only classifier hint placed on a `var` or -`const`. It does not publish a spec entity of its own — it has no standalone -output — so most spec defaults are carried by the `default:` keyword above -rather than this annotation. +{{% notice style="warning" %}} +`swagger:default` never emitted a `default` into the spec. It is now an inert +sink that raises a `validate.deprecated` diagnostic. Use the `default:` keyword +above. +{{% /notice %}} -{{< code file="concepts/examples/examples.go" lang="go" region="swaggerdefault" >}} +The keyword covers every place OpenAPI 2.0 admits a default value: a model field, +a non-body parameter, a header, and array items. The one remaining sense of +"default" — an operation's *default response* — is not a value at all; it is +written as a response code in a route's `responses:` body: + +{{< code file="concepts/routes/routes.go" lang="go" region="route" >}} ## On a defined-type field diff --git a/docs/examples/concepts/examples/examples.go b/docs/examples/concepts/examples/examples.go index 5948e6c6..b77f3a9f 100644 --- a/docs/examples/concepts/examples/examples.go +++ b/docs/examples/concepts/examples/examples.go @@ -48,16 +48,6 @@ type Settings struct { // endsnippet:default -// snippet:swaggerdefault - -// DefaultPort is the fallback port used wherever Port is not supplied. The -// swagger:default annotation is a narrow value-only discovery hint. -// -// swagger:default -var DefaultPort = 8080 //nolint:gochecknoglobals // demo example - -// endsnippet:swaggerdefault - // snippet:reffield // Currency is a named (defined) string type, so it earns its own definition and diff --git a/fixtures/enhancements/annotation-noise/types.go b/fixtures/enhancements/annotation-noise/types.go new file mode 100644 index 00000000..d05d07a3 --- /dev/null +++ b/fixtures/enhancements/annotation-noise/types.go @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package annotation_noise witnesses a classifier annotation written in a +// position that never consults it. +// +// `swagger:strfmt` and `swagger:type` in an EMBEDDED field's own comment were +// parsed, validated and discarded without a word, while the same annotation on a +// regular field one line away is honoured. The scanner rejects an UNKNOWN +// annotation in that same comment (see the unknown-annotation fixture), so the +// author got validation feedback implying the annotation was meaningful and +// nothing saying it had been dropped. +package annotation_noise + +// Target is the embedded type. Its own declaration is where a format belongs. +type Target struct { + // Left is a plain property. + Left string `json:"left"` +} + +// Scalar is a named basic used as a regular field, where the annotations DO work. +type Scalar int + +// IneffectiveOnAllOf annotates an allOf embed with classifiers the arm ignores. +// +// swagger:model IneffectiveOnAllOf +type IneffectiveOnAllOf struct { + // swagger:allOf + // swagger:strfmt uuid + // swagger:type string + Target + + // Note is the composing struct's own field. + Note string `json:"note"` +} + +// IneffectiveOnPlain annotates a PLAIN embed with the same classifiers. +// +// swagger:model IneffectiveOnPlain +type IneffectiveOnPlain struct { + // swagger:strfmt uuid + Target + + // Note is the embedding struct's own field. + Note string `json:"note"` +} + +// EffectiveOnField is the control: the same annotations on regular fields, where +// both are honoured. +// +// swagger:model EffectiveOnField +type EffectiveOnField struct { + // Fmt takes the format. + // + // swagger:strfmt uuid + Fmt Scalar `json:"fmt"` + + // Typ takes the type override. + // + // swagger:type string + Typ Scalar `json:"typ"` +} diff --git a/fixtures/enhancements/builder-conformance/types.go b/fixtures/enhancements/builder-conformance/types.go new file mode 100644 index 00000000..02104514 --- /dev/null +++ b/fixtures/enhancements/builder-conformance/types.go @@ -0,0 +1,825 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package builder_conformance asserts that the schema, parameters and responses +// builders agree. +// +// Each of the three resolves Go types to spec constructs, and each carries its +// own copy of rules the others also need. Where they diverge, a fix verified on +// one of them reads as complete — which is how `swagger:type` on an alias came to +// work for a model field and a query parameter while silently dropping for a body +// parameter. +// +// # What is compared +// +// One Go shape reached from four FULL-SCHEMA positions, where no legitimate +// difference exists and the four must agree exactly: +// +// - a model field (schema builder, the control) +// - a body parameter (parameters builder) +// - a response body (responses builder) +// - an allOf member (schema builder, composition arm) +// +// The first three converge on one field dispatch; the fourth is reached by +// buildNamedAllOf instead, which keeps its own copy of the classifier cascade. +// +// SimpleSchema positions are deliberately excluded. A non-body parameter and a +// response header have a genuinely different legality surface — `type` is +// mandatory and restricted, `$ref` is forbidden — so they belong in a comparison +// with a declared projection, not in this one. That difference is the historical +// reason the builders grew separate paths at all. +// +// # Why the shapes are NOT nested +// +// Each subject is the field's type DIRECTLY. Nesting them inside a struct sends +// everything through the schema sub-builder by delegation, where the three agree +// trivially and the suite reports a comfortable all-clear. The divergences live in +// the hand-rolled short-circuits that fire when a parameter or response field is +// itself a named or alias type — `buildNamedField` and `buildFieldAlias` — so the +// subject has to be reached that way to be tested at all. +package builder_conformance + +import ( + "encoding/json" + "time" +) + +// FmtNamed carries a format on a named type. +// +// swagger:strfmt isbn +type FmtNamed string + +// FmtAlias carries the same format on an alias. +// +// swagger:strfmt isbn +type FmtAlias = string + +// TypeNamed carries a type override on a named type. +// +// swagger:type string +type TypeNamed int + +// TypeAlias carries the same override on an alias. This pair is the one that +// caught the body-branch gap. +// +// swagger:type string +type TypeAlias = int + +// EnumNamed is a named enum. +// +// swagger:enum EnumNamed +type EnumNamed uint64 + +const ( + // EnumLow is the low member. + EnumLow EnumNamed = 1 + + // EnumHigh is the high member. + EnumHigh EnumNamed = 2 +) + +// BytesNamed is a byte sequence carrying the whole-schema format. +// +// swagger:strfmt byte +type BytesNamed []byte + +// StampAlias is an alias to a recognised stdlib type. +type StampAlias = time.Time + +// RawAlias is an alias to the open "any JSON" stdlib type. +type RawAlias = json.RawMessage + +// --- stdlib-identity subjects: reached as the NAMED type, not through an alias --- +// +// The two subjects above name their stdlib type through an alias, which lands in +// the alias arm. A field typed `time.Time` or `json.RawMessage` DIRECTLY lands in +// the named arm instead, where each builder used to carry its own subset of the +// identity recognizers. Nothing reached that arm before these. +// +// `error` is the one that makes the subsets observable without a truncated +// package graph: it is predeclared, so its object has a nil package and no +// declaration exists to look up in any graph. A builder that demands the +// declaration before consulting the recognizer cannot degrade — it dereferences +// nil. + +// ErrAlias names the predeclared error through an alias. +// +// The alias's own object is this name, not `error`, so an identity recognizer +// keyed on the object never fires here — only after the alias dissolves. +type ErrAlias = error + +// --- shape subjects: the arms of the field dispatch, rather than the classifiers --- +// +// The subjects above are all named or alias types, which reach only two arms of +// `buildFromField`. These reach the rest — struct, interface, map, slice with an +// inline element, pointer, plain basic — so that a factorization of those arms is +// guarded in all three positions rather than by goldens alone. + +// EmailsNamed is a named STRING slice carrying a NON-special format. +// +// This is the pinned divergence. The element-driven rule (see +// common.ApplyArrayLikeStrfmt) asks whether the ELEMENT makes the sequence +// string-like: `byte` and `rune` do, so a format describes the whole value; +// `string` does not, so the format describes each element. The schema builder +// applies that rule. The parameters and responses builders short-circuit on a +// local `strfmtFromDoc` helper that predates it and writes +// `Typed("string", format)` unconditionally, claiming the value IS one email +// when the Go type is a list of them. +// +// swagger:strfmt email +type EmailsNamed []string + +// CodesNamed is the array flavour of the same divergence. +// +// swagger:strfmt email +type CodesNamed [4]string + +// Plain is a struct reached directly as a field. +type Plain struct { + // Left is a plain property. + Left string `json:"left"` +} + +// Speaker is a non-empty interface reached directly as a field. +type Speaker interface { + // Say returns a word. + Say() string +} + +// ModelHost reaches every subject as a MODEL FIELD — the schema builder's view, +// and the control for the other two. +// +// swagger:model ModelHost +type ModelHost struct { + // Fmt is the named-format subject. + Fmt FmtNamed `json:"fmt"` + + // FmtAl is the alias-format subject. + FmtAl FmtAlias `json:"fmtAl"` + + // Typ is the named-override subject. + Typ TypeNamed `json:"typ"` + + // TypAl is the alias-override subject. + TypAl TypeAlias `json:"typAl"` + + // Enum is the enum subject. + Enum EnumNamed `json:"enum"` + + // Bytes is the byte-sequence subject. + Bytes BytesNamed `json:"bytes"` + + // Stamp is the stdlib-alias subject. + Stamp StampAlias `json:"stamp"` + + // Raw is the open-schema subject. + Raw RawAlias `json:"raw"` + + // Struct is the struct-arm subject. + Struct Plain `json:"struct"` + + // Iface is the interface-arm subject. + Iface Speaker `json:"iface"` + + // Mapping is the map-arm subject. + Mapping map[string]Plain `json:"mapping"` + + // Inline is the slice arm with an inline element. + Inline []struct { + // Code is the inline element property. + Code string `json:"code"` + } `json:"inline"` + + // Ptr is the pointer arm. + Ptr *Plain `json:"ptr"` + + // Basic is the plain-basic arm. + Basic int32 `json:"basic"` + + // Emails is the pinned slice+non-special-format divergence. + Emails EmailsNamed `json:"emails"` + + // Codes is the array flavour of the same. + Codes CodesNamed `json:"codes"` + + // StampN is the stdlib time reached as the named type. + StampN time.Time `json:"stampN"` + + // RawN is the open-schema stdlib type reached as the named type. + RawN json.RawMessage `json:"rawN"` + + // AnyV is the predeclared any. + AnyV any `json:"anyv"` + + // ErrN is the predeclared error — no package, no declaration. + ErrN error `json:"errN"` + + // ErrAl names the same through an alias. + ErrAl ErrAlias `json:"errAl"` +} + +// AllOfHost reaches every EMBEDDABLE subject as an allOf MEMBER, one member per +// subject, in the same order ModelHost declares them. +// +// A member of an allOf is a full schema describing one type, exactly as a model +// field is, so the two must agree. It is reached by a different arm than any of +// the other three positions — `buildNamedAllOf` rather than the field dispatch — +// and that arm consults its own subset of the classifiers. +// +// One allOf rather than one host per subject, deliberately: members that resolve +// side by side also witness that no member's classifier leaks into its +// neighbours, which separate hosts could not show. The composing struct's own +// field lands in a trailing member, so member i is subject i. +// +// Subjects absent here are the ones Go cannot embed under a usable name: a map, a +// slice of an inline struct, and the pointer/basic/predeclared arms, whose +// embedded field name would either collide with another member or be unexported. +// +// swagger:model AllOfHost +type AllOfHost struct { + // swagger:allOf + FmtNamed + + // swagger:allOf + FmtAlias + + // swagger:allOf + TypeNamed + + // swagger:allOf + TypeAlias + + // swagger:allOf + EnumNamed + + // swagger:allOf + BytesNamed + + // swagger:allOf + StampAlias + + // swagger:allOf + RawAlias + + // swagger:allOf + Plain + + // swagger:allOf + Speaker + + // swagger:allOf + EmailsNamed + + // swagger:allOf + CodesNamed + + // swagger:allOf + time.Time + + // swagger:allOf + json.RawMessage + + // swagger:allOf + ErrAlias + + // Note is the composing struct's own field, which lands in the trailing member. + Note string `json:"note"` +} + +// ParamsFmt reaches FmtNamed as a body parameter. +// +// swagger:parameters confFmt +type ParamsFmt struct { + // in: body + Body FmtNamed `json:"body"` +} + +// ParamsFmtAl reaches FmtAlias as a body parameter. +// +// swagger:parameters confFmtAl +type ParamsFmtAl struct { + // in: body + Body FmtAlias `json:"body"` +} + +// ParamsTyp reaches TypeNamed as a body parameter. +// +// swagger:parameters confTyp +type ParamsTyp struct { + // in: body + Body TypeNamed `json:"body"` +} + +// ParamsTypAl reaches TypeAlias as a body parameter. +// +// swagger:parameters confTypAl +type ParamsTypAl struct { + // in: body + Body TypeAlias `json:"body"` +} + +// ParamsEnum reaches EnumNamed as a body parameter. +// +// swagger:parameters confEnum +type ParamsEnum struct { + // in: body + Body EnumNamed `json:"body"` +} + +// ParamsBytes reaches BytesNamed as a body parameter. +// +// swagger:parameters confBytes +type ParamsBytes struct { + // in: body + Body BytesNamed `json:"body"` +} + +// ParamsStamp reaches StampAlias as a body parameter. +// +// swagger:parameters confStamp +type ParamsStamp struct { + // in: body + Body StampAlias `json:"body"` +} + +// ParamsRaw reaches RawAlias as a body parameter. +// +// swagger:parameters confRaw +type ParamsRaw struct { + // in: body + Body RawAlias `json:"body"` +} + +// RespFmt reaches FmtNamed as a response body. +// +// swagger:response respFmt +type RespFmt struct { + // in: body + Body FmtNamed `json:"body"` +} + +// RespFmtAl reaches FmtAlias as a response body. +// +// swagger:response respFmtAl +type RespFmtAl struct { + // in: body + Body FmtAlias `json:"body"` +} + +// RespTyp reaches TypeNamed as a response body. +// +// swagger:response respTyp +type RespTyp struct { + // in: body + Body TypeNamed `json:"body"` +} + +// RespTypAl reaches TypeAlias as a response body. +// +// swagger:response respTypAl +type RespTypAl struct { + // in: body + Body TypeAlias `json:"body"` +} + +// RespEnum reaches EnumNamed as a response body. +// +// swagger:response respEnum +type RespEnum struct { + // in: body + Body EnumNamed `json:"body"` +} + +// RespBytes reaches BytesNamed as a response body. +// +// swagger:response respBytes +type RespBytes struct { + // in: body + Body BytesNamed `json:"body"` +} + +// RespStamp reaches StampAlias as a response body. +// +// swagger:response respStamp +type RespStamp struct { + // in: body + Body StampAlias `json:"body"` +} + +// RespRaw reaches RawAlias as a response body. +// +// swagger:response respRaw +type RespRaw struct { + // in: body + Body RawAlias `json:"body"` +} + +// HandlerFmt binds the format subject. +// +// swagger:route POST /fmt conf confFmt +// +// Responses: +// +// 200: respFmt +func HandlerFmt() {} + +// HandlerFmtAl binds the alias-format subject. +// +// swagger:route POST /fmt-al conf confFmtAl +// +// Responses: +// +// 200: respFmtAl +func HandlerFmtAl() {} + +// HandlerTyp binds the override subject. +// +// swagger:route POST /typ conf confTyp +// +// Responses: +// +// 200: respTyp +func HandlerTyp() {} + +// HandlerTypAl binds the alias-override subject. +// +// swagger:route POST /typ-al conf confTypAl +// +// Responses: +// +// 200: respTypAl +func HandlerTypAl() {} + +// HandlerEnum binds the enum subject. +// +// swagger:route POST /enum conf confEnum +// +// Responses: +// +// 200: respEnum +func HandlerEnum() {} + +// HandlerBytes binds the byte-sequence subject. +// +// swagger:route POST /bytes conf confBytes +// +// Responses: +// +// 200: respBytes +func HandlerBytes() {} + +// HandlerStamp binds the stdlib-alias subject. +// +// swagger:route POST /stamp conf confStamp +// +// Responses: +// +// 200: respStamp +func HandlerStamp() {} + +// HandlerRaw binds the open-schema subject. +// +// swagger:route POST /raw conf confRaw +// +// Responses: +// +// 200: respRaw +func HandlerRaw() {} + +// ParamsStruct reaches the struct subject as a body parameter. +// +// swagger:parameters confStruct +type ParamsStruct struct { + // in: body + Body Plain `json:"body"` +} + +// RespStruct reaches the struct subject as a response body. +// +// swagger:response respStruct +type RespStruct struct { + // in: body + Body Plain `json:"body"` +} + +// HandlerStruct binds the struct subject. +// +// swagger:route POST /struct conf confStruct +// +// Responses: +// +// 200: respStruct +func HandlerStruct() {} + +// ParamsIface reaches the iface subject as a body parameter. +// +// swagger:parameters confIface +type ParamsIface struct { + // in: body + Body Speaker `json:"body"` +} + +// RespIface reaches the iface subject as a response body. +// +// swagger:response respIface +type RespIface struct { + // in: body + Body Speaker `json:"body"` +} + +// HandlerIface binds the iface subject. +// +// swagger:route POST /iface conf confIface +// +// Responses: +// +// 200: respIface +func HandlerIface() {} + +// ParamsMapping reaches the mapping subject as a body parameter. +// +// swagger:parameters confMapping +type ParamsMapping struct { + // in: body + Body map[string]Plain `json:"body"` +} + +// RespMapping reaches the mapping subject as a response body. +// +// swagger:response respMapping +type RespMapping struct { + // in: body + Body map[string]Plain `json:"body"` +} + +// HandlerMapping binds the mapping subject. +// +// swagger:route POST /mapping conf confMapping +// +// Responses: +// +// 200: respMapping +func HandlerMapping() {} + +// ParamsPtr reaches the ptr subject as a body parameter. +// +// swagger:parameters confPtr +type ParamsPtr struct { + // in: body + Body *Plain `json:"body"` +} + +// RespPtr reaches the ptr subject as a response body. +// +// swagger:response respPtr +type RespPtr struct { + // in: body + Body *Plain `json:"body"` +} + +// HandlerPtr binds the ptr subject. +// +// swagger:route POST /ptr conf confPtr +// +// Responses: +// +// 200: respPtr +func HandlerPtr() {} + +// ParamsBasic reaches the basic subject as a body parameter. +// +// swagger:parameters confBasic +type ParamsBasic struct { + // in: body + Body int32 `json:"body"` +} + +// RespBasic reaches the basic subject as a response body. +// +// swagger:response respBasic +type RespBasic struct { + // in: body + Body int32 `json:"body"` +} + +// HandlerBasic binds the basic subject. +// +// swagger:route POST /basic conf confBasic +// +// Responses: +// +// 200: respBasic +func HandlerBasic() {} + +// ParamsInline reaches the inline-element slice as a body parameter. +// +// swagger:parameters confInline +type ParamsInline struct { + // in: body + Body []struct { + // Code is the inline element property. + Code string `json:"code"` + } `json:"body"` +} + +// RespInline reaches the inline-element slice as a response body. +// +// swagger:response respInline +type RespInline struct { + // in: body + Body []struct { + // Code is the inline element property. + Code string `json:"code"` + } `json:"body"` +} + +// HandlerInline binds the inline-slice subject. +// +// swagger:route POST /inline conf confInline +// +// Responses: +// +// 200: respInline +func HandlerInline() {} + +// ParamsEmails reaches the emails subject as a body parameter. +// +// swagger:parameters confEmails +type ParamsEmails struct { + // in: body + Body EmailsNamed `json:"body"` +} + +// RespEmails reaches the emails subject as a response body. +// +// swagger:response respEmails +type RespEmails struct { + // in: body + Body EmailsNamed `json:"body"` +} + +// HandlerEmails binds the emails subject. +// +// swagger:route POST /emails conf confEmails +// +// Responses: +// +// 200: respEmails +func HandlerEmails() {} + +// ParamsCodes reaches the codes subject as a body parameter. +// +// swagger:parameters confCodes +type ParamsCodes struct { + // in: body + Body CodesNamed `json:"body"` +} + +// RespCodes reaches the codes subject as a response body. +// +// swagger:response respCodes +type RespCodes struct { + // in: body + Body CodesNamed `json:"body"` +} + +// HandlerCodes binds the codes subject. +// +// swagger:route POST /codes conf confCodes +// +// Responses: +// +// 200: respCodes +func HandlerCodes() {} + +// --- stdlib-identity subjects in the other two positions --- + +// ParamsStampN reaches the named stdlib time as a body parameter. +// +// swagger:parameters confStampN +type ParamsStampN struct { + // in: body + Body time.Time `json:"body"` +} + +// RespStampN reaches the named stdlib time as a response body. +// +// swagger:response respStampN +type RespStampN struct { + // in: body + Body time.Time `json:"body"` +} + +// HandlerStampN binds the named stdlib time subject. +// +// swagger:route POST /stamp-n conf confStampN +// +// Responses: +// +// 200: respStampN +func HandlerStampN() {} + +// ParamsRawN reaches the named open-schema type as a body parameter. +// +// swagger:parameters confRawN +type ParamsRawN struct { + // in: body + Body json.RawMessage `json:"body"` +} + +// RespRawN reaches the named open-schema type as a response body. +// +// swagger:response respRawN +type RespRawN struct { + // in: body + Body json.RawMessage `json:"body"` +} + +// HandlerRawN binds the named open-schema subject. +// +// swagger:route POST /raw-n conf confRawN +// +// Responses: +// +// 200: respRawN +func HandlerRawN() {} + +// ParamsAnyV reaches the predeclared any as a body parameter. +// +// swagger:parameters confAnyV +type ParamsAnyV struct { + // in: body + Body any `json:"body"` +} + +// RespAnyV reaches the predeclared any as a response body. +// +// swagger:response respAnyV +type RespAnyV struct { + // in: body + Body any `json:"body"` +} + +// HandlerAnyV binds the predeclared-any subject. +// +// swagger:route POST /anyv conf confAnyV +// +// Responses: +// +// 200: respAnyV +func HandlerAnyV() {} + +// ParamsErrN reaches the predeclared error as a body parameter. +// +// swagger:parameters confErrN +type ParamsErrN struct { + // The name is the subject's, not the usual "body": the diagnostic raised when this parameter is + // dropped has to be attributable to it. + // + // in: body + Body error `json:"errN"` +} + +// RespErrN reaches the predeclared error as a response body. +// +// swagger:response respErrN +type RespErrN struct { + // in: body + Body error `json:"body"` +} + +// HandlerErrN binds the predeclared-error subject. +// +// swagger:route POST /err-n conf confErrN +// +// Responses: +// +// 200: respErrN +func HandlerErrN() {} + +// ParamsErrAl reaches the aliased error as a body parameter. +// +// swagger:parameters confErrAl +type ParamsErrAl struct { + // Named for the subject, as above. + // + // in: body + Body ErrAlias `json:"errAl"` +} + +// RespErrAl reaches the aliased error as a response body. +// +// swagger:response respErrAl +type RespErrAl struct { + // in: body + Body ErrAlias `json:"body"` +} + +// HandlerErrAl binds the aliased-error subject. +// +// swagger:route POST /err-al conf confErrAl +// +// Responses: +// +// 200: respErrAl +func HandlerErrAl() {} diff --git a/fixtures/enhancements/default-allof-embeds-override/api.go b/fixtures/enhancements/default-allof-embeds-override/api.go index a18232a8..ecbfbf05 100644 --- a/fixtures/enhancements/default-allof-embeds-override/api.go +++ b/fixtures/enhancements/default-allof-embeds-override/api.go @@ -5,10 +5,15 @@ // field-override embed under Options.DefaultAllOfForEmbeds. // // The override idiom (go-swagger#1992): a struct embeds a shared, annotation-free -// domain type and RE-DECLARES one of the promoted fields — either to decorate it -// (`read only: true`, description, validations) or to drop it from the wire with -// `json:"-"`. Go resolves this by depth: the outer field shadows the promoted one, -// so exactly one field of that name exists at the shallowest depth. +// domain type and RE-DECLARES one of the promoted fields to decorate it +// (`read only: true`, description, validations). Go resolves this by depth: the +// outer field shadows the promoted one, so exactly one field of that name exists +// at the shallowest depth. +// +// `json:"-"` is NOT part of that idiom, though it looks like it. encoding/json +// ignores a `-` field entirely — it never enters the name set, so it shadows +// nothing and the promoted field keeps marshalling. `swagger:omit` on the embed is +// what actually drops a promoted field. // // Inlined (the default), the schema builder mirrors that: the re-declaration wins // and one property is emitted. Composed into `allOf` by DefaultAllOfForEmbeds, the @@ -36,7 +41,10 @@ type Decorated struct { ID int64 } -// Muted re-declares a promoted field to drop it from the wire. +// Muted re-declares a promoted field with `json:"-"`, INTENDING to drop it from +// the wire — and fails to, because Go ignores such a field rather than letting it +// shadow. Created stays on the wire and in the schema; the scan raises +// `scan.shadowed-embed-field` pointing at swagger:omit. // // swagger:model Muted type Muted struct { diff --git a/fixtures/enhancements/default-example-typing/types.go b/fixtures/enhancements/default-example-typing/types.go new file mode 100644 index 00000000..4bd8101a --- /dev/null +++ b/fixtures/enhancements/default-example-typing/types.go @@ -0,0 +1,235 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package default_example_typing witnesses how `default:` and `example:` values +// are TYPED, across every site that accepts them. +// +// The two keywords are deliberately paired everywhere: they share +// `validations.ParseDefault` and the same dispatch arms, so a design choice for +// one applies to the other, and any divergence between them is itself a defect. +// +// # The reported defect +// +// A value on a TYPE DECLARATION is coerced against an empty type. The decl +// comment block is dispatched by `applyDeclCommentBlock` before the Go type is +// resolved onto the schema, so `SchemaTypeOf(ps)` is "" and `ParseDefault` falls +// back to a string. The same keyword on a struct field, where the type is known, +// coerces correctly. Each decl cell below has a field-site control carrying the +// identical literal. +// +// # Default VALUE vs default RESPONSE +// +// These are unrelated mechanisms and must not be conflated: +// +// - a default VALUE is the `default:` keyword, legal in the schema, parameter, +// header and items contexts; +// - a default RESPONSE is the `default` code head in a route's `Responses:` +// body, which names a response — it is not a value at all. +// +// `default:` is NOT legal in a response block context (`KwDefault`'s context set +// omits `CtxResponse`), so the two cannot collide. The route at the bottom pins +// the response sense while the types above pin the value sense. +package default_example_typing + +// DeclInt carries an integer value on the declaration. +// +// swagger:model DeclInt +// default: 8080 +// example: 9090 +type DeclInt int + +// DeclNumber carries a floating-point value on the declaration. +// +// swagger:model DeclNumber +// default: 1.5 +// example: 2.5 +type DeclNumber float64 + +// DeclBool carries a boolean value on the declaration. +// +// swagger:model DeclBool +// default: false +// example: true +type DeclBool bool + +// DeclString carries a string value on the declaration — the one case where a +// string fallback is indistinguishable from a correct coercion, so it is the +// control for the controls. +// +// swagger:model DeclString +// default: auto +// example: manual +type DeclString string + +// DeclIntSlice carries a JSON array on the declaration. +// +// swagger:model DeclIntSlice +// default: [1,2,3] +// example: [4,5] +type DeclIntSlice []int + +// DeclEnumInt carries an enum alongside a default on the declaration, so the +// enum members and the default can be compared for consistent typing. +// +// swagger:model DeclEnumInt +// enum: 1,2,3 +// default: 2 +type DeclEnumInt int + +// DeclUncoercible carries values that cannot be read as the declared type. Each +// must be DROPPED with a warning rather than emitted at the wrong type — a +// document carrying `"notanumber"` on an integer schema is one no validator +// accepts, whereas a document missing a default is merely incomplete. +// +// The enum is partially bad: 1 and 3 survive, "two" is dropped. That narrows a +// closed set, which is a real change to the author's contract, so the warning +// names the member. +// +// swagger:model DeclUncoercible +// default: notanumber +// example: alsonotanumber +// enum: 1, two, 3 +type DeclUncoercible int + +// FieldUncoercible is the field-site counterpart. It always dropped the value — +// but silently, which is the half of the defect that was invisible. +// +// swagger:model FieldUncoercible +type FieldUncoercible struct { + // Port has an uncoercible default and example. + // + // default: notanumber + // example: alsonotanumber + Port int `json:"port"` + + // Grade has a partially uncoercible enum. + // + // enum: 1, two, 3 + Grade int `json:"grade"` +} + +// FieldControls carries the identical literals at FIELD sites, where the Go type +// is already resolved when the keyword walk runs. Every property here is the +// control for the like-named declaration above. +// +// swagger:model FieldControls +type FieldControls struct { + // Port is the integer control. + // + // default: 8080 + // example: 9090 + Port int `json:"port"` + + // Ratio is the floating-point control. + // + // default: 1.5 + // example: 2.5 + Ratio float64 `json:"ratio"` + + // Flag is the boolean control. + // + // default: false + // example: true + Flag bool `json:"flag"` + + // Mode is the string control. + // + // default: auto + // example: manual + Mode string `json:"mode"` + + // Numbers is the JSON-array control. + // + // default: [1,2,3] + // example: [4,5] + Numbers []int `json:"numbers"` + + // Grade is the enum control. + // + // enum: 1,2,3 + // default: 2 + Grade int `json:"grade"` +} + +// TypingParams carries the same literals in parameter positions — non-body +// (SimpleSchema) and body (full schema). +// +// swagger:parameters typingOp +type TypingParams struct { + // QueryPort is a non-body parameter: SimpleSchema, no $ref allowed. + // + // in: query + // default: 8080 + // example: 9090 + QueryPort int `json:"queryPort"` + + // QueryFlag is a non-body boolean parameter. + // + // in: query + // default: false + QueryFlag bool `json:"queryFlag"` + + // Body is a body parameter; its fields are full-schema properties. + // + // in: body + Body struct { + // Retries is a body-schema property. + // + // default: 3 + // example: 5 + Retries int `json:"retries"` + } `json:"body"` +} + +// TypingResponse carries the same literals on response HEADERS, which are +// SimpleSchema locations, and on a body property. +// +// Note there is no `default:` on the response block itself — that keyword is not +// legal in a response context, and a default RESPONSE is expressed by the route +// below instead. +// +// swagger:response typingResponse +type TypingResponse struct { + // XRateLimit is a response header: SimpleSchema. + // + // in: header + // default: 60 + // example: 120 + XRateLimit int `json:"X-Rate-Limit"` + + // Body is the response payload. + // + // in: body + Body struct { + // Retries is a response-body property. + // + // default: 3 + // example: 5 + Retries int `json:"retries"` + } `json:"body"` +} + +// ErrorResponse is the operation's default response — the OTHER sense of +// "default", carried by a response code rather than a value. +// +// swagger:response errorResponse +type ErrorResponse struct { + // Body is the error payload. + // + // in: body + Body struct { + // Message describes the failure. + Message string `json:"message"` + } `json:"body"` +} + +// TypingHandler binds the parameters and both responses to an operation. The +// `default:` code head names a RESPONSE; it must never be read as a value. +// +// swagger:route GET /typing typing typingOp +// +// Responses: +// +// 200: typingResponse +// default: errorResponse +func TypingHandler() {} diff --git a/fixtures/enhancements/json-tag-fidelity/types.go b/fixtures/enhancements/json-tag-fidelity/types.go new file mode 100644 index 00000000..cb5f2880 --- /dev/null +++ b/fixtures/enhancements/json-tag-fidelity/types.go @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package json_tag_fidelity witnesses that the emitted property set matches what +// `encoding/json` actually puts on the wire, for every shape of the `json:"-"` +// tag. +// +// This corpus has an ORACLE, which is unusual: the correct answer is not a design +// choice, it is whatever encoding/json does. `wire_test.go` marshals each type +// here and captures the resulting key set as `wire.golden.json`; the integration +// test scans the same types and asserts the property sets agree. Neither side +// hard-codes an expectation. +// +// Values are chosen non-zero so `omitempty` never hides a key that the wire would +// otherwise carry. +package json_tag_fidelity + +// Base is the shared embedded type. Every promoted name below comes from here. +type Base struct { + // ID is a plain promoted field. + ID int64 `json:"id"` + + // Name is a plain promoted field. + Name string `json:"name"` + + // Age is the field the outer structs re-declare. + Age int32 `json:"age"` +} + +// IgnoreShadow re-declares a promoted field with `json:"-"`. +// +// encoding/json ignores a `-` field ENTIRELY: it never enters the name set, so it +// does not shadow the promoted `age`, which Go still marshals. An author writing +// this usually means "drop it" — `swagger:omit` on the embed is the honest way to +// say that, and the scan raises a Hint pointing there. +// +// swagger:model IgnoreShadow +type IgnoreShadow struct { + Base + + Age int32 `json:"-"` +} + +// RenameShadow re-declares a promoted field under a real name — the control. +// Here Go's depth rule DOES apply and the outer declaration wins. +// +// swagger:model RenameShadow +type RenameShadow struct { + Base + + Age int32 `json:"age"` +} + +// PlainIgnore carries `json:"-"` with nothing to shadow — the control for the +// common case, where dropping the property is correct. +// +// swagger:model PlainIgnore +type PlainIgnore struct { + // Keep stays on the wire. + Keep string `json:"keep"` + + // Drop is ignored entirely by encoding/json. + Drop string `json:"-"` +} + +// DashName uses the `json:"-,"` escape, which names the field literally `-` +// rather than ignoring it. The trailing comma is the whole difference. +// +// swagger:model DashName +type DashName struct { + // Weird is emitted under the name "-". + Weird string `json:"-,"` +} + +// DashNameOmitEmpty is the `-,omitempty` variant, which the historic corpus +// already contains (classification/models/nomodel.go). Non-zero here, so the key +// is present on the wire. +// +// swagger:model DashNameOmitEmpty +type DashNameOmitEmpty struct { + // Weird is emitted under the name "-" when non-empty. + Weird string `json:"-,omitempty"` +} + +// EmbedIgnored tags the EMBED itself `json:"-"`, which does drop the whole embed +// — the control showing that `-` on an embed and `-` on a re-declaration are +// different acts. +// +// swagger:model EmbedIgnored +type EmbedIgnored struct { + Base `json:"-"` + + // Extra is the only field that survives. + Extra string `json:"extra"` +} diff --git a/fixtures/enhancements/json-tag-fidelity/wire.golden.json b/fixtures/enhancements/json-tag-fidelity/wire.golden.json new file mode 100644 index 00000000..8dcea2cf --- /dev/null +++ b/fixtures/enhancements/json-tag-fidelity/wire.golden.json @@ -0,0 +1,24 @@ +{ + "DashName": [ + "-" + ], + "DashNameOmitEmpty": [ + "-" + ], + "EmbedIgnored": [ + "extra" + ], + "IgnoreShadow": [ + "age", + "id", + "name" + ], + "PlainIgnore": [ + "keep" + ], + "RenameShadow": [ + "age", + "id", + "name" + ] +} diff --git a/fixtures/enhancements/json-tag-fidelity/wire_test.go b/fixtures/enhancements/json-tag-fidelity/wire_test.go new file mode 100644 index 00000000..7c663ac7 --- /dev/null +++ b/fixtures/enhancements/json-tag-fidelity/wire_test.go @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package json_tag_fidelity + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "sort" + "testing" +) + +// This is the only test in the fixtures module, and it earns the exception: it +// produces the ORACLE for the json-tag-fidelity corpus. +// +// The types live here, so only this package can marshal them. The integration +// test cannot import across the module boundary (the library must not gain a +// dependency on its own fixtures), so the two sides meet at a committed +// artifact instead: this test writes the wire key set, the integration test +// asserts the emitted property set matches it. Neither hard-codes an answer. +// +// Regenerate with UPDATE_GOLDEN=1, like every other golden in the repo. +const wireGolden = "wire.golden.json" + +func TestWireShapes(t *testing.T) { + // Non-zero values throughout, so `omitempty` never hides a key. + subjects := map[string]any{ + "IgnoreShadow": IgnoreShadow{Base: Base{ID: 1, Name: "n", Age: 42}, Age: 99}, + "RenameShadow": RenameShadow{Base: Base{ID: 1, Name: "n", Age: 42}, Age: 99}, + "PlainIgnore": PlainIgnore{Keep: "k", Drop: "d"}, + "DashName": DashName{Weird: "w"}, + "DashNameOmitEmpty": DashNameOmitEmpty{Weird: "w"}, + "EmbedIgnored": EmbedIgnored{Base: Base{ID: 1, Name: "n", Age: 42}, Extra: "x"}, + } + + got := make(map[string][]string, len(subjects)) + for name, v := range subjects { + raw, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal %s: %v", name, err) + } + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + t.Fatalf("unmarshal %s (%s): %v", name, raw, err) + } + keys := make([]string, 0, len(obj)) + for k := range obj { + keys = append(keys, k) + } + sort.Strings(keys) + got[name] = keys + } + + data, err := json.MarshalIndent(got, "", " ") + if err != nil { + t.Fatal(err) + } + data = append(data, '\n') + + path := filepath.Join(".", wireGolden) + if os.Getenv("UPDATE_GOLDEN") == "1" { + const filePerm = 0o600 + if err := os.WriteFile(path, data, filePerm); err != nil { + t.Fatal(err) + } + t.Logf("wrote %s", wireGolden) + + return + } + + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("missing %s — run with UPDATE_GOLDEN=1 to create: %v", wireGolden, err) + } + if !reflect.DeepEqual(string(want), string(data)) { + t.Errorf("wire shapes drifted.\nwant:\n%s\ngot:\n%s", want, data) + } +} diff --git a/fixtures/enhancements/named-basic/types.go b/fixtures/enhancements/named-basic/types.go index 74e6a5ae..379eb98c 100644 --- a/fixtures/enhancements/named-basic/types.go +++ b/fixtures/enhancements/named-basic/types.go @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 // Package named_basic exercises the schemaBuilder.buildNamedBasic branches -// for named basic types carrying swagger:strfmt, swagger:type and -// swagger:default annotations. +// for named basic types carrying swagger:strfmt, swagger:type and the +// deprecated swagger:default annotation. package named_basic // Email is a named string with a swagger:strfmt tag. The scanner must @@ -18,8 +18,14 @@ type Email string // swagger:type string type Colour int -// Grade is a named int tagged with swagger:default which causes the -// scanner to emit an empty schema for the declared type. +// Grade is a named int tagged with the DEPRECATED swagger:default +// annotation. The annotation is an inert sink: Grade must emit exactly +// what it would without it — a plain named int, referenced by $ref from +// the field site — and the scan must raise a deprecation diagnostic. +// +// It used to claim the target without writing it, publishing a typeless +// schema for the declared type and a typeless property for every field +// referencing it. // // swagger:default Grade type Grade int diff --git a/fixtures/enhancements/provenance-params-responses/api.go b/fixtures/enhancements/provenance-params-responses/api.go index dd295c1c..b2338799 100644 --- a/fixtures/enhancements/provenance-params-responses/api.go +++ b/fixtures/enhancements/provenance-params-responses/api.go @@ -34,6 +34,39 @@ type provResp struct { } `json:"body"` } +// provListResp is a response whose body is an ARRAY with an INLINE element. +// +// This is the only shape in which the responses builder's descendBody("items") +// decides an anchor: it advances the body cursor as the builder peels its own +// array layer, so the element's properties land under …/schema/items/… rather +// than directly under …/schema/. With a NAMED element the anchor moves to that +// element's own definition instead and descendBody is unobservable — so a +// witness for it has to use an inline element or it proves nothing. +// +// descendBody affects cross-ref pointers only, never the emitted spec, so no +// golden can guard it and neither can a schema-comparing conformance suite. +// This anchor is its only detector. +// +// swagger:response provListResp +type provListResp struct { + // in: body + Body []struct { + // Code is the inline element property whose anchor is at stake. + Code string `json:"code"` + } `json:"body"` +} + +// provListHandler binds the array-bodied response. +// +// swagger:route GET /prov-list provListOp +// +// Prov list. +// +// responses: +// +// 200: provListResp +func provListHandler() {} + // provHandler is the route. It references provResp so the response is bound to // the operation, and provOp ties the parameter set to this path/method. // diff --git a/fixtures/enhancements/response-named-nonstruct/types.go b/fixtures/enhancements/response-named-nonstruct/types.go new file mode 100644 index 00000000..4894406a --- /dev/null +++ b/fixtures/enhancements/response-named-nonstruct/types.go @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package response_named_nonstruct witnesses a `swagger:response` declared on a +// NAMED type whose underlying is not a struct. +// +// That arm of the responses builder used to short-circuit on the stdlib time +// recognizer and on a local format helper, both of which wrote into a local +// schema and returned without attaching it — so the response came out carrying a +// description and nothing else. It also handed the sub-build the type's +// UNDERLYING rather than its declaration, which discards the named type the +// recognizer and the declaration's own classifiers key on. +// +// Each subject here is paired with the same type reached as a model field, whose +// rendering is pinned by its own witnesses. The two must agree: a response body +// and a model field are both full-schema positions, and there is no reason for a +// declaration to mean something different depending on which one reads it. +package response_named_nonstruct + +import "time" + +// Stamp is a named time.Time — the stdlib recognizer's subject. +type Stamp time.Time + +// Emails is a named string slice carrying a non-special format, so the +// element-driven rule applies and the format belongs on the items. +// +// swagger:strfmt email +type Emails []string + +// Code is a named string carrying a format. +// +// swagger:strfmt isbn +type Code string + +// Count is a named integer with no annotation at all — the control that isolates +// "did the schema get attached" from "was it built correctly". +type Count int64 + +// Host reaches every subject as a MODEL FIELD, the control for the response side. +// +// swagger:model Host +type Host struct { + // Stamp is the stdlib-recognizer subject. + Stamp Stamp `json:"stamp"` + + // Emails is the element-driven-format subject. + Emails Emails `json:"emails"` + + // Code is the whole-schema-format subject. + Code Code `json:"code"` + + // Count is the unannotated control. + Count Count `json:"count"` +} + +// StampResp declares the response on the named time.Time. +// +// swagger:response stampResp +type StampResp Stamp + +// EmailsResp declares the response on the formatted slice. +// +// swagger:response emailsResp +type EmailsResp = Emails + +// CodeResp declares the response on the formatted string. +// +// swagger:response codeResp +type CodeResp = Code + +// CountResp declares the response on the unannotated control. +// +// swagger:response countResp +type CountResp = Count + +// HandlerStamp binds the stdlib-recognizer response. +// +// swagger:route GET /stamp resp opStamp +// +// Responses: +// +// 200: stampResp +func HandlerStamp() {} + +// HandlerEmails binds the element-driven-format response. +// +// swagger:route GET /emails resp opEmails +// +// Responses: +// +// 200: emailsResp +func HandlerEmails() {} + +// HandlerCode binds the whole-schema-format response. +// +// swagger:route GET /code resp opCode +// +// Responses: +// +// 200: codeResp +func HandlerCode() {} + +// HandlerCount binds the control response. +// +// swagger:route GET /count resp opCount +// +// Responses: +// +// 200: countResp +func HandlerCount() {} diff --git a/fixtures/enhancements/route-name-shapes/api.go b/fixtures/enhancements/route-name-shapes/api.go new file mode 100644 index 00000000..d2b5aff1 --- /dev/null +++ b/fixtures/enhancements/route-name-shapes/api.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package route_name_shapes witnesses the name shapes a path annotation accepts, +// and what happens to one it cannot parse. +// +// A tag and an operationId of a SINGLE character used to void the entire +// annotation. The failure was not local to the short name: the tags group is +// optional, so the parse fell back to matching with no tags at all, leaving the +// operationId pattern to swallow `e listOne` — which its alphabet has no space +// for. The line then matched nothing, and a swagger:route matching nothing is +// not a malformed route, it is not a route, so no diagnostic was possible and +// the path simply never appeared. +package route_name_shapes + +// HandlerShortTag has a one-character tag. +// +// swagger:route GET /short-tag e listOne +// +// Responses: +// +// 200: emptyResp +func HandlerShortTag() {} + +// HandlerShortID has a one-character operationId. +// +// swagger:route GET /short-id shapes l +// +// Responses: +// +// 200: emptyResp +func HandlerShortID() {} + +// HandlerShortBoth has both, which is where the fallback used to bite hardest. +// +// swagger:route GET /short-both e l +// +// Responses: +// +// 200: emptyResp +func HandlerShortBoth() {} + +// HandlerShortIDNoTags has a one-character operationId and no tags at all. +// +// swagger:route GET /short-id-no-tags q +// +// Responses: +// +// 200: emptyResp +func HandlerShortIDNoTags() {} + +// HandlerShortAmongTags carries a one-character tag beside a longer one. +// +// swagger:route GET /short-among a shapes listAmong +// +// Responses: +// +// 200: emptyResp +func HandlerShortAmongTags() {} + +// HandlerUnparsed is the negative case: recognisably a route annotation — keyword, +// method, path — with an operationId that cannot be one. It yields no path, and +// the point of the fixture is that it now says so instead of vanishing. +// +// swagger:route GET /unparsed shapes 42 +// +// Responses: +// +// 200: emptyResp +func HandlerUnparsed() {} + +// EmptyResp is the shared response body. +// +// swagger:response emptyResp +type EmptyResp struct { + // in: body + Body string `json:"body"` +} diff --git a/fixtures/enhancements/simple-schema-violation/api.go b/fixtures/enhancements/simple-schema-violation/api.go index 0ff8d178..67248351 100644 --- a/fixtures/enhancements/simple-schema-violation/api.go +++ b/fixtures/enhancements/simple-schema-violation/api.go @@ -1,19 +1,26 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -// Package simple_schema_violation exercises the M1 exit validator on -// the parameter SimpleSchema path. A non-body parameter typed as a -// named string with a `swagger:type object` decl-level override -// resolves to a schema with `Type == "object"` — invalid under OAS v2 -// SimpleSchema. The exit validator emits -// CodeUnsupportedInSimpleSchema and resets the target to empty `{}`. +// Package simple_schema_violation exercises the two ways a non-body +// parameter can fail to be an OAS v2 SimpleSchema, which have +// different remedies. +// +// 1. The ANNOTATION asks for something the location cannot carry — +// `swagger:type object` on a query parameter. `type` is mandatory +// under SimpleSchema, so refusing the override and keeping the +// Go-derived type leaves a valid parameter; the diagnostic names +// the annotation. Honouring it and then wiping the result, as this +// fixture used to witness, left the parameter untyped. +// +// 2. The GO TYPE itself is not representable. There is no override to +// refuse and no fallback to keep, so the exit validator wipes the +// target and says so — honest over lossy. package simple_schema_violation // ObjectOverride is a named string carrying a decl-level -// `swagger:type object` override. The override is honoured by the -// schema builder (classifierNamedBasic arm); under SimpleSchema mode -// the exit validator catches the resulting `Type == "object"` and -// resets the parameter back to empty `{}`. +// `swagger:type object` override — case 1. Under SimpleSchema the +// override is refused before it is applied and the parameter keeps +// `{type: string}`, the type its Go declaration gives it. // // swagger:type object type ObjectOverride string @@ -23,13 +30,29 @@ type ObjectOverride string // // swagger:parameters violationOp type ViolatingParams struct { - // Bad is the offending parameter — its type carries a - // decl-level override that the schema builder honours, producing - // an object-typed SimpleSchema. The M1 exit validator emits a - // CodeUnsupportedInSimpleSchema diagnostic and wipes the target. + // Bad carries an override the location cannot honour (case 1): the + // annotation is ignored with a diagnostic and the Go type stands. // // in: query Bad ObjectOverride `json:"bad"` + + // Unrepresentable is case 2 — a struct has no SimpleSchema form and + // there is no annotation to refuse, so the exit validator wipes it. + // + // in: query + Unrepresentable struct { + Left string `json:"left"` + } `json:"unrepresentable"` + + // Errored is case 3 — an `error` has no meaning as a parameter, so the + // field is dropped rather than described. A struct shared between a + // parameter set and a response should lose it on the parameter side. + // + // This used to abort the whole scan; skip-with-a-diagnostic is the house + // rule, and the sibling above already followed it. + // + // in: query + Errored error `json:"errored"` } // DoViolation handles the violating route. diff --git a/fixtures/enhancements/strfmt-arrays/types.go b/fixtures/enhancements/strfmt-arrays/types.go index 10e8b8f7..31e0ce63 100644 --- a/fixtures/enhancements/strfmt-arrays/types.go +++ b/fixtures/enhancements/strfmt-arrays/types.go @@ -2,7 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 // Package strfmt_arrays exercises strfmt handling on named array and slice -// types, including the byte/bsonobjectid fast paths. +// types. Whether a format describes the whole value or its items is decided by +// the ELEMENT type: byte and rune sequences are string-like and take the format +// on the schema, everything else takes it on the items. package strfmt_arrays // Hash is a 32-byte array tagged as the byte swagger strfmt. @@ -15,7 +17,9 @@ type Hash [32]byte // swagger:strfmt bsonobjectid type ObjectID [12]byte -// Signature is a named array that carries a generic strfmt tag. +// Signature is a named byte array carrying a format that is not one of the two +// names the old allowlist knew. It is still a byte sequence, so the format +// describes the whole value — it used to emit an array of 64 password strings. // // swagger:strfmt password type Signature [64]byte diff --git a/fixtures/enhancements/strfmt-decl-arraylike/types.go b/fixtures/enhancements/strfmt-decl-arraylike/types.go new file mode 100644 index 00000000..9e16195a --- /dev/null +++ b/fixtures/enhancements/strfmt-decl-arraylike/types.go @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package strfmt_decl_arraylike witnesses a format annotation on a type whose +// underlying is an ARRAY or SLICE, at the declaration site. +// +// `buildFromDecl`'s underlying-kind switch has arms for struct and basic only, +// so `classifierNamedArrayLike` never fires there. Downstream cannot compensate: +// the `refModel` gate skips the inline classifiers for a model type precisely +// because the declaration is supposed to have published the override already. +// +// The existing array/slice format fixtures all use `byte` or `bsonobjectid` — +// the two whole-schema specials — where the defect is invisible because both +// land on the schema either way. Every format here is deliberately NON-special. +// +// # The two shapes are not the same question +// +// - `[16]byte` annotated `uuid` — the format describes the WHOLE value; the +// array is a representation detail, and an "array of uuid strings" is not +// what the author means. +// - `[]string` annotated `email` — the format describes each ELEMENT; an array +// of emails is exactly what the author means. +// +// Both are here so the items-vs-whole rule can be judged on evidence rather than +// on the `byte` special that currently stands in for it. +// +// See [§aliases](../../../internal/builders/schema/README.md#aliases). +package strfmt_decl_arraylike + +// IDNamedModeled is a fixed byte array that IS a uuid, published as a model. +// +// swagger:model IDNamedModeled +// swagger:strfmt uuid +type IDNamedModeled [16]byte + +// IDAliasModeled is the alias half of the same pair. +// +// swagger:model IDAliasModeled +// swagger:strfmt uuid +type IDAliasModeled = [16]byte + +// IDNamedPlain is the same byte array with no model annotation, so field sites +// reach the inline classifier instead of a $ref. +// +// swagger:strfmt uuid +type IDNamedPlain [16]byte + +// IDAliasPlain is the alias half of the unannotated pair. +// +// swagger:strfmt uuid +type IDAliasPlain = [16]byte + +// ULIDNamedModeled shows the rule generalising to a strfmt type that never had +// an entry in the old allowlist. +// +// swagger:model ULIDNamedModeled +// swagger:strfmt ulid +type ULIDNamedModeled [16]byte + +// ULIDAliasModeled is the alias half of the ULID pair. +// +// swagger:model ULIDAliasModeled +// swagger:strfmt ulid +type ULIDAliasModeled = [16]byte + +// RunesNamedModeled is a rune sequence — string-like for the same reason a byte +// sequence is, so the format describes the whole value. +// +// swagger:model RunesNamedModeled +// swagger:strfmt password +type RunesNamedModeled []rune + +// RunesAliasModeled is the alias half of the rune pair. +// +// swagger:model RunesAliasModeled +// swagger:strfmt password +type RunesAliasModeled = []rune + +// EmailsNamedModeled is a string slice whose format describes each ELEMENT. +// +// swagger:model EmailsNamedModeled +// swagger:strfmt email +type EmailsNamedModeled []string + +// EmailsAliasModeled is the alias half of the element-format pair. +// +// swagger:model EmailsAliasModeled +// swagger:strfmt email +type EmailsAliasModeled = []string + +// Envelope reaches the non-model pairs from a field site, where the inline +// classifier still runs. +// +// swagger:model Envelope +type Envelope struct { + // FieldIDNamed is the whole-value format, named half, at a field site. + FieldIDNamed IDNamedPlain `json:"fieldIdNamed"` + + // FieldIDAlias is the whole-value format, alias half, at a field site. + FieldIDAlias IDAliasPlain `json:"fieldIdAlias"` +} diff --git a/fixtures/enhancements/strfmt-symmetry-composition/types.go b/fixtures/enhancements/strfmt-symmetry-composition/types.go new file mode 100644 index 00000000..a341c390 --- /dev/null +++ b/fixtures/enhancements/strfmt-symmetry-composition/types.go @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package strfmt_symmetry_composition exercises the strfmt annotation at the two +// COMPOSITION dispatch sites — the ones that do not go through `buildFromType`: +// +// - plain struct embed → `buildEmbedded` (`embedded.go:40`), which unaliases and +// recurses into `buildNamedEmbedded` +// - allOf member → `buildAllOf` (`allof.go:172`), whose named arm +// `buildNamedAllOf` runs `classifierAliasTargetStrfmt` while its alias arm +// drops straight into `buildAlias` +// +// Each composing type is one half of a PAIR: same member type, same annotation, +// same composition, differing only by the member's `=`. The pair is compared at +// DEFINITION level, because a composition has no enclosing property to inspect. +// +// # The third composition site carries no cell, deliberately +// +// `processEmbeddedType` (`embedded.go:155`) is the interface-side allOf walk and +// is the fourth caller of `buildAlias`. Go interfaces can only embed interfaces, +// so the only alias reachable there is an alias-to-interface — and no classifier +// consumes a format on an interface underlying on EITHER side (the named arm goes +// to `resolveRefOrErr`). There is no meaningful strfmt cell to write; the site is +// out of scope for this matrix rather than untested. +// +// See [§aliases](../../../internal/builders/schema/README.md#aliases) and +// [§allof](../../../internal/builders/schema/README.md#allof). +package strfmt_symmetry_composition + +// PlainTarget is the unannotated struct behind the struct-kind pair. +type PlainTarget struct { + // Left is a plain field. + Left string `json:"left"` + + // Right is a plain field. + Right int32 `json:"right"` +} + +// FmtBasicNamed is a named type over a primitive, carrying a format. +// +// swagger:strfmt isbn +type FmtBasicNamed string + +// FmtBasicAlias is an alias over the same primitive, same format. +// +// swagger:strfmt isbn +type FmtBasicAlias = string + +// FmtStructNamed is a named type over a struct, carrying a format. +// +// swagger:strfmt duration +type FmtStructNamed PlainTarget + +// FmtStructAlias is an alias over the same struct, same format. +// +// swagger:strfmt duration +type FmtStructAlias = PlainTarget + +// --- plain struct embed: buildEmbedded --- + +// EmbedBasicNamed plainly embeds the basic pair's named half. +// +// swagger:model EmbedBasicNamed +type EmbedBasicNamed struct { + FmtBasicNamed + + // Label is the embedding struct's own field. + Label string `json:"label"` +} + +// EmbedBasicAlias plainly embeds the basic pair's alias half. +// +// swagger:model EmbedBasicAlias +type EmbedBasicAlias struct { + FmtBasicAlias + + // Label is the embedding struct's own field. + Label string `json:"label"` +} + +// EmbedStructNamed plainly embeds the struct pair's named half. +// +// swagger:model EmbedStructNamed +type EmbedStructNamed struct { + FmtStructNamed + + // Label is the embedding struct's own field. + Label string `json:"label"` +} + +// EmbedStructAlias plainly embeds the struct pair's alias half. +// +// swagger:model EmbedStructAlias +type EmbedStructAlias struct { + FmtStructAlias + + // Label is the embedding struct's own field. + Label string `json:"label"` +} + +// --- allOf member: buildAllOf --- + +// AllOfBasicNamed composes the basic pair's named half as an allOf member. +// +// swagger:model AllOfBasicNamed +type AllOfBasicNamed struct { + // swagger:allOf + FmtBasicNamed + + // Note is the composing struct's own field. + Note string `json:"note"` +} + +// AllOfBasicAlias composes the basic pair's alias half as an allOf member. +// +// swagger:model AllOfBasicAlias +type AllOfBasicAlias struct { + // swagger:allOf + FmtBasicAlias + + // Note is the composing struct's own field. + Note string `json:"note"` +} + +// AllOfStructNamed composes the struct pair's named half as an allOf member. +// +// swagger:model AllOfStructNamed +type AllOfStructNamed struct { + // swagger:allOf + FmtStructNamed + + // Note is the composing struct's own field. + Note string `json:"note"` +} + +// AllOfStructAlias composes the struct pair's alias half as an allOf member. +// +// swagger:model AllOfStructAlias +type AllOfStructAlias struct { + // swagger:allOf + FmtStructAlias + + // Note is the composing struct's own field. + Note string `json:"note"` +} diff --git a/fixtures/enhancements/strfmt-symmetry-core/types.go b/fixtures/enhancements/strfmt-symmetry-core/types.go new file mode 100644 index 00000000..bfda13c7 --- /dev/null +++ b/fixtures/enhancements/strfmt-symmetry-core/types.go @@ -0,0 +1,237 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package strfmt_symmetry_core exercises the strfmt annotation across every dispatch +// site reachable through `buildFromType` — the busiest of `buildAlias`'s four +// callers (`schema.go:351`). +// +// Every type here is one half of a PAIR: a named declaration and an alias +// declaration over the SAME right-hand side, carrying the SAME annotation, used +// at the SAME sites. Only the `=` differs. The named half is the control — it +// defines what the cell's correct output is — so the ledger test compares the +// two halves against each other and never hand-writes an expectation. +// +// # RHS kinds +// +// Each kind is here because it selects a DIFFERENT classifier on the named side, +// so each is a distinct thing the alias side would have to learn: +// +// - basic → classifierNamedBasic +// - struct → classifierNamedStructStrfmt (strfmt on a struct replaces the whole type) +// - slice → classifierNamedArrayLike; the "byte" format is a whole-schema +// special, NOT an items one +// - array → classifierNamedArrayLike; the `bsonobjectid` special fires for +// arrays only, never slices +// - chain → inheritedStrfmt; the annotation sits one declaration to the RIGHT +// +// # Cells exercised +// +// decl × {basic, struct, slice, array, chain} × {named, alias} +// field × {basic, struct, slice, array, chain} × {named, alias} +// pointer × {basic, struct} × {named, alias} +// slice elem × {basic, struct} × {named, alias} +// map value × {basic, struct} × {named, alias} +// +// Pointer / slice-elem / map-value are restricted to {basic, struct} on purpose: +// all three recurse straight back into `buildFromType`, so they re-enter the same +// dispatch the plain field cell already covers. They are here to prove that, not +// to multiply rows. +// +// `EnvelopeModeled` adds the model-annotation axis: the same pairs declared as +// first-class models, which gates whether `buildAlias` dissolves at the use site +// (`schema.go:426`). +// +// See [§aliases](../../../internal/builders/schema/README.md#aliases). +package strfmt_symmetry_core + +// PlainTarget is an unannotated struct used as the RHS of the struct-kind pair. +// It carries no format annotation of its own, so any format on the pair's members +// can only come from the pair's own declaration. +type PlainTarget struct { + // Left is a plain field, present so the struct has observable content when + // the format is NOT applied. + Left string `json:"left"` + + // Right is a plain field. + Right int32 `json:"right"` +} + +// BaseFormatted is the right-hand end of the chain pair: the declaration that +// actually carries the annotation. `StrfmtChainNamed` / `StrfmtChainAlias` are +// declared OVER it and carry none of their own. +// +// swagger:strfmt ssn +type BaseFormatted string + +// StrfmtBasicNamed is a named type over a primitive. +// +// swagger:strfmt isbn +type StrfmtBasicNamed string + +// StrfmtBasicAlias is an alias over the same primitive, same annotation. +// +// swagger:strfmt isbn +type StrfmtBasicAlias = string + +// StrfmtStructNamed is a named type over a struct. +// +// swagger:strfmt duration +type StrfmtStructNamed PlainTarget + +// StrfmtStructAlias is an alias over the same struct, same annotation. +// +// swagger:strfmt duration +type StrfmtStructAlias = PlainTarget + +// StrfmtSliceNamed is a named type over a byte slice. `byte` is the whole-schema +// special in classifierNamedArrayLike — it must NOT land on items. +// +// swagger:strfmt byte +type StrfmtSliceNamed []byte + +// StrfmtSliceAlias is an alias over the same byte slice, same annotation. +// +// swagger:strfmt byte +type StrfmtSliceAlias = []byte + +// StrfmtArrayNamed is a named type over a fixed byte array. `bsonobjectid` is the +// array-only special — the slice half of classifierNamedArrayLike does not have it. +// +// swagger:strfmt bsonobjectid +type StrfmtArrayNamed [12]byte + +// StrfmtArrayAlias is an alias over the same fixed array, same annotation. +// +// swagger:strfmt bsonobjectid +type StrfmtArrayAlias = [12]byte + +// StrfmtChainNamed is a named type declared over an ALREADY-annotated named type. +// It carries no annotation itself: the format must be inherited from +// BaseFormatted, one declaration to the right (inheritedStrfmt). +type StrfmtChainNamed BaseFormatted + +// StrfmtChainAlias is an alias over the same annotated named type, also carrying +// no annotation of its own. +type StrfmtChainAlias = BaseFormatted + +// Envelope reaches every pair from a use site. Field names are the lower-camel +// cell ID, so a golden diff names its own cell. +// +// swagger:model Envelope +type Envelope struct { + // --- field × {basic, struct, slice, array, chain} --- + + // FieldBasicNamed is the basic pair, named half, in plain field position. + FieldBasicNamed StrfmtBasicNamed `json:"fieldBasicNamed"` + + // FieldBasicAlias is the basic pair, alias half, in plain field position. + FieldBasicAlias StrfmtBasicAlias `json:"fieldBasicAlias"` + + // FieldStructNamed is the struct pair, named half. + FieldStructNamed StrfmtStructNamed `json:"fieldStructNamed"` + + // FieldStructAlias is the struct pair, alias half. + FieldStructAlias StrfmtStructAlias `json:"fieldStructAlias"` + + // FieldSliceNamed is the slice pair, named half. + FieldSliceNamed StrfmtSliceNamed `json:"fieldSliceNamed"` + + // FieldSliceAlias is the slice pair, alias half. + FieldSliceAlias StrfmtSliceAlias `json:"fieldSliceAlias"` + + // FieldArrayNamed is the array pair, named half. + FieldArrayNamed StrfmtArrayNamed `json:"fieldArrayNamed"` + + // FieldArrayAlias is the array pair, alias half. + FieldArrayAlias StrfmtArrayAlias `json:"fieldArrayAlias"` + + // FieldChainNamed is the chain pair, named half — format inherited from BaseFormatted. + FieldChainNamed StrfmtChainNamed `json:"fieldChainNamed"` + + // FieldChainAlias is the chain pair, alias half. + FieldChainAlias StrfmtChainAlias `json:"fieldChainAlias"` + + // --- pointer × {basic, struct} --- + + // PointerBasicNamed reaches the basic pair's named half through a pointer. + PointerBasicNamed *StrfmtBasicNamed `json:"pointerBasicNamed"` + + // PointerBasicAlias reaches the basic pair's alias half through a pointer. + PointerBasicAlias *StrfmtBasicAlias `json:"pointerBasicAlias"` + + // PointerStructNamed reaches the struct pair's named half through a pointer. + PointerStructNamed *StrfmtStructNamed `json:"pointerStructNamed"` + + // PointerStructAlias reaches the struct pair's alias half through a pointer. + PointerStructAlias *StrfmtStructAlias `json:"pointerStructAlias"` + + // --- slice element × {basic, struct} --- + + // SliceElemBasicNamed reaches the basic pair's named half as a slice element. + SliceElemBasicNamed []StrfmtBasicNamed `json:"sliceElemBasicNamed"` + + // SliceElemBasicAlias reaches the basic pair's alias half as a slice element. + SliceElemBasicAlias []StrfmtBasicAlias `json:"sliceElemBasicAlias"` + + // SliceElemStructNamed reaches the struct pair's named half as a slice element. + SliceElemStructNamed []StrfmtStructNamed `json:"sliceElemStructNamed"` + + // SliceElemStructAlias reaches the struct pair's alias half as a slice element. + SliceElemStructAlias []StrfmtStructAlias `json:"sliceElemStructAlias"` + + // --- map value × {basic, struct} --- + + // MapValueBasicNamed reaches the basic pair's named half as a map value. + MapValueBasicNamed map[string]StrfmtBasicNamed `json:"mapValueBasicNamed"` + + // MapValueBasicAlias reaches the basic pair's alias half as a map value. + MapValueBasicAlias map[string]StrfmtBasicAlias `json:"mapValueBasicAlias"` + + // MapValueStructNamed reaches the struct pair's named half as a map value. + MapValueStructNamed map[string]StrfmtStructNamed `json:"mapValueStructNamed"` + + // MapValueStructAlias reaches the struct pair's alias half as a map value. + MapValueStructAlias map[string]StrfmtStructAlias `json:"mapValueStructAlias"` +} + +// ModeledBasicNamed is the basic pair's named half WITH a model annotation. +// +// swagger:model ModeledBasicNamed +// swagger:strfmt isbn +type ModeledBasicNamed string + +// ModeledBasicAlias is the basic pair's alias half WITH a model annotation — the +// annotation that stops buildAlias dissolving at the use site (schema.go:426). +// +// swagger:model ModeledBasicAlias +// swagger:strfmt isbn +type ModeledBasicAlias = string + +// ModeledStructNamed is the struct pair's named half WITH a model annotation. +// +// swagger:model ModeledStructNamed +// swagger:strfmt duration +type ModeledStructNamed PlainTarget + +// ModeledStructAlias is the struct pair's alias half WITH a model annotation. +// +// swagger:model ModeledStructAlias +// swagger:strfmt duration +type ModeledStructAlias = PlainTarget + +// EnvelopeModeled reaches the model-annotated pairs from a use site. +// +// swagger:model EnvelopeModeled +type EnvelopeModeled struct { + // ModeledBasicNamed is the annotated basic pair, named half. + ModeledBasicNamed ModeledBasicNamed `json:"modeledBasicNamed"` + + // ModeledBasicAlias is the annotated basic pair, alias half. + ModeledBasicAlias ModeledBasicAlias `json:"modeledBasicAlias"` + + // ModeledStructNamed is the annotated struct pair, named half. + ModeledStructNamed ModeledStructNamed `json:"modeledStructNamed"` + + // ModeledStructAlias is the annotated struct pair, alias half. + ModeledStructAlias ModeledStructAlias `json:"modeledStructAlias"` +} diff --git a/fixtures/enhancements/strfmt-symmetry-simpleschema/types.go b/fixtures/enhancements/strfmt-symmetry-simpleschema/types.go new file mode 100644 index 00000000..be9de6b6 --- /dev/null +++ b/fixtures/enhancements/strfmt-symmetry-simpleschema/types.go @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package strfmt_symmetry_simpleschema exercises the strfmt annotation at the +// SimpleSchema dispatch sites — non-body parameters and response headers, where +// OAS v2 forbids `$ref` so the builder runs with `simpleSchema` set and the +// `refModel` gate flips (`schema.go:475`). +// +// Body parameters and response bodies are deliberately absent: both are +// full-schema locations that re-enter `buildFromType` exactly as a struct field +// does, so `strfmt-symmetry-core` already covers that dispatch. +// +// Each pair is the same right-hand side with the same annotation, differing only +// by the `=`, reached from the same parameter set / response. +// +// See [§aliases](../../../internal/builders/schema/README.md#aliases). +package strfmt_symmetry_simpleschema + +// FmtBasicNamed is a named type over a primitive, carrying a format. +// +// swagger:strfmt isbn +type FmtBasicNamed string + +// FmtBasicAlias is an alias over the same primitive, same format. +// +// swagger:strfmt isbn +type FmtBasicAlias = string + +// FmtSliceNamed is a named type over a byte slice, carrying the whole-schema +// "byte" format special. +// +// swagger:strfmt byte +type FmtSliceNamed []byte + +// FmtSliceAlias is an alias over the same byte slice, same format. +// +// swagger:strfmt byte +type FmtSliceAlias = []byte + +// SimpleParams carries the non-body parameter cells. +// +// swagger:parameters simpleOp +type SimpleParams struct { + // QueryBasicNamed is the basic pair's named half in query position. + // + // in: query + QueryBasicNamed FmtBasicNamed `json:"queryBasicNamed"` + + // QueryBasicAlias is the basic pair's alias half in query position. + // + // in: query + QueryBasicAlias FmtBasicAlias `json:"queryBasicAlias"` + + // QuerySliceNamed is the slice pair's named half in query position. + // + // in: query + QuerySliceNamed FmtSliceNamed `json:"querySliceNamed"` + + // QuerySliceAlias is the slice pair's alias half in query position. + // + // in: query + QuerySliceAlias FmtSliceAlias `json:"querySliceAlias"` +} + +// SimpleResponse carries the response-header cells. Non-body fields of a +// response struct become headers, which are SimpleSchema locations too. +// +// swagger:response simpleResponse +type SimpleResponse struct { + // HeaderBasicNamed is the basic pair's named half in header position. + // + // in: header + HeaderBasicNamed FmtBasicNamed `json:"headerBasicNamed"` + + // HeaderBasicAlias is the basic pair's alias half in header position. + // + // in: header + HeaderBasicAlias FmtBasicAlias `json:"headerBasicAlias"` + + // HeaderSliceNamed is the slice pair's named half in header position. + // + // in: header + HeaderSliceNamed FmtSliceNamed `json:"headerSliceNamed"` + + // HeaderSliceAlias is the slice pair's alias half in header position. + // + // in: header + HeaderSliceAlias FmtSliceAlias `json:"headerSliceAlias"` +} + +// SimpleHandler binds the parameter set and the response to a real operation, so +// `paths` populates and the SimpleSchema locations are observable. +// +// swagger:route GET /simple symmetry simpleOp +// +// Responses: +// +// 200: simpleResponse +func SimpleHandler() {} diff --git a/fixtures/enhancements/strfmt-symmetry-stdlib/types.go b/fixtures/enhancements/strfmt-symmetry-stdlib/types.go new file mode 100644 index 00000000..ff2b6bd8 --- /dev/null +++ b/fixtures/enhancements/strfmt-symmetry-stdlib/types.go @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package strfmt_symmetry_stdlib exercises the strfmt annotation over a stdlib +// type that the builder ALSO recognizes on its own — the precedence cell. +// +// This is not simply a missing classifier call like the rest of the matrix. On +// the named half the classifier wins, because `applyStdlibSpecials` is keyed on +// the declaration's own identity (`StampNamed` is not `time.Time`) and so does +// not fire. On the alias half the dissolve reaches the stdlib type itself, the +// recognizer fires, and the author's annotation is overruled by a DIFFERENT +// answer rather than by no answer at all. +// +// The contract is that the author always wins: the annotation is the escape hatch +// for formats the library cannot infer, so a recognizer is a default for +// un-annotated code and never an override. Kept in its own package because these +// goldens move with that precedence rule rather than with the alias dispatch. +// +// See [§special-types](../../../internal/builders/schema/README.md#special-types). +package strfmt_symmetry_stdlib + +import ( + "encoding/json" + "time" +) + +// StampNamed is a named type over time.Time, annotated as a plain date. +// +// swagger:strfmt date +type StampNamed time.Time + +// StampAlias is an alias over time.Time, same annotation. +// +// swagger:strfmt date +type StampAlias = time.Time + +// RawNamed is a named type over json.RawMessage, annotated as base64 bytes. +// +// swagger:strfmt byte +type RawNamed json.RawMessage + +// RawAlias is an alias over json.RawMessage, same annotation. +// +// swagger:strfmt byte +type RawAlias = json.RawMessage + +// Envelope reaches both pairs from a field site. +// +// swagger:model Envelope +type Envelope struct { + // FieldTimeNamed is the time pair's named half. + FieldTimeNamed StampNamed `json:"fieldTimeNamed"` + + // FieldTimeAlias is the time pair's alias half. + FieldTimeAlias StampAlias `json:"fieldTimeAlias"` + + // FieldRawNamed is the raw-message pair's named half. + FieldRawNamed RawNamed `json:"fieldRawNamed"` + + // FieldRawAlias is the raw-message pair's alias half. + FieldRawAlias RawAlias `json:"fieldRawAlias"` +} diff --git a/fixtures/enhancements/type-override-symmetry/types.go b/fixtures/enhancements/type-override-symmetry/types.go new file mode 100644 index 00000000..06a14795 --- /dev/null +++ b/fixtures/enhancements/type-override-symmetry/types.go @@ -0,0 +1,307 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package type_override_symmetry witnesses `swagger:type` on a NAMED declaration +// against the same annotation on an ALIAS one, at every dispatch site. +// +// Sibling of `strfmt-symmetry-core`, for the other half of Q32. `swagger:type` +// has a wider surface than `swagger:strfmt` — a scalar / Go-builtin / OAS-2 name, +// `[]T` array prefixes, the `inline` keyword, and a reference to another scanned +// type — so each form gets its own pair rather than assuming they share a path. +// +// Every cell is a PAIR differing only by the `=`. The named half is the control: +// it defines the cell's correct output, so nothing here hard-codes an expectation. +// +// Sites covered: declaration, struct field, pointer, slice element, map value, +// allOf member, and the SimpleSchema locations (non-body parameter, response +// header). The last group is deliberate — the parameters and responses builders +// have their own dispatch, and a fix verified only on the schema builder has +// already once looked complete when it was not. +package type_override_symmetry + +// PlainTarget is a struct used as the target of a type-name reference override. +type PlainTarget struct { + // Left is a plain field. + Left string `json:"left"` + + // Right is a plain field. + Right int32 `json:"right"` +} + +// --- scalar override: an int declared to be a string --- + +// ScalarNamed is a named int overridden to a string. +// +// swagger:type string +type ScalarNamed int + +// ScalarAlias is an alias to the same int, same override. +// +// swagger:type string +type ScalarAlias = int + +// --- array override: `[]T` prefixes --- + +// ArrayNamed is a named string overridden to an array of strings. +// +// swagger:type []string +type ArrayNamed string + +// ArrayAlias is an alias to the same string, same override. +// +// swagger:type []string +type ArrayAlias = string + +// --- type-name reference: inline another scanned type in place --- + +// RefNamed is a named int overridden to PlainTarget's shape. +// +// swagger:type PlainTarget +type RefNamed int + +// RefAlias is an alias to the same int, same override. +// +// swagger:type PlainTarget +type RefAlias = int + +// --- swagger:type with a co-present swagger:strfmt (type wins, format advisory) --- + +// FormattedNamed is a named int overridden to a string carrying a format. +// +// swagger:type string +// swagger:strfmt uuid +type FormattedNamed int + +// FormattedAlias is the alias half of the same pair. +// +// swagger:type string +// swagger:strfmt uuid +type FormattedAlias = int + +// Envelope reaches every pair from the buildFromType sites. +// +// swagger:model Envelope +type Envelope struct { + // FieldScalarNamed is the scalar pair, named half. + FieldScalarNamed ScalarNamed `json:"fieldScalarNamed"` + + // FieldScalarAlias is the scalar pair, alias half. + FieldScalarAlias ScalarAlias `json:"fieldScalarAlias"` + + // FieldArrayNamed is the array pair, named half. + FieldArrayNamed ArrayNamed `json:"fieldArrayNamed"` + + // FieldArrayAlias is the array pair, alias half. + FieldArrayAlias ArrayAlias `json:"fieldArrayAlias"` + + // FieldRefNamed is the type-reference pair, named half. + FieldRefNamed RefNamed `json:"fieldRefNamed"` + + // FieldRefAlias is the type-reference pair, alias half. + FieldRefAlias RefAlias `json:"fieldRefAlias"` + + // FieldFormattedNamed is the type+strfmt pair, named half. + FieldFormattedNamed FormattedNamed `json:"fieldFormattedNamed"` + + // FieldFormattedAlias is the type+strfmt pair, alias half. + FieldFormattedAlias FormattedAlias `json:"fieldFormattedAlias"` + + // PointerScalarNamed reaches the scalar pair's named half through a pointer. + PointerScalarNamed *ScalarNamed `json:"pointerScalarNamed"` + + // PointerScalarAlias reaches the scalar pair's alias half through a pointer. + PointerScalarAlias *ScalarAlias `json:"pointerScalarAlias"` + + // SliceElemScalarNamed reaches the scalar pair's named half as a slice element. + SliceElemScalarNamed []ScalarNamed `json:"sliceElemScalarNamed"` + + // SliceElemScalarAlias reaches the scalar pair's alias half as a slice element. + SliceElemScalarAlias []ScalarAlias `json:"sliceElemScalarAlias"` + + // MapValueScalarNamed reaches the scalar pair's named half as a map value. + MapValueScalarNamed map[string]ScalarNamed `json:"mapValueScalarNamed"` + + // MapValueScalarAlias reaches the scalar pair's alias half as a map value. + MapValueScalarAlias map[string]ScalarAlias `json:"mapValueScalarAlias"` +} + +// --- allOf members --- + +// AllOfScalarNamed composes the scalar pair's named half as an allOf member. +// +// swagger:model AllOfScalarNamed +type AllOfScalarNamed struct { + // swagger:allOf + ScalarNamed + + // Note is the composing struct's own field. + Note string `json:"note"` +} + +// AllOfScalarAlias composes the scalar pair's alias half as an allOf member. +// +// swagger:model AllOfScalarAlias +type AllOfScalarAlias struct { + // swagger:allOf + ScalarAlias + + // Note is the composing struct's own field. + Note string `json:"note"` +} + +// --- SimpleSchema sites --- + +// TypeParams carries the pairs in non-body parameter positions. +// +// swagger:parameters typeOverrideOp +type TypeParams struct { + // QueryScalarNamed is the scalar pair's named half in query position. + // + // in: query + QueryScalarNamed ScalarNamed `json:"queryScalarNamed"` + + // QueryScalarAlias is the scalar pair's alias half in query position. + // + // in: query + QueryScalarAlias ScalarAlias `json:"queryScalarAlias"` +} + +// TypeResponse carries the pairs on response headers. +// +// swagger:response typeOverrideResponse +type TypeResponse struct { + // HeaderScalarNamed is the scalar pair's named half in header position. + // + // in: header + HeaderScalarNamed ScalarNamed `json:"X-Named"` + + // HeaderScalarAlias is the scalar pair's alias half in header position. + // + // in: header + HeaderScalarAlias ScalarAlias `json:"X-Alias"` +} + +// --- `swagger:type file` is a synonym for `swagger:file` --- +// +// `file` is an OAS v2 type name like any other, so the annotation that names +// types should be able to name it; `swagger:file` is the older, extraneous +// spelling and is expected to be deprecated. The two must produce identical +// output wherever `file` is legal — a formData parameter and a response body — +// and the location gate is shared, so neither spelling can leak `file` into a +// place OAS 2.0 forbids. + +// FileParams pairs the two spellings in formData, plus an illegal location. +// +// swagger:parameters fileSynonymOp +type FileParams struct { + // ViaAnnotation uses the legacy spelling. + // + // swagger:file + // in: formData + ViaAnnotation interface{} `json:"viaAnnotation"` + + // ViaType uses the preferred spelling and must match it exactly. + // + // swagger:type file + // in: formData + ViaType interface{} `json:"viaType"` + + // QueryFile is illegal: `file` is formData-only, so the override is refused + // and the Go type stands. + // + // swagger:type file + // in: query + QueryFile string `json:"queryFile"` +} + +// FileBodyAnnotation is a file-download response, legacy spelling. +// +// swagger:response fileBodyAnnotation +type FileBodyAnnotation struct { + // Body is the download. + // + // swagger:file + // in: body + Body interface{} `json:"body"` +} + +// FileBodyType is the same response via the preferred spelling. +// +// swagger:response fileBodyType +type FileBodyType struct { + // Body is the download. + // + // swagger:type file + // in: body + Body interface{} `json:"body"` +} + +// FileHandler binds the file-synonym parameters and responses. +// +// swagger:route POST /file-synonym symmetry fileSynonymOp +// +// Responses: +// +// 200: fileBodyAnnotation +// 201: fileBodyType +func FileHandler() {} + +// --- `swagger:enum` on an alias: unfixable, so it must say so --- + +// NamedEnum is the control: a named type over a basic, whose constants are +// collectable because the type survives into the type-checker's view of them. +// +// swagger:enum NamedEnum +type NamedEnum uint64 + +const ( + // NamedLow is the low value. + NamedLow NamedEnum = 1 + // NamedHigh is the high value. + NamedHigh NamedEnum = 2 +) + +// AliasEnum is an alias to a BASIC type. `const AliasLow AliasEnum = 10` is a +// `uint64` constant indistinguishable from any other, so there is nothing to +// collect — unlike swagger:strfmt and swagger:type, which merely decorate the +// emitted schema and were fixed. The annotation must raise a diagnostic instead +// of silently producing no members. +// +// swagger:enum AliasEnum +type AliasEnum = uint64 + +const ( + // AliasLow is the low value. + AliasLow AliasEnum = 10 + // AliasHigh is the high value. + AliasHigh AliasEnum = 20 +) + +// AliasToNamed is an alias to a NAMED enum type, which DOES work: the named type +// survives the alias, so the members resolve. It must stay silent. +// +// swagger:enum AliasToNamed +type AliasToNamed = NamedEnum + +// EnumEnvelope reaches all three. +// +// swagger:model EnumEnvelope +type EnumEnvelope struct { + // Named is the control. + Named NamedEnum `json:"named"` + + // Alias is the unfixable alias-to-basic. + Alias AliasEnum `json:"alias"` + + // ToNamed is the alias-to-named, which works. + ToNamed AliasToNamed `json:"toNamed"` +} + +// TypeHandler binds the parameters and the response to an operation. +// +// swagger:route GET /type-override symmetry typeOverrideOp +// +// Responses: +// +// 200: typeOverrideResponse +func TypeHandler() {} diff --git a/fixtures/enhancements/unknown-annotation/types.go b/fixtures/enhancements/unknown-annotation/types.go index 1e598ad1..43baa769 100644 --- a/fixtures/enhancements/unknown-annotation/types.go +++ b/fixtures/enhancements/unknown-annotation/types.go @@ -1,13 +1,19 @@ // SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers // SPDX-License-Identifier: Apache-2.0 -// Package unknown_annotation carries a deliberately bogus swagger -// annotation so the classifier returns an error from detectNodes. +// Package unknown_annotation carries a deliberately bogus swagger annotation. +// +// The classifier reads it, cannot place it, and skips it with a warning. It used +// to abort the whole scan instead, so one mistyped keyword in one comment was +// enough to produce nothing at all from an entire package graph. package unknown_annotation -// Bogus uses an unknown swagger annotation. +// Bogus uses an unknown swagger annotation, and is emitted regardless: the +// comment carrying it is treated as prose. // // swagger:doesnotexist BogusTag +// +// swagger:model Bogus type Bogus struct { ID int64 `json:"id"` } diff --git a/fixtures/goparsing/classification/models/nomodel.go b/fixtures/goparsing/classification/models/nomodel.go index 816c460c..0fdfe4f2 100644 --- a/fixtures/goparsing/classification/models/nomodel.go +++ b/fixtures/goparsing/classification/models/nomodel.go @@ -36,7 +36,12 @@ type NoModel struct { // default: 11 ID int64 `json:"id"` - Ignored string `json:"-"` + // Ignored is skipped entirely: the whole json tag is "-". + Ignored string `json:"-"` + + // IgnoredOther is NOT ignored, despite the name it was given here long ago: the + // trailing comma makes "-" the field's literal wire name. encoding/json compares + // the whole tag to "-", so `-,omitempty` names rather than skips. IgnoredOther string `json:"-,omitempty"` // A field which has omitempty set but no name diff --git a/fixtures/goparsing/classification/operations/noparams.go b/fixtures/goparsing/classification/operations/noparams.go index e703f30c..f1668781 100644 --- a/fixtures/goparsing/classification/operations/noparams.go +++ b/fixtures/goparsing/classification/operations/noparams.go @@ -29,6 +29,8 @@ type MyFileParams struct { // // in: formData // + // maximum: 3 + // // swagger:file MyFormFile *bytes.Buffer `json:"myFormFile"` } diff --git a/fixtures/integration/golden/enhancements_annotation_noise.json b/fixtures/integration/golden/enhancements_annotation_noise.json new file mode 100644 index 00000000..0b9725ec --- /dev/null +++ b/fixtures/integration/golden/enhancements_annotation_noise.json @@ -0,0 +1,85 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "EffectiveOnField": { + "description": "EffectiveOnField is the control: the same annotations on regular fields, where\nboth are honoured.", + "type": "object", + "properties": { + "fmt": { + "description": "Fmt takes the format.", + "type": "string", + "format": "uuid", + "x-go-name": "Fmt" + }, + "typ": { + "description": "Typ takes the type override.", + "type": "string", + "x-go-name": "Typ" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/annotation-noise" + }, + "IneffectiveOnAllOf": { + "title": "IneffectiveOnAllOf annotates an allOf embed with classifiers the arm ignores.", + "allOf": [ + { + "type": "object", + "properties": { + "left": { + "description": "Left is a plain property.", + "type": "string", + "x-go-name": "Left" + } + } + }, + { + "type": "object", + "properties": { + "note": { + "description": "Note is the composing struct's own field.", + "type": "string", + "x-go-name": "Note" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/annotation-noise" + }, + "IneffectiveOnPlain": { + "type": "object", + "title": "IneffectiveOnPlain annotates a PLAIN embed with the same classifiers.", + "properties": { + "left": { + "description": "Left is a plain property.", + "type": "string", + "x-go-name": "Left" + }, + "note": { + "description": "Note is the embedding struct's own field.", + "type": "string", + "x-go-name": "Note" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/annotation-noise" + }, + "Scalar": { + "type": "integer", + "format": "int64", + "title": "Scalar is a named basic used as a regular field, where the annotations DO work.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/annotation-noise" + }, + "Target": { + "type": "object", + "title": "Target is the embedded type. Its own declaration is where a format belongs.", + "properties": { + "left": { + "description": "Left is a plain property.", + "type": "string", + "x-go-name": "Left" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/annotation-noise" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_builder_conformance.json b/fixtures/integration/golden/enhancements_builder_conformance.json new file mode 100644 index 00000000..bc13b7e8 --- /dev/null +++ b/fixtures/integration/golden/enhancements_builder_conformance.json @@ -0,0 +1,904 @@ +{ + "swagger": "2.0", + "paths": { + "/anyv": { + "post": { + "tags": [ + "conf" + ], + "operationId": "confAnyV", + "parameters": [ + { + "x-go-name": "Body", + "name": "body", + "in": "body", + "schema": {} + } + ], + "responses": { + "200": { + "$ref": "#/responses/respAnyV" + } + } + } + }, + "/basic": { + "post": { + "tags": [ + "conf" + ], + "operationId": "confBasic", + "parameters": [ + { + "x-go-name": "Body", + "name": "body", + "in": "body", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/respBasic" + } + } + } + }, + "/bytes": { + "post": { + "tags": [ + "conf" + ], + "operationId": "confBytes", + "parameters": [ + { + "x-go-name": "Body", + "name": "body", + "in": "body", + "schema": { + "type": "string", + "format": "byte" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/respBytes" + } + } + } + }, + "/codes": { + "post": { + "tags": [ + "conf" + ], + "operationId": "confCodes", + "parameters": [ + { + "x-go-name": "Body", + "name": "body", + "in": "body", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "email" + } + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/respCodes" + } + } + } + }, + "/emails": { + "post": { + "tags": [ + "conf" + ], + "operationId": "confEmails", + "parameters": [ + { + "x-go-name": "Body", + "name": "body", + "in": "body", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "email" + } + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/respEmails" + } + } + } + }, + "/enum": { + "post": { + "tags": [ + "conf" + ], + "operationId": "confEnum", + "parameters": [ + { + "x-go-name": "Body", + "name": "body", + "in": "body", + "schema": { + "type": "integer", + "format": "uint64", + "enum": [ + 1, + 2 + ], + "x-go-enum-desc": "1 EnumLow is the low member.\n2 EnumHigh is the high member." + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/respEnum" + } + } + } + }, + "/err-al": { + "post": { + "tags": [ + "conf" + ], + "operationId": "confErrAl", + "responses": { + "200": { + "$ref": "#/responses/respErrAl" + } + } + } + }, + "/err-n": { + "post": { + "tags": [ + "conf" + ], + "operationId": "confErrN", + "responses": { + "200": { + "$ref": "#/responses/respErrN" + } + } + } + }, + "/fmt": { + "post": { + "tags": [ + "conf" + ], + "operationId": "confFmt", + "parameters": [ + { + "x-go-name": "Body", + "name": "body", + "in": "body", + "schema": { + "type": "string", + "format": "isbn" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/respFmt" + } + } + } + }, + "/fmt-al": { + "post": { + "tags": [ + "conf" + ], + "operationId": "confFmtAl", + "parameters": [ + { + "x-go-name": "Body", + "name": "body", + "in": "body", + "schema": { + "type": "string", + "format": "isbn" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/respFmtAl" + } + } + } + }, + "/iface": { + "post": { + "tags": [ + "conf" + ], + "operationId": "confIface", + "parameters": [ + { + "x-go-name": "Body", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/Speaker" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/respIface" + } + } + } + }, + "/inline": { + "post": { + "tags": [ + "conf" + ], + "operationId": "confInline", + "parameters": [ + { + "x-go-name": "Body", + "name": "body", + "in": "body", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { + "description": "Code is the inline element property.", + "type": "string", + "x-go-name": "Code" + } + } + } + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/respInline" + } + } + } + }, + "/mapping": { + "post": { + "tags": [ + "conf" + ], + "operationId": "confMapping", + "parameters": [ + { + "x-go-name": "Body", + "name": "body", + "in": "body", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Plain" + } + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/respMapping" + } + } + } + }, + "/ptr": { + "post": { + "tags": [ + "conf" + ], + "operationId": "confPtr", + "parameters": [ + { + "x-go-name": "Body", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/Plain" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/respPtr" + } + } + } + }, + "/raw": { + "post": { + "tags": [ + "conf" + ], + "operationId": "confRaw", + "parameters": [ + { + "x-go-name": "Body", + "name": "body", + "in": "body", + "schema": {} + } + ], + "responses": { + "200": { + "$ref": "#/responses/respRaw" + } + } + } + }, + "/raw-n": { + "post": { + "tags": [ + "conf" + ], + "operationId": "confRawN", + "parameters": [ + { + "x-go-name": "Body", + "name": "body", + "in": "body", + "schema": {} + } + ], + "responses": { + "200": { + "$ref": "#/responses/respRawN" + } + } + } + }, + "/stamp": { + "post": { + "tags": [ + "conf" + ], + "operationId": "confStamp", + "parameters": [ + { + "x-go-name": "Body", + "name": "body", + "in": "body", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/respStamp" + } + } + } + }, + "/stamp-n": { + "post": { + "tags": [ + "conf" + ], + "operationId": "confStampN", + "parameters": [ + { + "x-go-name": "Body", + "name": "body", + "in": "body", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/respStampN" + } + } + } + }, + "/struct": { + "post": { + "tags": [ + "conf" + ], + "operationId": "confStruct", + "parameters": [ + { + "x-go-name": "Body", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/Plain" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/respStruct" + } + } + } + }, + "/typ": { + "post": { + "tags": [ + "conf" + ], + "operationId": "confTyp", + "parameters": [ + { + "x-go-name": "Body", + "name": "body", + "in": "body", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/respTyp" + } + } + } + }, + "/typ-al": { + "post": { + "tags": [ + "conf" + ], + "operationId": "confTypAl", + "parameters": [ + { + "x-go-name": "Body", + "name": "body", + "in": "body", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/respTypAl" + } + } + } + } + }, + "definitions": { + "AllOfHost": { + "description": "A member of an allOf is a full schema describing one type, exactly as a model\nfield is, so the two must agree. It is reached by a different arm than any of\nthe other three positions — `buildNamedAllOf` rather than the field dispatch —\nand that arm consults its own subset of the classifiers.\n\nOne allOf rather than one host per subject, deliberately: members that resolve\nside by side also witness that no member's classifier leaks into its\nneighbours, which separate hosts could not show. The composing struct's own\nfield lands in a trailing member, so member i is subject i.\n\nSubjects absent here are the ones Go cannot embed under a usable name: a map, a\nslice of an inline struct, and the pointer/basic/predeclared arms, whose\nembedded field name would either collide with another member or be unexported.", + "title": "AllOfHost reaches every EMBEDDABLE subject as an allOf MEMBER, one member per\nsubject, in the same order ModelHost declares them.", + "allOf": [ + { + "type": "string", + "format": "isbn" + }, + { + "type": "string", + "format": "isbn" + }, + { + "type": "string" + }, + { + "type": "string" + }, + { + "type": "integer", + "format": "uint64", + "enum": [ + 1, + 2 + ], + "x-go-enum-desc": "1 EnumLow is the low member.\n2 EnumHigh is the high member." + }, + { + "type": "string", + "format": "byte" + }, + { + "type": "string", + "format": "date-time" + }, + {}, + { + "type": "object", + "properties": { + "left": { + "description": "Left is a plain property.", + "type": "string", + "x-go-name": "Left" + } + } + }, + { + "type": "object", + "properties": { + "say": { + "description": "Say returns a word.", + "type": "string", + "x-go-name": "Say" + } + } + }, + { + "type": "array", + "items": { + "type": "string", + "format": "email" + } + }, + { + "type": "array", + "items": { + "type": "string", + "format": "email" + } + }, + { + "type": "string", + "format": "date-time" + }, + {}, + { + "type": "string", + "x-go-type": "error" + }, + { + "type": "object", + "properties": { + "note": { + "description": "Note is the composing struct's own field, which lands in the trailing member.", + "type": "string", + "x-go-name": "Note" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/builder-conformance" + }, + "ModelHost": { + "description": "and the control for the other two.", + "type": "object", + "title": "ModelHost reaches every subject as a MODEL FIELD — the schema builder's view,", + "properties": { + "anyv": { + "description": "AnyV is the predeclared any.", + "x-go-name": "AnyV" + }, + "basic": { + "description": "Basic is the plain-basic arm.", + "type": "integer", + "format": "int32", + "x-go-name": "Basic" + }, + "bytes": { + "description": "Bytes is the byte-sequence subject.", + "type": "string", + "format": "byte", + "x-go-name": "Bytes" + }, + "codes": { + "description": "Codes is the array flavour of the same.", + "type": "array", + "items": { + "type": "string", + "format": "email" + }, + "x-go-name": "Codes" + }, + "emails": { + "description": "Emails is the pinned slice+non-special-format divergence.", + "type": "array", + "items": { + "type": "string", + "format": "email" + }, + "x-go-name": "Emails" + }, + "enum": { + "description": "Enum is the enum subject.\n1 EnumLow is the low member.\n2 EnumHigh is the high member.", + "type": "integer", + "format": "uint64", + "enum": [ + 1, + 2 + ], + "x-go-enum-desc": "1 EnumLow is the low member.\n2 EnumHigh is the high member.", + "x-go-name": "Enum" + }, + "errAl": { + "description": "ErrAl names the same through an alias.", + "type": "string", + "x-go-name": "ErrAl", + "x-go-type": "error" + }, + "errN": { + "description": "ErrN is the predeclared error — no package, no declaration.", + "type": "string", + "x-go-name": "ErrN", + "x-go-type": "error" + }, + "fmt": { + "description": "Fmt is the named-format subject.", + "type": "string", + "format": "isbn", + "x-go-name": "Fmt" + }, + "fmtAl": { + "description": "FmtAl is the alias-format subject.", + "type": "string", + "format": "isbn", + "x-go-name": "FmtAl" + }, + "iface": { + "$ref": "#/definitions/Speaker" + }, + "inline": { + "description": "Inline is the slice arm with an inline element.", + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { + "description": "Code is the inline element property.", + "type": "string", + "x-go-name": "Code" + } + } + }, + "x-go-name": "Inline" + }, + "mapping": { + "description": "Mapping is the map-arm subject.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Plain" + }, + "x-go-name": "Mapping" + }, + "ptr": { + "$ref": "#/definitions/Plain" + }, + "raw": { + "description": "Raw is the open-schema subject.", + "x-go-name": "Raw" + }, + "rawN": { + "description": "RawN is the open-schema stdlib type reached as the named type.", + "x-go-name": "RawN" + }, + "stamp": { + "description": "Stamp is the stdlib-alias subject.", + "type": "string", + "format": "date-time", + "x-go-name": "Stamp" + }, + "stampN": { + "description": "StampN is the stdlib time reached as the named type.", + "type": "string", + "format": "date-time", + "x-go-name": "StampN" + }, + "struct": { + "$ref": "#/definitions/Plain" + }, + "typ": { + "description": "Typ is the named-override subject.", + "type": "string", + "x-go-name": "Typ" + }, + "typAl": { + "description": "TypAl is the alias-override subject.", + "type": "string", + "x-go-name": "TypAl" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/builder-conformance" + }, + "Plain": { + "type": "object", + "title": "Plain is a struct reached directly as a field.", + "properties": { + "left": { + "description": "Left is a plain property.", + "type": "string", + "x-go-name": "Left" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/builder-conformance" + }, + "Speaker": { + "type": "object", + "title": "Speaker is a non-empty interface reached directly as a field.", + "properties": { + "say": { + "description": "Say returns a word.", + "type": "string", + "x-go-name": "Say" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/builder-conformance" + } + }, + "responses": { + "respAnyV": { + "description": "RespAnyV reaches the predeclared any as a response body.", + "schema": {} + }, + "respBasic": { + "description": "RespBasic reaches the basic subject as a response body.", + "schema": { + "type": "integer", + "format": "int32" + } + }, + "respBytes": { + "description": "RespBytes reaches BytesNamed as a response body.", + "schema": { + "type": "string", + "format": "byte" + } + }, + "respCodes": { + "description": "RespCodes reaches the codes subject as a response body.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "email" + } + } + }, + "respEmails": { + "description": "RespEmails reaches the emails subject as a response body.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "email" + } + } + }, + "respEnum": { + "description": "RespEnum reaches EnumNamed as a response body.", + "schema": { + "type": "integer", + "format": "uint64" + } + }, + "respErrAl": { + "description": "RespErrAl reaches the aliased error as a response body.", + "schema": { + "type": "string", + "x-go-type": "error" + } + }, + "respErrN": { + "description": "RespErrN reaches the predeclared error as a response body.", + "schema": { + "type": "string", + "x-go-type": "error" + } + }, + "respFmt": { + "description": "RespFmt reaches FmtNamed as a response body.", + "schema": { + "type": "string", + "format": "isbn" + } + }, + "respFmtAl": { + "description": "RespFmtAl reaches FmtAlias as a response body.", + "schema": { + "type": "string", + "format": "isbn" + } + }, + "respIface": { + "description": "RespIface reaches the iface subject as a response body.", + "schema": { + "$ref": "#/definitions/Speaker" + } + }, + "respInline": { + "description": "RespInline reaches the inline-element slice as a response body.", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { + "description": "Code is the inline element property.", + "type": "string", + "x-go-name": "Code" + } + } + } + } + }, + "respMapping": { + "description": "RespMapping reaches the mapping subject as a response body.", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Plain" + } + } + }, + "respPtr": { + "description": "RespPtr reaches the ptr subject as a response body.", + "schema": { + "$ref": "#/definitions/Plain" + } + }, + "respRaw": { + "description": "RespRaw reaches RawAlias as a response body.", + "schema": {} + }, + "respRawN": { + "description": "RespRawN reaches the named open-schema type as a response body.", + "schema": {} + }, + "respStamp": { + "description": "RespStamp reaches StampAlias as a response body.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + "respStampN": { + "description": "RespStampN reaches the named stdlib time as a response body.", + "schema": { + "type": "string", + "format": "date-time" + } + }, + "respStruct": { + "description": "RespStruct reaches the struct subject as a response body.", + "schema": { + "$ref": "#/definitions/Plain" + } + }, + "respTyp": { + "description": "RespTyp reaches TypeNamed as a response body.", + "schema": { + "type": "string" + } + }, + "respTypAl": { + "description": "RespTypAl reaches TypeAlias as a response body.", + "schema": { + "type": "string" + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_default_example_typing.json b/fixtures/integration/golden/enhancements_default_example_typing.json new file mode 100644 index 00000000..ed4606db --- /dev/null +++ b/fixtures/integration/golden/enhancements_default_example_typing.json @@ -0,0 +1,263 @@ +{ + "swagger": "2.0", + "paths": { + "/typing": { + "get": { + "tags": [ + "typing" + ], + "operationId": "typingOp", + "parameters": [ + { + "type": "integer", + "format": "int64", + "default": 8080, + "example": 9090, + "x-go-name": "QueryPort", + "description": "QueryPort is a non-body parameter: SimpleSchema, no $ref allowed.", + "name": "queryPort", + "in": "query" + }, + { + "type": "boolean", + "default": false, + "x-go-name": "QueryFlag", + "description": "QueryFlag is a non-body boolean parameter.", + "name": "queryFlag", + "in": "query" + }, + { + "x-go-name": "Body", + "description": "Body is a body parameter; its fields are full-schema properties.", + "name": "body", + "in": "body", + "schema": { + "type": "object", + "properties": { + "retries": { + "description": "Retries is a body-schema property.", + "type": "integer", + "format": "int64", + "default": 3, + "x-go-name": "Retries", + "example": 5 + } + } + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/typingResponse" + }, + "default": { + "$ref": "#/responses/errorResponse" + } + } + } + } + }, + "definitions": { + "DeclBool": { + "type": "boolean", + "title": "DeclBool carries a boolean value on the declaration.", + "default": false, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/default-example-typing", + "example": true + }, + "DeclEnumInt": { + "description": "DeclEnumInt carries an enum alongside a default on the declaration, so the\nenum members and the default can be compared for consistent typing.", + "type": "integer", + "format": "int64", + "default": 2, + "enum": [ + 1, + 2, + 3 + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/default-example-typing" + }, + "DeclInt": { + "type": "integer", + "format": "int64", + "title": "DeclInt carries an integer value on the declaration.", + "default": 8080, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/default-example-typing", + "example": 9090 + }, + "DeclIntSlice": { + "type": "array", + "title": "DeclIntSlice carries a JSON array on the declaration.", + "default": [ + 1, + 2, + 3 + ], + "items": { + "type": "integer", + "format": "int64" + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/default-example-typing", + "example": [ + 4, + 5 + ] + }, + "DeclNumber": { + "type": "number", + "format": "double", + "title": "DeclNumber carries a floating-point value on the declaration.", + "default": 1.5, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/default-example-typing", + "example": 2.5 + }, + "DeclString": { + "description": "DeclString carries a string value on the declaration — the one case where a\nstring fallback is indistinguishable from a correct coercion, so it is the\ncontrol for the controls.", + "type": "string", + "default": "auto", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/default-example-typing", + "example": "manual" + }, + "DeclUncoercible": { + "description": "The enum is partially bad: 1 and 3 survive, \"two\" is dropped. That narrows a\nclosed set, which is a real change to the author's contract, so the warning\nnames the member.", + "type": "integer", + "format": "int64", + "title": "DeclUncoercible carries values that cannot be read as the declared type. Each\nmust be DROPPED with a warning rather than emitted at the wrong type — a\ndocument carrying `\"notanumber\"` on an integer schema is one no validator\naccepts, whereas a document missing a default is merely incomplete.", + "enum": [ + 1, + 3 + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/default-example-typing" + }, + "FieldControls": { + "description": "FieldControls carries the identical literals at FIELD sites, where the Go type\nis already resolved when the keyword walk runs. Every property here is the\ncontrol for the like-named declaration above.", + "type": "object", + "properties": { + "flag": { + "description": "Flag is the boolean control.", + "type": "boolean", + "default": false, + "x-go-name": "Flag", + "example": true + }, + "grade": { + "description": "Grade is the enum control.", + "type": "integer", + "format": "int64", + "default": 2, + "enum": [ + 1, + 2, + 3 + ], + "x-go-name": "Grade" + }, + "mode": { + "description": "Mode is the string control.", + "type": "string", + "default": "auto", + "x-go-name": "Mode", + "example": "manual" + }, + "numbers": { + "description": "Numbers is the JSON-array control.", + "type": "array", + "default": [ + 1, + 2, + 3 + ], + "items": { + "type": "integer", + "format": "int64" + }, + "x-go-name": "Numbers", + "example": [ + 4, + 5 + ] + }, + "port": { + "description": "Port is the integer control.", + "type": "integer", + "format": "int64", + "default": 8080, + "x-go-name": "Port", + "example": 9090 + }, + "ratio": { + "description": "Ratio is the floating-point control.", + "type": "number", + "format": "double", + "default": 1.5, + "x-go-name": "Ratio", + "example": 2.5 + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/default-example-typing" + }, + "FieldUncoercible": { + "description": "FieldUncoercible is the field-site counterpart. It always dropped the value —\nbut silently, which is the half of the defect that was invisible.", + "type": "object", + "properties": { + "grade": { + "description": "Grade has a partially uncoercible enum.", + "type": "integer", + "format": "int64", + "enum": [ + 1, + 3 + ], + "x-go-name": "Grade" + }, + "port": { + "description": "Port has an uncoercible default and example.", + "type": "integer", + "format": "int64", + "x-go-name": "Port" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/default-example-typing" + } + }, + "responses": { + "errorResponse": { + "description": "ErrorResponse is the operation's default response — the OTHER sense of\n\"default\", carried by a response code rather than a value.", + "schema": { + "type": "object", + "properties": { + "message": { + "description": "Message describes the failure.", + "type": "string", + "x-go-name": "Message" + } + } + } + }, + "typingResponse": { + "description": "TypingResponse carries the same literals on response HEADERS, which are\nSimpleSchema locations, and on a body property.\n\nNote there is no `default:` on the response block itself — that keyword is not\nlegal in a response context, and a default RESPONSE is expressed by the route\nbelow instead.", + "schema": { + "type": "object", + "properties": { + "retries": { + "description": "Retries is a response-body property.", + "type": "integer", + "format": "int64", + "default": 3, + "x-go-name": "Retries", + "example": 5 + } + } + }, + "headers": { + "X-Rate-Limit": { + "type": "integer", + "format": "int64", + "default": 60, + "example": 120, + "description": "XRateLimit is a response header: SimpleSchema." + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_embed_override.json b/fixtures/integration/golden/enhancements_embed_override.json index edc845c9..c802a8dd 100644 --- a/fixtures/integration/golden/enhancements_embed_override.json +++ b/fixtures/integration/golden/enhancements_embed_override.json @@ -37,9 +37,12 @@ "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/default-allof-embeds-override" }, "Muted": { + "description": "Muted re-declares a promoted field with `json:\"-\"`, INTENDING to drop it from\nthe wire — and fails to, because Go ignores such a field rather than letting it\nshadow. Created stays on the wire and in the schema; the scan raises\n`scan.shadowed-embed-field` pointing at swagger:omit.", "type": "object", - "title": "Muted re-declares a promoted field to drop it from the wire.", "properties": { + "Created": { + "type": "string" + }, "ID": { "type": "integer", "format": "int64" diff --git a/fixtures/integration/golden/enhancements_embed_override_allof.json b/fixtures/integration/golden/enhancements_embed_override_allof.json index 69e446c4..4c43ad25 100644 --- a/fixtures/integration/golden/enhancements_embed_override_allof.json +++ b/fixtures/integration/golden/enhancements_embed_override_allof.json @@ -50,7 +50,7 @@ "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/default-allof-embeds-override" }, "Muted": { - "title": "Muted re-declares a promoted field to drop it from the wire.", + "description": "Muted re-declares a promoted field with `json:\"-\"`, INTENDING to drop it from\nthe wire — and fails to, because Go ignores such a field rather than letting it\nshadow. Created stays on the wire and in the schema; the scan raises\n`scan.shadowed-embed-field` pointing at swagger:omit.", "allOf": [ { "type": "object", diff --git a/fixtures/integration/golden/enhancements_json_tag_fidelity.json b/fixtures/integration/golden/enhancements_json_tag_fidelity.json new file mode 100644 index 00000000..cbb5f54f --- /dev/null +++ b/fixtures/integration/golden/enhancements_json_tag_fidelity.json @@ -0,0 +1,127 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Base": { + "type": "object", + "title": "Base is the shared embedded type. Every promoted name below comes from here.", + "properties": { + "age": { + "description": "Age is the field the outer structs re-declare.", + "type": "integer", + "format": "int32", + "x-go-name": "Age" + }, + "id": { + "description": "ID is a plain promoted field.", + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "name": { + "description": "Name is a plain promoted field.", + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/json-tag-fidelity" + }, + "DashName": { + "description": "DashName uses the `json:\"-,\"` escape, which names the field literally `-`\nrather than ignoring it. The trailing comma is the whole difference.", + "type": "object", + "properties": { + "-": { + "description": "Weird is emitted under the name \"-\".", + "type": "string", + "x-go-name": "Weird" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/json-tag-fidelity" + }, + "DashNameOmitEmpty": { + "description": "DashNameOmitEmpty is the `-,omitempty` variant, which the historic corpus\nalready contains (classification/models/nomodel.go). Non-zero here, so the key\nis present on the wire.", + "type": "object", + "properties": { + "-": { + "description": "Weird is emitted under the name \"-\" when non-empty.", + "type": "string", + "x-go-name": "Weird" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/json-tag-fidelity" + }, + "EmbedIgnored": { + "description": "EmbedIgnored tags the EMBED itself `json:\"-\"`, which does drop the whole embed\n— the control showing that `-` on an embed and `-` on a re-declaration are\ndifferent acts.", + "type": "object", + "properties": { + "extra": { + "description": "Extra is the only field that survives.", + "type": "string", + "x-go-name": "Extra" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/json-tag-fidelity" + }, + "IgnoreShadow": { + "description": "encoding/json ignores a `-` field ENTIRELY: it never enters the name set, so it\ndoes not shadow the promoted `age`, which Go still marshals. An author writing\nthis usually means \"drop it\" — `swagger:omit` on the embed is the honest way to\nsay that, and the scan raises a Hint pointing there.", + "type": "object", + "title": "IgnoreShadow re-declares a promoted field with `json:\"-\"`.", + "properties": { + "age": { + "description": "Age is the field the outer structs re-declare.", + "type": "integer", + "format": "int32", + "x-go-name": "Age" + }, + "id": { + "description": "ID is a plain promoted field.", + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "name": { + "description": "Name is a plain promoted field.", + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/json-tag-fidelity" + }, + "PlainIgnore": { + "description": "PlainIgnore carries `json:\"-\"` with nothing to shadow — the control for the\ncommon case, where dropping the property is correct.", + "type": "object", + "properties": { + "keep": { + "description": "Keep stays on the wire.", + "type": "string", + "x-go-name": "Keep" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/json-tag-fidelity" + }, + "RenameShadow": { + "description": "Here Go's depth rule DOES apply and the outer declaration wins.", + "type": "object", + "title": "RenameShadow re-declares a promoted field under a real name — the control.", + "properties": { + "age": { + "type": "integer", + "format": "int32", + "x-go-name": "Age" + }, + "id": { + "description": "ID is a plain promoted field.", + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "name": { + "description": "Name is a plain promoted field.", + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/json-tag-fidelity" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_named_basic.json b/fixtures/integration/golden/enhancements_named_basic.json index abe51bf0..ea1de969 100644 --- a/fixtures/integration/golden/enhancements_named_basic.json +++ b/fixtures/integration/golden/enhancements_named_basic.json @@ -2,6 +2,13 @@ "swagger": "2.0", "paths": {}, "definitions": { + "Grade": { + "description": "It used to claim the target without writing it, publishing a typeless\nschema for the declared type and a typeless property for every field\nreferencing it.", + "type": "integer", + "format": "int64", + "title": "Grade is a named int tagged with the DEPRECATED swagger:default\nannotation. The annotation is an inert sink: Grade must emit exactly\nwhat it would without it — a plain named int, referenced by $ref from\nthe field site — and the scan must raise a deprecation diagnostic.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/named-basic" + }, "User": { "description": "User embeds the three named basic types above so that the full scan\nwalks buildNamedBasic for each field.", "type": "object", @@ -19,7 +26,7 @@ "x-go-name": "Email" }, "grade": { - "x-go-name": "Grade" + "$ref": "#/definitions/Grade" }, "id": { "type": "integer", diff --git a/fixtures/integration/golden/enhancements_response_named_nonstruct.json b/fixtures/integration/golden/enhancements_response_named_nonstruct.json new file mode 100644 index 00000000..f69099b8 --- /dev/null +++ b/fixtures/integration/golden/enhancements_response_named_nonstruct.json @@ -0,0 +1,131 @@ +{ + "swagger": "2.0", + "paths": { + "/code": { + "get": { + "tags": [ + "resp" + ], + "operationId": "opCode", + "responses": { + "200": { + "$ref": "#/responses/codeResp" + } + } + } + }, + "/count": { + "get": { + "tags": [ + "resp" + ], + "operationId": "opCount", + "responses": { + "200": { + "$ref": "#/responses/countResp" + } + } + } + }, + "/emails": { + "get": { + "tags": [ + "resp" + ], + "operationId": "opEmails", + "responses": { + "200": { + "$ref": "#/responses/emailsResp" + } + } + } + }, + "/stamp": { + "get": { + "tags": [ + "resp" + ], + "operationId": "opStamp", + "responses": { + "200": { + "$ref": "#/responses/stampResp" + } + } + } + } + }, + "definitions": { + "Count": { + "description": "Count is a named integer with no annotation at all — the control that isolates\n\"did the schema get attached\" from \"was it built correctly\".", + "type": "integer", + "format": "int64", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/response-named-nonstruct" + }, + "Host": { + "type": "object", + "title": "Host reaches every subject as a MODEL FIELD, the control for the response side.", + "properties": { + "code": { + "description": "Code is the whole-schema-format subject.", + "type": "string", + "format": "isbn", + "x-go-name": "Code" + }, + "count": { + "$ref": "#/definitions/Count" + }, + "emails": { + "description": "Emails is the element-driven-format subject.", + "type": "array", + "items": { + "type": "string", + "format": "email" + }, + "x-go-name": "Emails" + }, + "stamp": { + "$ref": "#/definitions/Stamp" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/response-named-nonstruct" + }, + "Stamp": { + "type": "string", + "format": "date-time", + "title": "Stamp is a named time.Time — the stdlib recognizer's subject.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/response-named-nonstruct" + } + }, + "responses": { + "codeResp": { + "description": "CodeResp declares the response on the formatted string.", + "schema": { + "type": "string", + "format": "isbn" + } + }, + "countResp": { + "description": "CountResp declares the response on the unannotated control.", + "schema": { + "type": "integer", + "format": "int64" + } + }, + "emailsResp": { + "description": "EmailsResp declares the response on the formatted slice.", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "email" + } + } + }, + "stampResp": { + "description": "StampResp declares the response on the named time.Time.", + "schema": { + "$ref": "#/definitions/Stamp" + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_route_name_shapes.json b/fixtures/integration/golden/enhancements_route_name_shapes.json new file mode 100644 index 00000000..8ed24673 --- /dev/null +++ b/fixtures/integration/golden/enhancements_route_name_shapes.json @@ -0,0 +1,76 @@ +{ + "swagger": "2.0", + "paths": { + "/short-among": { + "get": { + "tags": [ + "a", + "shapes" + ], + "operationId": "listAmong", + "responses": { + "200": { + "$ref": "#/responses/emptyResp" + } + } + } + }, + "/short-both": { + "get": { + "tags": [ + "e" + ], + "operationId": "l", + "responses": { + "200": { + "$ref": "#/responses/emptyResp" + } + } + } + }, + "/short-id": { + "get": { + "tags": [ + "shapes" + ], + "operationId": "l", + "responses": { + "200": { + "$ref": "#/responses/emptyResp" + } + } + } + }, + "/short-id-no-tags": { + "get": { + "operationId": "q", + "responses": { + "200": { + "$ref": "#/responses/emptyResp" + } + } + } + }, + "/short-tag": { + "get": { + "tags": [ + "e" + ], + "operationId": "listOne", + "responses": { + "200": { + "$ref": "#/responses/emptyResp" + } + } + } + } + }, + "responses": { + "emptyResp": { + "description": "EmptyResp is the shared response body.", + "schema": { + "type": "string" + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/enhancements_strfmt_arrays.json b/fixtures/integration/golden/enhancements_strfmt_arrays.json index 6be16224..ec6a2974 100644 --- a/fixtures/integration/golden/enhancements_strfmt_arrays.json +++ b/fixtures/integration/golden/enhancements_strfmt_arrays.json @@ -25,11 +25,8 @@ "x-go-name": "ObjectID" }, "signature": { - "type": "array", - "items": { - "type": "string", - "format": "password" - }, + "type": "string", + "format": "password", "x-go-name": "Signature" }, "token": { diff --git a/fixtures/integration/golden/enhancements_swagger_omit.json b/fixtures/integration/golden/enhancements_swagger_omit.json index 3b316890..59699a1e 100644 --- a/fixtures/integration/golden/enhancements_swagger_omit.json +++ b/fixtures/integration/golden/enhancements_swagger_omit.json @@ -162,6 +162,9 @@ "description": "Shadowed re-declares a promoted field with `json:\"-\"`, which does NOT hide it in Go — the Hint\npoints at swagger:omit.", "type": "object", "properties": { + "Created": { + "type": "string" + }, "ID": { "type": "integer", "format": "int64" diff --git a/fixtures/integration/golden/strfmt_decl_arraylike_default.json b/fixtures/integration/golden/strfmt_decl_arraylike_default.json new file mode 100644 index 00000000..d73576a3 --- /dev/null +++ b/fixtures/integration/golden/strfmt_decl_arraylike_default.json @@ -0,0 +1,79 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "EmailsAliasModeled": { + "type": "array", + "title": "EmailsAliasModeled is the alias half of the element-format pair.", + "items": { + "type": "string", + "format": "email" + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "EmailsNamedModeled": { + "type": "array", + "title": "EmailsNamedModeled is a string slice whose format describes each ELEMENT.", + "items": { + "type": "string", + "format": "email" + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "Envelope": { + "description": "Envelope reaches the non-model pairs from a field site, where the inline\nclassifier still runs.", + "type": "object", + "properties": { + "fieldIdAlias": { + "description": "FieldIDAlias is the whole-value format, alias half, at a field site.", + "type": "string", + "format": "uuid", + "x-go-name": "FieldIDAlias" + }, + "fieldIdNamed": { + "description": "FieldIDNamed is the whole-value format, named half, at a field site.", + "type": "string", + "format": "uuid", + "x-go-name": "FieldIDNamed" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "IDAliasModeled": { + "type": "string", + "format": "uuid", + "title": "IDAliasModeled is the alias half of the same pair.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "IDNamedModeled": { + "type": "string", + "format": "uuid", + "title": "IDNamedModeled is a fixed byte array that IS a uuid, published as a model.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "RunesAliasModeled": { + "type": "string", + "format": "password", + "title": "RunesAliasModeled is the alias half of the rune pair.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "RunesNamedModeled": { + "description": "RunesNamedModeled is a rune sequence — string-like for the same reason a byte\nsequence is, so the format describes the whole value.", + "type": "string", + "format": "password", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "ULIDAliasModeled": { + "type": "string", + "format": "ulid", + "title": "ULIDAliasModeled is the alias half of the ULID pair.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "ULIDNamedModeled": { + "description": "ULIDNamedModeled shows the rule generalising to a strfmt type that never had\nan entry in the old allowlist.", + "type": "string", + "format": "ulid", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/strfmt_decl_arraylike_refaliases.json b/fixtures/integration/golden/strfmt_decl_arraylike_refaliases.json new file mode 100644 index 00000000..d73576a3 --- /dev/null +++ b/fixtures/integration/golden/strfmt_decl_arraylike_refaliases.json @@ -0,0 +1,79 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "EmailsAliasModeled": { + "type": "array", + "title": "EmailsAliasModeled is the alias half of the element-format pair.", + "items": { + "type": "string", + "format": "email" + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "EmailsNamedModeled": { + "type": "array", + "title": "EmailsNamedModeled is a string slice whose format describes each ELEMENT.", + "items": { + "type": "string", + "format": "email" + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "Envelope": { + "description": "Envelope reaches the non-model pairs from a field site, where the inline\nclassifier still runs.", + "type": "object", + "properties": { + "fieldIdAlias": { + "description": "FieldIDAlias is the whole-value format, alias half, at a field site.", + "type": "string", + "format": "uuid", + "x-go-name": "FieldIDAlias" + }, + "fieldIdNamed": { + "description": "FieldIDNamed is the whole-value format, named half, at a field site.", + "type": "string", + "format": "uuid", + "x-go-name": "FieldIDNamed" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "IDAliasModeled": { + "type": "string", + "format": "uuid", + "title": "IDAliasModeled is the alias half of the same pair.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "IDNamedModeled": { + "type": "string", + "format": "uuid", + "title": "IDNamedModeled is a fixed byte array that IS a uuid, published as a model.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "RunesAliasModeled": { + "type": "string", + "format": "password", + "title": "RunesAliasModeled is the alias half of the rune pair.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "RunesNamedModeled": { + "description": "RunesNamedModeled is a rune sequence — string-like for the same reason a byte\nsequence is, so the format describes the whole value.", + "type": "string", + "format": "password", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "ULIDAliasModeled": { + "type": "string", + "format": "ulid", + "title": "ULIDAliasModeled is the alias half of the ULID pair.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "ULIDNamedModeled": { + "description": "ULIDNamedModeled shows the rule generalising to a strfmt type that never had\nan entry in the old allowlist.", + "type": "string", + "format": "ulid", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/strfmt_decl_arraylike_transparentaliases.json b/fixtures/integration/golden/strfmt_decl_arraylike_transparentaliases.json new file mode 100644 index 00000000..d73576a3 --- /dev/null +++ b/fixtures/integration/golden/strfmt_decl_arraylike_transparentaliases.json @@ -0,0 +1,79 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "EmailsAliasModeled": { + "type": "array", + "title": "EmailsAliasModeled is the alias half of the element-format pair.", + "items": { + "type": "string", + "format": "email" + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "EmailsNamedModeled": { + "type": "array", + "title": "EmailsNamedModeled is a string slice whose format describes each ELEMENT.", + "items": { + "type": "string", + "format": "email" + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "Envelope": { + "description": "Envelope reaches the non-model pairs from a field site, where the inline\nclassifier still runs.", + "type": "object", + "properties": { + "fieldIdAlias": { + "description": "FieldIDAlias is the whole-value format, alias half, at a field site.", + "type": "string", + "format": "uuid", + "x-go-name": "FieldIDAlias" + }, + "fieldIdNamed": { + "description": "FieldIDNamed is the whole-value format, named half, at a field site.", + "type": "string", + "format": "uuid", + "x-go-name": "FieldIDNamed" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "IDAliasModeled": { + "type": "string", + "format": "uuid", + "title": "IDAliasModeled is the alias half of the same pair.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "IDNamedModeled": { + "type": "string", + "format": "uuid", + "title": "IDNamedModeled is a fixed byte array that IS a uuid, published as a model.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "RunesAliasModeled": { + "type": "string", + "format": "password", + "title": "RunesAliasModeled is the alias half of the rune pair.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "RunesNamedModeled": { + "description": "RunesNamedModeled is a rune sequence — string-like for the same reason a byte\nsequence is, so the format describes the whole value.", + "type": "string", + "format": "password", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "ULIDAliasModeled": { + "type": "string", + "format": "ulid", + "title": "ULIDAliasModeled is the alias half of the ULID pair.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + }, + "ULIDNamedModeled": { + "description": "ULIDNamedModeled shows the rule generalising to a strfmt type that never had\nan entry in the old allowlist.", + "type": "string", + "format": "ulid", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-decl-arraylike" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/strfmt_symmetry_composition_default.json b/fixtures/integration/golden/strfmt_symmetry_composition_default.json new file mode 100644 index 00000000..e606c16c --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_composition_default.json @@ -0,0 +1,180 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "AllOfBasicAlias": { + "title": "AllOfBasicAlias composes the basic pair's alias half as an allOf member.", + "allOf": [ + { + "type": "string", + "format": "isbn" + }, + { + "type": "object", + "properties": { + "note": { + "description": "Note is the composing struct's own field.", + "type": "string", + "x-go-name": "Note" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "AllOfBasicNamed": { + "title": "AllOfBasicNamed composes the basic pair's named half as an allOf member.", + "allOf": [ + { + "type": "string", + "format": "isbn" + }, + { + "type": "object", + "properties": { + "note": { + "description": "Note is the composing struct's own field.", + "type": "string", + "x-go-name": "Note" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "AllOfStructAlias": { + "title": "AllOfStructAlias composes the struct pair's alias half as an allOf member.", + "allOf": [ + { + "type": "string", + "format": "duration" + }, + { + "type": "object", + "properties": { + "note": { + "description": "Note is the composing struct's own field.", + "type": "string", + "x-go-name": "Note" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "AllOfStructNamed": { + "title": "AllOfStructNamed composes the struct pair's named half as an allOf member.", + "allOf": [ + { + "type": "string", + "format": "duration" + }, + { + "type": "object", + "properties": { + "note": { + "description": "Note is the composing struct's own field.", + "type": "string", + "x-go-name": "Note" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "EmbedBasicAlias": { + "type": "object", + "title": "EmbedBasicAlias plainly embeds the basic pair's alias half.", + "properties": { + "label": { + "description": "Label is the embedding struct's own field.", + "type": "string", + "x-go-name": "Label" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "EmbedBasicNamed": { + "type": "object", + "title": "EmbedBasicNamed plainly embeds the basic pair's named half.", + "properties": { + "label": { + "description": "Label is the embedding struct's own field.", + "type": "string", + "x-go-name": "Label" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "EmbedStructAlias": { + "type": "object", + "title": "EmbedStructAlias plainly embeds the struct pair's alias half.", + "properties": { + "label": { + "description": "Label is the embedding struct's own field.", + "type": "string", + "x-go-name": "Label" + }, + "left": { + "description": "Left is a plain field.", + "type": "string", + "x-go-name": "Left" + }, + "right": { + "description": "Right is a plain field.", + "type": "integer", + "format": "int32", + "x-go-name": "Right" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "EmbedStructNamed": { + "type": "object", + "title": "EmbedStructNamed plainly embeds the struct pair's named half.", + "properties": { + "label": { + "description": "Label is the embedding struct's own field.", + "type": "string", + "x-go-name": "Label" + }, + "left": { + "description": "Left is a plain field.", + "type": "string", + "x-go-name": "Left" + }, + "right": { + "description": "Right is a plain field.", + "type": "integer", + "format": "int32", + "x-go-name": "Right" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "FmtStructNamed": { + "type": "string", + "format": "duration", + "title": "FmtStructNamed is a named type over a struct, carrying a format.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "PlainTarget": { + "type": "object", + "title": "PlainTarget is the unannotated struct behind the struct-kind pair.", + "properties": { + "left": { + "description": "Left is a plain field.", + "type": "string", + "x-go-name": "Left" + }, + "right": { + "description": "Right is a plain field.", + "type": "integer", + "format": "int32", + "x-go-name": "Right" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/strfmt_symmetry_composition_refaliases.json b/fixtures/integration/golden/strfmt_symmetry_composition_refaliases.json new file mode 100644 index 00000000..e606c16c --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_composition_refaliases.json @@ -0,0 +1,180 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "AllOfBasicAlias": { + "title": "AllOfBasicAlias composes the basic pair's alias half as an allOf member.", + "allOf": [ + { + "type": "string", + "format": "isbn" + }, + { + "type": "object", + "properties": { + "note": { + "description": "Note is the composing struct's own field.", + "type": "string", + "x-go-name": "Note" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "AllOfBasicNamed": { + "title": "AllOfBasicNamed composes the basic pair's named half as an allOf member.", + "allOf": [ + { + "type": "string", + "format": "isbn" + }, + { + "type": "object", + "properties": { + "note": { + "description": "Note is the composing struct's own field.", + "type": "string", + "x-go-name": "Note" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "AllOfStructAlias": { + "title": "AllOfStructAlias composes the struct pair's alias half as an allOf member.", + "allOf": [ + { + "type": "string", + "format": "duration" + }, + { + "type": "object", + "properties": { + "note": { + "description": "Note is the composing struct's own field.", + "type": "string", + "x-go-name": "Note" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "AllOfStructNamed": { + "title": "AllOfStructNamed composes the struct pair's named half as an allOf member.", + "allOf": [ + { + "type": "string", + "format": "duration" + }, + { + "type": "object", + "properties": { + "note": { + "description": "Note is the composing struct's own field.", + "type": "string", + "x-go-name": "Note" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "EmbedBasicAlias": { + "type": "object", + "title": "EmbedBasicAlias plainly embeds the basic pair's alias half.", + "properties": { + "label": { + "description": "Label is the embedding struct's own field.", + "type": "string", + "x-go-name": "Label" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "EmbedBasicNamed": { + "type": "object", + "title": "EmbedBasicNamed plainly embeds the basic pair's named half.", + "properties": { + "label": { + "description": "Label is the embedding struct's own field.", + "type": "string", + "x-go-name": "Label" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "EmbedStructAlias": { + "type": "object", + "title": "EmbedStructAlias plainly embeds the struct pair's alias half.", + "properties": { + "label": { + "description": "Label is the embedding struct's own field.", + "type": "string", + "x-go-name": "Label" + }, + "left": { + "description": "Left is a plain field.", + "type": "string", + "x-go-name": "Left" + }, + "right": { + "description": "Right is a plain field.", + "type": "integer", + "format": "int32", + "x-go-name": "Right" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "EmbedStructNamed": { + "type": "object", + "title": "EmbedStructNamed plainly embeds the struct pair's named half.", + "properties": { + "label": { + "description": "Label is the embedding struct's own field.", + "type": "string", + "x-go-name": "Label" + }, + "left": { + "description": "Left is a plain field.", + "type": "string", + "x-go-name": "Left" + }, + "right": { + "description": "Right is a plain field.", + "type": "integer", + "format": "int32", + "x-go-name": "Right" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "FmtStructNamed": { + "type": "string", + "format": "duration", + "title": "FmtStructNamed is a named type over a struct, carrying a format.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "PlainTarget": { + "type": "object", + "title": "PlainTarget is the unannotated struct behind the struct-kind pair.", + "properties": { + "left": { + "description": "Left is a plain field.", + "type": "string", + "x-go-name": "Left" + }, + "right": { + "description": "Right is a plain field.", + "type": "integer", + "format": "int32", + "x-go-name": "Right" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/strfmt_symmetry_composition_transparentaliases.json b/fixtures/integration/golden/strfmt_symmetry_composition_transparentaliases.json new file mode 100644 index 00000000..e606c16c --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_composition_transparentaliases.json @@ -0,0 +1,180 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "AllOfBasicAlias": { + "title": "AllOfBasicAlias composes the basic pair's alias half as an allOf member.", + "allOf": [ + { + "type": "string", + "format": "isbn" + }, + { + "type": "object", + "properties": { + "note": { + "description": "Note is the composing struct's own field.", + "type": "string", + "x-go-name": "Note" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "AllOfBasicNamed": { + "title": "AllOfBasicNamed composes the basic pair's named half as an allOf member.", + "allOf": [ + { + "type": "string", + "format": "isbn" + }, + { + "type": "object", + "properties": { + "note": { + "description": "Note is the composing struct's own field.", + "type": "string", + "x-go-name": "Note" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "AllOfStructAlias": { + "title": "AllOfStructAlias composes the struct pair's alias half as an allOf member.", + "allOf": [ + { + "type": "string", + "format": "duration" + }, + { + "type": "object", + "properties": { + "note": { + "description": "Note is the composing struct's own field.", + "type": "string", + "x-go-name": "Note" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "AllOfStructNamed": { + "title": "AllOfStructNamed composes the struct pair's named half as an allOf member.", + "allOf": [ + { + "type": "string", + "format": "duration" + }, + { + "type": "object", + "properties": { + "note": { + "description": "Note is the composing struct's own field.", + "type": "string", + "x-go-name": "Note" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "EmbedBasicAlias": { + "type": "object", + "title": "EmbedBasicAlias plainly embeds the basic pair's alias half.", + "properties": { + "label": { + "description": "Label is the embedding struct's own field.", + "type": "string", + "x-go-name": "Label" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "EmbedBasicNamed": { + "type": "object", + "title": "EmbedBasicNamed plainly embeds the basic pair's named half.", + "properties": { + "label": { + "description": "Label is the embedding struct's own field.", + "type": "string", + "x-go-name": "Label" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "EmbedStructAlias": { + "type": "object", + "title": "EmbedStructAlias plainly embeds the struct pair's alias half.", + "properties": { + "label": { + "description": "Label is the embedding struct's own field.", + "type": "string", + "x-go-name": "Label" + }, + "left": { + "description": "Left is a plain field.", + "type": "string", + "x-go-name": "Left" + }, + "right": { + "description": "Right is a plain field.", + "type": "integer", + "format": "int32", + "x-go-name": "Right" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "EmbedStructNamed": { + "type": "object", + "title": "EmbedStructNamed plainly embeds the struct pair's named half.", + "properties": { + "label": { + "description": "Label is the embedding struct's own field.", + "type": "string", + "x-go-name": "Label" + }, + "left": { + "description": "Left is a plain field.", + "type": "string", + "x-go-name": "Left" + }, + "right": { + "description": "Right is a plain field.", + "type": "integer", + "format": "int32", + "x-go-name": "Right" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "FmtStructNamed": { + "type": "string", + "format": "duration", + "title": "FmtStructNamed is a named type over a struct, carrying a format.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + }, + "PlainTarget": { + "type": "object", + "title": "PlainTarget is the unannotated struct behind the struct-kind pair.", + "properties": { + "left": { + "description": "Left is a plain field.", + "type": "string", + "x-go-name": "Left" + }, + "right": { + "description": "Right is a plain field.", + "type": "integer", + "format": "int32", + "x-go-name": "Right" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-composition" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/strfmt_symmetry_core_default.json b/fixtures/integration/golden/strfmt_symmetry_core_default.json new file mode 100644 index 00000000..1034c077 --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_core_default.json @@ -0,0 +1,216 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Envelope": { + "description": "Envelope reaches every pair from a use site. Field names are the lower-camel\ncell ID, so a golden diff names its own cell.", + "type": "object", + "properties": { + "fieldArrayAlias": { + "description": "FieldArrayAlias is the array pair, alias half.", + "type": "string", + "format": "bsonobjectid", + "x-go-name": "FieldArrayAlias" + }, + "fieldArrayNamed": { + "description": "FieldArrayNamed is the array pair, named half.", + "type": "string", + "format": "bsonobjectid", + "x-go-name": "FieldArrayNamed" + }, + "fieldBasicAlias": { + "description": "FieldBasicAlias is the basic pair, alias half, in plain field position.", + "type": "string", + "format": "isbn", + "x-go-name": "FieldBasicAlias" + }, + "fieldBasicNamed": { + "description": "FieldBasicNamed is the basic pair, named half, in plain field position.", + "type": "string", + "format": "isbn", + "x-go-name": "FieldBasicNamed" + }, + "fieldChainAlias": { + "description": "FieldChainAlias is the chain pair, alias half.", + "type": "string", + "format": "ssn", + "x-go-name": "FieldChainAlias" + }, + "fieldChainNamed": { + "$ref": "#/definitions/StrfmtChainNamed" + }, + "fieldSliceAlias": { + "description": "FieldSliceAlias is the slice pair, alias half.", + "type": "string", + "format": "byte", + "x-go-name": "FieldSliceAlias" + }, + "fieldSliceNamed": { + "description": "FieldSliceNamed is the slice pair, named half.", + "type": "string", + "format": "byte", + "x-go-name": "FieldSliceNamed" + }, + "fieldStructAlias": { + "description": "FieldStructAlias is the struct pair, alias half.", + "type": "string", + "format": "duration", + "x-go-name": "FieldStructAlias" + }, + "fieldStructNamed": { + "description": "FieldStructNamed is the struct pair, named half.", + "type": "string", + "format": "duration", + "x-go-name": "FieldStructNamed" + }, + "mapValueBasicAlias": { + "description": "MapValueBasicAlias reaches the basic pair's alias half as a map value.", + "type": "object", + "additionalProperties": { + "type": "string", + "format": "isbn" + }, + "x-go-name": "MapValueBasicAlias" + }, + "mapValueBasicNamed": { + "description": "MapValueBasicNamed reaches the basic pair's named half as a map value.", + "type": "object", + "additionalProperties": { + "type": "string", + "format": "isbn" + }, + "x-go-name": "MapValueBasicNamed" + }, + "mapValueStructAlias": { + "description": "MapValueStructAlias reaches the struct pair's alias half as a map value.", + "type": "object", + "additionalProperties": { + "type": "string", + "format": "duration" + }, + "x-go-name": "MapValueStructAlias" + }, + "mapValueStructNamed": { + "description": "MapValueStructNamed reaches the struct pair's named half as a map value.", + "type": "object", + "additionalProperties": { + "type": "string", + "format": "duration" + }, + "x-go-name": "MapValueStructNamed" + }, + "pointerBasicAlias": { + "description": "PointerBasicAlias reaches the basic pair's alias half through a pointer.", + "type": "string", + "format": "isbn", + "x-go-name": "PointerBasicAlias" + }, + "pointerBasicNamed": { + "description": "PointerBasicNamed reaches the basic pair's named half through a pointer.", + "type": "string", + "format": "isbn", + "x-go-name": "PointerBasicNamed" + }, + "pointerStructAlias": { + "description": "PointerStructAlias reaches the struct pair's alias half through a pointer.", + "type": "string", + "format": "duration", + "x-go-name": "PointerStructAlias" + }, + "pointerStructNamed": { + "description": "PointerStructNamed reaches the struct pair's named half through a pointer.", + "type": "string", + "format": "duration", + "x-go-name": "PointerStructNamed" + }, + "sliceElemBasicAlias": { + "description": "SliceElemBasicAlias reaches the basic pair's alias half as a slice element.", + "type": "array", + "items": { + "type": "string", + "format": "isbn" + }, + "x-go-name": "SliceElemBasicAlias" + }, + "sliceElemBasicNamed": { + "description": "SliceElemBasicNamed reaches the basic pair's named half as a slice element.", + "type": "array", + "items": { + "type": "string", + "format": "isbn" + }, + "x-go-name": "SliceElemBasicNamed" + }, + "sliceElemStructAlias": { + "description": "SliceElemStructAlias reaches the struct pair's alias half as a slice element.", + "type": "array", + "items": { + "type": "string", + "format": "duration" + }, + "x-go-name": "SliceElemStructAlias" + }, + "sliceElemStructNamed": { + "description": "SliceElemStructNamed reaches the struct pair's named half as a slice element.", + "type": "array", + "items": { + "type": "string", + "format": "duration" + }, + "x-go-name": "SliceElemStructNamed" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-core" + }, + "EnvelopeModeled": { + "type": "object", + "title": "EnvelopeModeled reaches the model-annotated pairs from a use site.", + "properties": { + "modeledBasicAlias": { + "$ref": "#/definitions/ModeledBasicAlias" + }, + "modeledBasicNamed": { + "$ref": "#/definitions/ModeledBasicNamed" + }, + "modeledStructAlias": { + "$ref": "#/definitions/ModeledStructAlias" + }, + "modeledStructNamed": { + "$ref": "#/definitions/ModeledStructNamed" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-core" + }, + "ModeledBasicAlias": { + "description": "ModeledBasicAlias is the basic pair's alias half WITH a model annotation — the\nannotation that stops buildAlias dissolving at the use site (schema.go:426).", + "type": "string", + "format": "isbn", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-core" + }, + "ModeledBasicNamed": { + "type": "string", + "format": "isbn", + "title": "ModeledBasicNamed is the basic pair's named half WITH a model annotation.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-core" + }, + "ModeledStructAlias": { + "type": "string", + "format": "duration", + "title": "ModeledStructAlias is the struct pair's alias half WITH a model annotation.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-core" + }, + "ModeledStructNamed": { + "type": "string", + "format": "duration", + "title": "ModeledStructNamed is the struct pair's named half WITH a model annotation.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-core" + }, + "StrfmtChainNamed": { + "description": "It carries no annotation itself: the format must be inherited from\nBaseFormatted, one declaration to the right (inheritedStrfmt).", + "type": "string", + "format": "ssn", + "title": "StrfmtChainNamed is a named type declared over an ALREADY-annotated named type.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-core" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/strfmt_symmetry_core_refaliases.json b/fixtures/integration/golden/strfmt_symmetry_core_refaliases.json new file mode 100644 index 00000000..1034c077 --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_core_refaliases.json @@ -0,0 +1,216 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Envelope": { + "description": "Envelope reaches every pair from a use site. Field names are the lower-camel\ncell ID, so a golden diff names its own cell.", + "type": "object", + "properties": { + "fieldArrayAlias": { + "description": "FieldArrayAlias is the array pair, alias half.", + "type": "string", + "format": "bsonobjectid", + "x-go-name": "FieldArrayAlias" + }, + "fieldArrayNamed": { + "description": "FieldArrayNamed is the array pair, named half.", + "type": "string", + "format": "bsonobjectid", + "x-go-name": "FieldArrayNamed" + }, + "fieldBasicAlias": { + "description": "FieldBasicAlias is the basic pair, alias half, in plain field position.", + "type": "string", + "format": "isbn", + "x-go-name": "FieldBasicAlias" + }, + "fieldBasicNamed": { + "description": "FieldBasicNamed is the basic pair, named half, in plain field position.", + "type": "string", + "format": "isbn", + "x-go-name": "FieldBasicNamed" + }, + "fieldChainAlias": { + "description": "FieldChainAlias is the chain pair, alias half.", + "type": "string", + "format": "ssn", + "x-go-name": "FieldChainAlias" + }, + "fieldChainNamed": { + "$ref": "#/definitions/StrfmtChainNamed" + }, + "fieldSliceAlias": { + "description": "FieldSliceAlias is the slice pair, alias half.", + "type": "string", + "format": "byte", + "x-go-name": "FieldSliceAlias" + }, + "fieldSliceNamed": { + "description": "FieldSliceNamed is the slice pair, named half.", + "type": "string", + "format": "byte", + "x-go-name": "FieldSliceNamed" + }, + "fieldStructAlias": { + "description": "FieldStructAlias is the struct pair, alias half.", + "type": "string", + "format": "duration", + "x-go-name": "FieldStructAlias" + }, + "fieldStructNamed": { + "description": "FieldStructNamed is the struct pair, named half.", + "type": "string", + "format": "duration", + "x-go-name": "FieldStructNamed" + }, + "mapValueBasicAlias": { + "description": "MapValueBasicAlias reaches the basic pair's alias half as a map value.", + "type": "object", + "additionalProperties": { + "type": "string", + "format": "isbn" + }, + "x-go-name": "MapValueBasicAlias" + }, + "mapValueBasicNamed": { + "description": "MapValueBasicNamed reaches the basic pair's named half as a map value.", + "type": "object", + "additionalProperties": { + "type": "string", + "format": "isbn" + }, + "x-go-name": "MapValueBasicNamed" + }, + "mapValueStructAlias": { + "description": "MapValueStructAlias reaches the struct pair's alias half as a map value.", + "type": "object", + "additionalProperties": { + "type": "string", + "format": "duration" + }, + "x-go-name": "MapValueStructAlias" + }, + "mapValueStructNamed": { + "description": "MapValueStructNamed reaches the struct pair's named half as a map value.", + "type": "object", + "additionalProperties": { + "type": "string", + "format": "duration" + }, + "x-go-name": "MapValueStructNamed" + }, + "pointerBasicAlias": { + "description": "PointerBasicAlias reaches the basic pair's alias half through a pointer.", + "type": "string", + "format": "isbn", + "x-go-name": "PointerBasicAlias" + }, + "pointerBasicNamed": { + "description": "PointerBasicNamed reaches the basic pair's named half through a pointer.", + "type": "string", + "format": "isbn", + "x-go-name": "PointerBasicNamed" + }, + "pointerStructAlias": { + "description": "PointerStructAlias reaches the struct pair's alias half through a pointer.", + "type": "string", + "format": "duration", + "x-go-name": "PointerStructAlias" + }, + "pointerStructNamed": { + "description": "PointerStructNamed reaches the struct pair's named half through a pointer.", + "type": "string", + "format": "duration", + "x-go-name": "PointerStructNamed" + }, + "sliceElemBasicAlias": { + "description": "SliceElemBasicAlias reaches the basic pair's alias half as a slice element.", + "type": "array", + "items": { + "type": "string", + "format": "isbn" + }, + "x-go-name": "SliceElemBasicAlias" + }, + "sliceElemBasicNamed": { + "description": "SliceElemBasicNamed reaches the basic pair's named half as a slice element.", + "type": "array", + "items": { + "type": "string", + "format": "isbn" + }, + "x-go-name": "SliceElemBasicNamed" + }, + "sliceElemStructAlias": { + "description": "SliceElemStructAlias reaches the struct pair's alias half as a slice element.", + "type": "array", + "items": { + "type": "string", + "format": "duration" + }, + "x-go-name": "SliceElemStructAlias" + }, + "sliceElemStructNamed": { + "description": "SliceElemStructNamed reaches the struct pair's named half as a slice element.", + "type": "array", + "items": { + "type": "string", + "format": "duration" + }, + "x-go-name": "SliceElemStructNamed" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-core" + }, + "EnvelopeModeled": { + "type": "object", + "title": "EnvelopeModeled reaches the model-annotated pairs from a use site.", + "properties": { + "modeledBasicAlias": { + "$ref": "#/definitions/ModeledBasicAlias" + }, + "modeledBasicNamed": { + "$ref": "#/definitions/ModeledBasicNamed" + }, + "modeledStructAlias": { + "$ref": "#/definitions/ModeledStructAlias" + }, + "modeledStructNamed": { + "$ref": "#/definitions/ModeledStructNamed" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-core" + }, + "ModeledBasicAlias": { + "description": "ModeledBasicAlias is the basic pair's alias half WITH a model annotation — the\nannotation that stops buildAlias dissolving at the use site (schema.go:426).", + "type": "string", + "format": "isbn", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-core" + }, + "ModeledBasicNamed": { + "type": "string", + "format": "isbn", + "title": "ModeledBasicNamed is the basic pair's named half WITH a model annotation.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-core" + }, + "ModeledStructAlias": { + "type": "string", + "format": "duration", + "title": "ModeledStructAlias is the struct pair's alias half WITH a model annotation.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-core" + }, + "ModeledStructNamed": { + "type": "string", + "format": "duration", + "title": "ModeledStructNamed is the struct pair's named half WITH a model annotation.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-core" + }, + "StrfmtChainNamed": { + "description": "It carries no annotation itself: the format must be inherited from\nBaseFormatted, one declaration to the right (inheritedStrfmt).", + "type": "string", + "format": "ssn", + "title": "StrfmtChainNamed is a named type declared over an ALREADY-annotated named type.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-core" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/strfmt_symmetry_core_transparentaliases.json b/fixtures/integration/golden/strfmt_symmetry_core_transparentaliases.json new file mode 100644 index 00000000..ae3987da --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_core_transparentaliases.json @@ -0,0 +1,222 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Envelope": { + "description": "Envelope reaches every pair from a use site. Field names are the lower-camel\ncell ID, so a golden diff names its own cell.", + "type": "object", + "properties": { + "fieldArrayAlias": { + "description": "FieldArrayAlias is the array pair, alias half.", + "type": "string", + "format": "bsonobjectid", + "x-go-name": "FieldArrayAlias" + }, + "fieldArrayNamed": { + "description": "FieldArrayNamed is the array pair, named half.", + "type": "string", + "format": "bsonobjectid", + "x-go-name": "FieldArrayNamed" + }, + "fieldBasicAlias": { + "description": "FieldBasicAlias is the basic pair, alias half, in plain field position.", + "type": "string", + "format": "isbn", + "x-go-name": "FieldBasicAlias" + }, + "fieldBasicNamed": { + "description": "FieldBasicNamed is the basic pair, named half, in plain field position.", + "type": "string", + "format": "isbn", + "x-go-name": "FieldBasicNamed" + }, + "fieldChainAlias": { + "description": "FieldChainAlias is the chain pair, alias half.", + "type": "string", + "format": "ssn", + "x-go-name": "FieldChainAlias" + }, + "fieldChainNamed": { + "$ref": "#/definitions/StrfmtChainNamed" + }, + "fieldSliceAlias": { + "description": "FieldSliceAlias is the slice pair, alias half.", + "type": "string", + "format": "byte", + "x-go-name": "FieldSliceAlias" + }, + "fieldSliceNamed": { + "description": "FieldSliceNamed is the slice pair, named half.", + "type": "string", + "format": "byte", + "x-go-name": "FieldSliceNamed" + }, + "fieldStructAlias": { + "description": "FieldStructAlias is the struct pair, alias half.", + "type": "string", + "format": "duration", + "x-go-name": "FieldStructAlias" + }, + "fieldStructNamed": { + "description": "FieldStructNamed is the struct pair, named half.", + "type": "string", + "format": "duration", + "x-go-name": "FieldStructNamed" + }, + "mapValueBasicAlias": { + "description": "MapValueBasicAlias reaches the basic pair's alias half as a map value.", + "type": "object", + "additionalProperties": { + "type": "string", + "format": "isbn" + }, + "x-go-name": "MapValueBasicAlias" + }, + "mapValueBasicNamed": { + "description": "MapValueBasicNamed reaches the basic pair's named half as a map value.", + "type": "object", + "additionalProperties": { + "type": "string", + "format": "isbn" + }, + "x-go-name": "MapValueBasicNamed" + }, + "mapValueStructAlias": { + "description": "MapValueStructAlias reaches the struct pair's alias half as a map value.", + "type": "object", + "additionalProperties": { + "type": "string", + "format": "duration" + }, + "x-go-name": "MapValueStructAlias" + }, + "mapValueStructNamed": { + "description": "MapValueStructNamed reaches the struct pair's named half as a map value.", + "type": "object", + "additionalProperties": { + "type": "string", + "format": "duration" + }, + "x-go-name": "MapValueStructNamed" + }, + "pointerBasicAlias": { + "description": "PointerBasicAlias reaches the basic pair's alias half through a pointer.", + "type": "string", + "format": "isbn", + "x-go-name": "PointerBasicAlias" + }, + "pointerBasicNamed": { + "description": "PointerBasicNamed reaches the basic pair's named half through a pointer.", + "type": "string", + "format": "isbn", + "x-go-name": "PointerBasicNamed" + }, + "pointerStructAlias": { + "description": "PointerStructAlias reaches the struct pair's alias half through a pointer.", + "type": "string", + "format": "duration", + "x-go-name": "PointerStructAlias" + }, + "pointerStructNamed": { + "description": "PointerStructNamed reaches the struct pair's named half through a pointer.", + "type": "string", + "format": "duration", + "x-go-name": "PointerStructNamed" + }, + "sliceElemBasicAlias": { + "description": "SliceElemBasicAlias reaches the basic pair's alias half as a slice element.", + "type": "array", + "items": { + "type": "string", + "format": "isbn" + }, + "x-go-name": "SliceElemBasicAlias" + }, + "sliceElemBasicNamed": { + "description": "SliceElemBasicNamed reaches the basic pair's named half as a slice element.", + "type": "array", + "items": { + "type": "string", + "format": "isbn" + }, + "x-go-name": "SliceElemBasicNamed" + }, + "sliceElemStructAlias": { + "description": "SliceElemStructAlias reaches the struct pair's alias half as a slice element.", + "type": "array", + "items": { + "type": "string", + "format": "duration" + }, + "x-go-name": "SliceElemStructAlias" + }, + "sliceElemStructNamed": { + "description": "SliceElemStructNamed reaches the struct pair's named half as a slice element.", + "type": "array", + "items": { + "type": "string", + "format": "duration" + }, + "x-go-name": "SliceElemStructNamed" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-core" + }, + "EnvelopeModeled": { + "type": "object", + "title": "EnvelopeModeled reaches the model-annotated pairs from a use site.", + "properties": { + "modeledBasicAlias": { + "description": "ModeledBasicAlias is the annotated basic pair, alias half.", + "type": "string", + "format": "isbn", + "x-go-name": "ModeledBasicAlias" + }, + "modeledBasicNamed": { + "$ref": "#/definitions/ModeledBasicNamed" + }, + "modeledStructAlias": { + "description": "ModeledStructAlias is the annotated struct pair, alias half.", + "type": "string", + "format": "duration", + "x-go-name": "ModeledStructAlias" + }, + "modeledStructNamed": { + "$ref": "#/definitions/ModeledStructNamed" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-core" + }, + "ModeledBasicAlias": { + "description": "ModeledBasicAlias is the basic pair's alias half WITH a model annotation — the\nannotation that stops buildAlias dissolving at the use site (schema.go:426).", + "type": "string", + "format": "isbn", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-core" + }, + "ModeledBasicNamed": { + "type": "string", + "format": "isbn", + "title": "ModeledBasicNamed is the basic pair's named half WITH a model annotation.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-core" + }, + "ModeledStructAlias": { + "type": "string", + "format": "duration", + "title": "ModeledStructAlias is the struct pair's alias half WITH a model annotation.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-core" + }, + "ModeledStructNamed": { + "type": "string", + "format": "duration", + "title": "ModeledStructNamed is the struct pair's named half WITH a model annotation.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-core" + }, + "StrfmtChainNamed": { + "description": "It carries no annotation itself: the format must be inherited from\nBaseFormatted, one declaration to the right (inheritedStrfmt).", + "type": "string", + "format": "ssn", + "title": "StrfmtChainNamed is a named type declared over an ALREADY-annotated named type.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-core" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/strfmt_symmetry_simpleschema_default.json b/fixtures/integration/golden/strfmt_symmetry_simpleschema_default.json new file mode 100644 index 00000000..fba3c87a --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_simpleschema_default.json @@ -0,0 +1,79 @@ +{ + "swagger": "2.0", + "paths": { + "/simple": { + "get": { + "tags": [ + "symmetry" + ], + "operationId": "simpleOp", + "parameters": [ + { + "type": "string", + "format": "isbn", + "x-go-name": "QueryBasicNamed", + "description": "QueryBasicNamed is the basic pair's named half in query position.", + "name": "queryBasicNamed", + "in": "query" + }, + { + "type": "string", + "format": "isbn", + "x-go-name": "QueryBasicAlias", + "description": "QueryBasicAlias is the basic pair's alias half in query position.", + "name": "queryBasicAlias", + "in": "query" + }, + { + "type": "string", + "format": "byte", + "x-go-name": "QuerySliceNamed", + "description": "QuerySliceNamed is the slice pair's named half in query position.", + "name": "querySliceNamed", + "in": "query" + }, + { + "type": "string", + "format": "byte", + "x-go-name": "QuerySliceAlias", + "description": "QuerySliceAlias is the slice pair's alias half in query position.", + "name": "querySliceAlias", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/simpleResponse" + } + } + } + } + }, + "responses": { + "simpleResponse": { + "description": "SimpleResponse carries the response-header cells. Non-body fields of a\nresponse struct become headers, which are SimpleSchema locations too.", + "headers": { + "headerBasicAlias": { + "type": "string", + "format": "isbn", + "description": "HeaderBasicAlias is the basic pair's alias half in header position." + }, + "headerBasicNamed": { + "type": "string", + "format": "isbn", + "description": "HeaderBasicNamed is the basic pair's named half in header position." + }, + "headerSliceAlias": { + "type": "string", + "format": "byte", + "description": "HeaderSliceAlias is the slice pair's alias half in header position." + }, + "headerSliceNamed": { + "type": "string", + "format": "byte", + "description": "HeaderSliceNamed is the slice pair's named half in header position." + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/strfmt_symmetry_simpleschema_refaliases.json b/fixtures/integration/golden/strfmt_symmetry_simpleschema_refaliases.json new file mode 100644 index 00000000..fba3c87a --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_simpleschema_refaliases.json @@ -0,0 +1,79 @@ +{ + "swagger": "2.0", + "paths": { + "/simple": { + "get": { + "tags": [ + "symmetry" + ], + "operationId": "simpleOp", + "parameters": [ + { + "type": "string", + "format": "isbn", + "x-go-name": "QueryBasicNamed", + "description": "QueryBasicNamed is the basic pair's named half in query position.", + "name": "queryBasicNamed", + "in": "query" + }, + { + "type": "string", + "format": "isbn", + "x-go-name": "QueryBasicAlias", + "description": "QueryBasicAlias is the basic pair's alias half in query position.", + "name": "queryBasicAlias", + "in": "query" + }, + { + "type": "string", + "format": "byte", + "x-go-name": "QuerySliceNamed", + "description": "QuerySliceNamed is the slice pair's named half in query position.", + "name": "querySliceNamed", + "in": "query" + }, + { + "type": "string", + "format": "byte", + "x-go-name": "QuerySliceAlias", + "description": "QuerySliceAlias is the slice pair's alias half in query position.", + "name": "querySliceAlias", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/simpleResponse" + } + } + } + } + }, + "responses": { + "simpleResponse": { + "description": "SimpleResponse carries the response-header cells. Non-body fields of a\nresponse struct become headers, which are SimpleSchema locations too.", + "headers": { + "headerBasicAlias": { + "type": "string", + "format": "isbn", + "description": "HeaderBasicAlias is the basic pair's alias half in header position." + }, + "headerBasicNamed": { + "type": "string", + "format": "isbn", + "description": "HeaderBasicNamed is the basic pair's named half in header position." + }, + "headerSliceAlias": { + "type": "string", + "format": "byte", + "description": "HeaderSliceAlias is the slice pair's alias half in header position." + }, + "headerSliceNamed": { + "type": "string", + "format": "byte", + "description": "HeaderSliceNamed is the slice pair's named half in header position." + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/strfmt_symmetry_simpleschema_transparentaliases.json b/fixtures/integration/golden/strfmt_symmetry_simpleschema_transparentaliases.json new file mode 100644 index 00000000..fba3c87a --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_simpleschema_transparentaliases.json @@ -0,0 +1,79 @@ +{ + "swagger": "2.0", + "paths": { + "/simple": { + "get": { + "tags": [ + "symmetry" + ], + "operationId": "simpleOp", + "parameters": [ + { + "type": "string", + "format": "isbn", + "x-go-name": "QueryBasicNamed", + "description": "QueryBasicNamed is the basic pair's named half in query position.", + "name": "queryBasicNamed", + "in": "query" + }, + { + "type": "string", + "format": "isbn", + "x-go-name": "QueryBasicAlias", + "description": "QueryBasicAlias is the basic pair's alias half in query position.", + "name": "queryBasicAlias", + "in": "query" + }, + { + "type": "string", + "format": "byte", + "x-go-name": "QuerySliceNamed", + "description": "QuerySliceNamed is the slice pair's named half in query position.", + "name": "querySliceNamed", + "in": "query" + }, + { + "type": "string", + "format": "byte", + "x-go-name": "QuerySliceAlias", + "description": "QuerySliceAlias is the slice pair's alias half in query position.", + "name": "querySliceAlias", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/simpleResponse" + } + } + } + } + }, + "responses": { + "simpleResponse": { + "description": "SimpleResponse carries the response-header cells. Non-body fields of a\nresponse struct become headers, which are SimpleSchema locations too.", + "headers": { + "headerBasicAlias": { + "type": "string", + "format": "isbn", + "description": "HeaderBasicAlias is the basic pair's alias half in header position." + }, + "headerBasicNamed": { + "type": "string", + "format": "isbn", + "description": "HeaderBasicNamed is the basic pair's named half in header position." + }, + "headerSliceAlias": { + "type": "string", + "format": "byte", + "description": "HeaderSliceAlias is the slice pair's alias half in header position." + }, + "headerSliceNamed": { + "type": "string", + "format": "byte", + "description": "HeaderSliceNamed is the slice pair's named half in header position." + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/strfmt_symmetry_stdlib_default.json b/fixtures/integration/golden/strfmt_symmetry_stdlib_default.json new file mode 100644 index 00000000..d3539116 --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_stdlib_default.json @@ -0,0 +1,37 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Envelope": { + "type": "object", + "title": "Envelope reaches both pairs from a field site.", + "properties": { + "fieldRawAlias": { + "description": "FieldRawAlias is the raw-message pair's alias half.", + "type": "string", + "format": "byte", + "x-go-name": "FieldRawAlias" + }, + "fieldRawNamed": { + "description": "FieldRawNamed is the raw-message pair's named half.", + "type": "string", + "format": "byte", + "x-go-name": "FieldRawNamed" + }, + "fieldTimeAlias": { + "description": "FieldTimeAlias is the time pair's alias half.", + "type": "string", + "format": "date", + "x-go-name": "FieldTimeAlias" + }, + "fieldTimeNamed": { + "description": "FieldTimeNamed is the time pair's named half.", + "type": "string", + "format": "date", + "x-go-name": "FieldTimeNamed" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-stdlib" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/strfmt_symmetry_stdlib_refaliases.json b/fixtures/integration/golden/strfmt_symmetry_stdlib_refaliases.json new file mode 100644 index 00000000..d3539116 --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_stdlib_refaliases.json @@ -0,0 +1,37 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Envelope": { + "type": "object", + "title": "Envelope reaches both pairs from a field site.", + "properties": { + "fieldRawAlias": { + "description": "FieldRawAlias is the raw-message pair's alias half.", + "type": "string", + "format": "byte", + "x-go-name": "FieldRawAlias" + }, + "fieldRawNamed": { + "description": "FieldRawNamed is the raw-message pair's named half.", + "type": "string", + "format": "byte", + "x-go-name": "FieldRawNamed" + }, + "fieldTimeAlias": { + "description": "FieldTimeAlias is the time pair's alias half.", + "type": "string", + "format": "date", + "x-go-name": "FieldTimeAlias" + }, + "fieldTimeNamed": { + "description": "FieldTimeNamed is the time pair's named half.", + "type": "string", + "format": "date", + "x-go-name": "FieldTimeNamed" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-stdlib" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/strfmt_symmetry_stdlib_transparentaliases.json b/fixtures/integration/golden/strfmt_symmetry_stdlib_transparentaliases.json new file mode 100644 index 00000000..d3539116 --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_stdlib_transparentaliases.json @@ -0,0 +1,37 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Envelope": { + "type": "object", + "title": "Envelope reaches both pairs from a field site.", + "properties": { + "fieldRawAlias": { + "description": "FieldRawAlias is the raw-message pair's alias half.", + "type": "string", + "format": "byte", + "x-go-name": "FieldRawAlias" + }, + "fieldRawNamed": { + "description": "FieldRawNamed is the raw-message pair's named half.", + "type": "string", + "format": "byte", + "x-go-name": "FieldRawNamed" + }, + "fieldTimeAlias": { + "description": "FieldTimeAlias is the time pair's alias half.", + "type": "string", + "format": "date", + "x-go-name": "FieldTimeAlias" + }, + "fieldTimeNamed": { + "description": "FieldTimeNamed is the time pair's named half.", + "type": "string", + "format": "date", + "x-go-name": "FieldTimeNamed" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/strfmt-symmetry-stdlib" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/type_override_symmetry_default.json b/fixtures/integration/golden/type_override_symmetry_default.json new file mode 100644 index 00000000..820468f5 --- /dev/null +++ b/fixtures/integration/golden/type_override_symmetry_default.json @@ -0,0 +1,298 @@ +{ + "swagger": "2.0", + "paths": { + "/file-synonym": { + "post": { + "tags": [ + "symmetry" + ], + "operationId": "fileSynonymOp", + "parameters": [ + { + "type": "file", + "x-go-name": "ViaAnnotation", + "description": "ViaAnnotation uses the legacy spelling.", + "name": "viaAnnotation", + "in": "formData" + }, + { + "type": "file", + "x-go-name": "ViaType", + "description": "ViaType uses the preferred spelling and must match it exactly.", + "name": "viaType", + "in": "formData" + }, + { + "type": "string", + "x-go-name": "QueryFile", + "description": "QueryFile is illegal: `file` is formData-only, so the override is refused\nand the Go type stands.", + "name": "queryFile", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/fileBodyAnnotation" + }, + "201": { + "$ref": "#/responses/fileBodyType" + } + } + } + }, + "/type-override": { + "get": { + "tags": [ + "symmetry" + ], + "operationId": "typeOverrideOp", + "parameters": [ + { + "type": "string", + "x-go-name": "QueryScalarNamed", + "description": "QueryScalarNamed is the scalar pair's named half in query position.", + "name": "queryScalarNamed", + "in": "query" + }, + { + "type": "string", + "x-go-name": "QueryScalarAlias", + "description": "QueryScalarAlias is the scalar pair's alias half in query position.", + "name": "queryScalarAlias", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/typeOverrideResponse" + } + } + } + } + }, + "definitions": { + "AllOfScalarAlias": { + "title": "AllOfScalarAlias composes the scalar pair's alias half as an allOf member.", + "allOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "note": { + "description": "Note is the composing struct's own field.", + "type": "string", + "x-go-name": "Note" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/type-override-symmetry" + }, + "AllOfScalarNamed": { + "title": "AllOfScalarNamed composes the scalar pair's named half as an allOf member.", + "allOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "note": { + "description": "Note is the composing struct's own field.", + "type": "string", + "x-go-name": "Note" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/type-override-symmetry" + }, + "EnumEnvelope": { + "type": "object", + "title": "EnumEnvelope reaches all three.", + "properties": { + "alias": { + "description": "Alias is the unfixable alias-to-basic.", + "type": "integer", + "format": "uint64", + "x-go-name": "Alias" + }, + "named": { + "description": "Named is the control.\n1 NamedLow is the low value.\n2 NamedHigh is the high value.", + "type": "integer", + "format": "uint64", + "enum": [ + 1, + 2 + ], + "x-go-enum-desc": "1 NamedLow is the low value.\n2 NamedHigh is the high value.", + "x-go-name": "Named" + }, + "toNamed": { + "description": "ToNamed is the alias-to-named, which works.\n1 NamedLow is the low value.\n2 NamedHigh is the high value.", + "type": "integer", + "format": "uint64", + "enum": [ + 1, + 2 + ], + "x-go-enum-desc": "1 NamedLow is the low value.\n2 NamedHigh is the high value.", + "x-go-name": "ToNamed" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/type-override-symmetry" + }, + "Envelope": { + "type": "object", + "title": "Envelope reaches every pair from the buildFromType sites.", + "properties": { + "fieldArrayAlias": { + "description": "FieldArrayAlias is the array pair, alias half.", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "FieldArrayAlias" + }, + "fieldArrayNamed": { + "description": "FieldArrayNamed is the array pair, named half.", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "FieldArrayNamed" + }, + "fieldFormattedAlias": { + "description": "FieldFormattedAlias is the type+strfmt pair, alias half.", + "type": "string", + "format": "uuid", + "x-go-name": "FieldFormattedAlias" + }, + "fieldFormattedNamed": { + "description": "FieldFormattedNamed is the type+strfmt pair, named half.", + "type": "string", + "format": "uuid", + "x-go-name": "FieldFormattedNamed" + }, + "fieldRefAlias": { + "description": "FieldRefAlias is the type-reference pair, alias half.", + "type": "object", + "properties": { + "left": { + "description": "Left is a plain field.", + "type": "string", + "x-go-name": "Left" + }, + "right": { + "description": "Right is a plain field.", + "type": "integer", + "format": "int32", + "x-go-name": "Right" + } + }, + "x-go-name": "FieldRefAlias" + }, + "fieldRefNamed": { + "description": "FieldRefNamed is the type-reference pair, named half.", + "type": "object", + "properties": { + "left": { + "description": "Left is a plain field.", + "type": "string", + "x-go-name": "Left" + }, + "right": { + "description": "Right is a plain field.", + "type": "integer", + "format": "int32", + "x-go-name": "Right" + } + }, + "x-go-name": "FieldRefNamed" + }, + "fieldScalarAlias": { + "description": "FieldScalarAlias is the scalar pair, alias half.", + "type": "string", + "x-go-name": "FieldScalarAlias" + }, + "fieldScalarNamed": { + "description": "FieldScalarNamed is the scalar pair, named half.", + "type": "string", + "x-go-name": "FieldScalarNamed" + }, + "mapValueScalarAlias": { + "description": "MapValueScalarAlias reaches the scalar pair's alias half as a map value.", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "x-go-name": "MapValueScalarAlias" + }, + "mapValueScalarNamed": { + "description": "MapValueScalarNamed reaches the scalar pair's named half as a map value.", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "x-go-name": "MapValueScalarNamed" + }, + "pointerScalarAlias": { + "description": "PointerScalarAlias reaches the scalar pair's alias half through a pointer.", + "type": "string", + "x-go-name": "PointerScalarAlias" + }, + "pointerScalarNamed": { + "description": "PointerScalarNamed reaches the scalar pair's named half through a pointer.", + "type": "string", + "x-go-name": "PointerScalarNamed" + }, + "sliceElemScalarAlias": { + "description": "SliceElemScalarAlias reaches the scalar pair's alias half as a slice element.", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "SliceElemScalarAlias" + }, + "sliceElemScalarNamed": { + "description": "SliceElemScalarNamed reaches the scalar pair's named half as a slice element.", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "SliceElemScalarNamed" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/type-override-symmetry" + } + }, + "responses": { + "fileBodyAnnotation": { + "description": "FileBodyAnnotation is a file-download response, legacy spelling.", + "schema": { + "type": "file" + } + }, + "fileBodyType": { + "description": "FileBodyType is the same response via the preferred spelling.", + "schema": { + "type": "file" + } + }, + "typeOverrideResponse": { + "description": "TypeResponse carries the pairs on response headers.", + "headers": { + "X-Alias": { + "type": "string", + "description": "HeaderScalarAlias is the scalar pair's alias half in header position." + }, + "X-Named": { + "type": "string", + "description": "HeaderScalarNamed is the scalar pair's named half in header position." + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/type_override_symmetry_refaliases.json b/fixtures/integration/golden/type_override_symmetry_refaliases.json new file mode 100644 index 00000000..820468f5 --- /dev/null +++ b/fixtures/integration/golden/type_override_symmetry_refaliases.json @@ -0,0 +1,298 @@ +{ + "swagger": "2.0", + "paths": { + "/file-synonym": { + "post": { + "tags": [ + "symmetry" + ], + "operationId": "fileSynonymOp", + "parameters": [ + { + "type": "file", + "x-go-name": "ViaAnnotation", + "description": "ViaAnnotation uses the legacy spelling.", + "name": "viaAnnotation", + "in": "formData" + }, + { + "type": "file", + "x-go-name": "ViaType", + "description": "ViaType uses the preferred spelling and must match it exactly.", + "name": "viaType", + "in": "formData" + }, + { + "type": "string", + "x-go-name": "QueryFile", + "description": "QueryFile is illegal: `file` is formData-only, so the override is refused\nand the Go type stands.", + "name": "queryFile", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/fileBodyAnnotation" + }, + "201": { + "$ref": "#/responses/fileBodyType" + } + } + } + }, + "/type-override": { + "get": { + "tags": [ + "symmetry" + ], + "operationId": "typeOverrideOp", + "parameters": [ + { + "type": "string", + "x-go-name": "QueryScalarNamed", + "description": "QueryScalarNamed is the scalar pair's named half in query position.", + "name": "queryScalarNamed", + "in": "query" + }, + { + "type": "string", + "x-go-name": "QueryScalarAlias", + "description": "QueryScalarAlias is the scalar pair's alias half in query position.", + "name": "queryScalarAlias", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/typeOverrideResponse" + } + } + } + } + }, + "definitions": { + "AllOfScalarAlias": { + "title": "AllOfScalarAlias composes the scalar pair's alias half as an allOf member.", + "allOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "note": { + "description": "Note is the composing struct's own field.", + "type": "string", + "x-go-name": "Note" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/type-override-symmetry" + }, + "AllOfScalarNamed": { + "title": "AllOfScalarNamed composes the scalar pair's named half as an allOf member.", + "allOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "note": { + "description": "Note is the composing struct's own field.", + "type": "string", + "x-go-name": "Note" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/type-override-symmetry" + }, + "EnumEnvelope": { + "type": "object", + "title": "EnumEnvelope reaches all three.", + "properties": { + "alias": { + "description": "Alias is the unfixable alias-to-basic.", + "type": "integer", + "format": "uint64", + "x-go-name": "Alias" + }, + "named": { + "description": "Named is the control.\n1 NamedLow is the low value.\n2 NamedHigh is the high value.", + "type": "integer", + "format": "uint64", + "enum": [ + 1, + 2 + ], + "x-go-enum-desc": "1 NamedLow is the low value.\n2 NamedHigh is the high value.", + "x-go-name": "Named" + }, + "toNamed": { + "description": "ToNamed is the alias-to-named, which works.\n1 NamedLow is the low value.\n2 NamedHigh is the high value.", + "type": "integer", + "format": "uint64", + "enum": [ + 1, + 2 + ], + "x-go-enum-desc": "1 NamedLow is the low value.\n2 NamedHigh is the high value.", + "x-go-name": "ToNamed" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/type-override-symmetry" + }, + "Envelope": { + "type": "object", + "title": "Envelope reaches every pair from the buildFromType sites.", + "properties": { + "fieldArrayAlias": { + "description": "FieldArrayAlias is the array pair, alias half.", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "FieldArrayAlias" + }, + "fieldArrayNamed": { + "description": "FieldArrayNamed is the array pair, named half.", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "FieldArrayNamed" + }, + "fieldFormattedAlias": { + "description": "FieldFormattedAlias is the type+strfmt pair, alias half.", + "type": "string", + "format": "uuid", + "x-go-name": "FieldFormattedAlias" + }, + "fieldFormattedNamed": { + "description": "FieldFormattedNamed is the type+strfmt pair, named half.", + "type": "string", + "format": "uuid", + "x-go-name": "FieldFormattedNamed" + }, + "fieldRefAlias": { + "description": "FieldRefAlias is the type-reference pair, alias half.", + "type": "object", + "properties": { + "left": { + "description": "Left is a plain field.", + "type": "string", + "x-go-name": "Left" + }, + "right": { + "description": "Right is a plain field.", + "type": "integer", + "format": "int32", + "x-go-name": "Right" + } + }, + "x-go-name": "FieldRefAlias" + }, + "fieldRefNamed": { + "description": "FieldRefNamed is the type-reference pair, named half.", + "type": "object", + "properties": { + "left": { + "description": "Left is a plain field.", + "type": "string", + "x-go-name": "Left" + }, + "right": { + "description": "Right is a plain field.", + "type": "integer", + "format": "int32", + "x-go-name": "Right" + } + }, + "x-go-name": "FieldRefNamed" + }, + "fieldScalarAlias": { + "description": "FieldScalarAlias is the scalar pair, alias half.", + "type": "string", + "x-go-name": "FieldScalarAlias" + }, + "fieldScalarNamed": { + "description": "FieldScalarNamed is the scalar pair, named half.", + "type": "string", + "x-go-name": "FieldScalarNamed" + }, + "mapValueScalarAlias": { + "description": "MapValueScalarAlias reaches the scalar pair's alias half as a map value.", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "x-go-name": "MapValueScalarAlias" + }, + "mapValueScalarNamed": { + "description": "MapValueScalarNamed reaches the scalar pair's named half as a map value.", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "x-go-name": "MapValueScalarNamed" + }, + "pointerScalarAlias": { + "description": "PointerScalarAlias reaches the scalar pair's alias half through a pointer.", + "type": "string", + "x-go-name": "PointerScalarAlias" + }, + "pointerScalarNamed": { + "description": "PointerScalarNamed reaches the scalar pair's named half through a pointer.", + "type": "string", + "x-go-name": "PointerScalarNamed" + }, + "sliceElemScalarAlias": { + "description": "SliceElemScalarAlias reaches the scalar pair's alias half as a slice element.", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "SliceElemScalarAlias" + }, + "sliceElemScalarNamed": { + "description": "SliceElemScalarNamed reaches the scalar pair's named half as a slice element.", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "SliceElemScalarNamed" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/type-override-symmetry" + } + }, + "responses": { + "fileBodyAnnotation": { + "description": "FileBodyAnnotation is a file-download response, legacy spelling.", + "schema": { + "type": "file" + } + }, + "fileBodyType": { + "description": "FileBodyType is the same response via the preferred spelling.", + "schema": { + "type": "file" + } + }, + "typeOverrideResponse": { + "description": "TypeResponse carries the pairs on response headers.", + "headers": { + "X-Alias": { + "type": "string", + "description": "HeaderScalarAlias is the scalar pair's alias half in header position." + }, + "X-Named": { + "type": "string", + "description": "HeaderScalarNamed is the scalar pair's named half in header position." + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/type_override_symmetry_transparentaliases.json b/fixtures/integration/golden/type_override_symmetry_transparentaliases.json new file mode 100644 index 00000000..820468f5 --- /dev/null +++ b/fixtures/integration/golden/type_override_symmetry_transparentaliases.json @@ -0,0 +1,298 @@ +{ + "swagger": "2.0", + "paths": { + "/file-synonym": { + "post": { + "tags": [ + "symmetry" + ], + "operationId": "fileSynonymOp", + "parameters": [ + { + "type": "file", + "x-go-name": "ViaAnnotation", + "description": "ViaAnnotation uses the legacy spelling.", + "name": "viaAnnotation", + "in": "formData" + }, + { + "type": "file", + "x-go-name": "ViaType", + "description": "ViaType uses the preferred spelling and must match it exactly.", + "name": "viaType", + "in": "formData" + }, + { + "type": "string", + "x-go-name": "QueryFile", + "description": "QueryFile is illegal: `file` is formData-only, so the override is refused\nand the Go type stands.", + "name": "queryFile", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/fileBodyAnnotation" + }, + "201": { + "$ref": "#/responses/fileBodyType" + } + } + } + }, + "/type-override": { + "get": { + "tags": [ + "symmetry" + ], + "operationId": "typeOverrideOp", + "parameters": [ + { + "type": "string", + "x-go-name": "QueryScalarNamed", + "description": "QueryScalarNamed is the scalar pair's named half in query position.", + "name": "queryScalarNamed", + "in": "query" + }, + { + "type": "string", + "x-go-name": "QueryScalarAlias", + "description": "QueryScalarAlias is the scalar pair's alias half in query position.", + "name": "queryScalarAlias", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/typeOverrideResponse" + } + } + } + } + }, + "definitions": { + "AllOfScalarAlias": { + "title": "AllOfScalarAlias composes the scalar pair's alias half as an allOf member.", + "allOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "note": { + "description": "Note is the composing struct's own field.", + "type": "string", + "x-go-name": "Note" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/type-override-symmetry" + }, + "AllOfScalarNamed": { + "title": "AllOfScalarNamed composes the scalar pair's named half as an allOf member.", + "allOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "note": { + "description": "Note is the composing struct's own field.", + "type": "string", + "x-go-name": "Note" + } + } + } + ], + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/type-override-symmetry" + }, + "EnumEnvelope": { + "type": "object", + "title": "EnumEnvelope reaches all three.", + "properties": { + "alias": { + "description": "Alias is the unfixable alias-to-basic.", + "type": "integer", + "format": "uint64", + "x-go-name": "Alias" + }, + "named": { + "description": "Named is the control.\n1 NamedLow is the low value.\n2 NamedHigh is the high value.", + "type": "integer", + "format": "uint64", + "enum": [ + 1, + 2 + ], + "x-go-enum-desc": "1 NamedLow is the low value.\n2 NamedHigh is the high value.", + "x-go-name": "Named" + }, + "toNamed": { + "description": "ToNamed is the alias-to-named, which works.\n1 NamedLow is the low value.\n2 NamedHigh is the high value.", + "type": "integer", + "format": "uint64", + "enum": [ + 1, + 2 + ], + "x-go-enum-desc": "1 NamedLow is the low value.\n2 NamedHigh is the high value.", + "x-go-name": "ToNamed" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/type-override-symmetry" + }, + "Envelope": { + "type": "object", + "title": "Envelope reaches every pair from the buildFromType sites.", + "properties": { + "fieldArrayAlias": { + "description": "FieldArrayAlias is the array pair, alias half.", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "FieldArrayAlias" + }, + "fieldArrayNamed": { + "description": "FieldArrayNamed is the array pair, named half.", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "FieldArrayNamed" + }, + "fieldFormattedAlias": { + "description": "FieldFormattedAlias is the type+strfmt pair, alias half.", + "type": "string", + "format": "uuid", + "x-go-name": "FieldFormattedAlias" + }, + "fieldFormattedNamed": { + "description": "FieldFormattedNamed is the type+strfmt pair, named half.", + "type": "string", + "format": "uuid", + "x-go-name": "FieldFormattedNamed" + }, + "fieldRefAlias": { + "description": "FieldRefAlias is the type-reference pair, alias half.", + "type": "object", + "properties": { + "left": { + "description": "Left is a plain field.", + "type": "string", + "x-go-name": "Left" + }, + "right": { + "description": "Right is a plain field.", + "type": "integer", + "format": "int32", + "x-go-name": "Right" + } + }, + "x-go-name": "FieldRefAlias" + }, + "fieldRefNamed": { + "description": "FieldRefNamed is the type-reference pair, named half.", + "type": "object", + "properties": { + "left": { + "description": "Left is a plain field.", + "type": "string", + "x-go-name": "Left" + }, + "right": { + "description": "Right is a plain field.", + "type": "integer", + "format": "int32", + "x-go-name": "Right" + } + }, + "x-go-name": "FieldRefNamed" + }, + "fieldScalarAlias": { + "description": "FieldScalarAlias is the scalar pair, alias half.", + "type": "string", + "x-go-name": "FieldScalarAlias" + }, + "fieldScalarNamed": { + "description": "FieldScalarNamed is the scalar pair, named half.", + "type": "string", + "x-go-name": "FieldScalarNamed" + }, + "mapValueScalarAlias": { + "description": "MapValueScalarAlias reaches the scalar pair's alias half as a map value.", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "x-go-name": "MapValueScalarAlias" + }, + "mapValueScalarNamed": { + "description": "MapValueScalarNamed reaches the scalar pair's named half as a map value.", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "x-go-name": "MapValueScalarNamed" + }, + "pointerScalarAlias": { + "description": "PointerScalarAlias reaches the scalar pair's alias half through a pointer.", + "type": "string", + "x-go-name": "PointerScalarAlias" + }, + "pointerScalarNamed": { + "description": "PointerScalarNamed reaches the scalar pair's named half through a pointer.", + "type": "string", + "x-go-name": "PointerScalarNamed" + }, + "sliceElemScalarAlias": { + "description": "SliceElemScalarAlias reaches the scalar pair's alias half as a slice element.", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "SliceElemScalarAlias" + }, + "sliceElemScalarNamed": { + "description": "SliceElemScalarNamed reaches the scalar pair's named half as a slice element.", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "SliceElemScalarNamed" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/enhancements/type-override-symmetry" + } + }, + "responses": { + "fileBodyAnnotation": { + "description": "FileBodyAnnotation is a file-download response, legacy spelling.", + "schema": { + "type": "file" + } + }, + "fileBodyType": { + "description": "FileBodyType is the same response via the preferred spelling.", + "schema": { + "type": "file" + } + }, + "typeOverrideResponse": { + "description": "TypeResponse carries the pairs on response headers.", + "headers": { + "X-Alias": { + "type": "string", + "description": "HeaderScalarAlias is the scalar pair's alias half in header position." + }, + "X-Named": { + "type": "string", + "description": "HeaderScalarNamed is the scalar pair's named half in header position." + } + } + } + } +} \ No newline at end of file diff --git a/internal/builders/common/builder.go b/internal/builders/common/builder.go index 03f064a0..c31f46b0 100644 --- a/internal/builders/common/builder.go +++ b/internal/builders/common/builder.go @@ -11,6 +11,8 @@ package common import ( "go/ast" "go/token" + "go/types" + "strings" "github.com/go-openapi/codescan/internal/builders/godoclink" "github.com/go-openapi/codescan/internal/ifaces" @@ -290,3 +292,101 @@ func (s *Builder) MakeRef(decl *scanner.EntityDecl, prop ifaces.SwaggerTypable) return nil } + +// FindAnnotationArg returns the first positional argument of the first Block of the given +// annotation kind in cg, filtered to non-empty single-word arguments and read through the +// ParseBlocks cache. +// +// Shared here rather than per-builder because the alias classifier below runs from schema, +// parameters and responses alike. +func (s *Builder) FindAnnotationArg(cg *ast.CommentGroup, kind grammar.AnnotationKind) (string, bool) { + for _, b := range s.ParseBlocks(cg) { + if b.AnnotationKind() != kind { + continue + } + arg, ok := b.AnnotationArg() + if !ok { + continue + } + if strings.ContainsAny(arg, " \t") { + continue + } + + return arg, true + } + + return "", false +} + +// IsStringLikeSequence reports whether an array/slice element type makes the sequence a +// STRING-LIKE value rather than a collection — a byte sequence (`[]byte`, `[16]byte`) or a rune +// sequence (`[]rune`). +// +// This is what decides whether a `swagger:strfmt` on the sequence describes the whole value or each +// element. It replaces a two-name allowlist (`byte`, `bsonobjectid`) that was really standing in for +// this question: both of those are formats for a byte sequence, `bsonobjectid` being a strfmt +// library type that happens to have an array underlying. Keying on the element instead of the format +// name generalises to every such type — `uuid` over `[16]byte`, `ulid`, and whatever comes next — +// without anyone having to extend a list. +// +// go/types cannot distinguish `rune` from `int32` (rune is an alias), so `[]int32` is treated +// alike. That is harmless: a STRING format on integer elements was already a contradiction. +func IsStringLikeSequence(elem types.Type) bool { + basic, ok := elem.Underlying().(*types.Basic) + if !ok { + return false + } + + switch basic.Kind() { + case types.Uint8, types.Int32: // byte, rune + return true + default: + return false + } +} + +// ApplyArrayLikeStrfmt writes a `swagger:strfmt` format onto an array/slice target, choosing +// between the whole schema and its items by the ELEMENT type — see [IsStringLikeSequence]. +// +// type ID [16]byte // swagger:strfmt uuid → {string, format: uuid} +// type Emails []string // swagger:strfmt email → {array, items: {string, format: email}} +// +// Note this settles only the mechanical half of the items-vs-whole question. A format on a sequence +// of some OTHER element type is genuinely ambiguous — it stays on the items, as it always has. +func ApplyArrayLikeStrfmt(format string, elem types.Type, tgt ifaces.SwaggerTypable) { + if IsStringLikeSequence(elem) { + tgt.Typed("string", format) + + return + } + tgt.Items().Typed("string", format) +} + +// ClassifierAliasStrfmt applies a `swagger:strfmt` carried by an ALIAS declaration, dispatching on +// the alias's underlying kind so the format lands exactly where the equivalent NAMED declaration +// would put it — whole-schema for a basic or struct underlying, items-or-whole for an array/slice. +// +// A named declaration reaches its format through the schema builder's classifier walkers, each +// keyed off the declaration found via DeclForType. Aliases have no such entry: every builder +// dissolves an alias to its right-hand side, and by then nothing remembers an alias was involved. +// This is that missing entry, and it must run BEFORE the dissolve. +// +// Scoped to `swagger:strfmt`: the other classifier annotations have separate handling on the alias +// path and are deliberately not swept in here. +func (s *Builder) ClassifierAliasStrfmt(cg *ast.CommentGroup, tpe *types.Alias, tgt ifaces.SwaggerTypable) bool { + format, ok := s.FindAnnotationArg(cg, grammar.AnnStrfmt) + if !ok { + return false + } + + switch ut := tpe.Underlying().(type) { + case *types.Array: + ApplyArrayLikeStrfmt(format, ut.Elem(), tgt) + case *types.Slice: + ApplyArrayLikeStrfmt(format, ut.Elem(), tgt) + default: + tgt.Typed("string", format) + } + + return true +} diff --git a/internal/builders/handlers/dispatch_schema.go b/internal/builders/handlers/dispatch_schema.go index da0aeffe..21435a7e 100644 --- a/internal/builders/handlers/dispatch_schema.go +++ b/internal/builders/handlers/dispatch_schema.go @@ -383,13 +383,18 @@ func schemaStringHandler(ps *oaispec.Schema, valid SchemaValidations, case grammar.KwDefault: if v, err := validations.ParseDefault(val, SchemaTypeOf(ps), ps.Format); err == nil { valid.SetDefault(v) + } else { + warnUncoercible(p.Pos, diag, grammar.KwDefault, val, SchemaTypeOf(ps)) } case grammar.KwExample: if v, err := validations.ParseDefault(val, SchemaTypeOf(ps), ps.Format); err == nil { valid.SetExample(v) + } else { + warnUncoercible(p.Pos, diag, grammar.KwExample, val, SchemaTypeOf(ps)) } case grammar.KwEnum: valid.SetEnum(val) + pruneUncoercibleEnum(ps, p.Pos, diag) } } } @@ -412,13 +417,18 @@ func schemaRawHandler(ps *oaispec.Schema, valid SchemaValidations, case grammar.KwDefault: if v, err := validations.ParseDefault(p.Value, SchemaTypeOf(ps), ps.Format); err == nil { valid.SetDefault(v) + } else { + warnUncoercible(p.Pos, diag, grammar.KwDefault, p.Value, SchemaTypeOf(ps)) } case grammar.KwExample: if v, err := validations.ParseDefault(p.Value, SchemaTypeOf(ps), ps.Format); err == nil { valid.SetExample(v) + } else { + warnUncoercible(p.Pos, diag, grammar.KwExample, p.Value, SchemaTypeOf(ps)) } case grammar.KwEnum: valid.SetEnum(p.Value) + pruneUncoercibleEnum(ps, p.Pos, diag) case grammar.KwExternalDocs: if opts.SimpleSchemaMode { if diag != nil { @@ -503,6 +513,117 @@ func checkShape(p grammar.Property, ps *oaispec.Schema, diag func(grammar.Diagno // // uniqueItems is intentionally not rechecked: its grammar keyword (`unique`) carries no type-domain // rule, matching the field/items paths which likewise never shape-gate it. diag may be nil. +// RecoerceDeclValues re-types `default`, `example` and `enum` on a declaration's schema once the Go +// type is known. +// +// A declaration's comment block is dispatched BEFORE its Go type is resolved onto the schema (see +// buildFromDecl), so these three keywords ran through ParseDefault / ParseEnumValues with an empty +// schema type and fell back to their raw string form: `default: 8080` on a named int became the +// string "8080", `enum: 1,2,3` became ["1","2","3"] — an enum no validator can satisfy on an +// integer schema — and a JSON array became a string holding JSON source. The same keywords on a +// struct field, a parameter or a header were always correct, because there the type is known when +// the walk runs. +// +// Re-coercion is safe precisely because the fallback preserved the author's raw text verbatim: a +// value still stored as a string is one that was never typed. A string-typed schema is skipped — +// there the fallback and the correct answer coincide, so there is nothing to redo. +// +// Runs beside RecheckSchemaShape, at the same "the type is known now" seam. +func RecoerceDeclValues(sch *oaispec.Schema, pos token.Position, diag func(grammar.Diagnostic)) { + if sch == nil || len(sch.Type) == 0 { + return + } + typ := sch.Type[0] + if typ == "string" { + // Coercion TOWARDS string never fails — every literal has a string form — so the fallback and + // the correct answer coincide here. Nothing to redo, and nothing that can be diagnosed. + return + } + + if raw, ok := sch.Default.(string); ok { + v, err := validations.ParseDefault(raw, typ, sch.Format) + if err != nil { + warnUncoercible(pos, diag, grammar.KwDefault, raw, typ) + sch.Default = nil + } else { + sch.Default = v + } + } + if raw, ok := sch.Example.(string); ok { + v, err := validations.ParseDefault(raw, typ, sch.Format) + if err != nil { + warnUncoercible(pos, diag, grammar.KwExample, raw, typ) + sch.Example = nil + } else { + sch.Example = v + } + } + + // Enum members were coerced individually against the empty type, so each is independently a + // string; re-coerce per member. + for i, member := range sch.Enum { + raw, ok := member.(string) + if !ok { + continue + } + if v, err := validations.CoerceValue(raw, &oaispec.SimpleSchema{Type: typ}); err == nil { + sch.Enum[i] = v + } + } + pruneUncoercibleEnum(sch, pos, diag) +} + +// warnUncoercible reports a value that could not be read as the schema's type and was dropped. +// +// Dropping beats keeping: a value of the wrong type emits a document no validator accepts, whereas +// dropping it emits an incomplete one — the lesser harm, now that the author is told. +func warnUncoercible(pos token.Position, diag func(grammar.Diagnostic), keyword, raw, typ string) { + if diag == nil { + return + } + diag(grammar.Warnf(pos, grammar.CodeShapeMismatch, + "%s: %q cannot be read as %s; value dropped", keyword, raw, typ)) +} + +// pruneUncoercibleEnum drops enum members that could not be read as the schema's type, warning for +// each one by name. +// +// CoerceEnum falls back to the raw string per member, so a member still stored as a string on a +// non-string schema is one that failed — `enum: 1, two, 3` on an integer schema yields +// [1, "two", 3], which no validator accepts. +// +// Dropping narrows a closed set, which is a real change to the author's contract, so the warning +// names the member rather than merely counting. It fails closed, matching what the `swagger:enum` +// annotation already does when a const value does not fit. +func pruneUncoercibleEnum(sch *oaispec.Schema, pos token.Position, diag func(grammar.Diagnostic)) { + if sch == nil || len(sch.Enum) == 0 || len(sch.Type) == 0 { + return + } + typ := sch.Type[0] + if typ == "string" { + return + } + + kept := make([]any, 0, len(sch.Enum)) + for _, member := range sch.Enum { + if raw, isString := member.(string); isString { + warnUncoercible(pos, diag, grammar.KwEnum, raw, typ) + + continue + } + kept = append(kept, member) + } + if len(kept) == len(sch.Enum) { + return + } + if len(kept) == 0 { + sch.Enum = nil + + return + } + sch.Enum = kept +} + func RecheckSchemaShape(sch *oaispec.Schema, pos token.Position, diag func(grammar.Diagnostic)) { if sch == nil || len(sch.Type) == 0 { return diff --git a/internal/builders/parameters/doc_signals.go b/internal/builders/parameters/doc_signals.go index 026a0f1c..08c99d1c 100644 --- a/internal/builders/parameters/doc_signals.go +++ b/internal/builders/parameters/doc_signals.go @@ -62,6 +62,13 @@ func scanFieldDocSignals(blocks []grammar.Block, doc *ast.CommentGroup) fieldDoc if arg, ok := b.AnnotationArg(); ok && !strings.ContainsAny(arg, " \t") { pd.swaggerType = arg pd.swTypeSet = true + // `swagger:type file` is a synonym for `swagger:file`, and the preferred spelling: + // `file` is an OAS v2 type name like any other, so the annotation that names types + // should be able to name it. Raising the same signal reuses the location gate that + // already governs swagger:file (formData only) rather than adding a second one. + if arg == fileTypeName { + pd.file = true + } } } } @@ -73,19 +80,3 @@ func scanFieldDocSignals(blocks []grammar.Block, doc *ast.CommentGroup) fieldDoc return pd } - -// strfmtFromDoc returns the argument of a `swagger:strfmt ` annotation present in blocks (the -// pre-parsed common.Builder cache slice for some CommentGroup). -// -// Single-word filter mirrors the schema package's `findAnnotationArg` rule. -func strfmtFromDoc(blocks []grammar.Block) (string, bool) { - for _, b := range blocks { - if b.AnnotationKind() != grammar.AnnStrfmt { - continue - } - if arg, ok := b.AnnotationArg(); ok && !strings.ContainsAny(arg, " \t") { - return arg, true - } - } - return "", false -} diff --git a/internal/builders/parameters/errors.go b/internal/builders/parameters/errors.go index 14f55495..3b7c5355 100644 --- a/internal/builders/parameters/errors.go +++ b/internal/builders/parameters/errors.go @@ -8,11 +8,21 @@ import "errors" // ErrParameters is the sentinel error for all errors originating from the parameters package. var ErrParameters = errors.New("codescan:builders:parameters") -// errUnrepresentableParam is an internal sentinel signalling that a struct field has no OAS v2 -// SimpleSchema representation in a non-body parameter context (query/formData/path/header) — e.g. -// a Go map. +// Two internal sentinels for "drop this field rather than fail the scan". Both are handled the same +// way by the field-level caller (processParamField) — record a located diagnostic, skip the field — +// but they say different things to the author, and one message cannot serve both. // -// The field-level caller (processParamField) recognizes it, records a diagnostic, and skips the -// field instead of failing the whole scan. -// See go-swagger/go-swagger#2804. -var errUnrepresentableParam = errors.New("codescan:builders:parameters:unrepresentable") +// The distinction is not cosmetic: reported under the wrong reason, a body parameter dropped because +// its Go type is meaningless to a client was blaming a SimpleSchema restriction that does not apply +// to `in: body` at all, sending the reader to fix a location that was never the problem. +var ( + // errUnrepresentableParam signals that a field has no OAS v2 SimpleSchema representation in a + // non-body parameter context (query/formData/path/header) — e.g. a Go map. The same type is + // perfectly representable under `in: body`, so the location is the whole of the reason. + // See go-swagger/go-swagger#2804. + errUnrepresentableParam = errors.New("codescan:builders:parameters:unrepresentable") + + // errNotAParameter signals a Go type that is meaningless as an inbound value in ANY location — + // currently `error`. No choice of `in:` makes it sendable, so the message must not name one. + errNotAParameter = errors.New("codescan:builders:parameters:not-a-parameter") +) diff --git a/internal/builders/parameters/parameters.go b/internal/builders/parameters/parameters.go index e2666a39..909f564f 100644 --- a/internal/builders/parameters/parameters.go +++ b/internal/builders/parameters/parameters.go @@ -21,6 +21,9 @@ import ( const inBody = "body" +// fileTypeName is the OAS v2 `file` type, spelled as a swagger:type argument. +const fileTypeName = "file" + // Builder constructs OAS v2 parameter entries for one `swagger:parameters` declaration and writes // them onto the matching operations. // @@ -293,43 +296,31 @@ func (p *Builder) buildAlias(tpe *types.Alias, op *oaispec.Operation, seen map[s return p.buildFromType(tpe.Rhs(), op, seen) } -func (p *Builder) buildFromField(fld *types.Var, tpe types.Type, typable ifaces.SwaggerTypable, seen map[string]oaispec.Parameter) error { +func (p *Builder) buildFromField(fld *types.Var, tpe types.Type, typable ifaces.SwaggerTypable) error { switch ftpe := tpe.(type) { case *types.Basic: return resolvers.SwaggerSchemaForType(ftpe.Name(), typable) case *types.Struct: - return p.buildFromFieldStruct(ftpe, typable) + return schema.Delegate(p.Builder, schema.OptionFor(ftpe, typable)) case *types.Pointer: - return p.buildFromField(fld, ftpe.Elem(), typable, seen) + return p.buildFromField(fld, ftpe.Elem(), typable) case *types.Interface: - return p.buildFromFieldInterface(ftpe, typable) + return schema.Delegate(p.Builder, schema.OptionFor(ftpe, typable)) case *types.Array: - return p.buildFromField(fld, ftpe.Elem(), typable.Items(), seen) + return p.buildFromField(fld, ftpe.Elem(), typable.Items()) case *types.Slice: - return p.buildFromField(fld, ftpe.Elem(), typable.Items(), seen) + return p.buildFromField(fld, ftpe.Elem(), typable.Items()) case *types.Map: return p.buildFromFieldMap(ftpe, typable) case *types.Named: return p.buildNamedField(ftpe, typable) case *types.Alias: - return p.buildFieldAlias(ftpe, typable, fld, seen) + return p.buildFieldAlias(ftpe, typable) default: return fmt.Errorf("unknown type for %s: %T: %w", fld.String(), fld.Type(), ErrParameters) } } -func (p *Builder) buildFromFieldStruct(tpe *types.Struct, typable ifaces.SwaggerTypable) error { - sb := schema.NewBuilder(p.Ctx, p.Decl) - if err := sb.Build(schema.OptionFor(tpe, typable)); err != nil { - return err - } - for _, d := range sb.PostDeclarations() { - p.AppendPostDecl(d) - } - - return nil -} - func (p *Builder) buildFromFieldMap(ftpe *types.Map, typable ifaces.SwaggerTypable) error { // A Go map is only representable under in=body (object + additionalProperties). // In any OAS v2 SimpleSchema location (query/formData/path/header) it has no representation: @@ -347,80 +338,50 @@ func (p *Builder) buildFromFieldMap(ftpe *types.Map, typable ifaces.SwaggerTypab Schema: sch, } - sb := schema.NewBuilder(p.Ctx, p.Decl) - if err := sb.Build(schema.WithType( + return schema.Delegate(p.Builder, schema.WithType( ftpe.Elem(), - schema.NewTypable(sch, typable.Level()+1, p.Ctx.SkipExtensions())), - ); err != nil { - return err - } - - // Propagate the sub-builder's PostDeclarations so a model discovered only through the map's value - // type (no swagger:model annotation, no other reference site) makes it into the spec's definitions - // section. - // - // Every sibling buildFromFieldXxx method does the same; this loop went missing in M2.5's - // schema-builder factor-out — see the parameters-map-postdecl fixture. - for _, d := range sb.PostDeclarations() { - p.AppendPostDecl(d) - } - - return nil -} - -func (p *Builder) buildFromFieldInterface(tpe *types.Interface, typable ifaces.SwaggerTypable) error { - sb := schema.NewBuilder(p.Ctx, p.Decl) - if err := sb.Build(schema.OptionFor(tpe, typable)); err != nil { - return err - } - - for _, d := range sb.PostDeclarations() { - p.AppendPostDecl(d) - } - - return nil + schema.NewTypable(sch, typable.Level()+1, p.Ctx.SkipExtensions()), + )) } func (p *Builder) buildNamedField(ftpe *types.Named, typable ifaces.SwaggerTypable) error { o := ftpe.Obj() - if resolvers.IsAny(o) { - // e.g. Field interface{} or Field any - return nil - } - if resolvers.IsStdError(o) { - return fmt.Errorf("%s type not supported in the context of a parameter definition: %w", o.Name(), ErrParameters) + if resolvers.IsStdErrorType(ftpe) { + // An `error` has no meaning as a parameter, and the schema builder's rendering of it + // (`{type: string}`) would be a lie about what a client should send. Dropping the field is the + // right outcome — a struct shared between a parameter set and a response should lose it on the + // parameter side rather than carry it. + // + // It used to abort the whole scan. Skip-with-a-diagnostic is the house rule, and its sibling + // two arms down already follows it for a Go type with no SimpleSchema form (go-swagger#2804); + // this guard predates that and never got the same treatment. + // + // This is the ONE recognizer where a parameter must not answer as the other two builders do, + // so it sits above the canonical set rather than inside it. + return errNotAParameter } resolvers.MustNotBeABuiltinType(o) - decl, found := p.Ctx.DeclForType(o.Type()) - if !found { - return fmt.Errorf("unable to find package and source file for: %s: %w", ftpe.String(), ErrParameters) - } - - if resolvers.IsStdTime(o) { - typable.Typed("string", "date-time") + // The rest of the identity recognizers answer from the object alone, so they run before the lookup + // rather than after it. The subset hand-rolled here was `any` only — which is unreachable in this + // arm, since the predeclared `any` is an alias and lands in the one below. + if schema.ApplyStdlibSpecials(o, typable, p.Ctx.SkipExtensions()) { return nil } - if sfnm, isf := strfmtFromDoc(p.ParseBlocks(decl.Comments)); isf { - typable.Typed("string", sfnm) - return nil - } - - sb := schema.NewBuilder(p.Ctx, decl) - sb.InferNames() - if err := sb.Build(schema.OptionFor(decl.ObjType(), typable)); err != nil { - return err - } - - for _, d := range sb.PostDeclarations() { - p.AppendPostDecl(d) + decl, found := p.Ctx.DeclForType(o.Type()) + if !found { + return fmt.Errorf("unable to find package and source file for: %s: %w", ftpe.String(), ErrParameters) } - return nil + // No local recognizer or format short-circuit here: the delegation below reaches the schema + // builder's own, which are element-aware. The shortcuts that used to sit here wrote + // `Typed("string", format)` unconditionally, so a format on a `[]string` landed on the whole + // schema instead of on its items — describing a list of email addresses as one email address. + return schema.DelegateAs(p.Builder, decl, schema.OptionFor(decl.ObjType(), typable)) } -func (p *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypable, fld *types.Var, seen map[string]oaispec.Parameter) error { +func (p *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypable) error { o := tpe.Obj() if resolvers.IsAny(o) { // e.g. Field interface{} or Field any @@ -428,54 +389,22 @@ func (p *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl return nil // just leave an empty schema } - if resolvers.IsStdError(o) { - return fmt.Errorf("%s type not supported in the context of a parameter definition: %w", o.Name(), ErrParameters) + if resolvers.IsStdErrorType(tpe) { + // Same refusal as the named arm, and it has to be spelled against the resolved type: an alias's + // own object is the alias name, so the object-level recognizer that used to sit here could never + // fire and `type Wrapped = error` was emitted as a parameter while a bare `error` was dropped. + // + // It also aborted the entire scan where its twin skips the field. + return errNotAParameter } resolvers.MustNotBeABuiltinType(o) - resolvers.MustHaveRightHandSide(tpe) - - // TransparentAliases supersedes annotation at use sites — dissolve to the unaliased target via - // the schema sub-builder. - if p.Ctx.TransparentAliases() { - sb := schema.NewBuilder(p.Ctx, p.Decl) - if err := sb.Build(schema.OptionFor(tpe.Rhs(), typable)); err != nil { - return err - } - for _, d := range sb.PostDeclarations() { - p.AppendPostDecl(d) - } - return nil - } - decl, ok := p.Ctx.GetModel(o.Pkg().Path(), o.Name()) - if !ok { + // The resolution itself is shared with the responses builder: every classifier an alias + // declaration may carry lives in the schema package, and two copies of the walk drifted apart + // three times before this. + return schema.BuildFieldAlias(p.Builder, tpe, typable, func() error { return fmt.Errorf("can't find source file for aliased type: %v -> %v: %w", tpe, tpe.Rhs(), ErrParameters) - } - - // Non-body parameters are SimpleSchema targets and cannot carry $ref — always expand the alias - // to its unaliased target regardless of annotation. - // Walking through every alias layer (types.Unalias) dissolves chains fully in one step. - if typable.In() != inBody { - return p.buildFromField(fld, types.Unalias(tpe), typable, seen) - } - - // Body field: annotation gates first-class identity at the use site. - // See [§alias-handling](./README.md#alias-handling) for the cross-builder rule. - // - // - annotated alias → $ref preserves the alias name; the alias - // gets its own definition via MakeRef's AppendPostDecl side effect. - // - unannotated alias → dissolve to the unaliased target (full - // chain collapse via types.Unalias); the alias produces no - // definition entry. - // - // The mode flag (RefAliases vs Default) only affects the shape of the alias decl's OWN definition - // downstream — it does not change the field-site $ref target, which is gated entirely by - // annotation. - if decl.HasModelAnnotation() { - return p.MakeRef(decl, typable) - } - - return p.buildFromField(fld, types.Unalias(tpe), typable, seen) + }) } func (p *Builder) buildFromStruct(decl *scanner.EntityDecl, tpe *types.Struct, op *oaispec.Operation, seen map[string]oaispec.Parameter) error { @@ -615,7 +544,7 @@ func stripArrayPrefixes(arg string) (base string, depth int) { // // Returns skip=true (with a recorded diagnostic) when the Go type has no OAS v2 SimpleSchema // representation in this location and the field should be dropped. -func (p *Builder) resolveParamType(signals fieldDocSignals, fld *types.Var, name, in string, pty ifaces.SwaggerTypable, seen map[string]oaispec.Parameter) (skip bool, err error) { +func (p *Builder) resolveParamType(signals fieldDocSignals, fld *types.Var, name, in string, pty ifaces.SwaggerTypable) (skip bool, err error) { switch { case in == "formData" && signals.file: pty.Typed("file", "") @@ -625,12 +554,13 @@ func (p *Builder) resolveParamType(signals fieldDocSignals, fld *types.Var, name // The override wins outright; the Go type is not consulted. // A compatible swagger:strfmt then rides as a supplementary format back in processParamField. default: - if err := p.buildFromField(fld, fld.Type(), pty, seen); err != nil { - if errors.Is(err, errUnrepresentableParam) { + if err := p.buildFromField(fld, fld.Type(), pty); err != nil { + // Both sentinels mean "skip the field rather than panic or fail the whole scan"; they differ + // only in what the author is told. + switch { + case errors.Is(err, errUnrepresentableParam): // The field type has no OAS v2 SimpleSchema representation in this non-body location (e.g. a - // map under in=query). - // Record a located diagnostic and skip the field instead of panicking or failing the whole - // scan. + // map under in=query). Naming the location is the point — the same type is fine in a body. // // See go-swagger/go-swagger#2804. p.RecordDiagnostic(grammar.Warnf( @@ -639,8 +569,21 @@ func (p *Builder) resolveParamType(signals fieldDocSignals, fld *types.Var, name "parameter %q (in=%q) has Go type %s, which has no OAS v2 SimpleSchema representation; parameter skipped", name, in, fld.Type().String(), )) + + return true, nil + case errors.Is(err, errNotAParameter): + // Meaningless in every location, so the message deliberately does NOT suggest that another + // `in:` would work. + p.RecordDiagnostic(grammar.Warnf( + p.Ctx.PosOf(fld.Pos()), + grammar.CodeUnsupportedGoType, + "parameter %q has Go type %s, which describes an outcome rather than a value a client can send; parameter skipped", + name, fld.Type().String(), + )) + return true, nil } + return false, err } } @@ -726,7 +669,7 @@ func (p *Builder) processParamField(fld *types.Var, decl *scanner.EntityDecl, se pty = schema.NewTypable(pty.Schema(), 0, p.Ctx.SkipExtensions()) } - if skip, err := p.resolveParamType(signals, fld, name, in, pty, seen); err != nil { + if skip, err := p.resolveParamType(signals, fld, name, in, pty); err != nil { return "", err } else if skip { return "", nil diff --git a/internal/builders/resolvers/assertions.go b/internal/builders/resolvers/assertions.go index 4ba5de01..a6a7926d 100644 --- a/internal/builders/resolvers/assertions.go +++ b/internal/builders/resolvers/assertions.go @@ -168,6 +168,17 @@ func IsStdError(o *types.TypeName) bool { return o.Pkg() == nil && o.Name() == "error" } +// IsStdErrorType reports whether t IS the predeclared error, however it is spelled. +// +// [IsStdError] keys on an object, and an alias's object is the alias's own name — so +// `type Wrapped = error` never matches it, and a rule written against the object alone applies to +// one spelling of the same type and not the other. +func IsStdErrorType(t types.Type) bool { + named, ok := types.Unalias(t).(*types.Named) + + return ok && IsStdError(named.Obj()) +} + func IsStdJSONRawMessage(o *types.TypeName) bool { return o.Pkg() != nil && o.Pkg().Path() == "encoding/json" && o.Name() == "RawMessage" } diff --git a/internal/builders/resolvers/resolvers.go b/internal/builders/resolvers/resolvers.go index 13a02829..34ceb195 100644 --- a/internal/builders/resolvers/resolvers.go +++ b/internal/builders/resolvers/resolvers.go @@ -174,6 +174,15 @@ func (t tagOptions) Name() string { return t[0] } +// jsonTagIgnores reports whether a json struct tag skips the field entirely. +// +// encoding/json compares the WHOLE tag to "-": `json:"-"` ignores the field, while `json:"-,"` and +// `json:"-,omitempty"` name it literally "-". Splitting on the comma first conflates the two, which +// dropped a field Go does marshal. +func jsonTagIgnores(st reflect.StructTag) bool { + return st.Get("json") == "-" +} + // ParseFieldTag derives the emitted name and the encoding/json directives for a struct field. // // The name is sourced from the first struct-tag type in nameTags that supplies a usable name — a @@ -221,7 +230,7 @@ func ParseFieldTag(field *ast.Field, goName string, nameTags []string) (name str isString = IsFieldStringable(field.Type) } omitEmpty = jsonParts.Contain("omitempty") - if jsonParts.Name() == "-" { + if jsonTagIgnores(st) { return name, true, isString, omitEmpty, nil } @@ -229,10 +238,19 @@ func ParseFieldTag(field *ast.Field, goName string, nameTags []string) (name str // A rename can't name N members of a multi-name group, so each keeps its own Go name. if len(field.Names) <= 1 { for _, tagType := range nameTags { - if candidate := tagOptions(strings.Split(st.Get(tagType), ",")).Name(); candidate != "" && candidate != "-" { - name = candidate - break + candidate := tagOptions(strings.Split(st.Get(tagType), ",")).Name() + if candidate == "" { + continue + } + // "-" is a legitimate name only from the json tag, and only because the whole-tag ignore case + // returned above: `json:"-,"` names the field literally "-". For any other tag type there is + // no encoding rule to appeal to, so "-" stays "no usable name". + if candidate == "-" && tagType != "json" { + continue } + name = candidate + + break } } @@ -255,9 +273,11 @@ func ExplicitJSONName(field *ast.Field) string { if err != nil || strings.TrimSpace(tv) == "" { return "" } - name := tagOptions(strings.Split(reflect.StructTag(tv).Get("json"), ",")).Name() - if name == "-" { + st := reflect.StructTag(tv) + if jsonTagIgnores(st) { return "" } - return name + + // A remaining "-" is the literal name (`json:"-,"`), not an ignore. + return tagOptions(strings.Split(st.Get("json"), ",")).Name() } diff --git a/internal/builders/responses/doc_signals.go b/internal/builders/responses/doc_signals.go index cb7f5d58..24a8c16a 100644 --- a/internal/builders/responses/doc_signals.go +++ b/internal/builders/responses/doc_signals.go @@ -49,7 +49,7 @@ func scanFieldDocSignals(blocks []grammar.Block, doc *ast.CommentGroup) fieldDoc } for _, b := range blocks { - switch b.AnnotationKind() { //nolint:exhaustive // only ignore/file/strfmt are relevant here + switch b.AnnotationKind() { //nolint:exhaustive // only ignore/file/strfmt/type are relevant here case grammar.AnnIgnore: pd.ignored = true case grammar.AnnFile: @@ -59,6 +59,14 @@ func scanFieldDocSignals(blocks []grammar.Block, doc *ast.CommentGroup) fieldDoc pd.strfmt = arg pd.strfmtSet = true } + case grammar.AnnType: + // `swagger:type file` is a synonym for `swagger:file`, and the preferred spelling. Raising + // the same signal reuses the body-only gate that already governs swagger:file on a + // response, rather than adding a second one. Other swagger:type arguments are handled by + // the schema builder, not here. + if arg, ok := b.AnnotationArg(); ok && arg == fileTypeName { + pd.file = true + } } } diff --git a/internal/builders/responses/responses.go b/internal/builders/responses/responses.go index 504c414f..747c3b24 100644 --- a/internal/builders/responses/responses.go +++ b/internal/builders/responses/responses.go @@ -24,6 +24,9 @@ const ( inHeader = "header" ) +// fileTypeName is the OAS v2 `file` type, spelled as a swagger:type argument. +const fileTypeName = "file" + // Builder constructs OAS v2 response entries for one `swagger:response` declaration. // // Embeds *common.Builder for shared state (Ctx, Decl, PostDeclarations, diagnostics, ParseBlocks @@ -173,46 +176,33 @@ func (r *Builder) bodyPathFor(typable ifaces.SwaggerTypable) string { return "" } -func (r *Builder) buildFromField(fld *types.Var, tpe types.Type, typable ifaces.SwaggerTypable, seen map[string]bool) error { +func (r *Builder) buildFromField(fld *types.Var, tpe types.Type, typable ifaces.SwaggerTypable) error { switch ftpe := tpe.(type) { case *types.Basic: return resolvers.SwaggerSchemaForType(ftpe.Name(), typable) case *types.Struct: - return r.buildFromFieldStruct(ftpe, typable) + return schema.Delegate(r.Builder, schema.OptionFor(ftpe, typable), schema.WithPath(r.bodyPathFor(typable))) case *types.Pointer: - return r.buildFromField(fld, ftpe.Elem(), typable, seen) + return r.buildFromField(fld, ftpe.Elem(), typable) case *types.Interface: - return r.buildFromFieldInterface(ftpe, typable) + return schema.Delegate(r.Builder, schema.OptionFor(ftpe, typable), schema.WithPath(r.bodyPathFor(typable))) case *types.Array: defer r.descendBody("items")() - return r.buildFromField(fld, ftpe.Elem(), typable.Items(), seen) + return r.buildFromField(fld, ftpe.Elem(), typable.Items()) case *types.Slice: defer r.descendBody("items")() - return r.buildFromField(fld, ftpe.Elem(), typable.Items(), seen) + return r.buildFromField(fld, ftpe.Elem(), typable.Items()) case *types.Map: return r.buildFromFieldMap(ftpe, typable) case *types.Named: return r.buildNamedField(ftpe, typable) case *types.Alias: - return r.buildFieldAlias(ftpe, typable, fld, seen) + return r.buildFieldAlias(ftpe, typable) default: return fmt.Errorf("unknown type for %s: %T: %w", fld.String(), fld.Type(), ErrResponses) } } -func (r *Builder) buildFromFieldStruct(ftpe *types.Struct, typable ifaces.SwaggerTypable) error { - sb := schema.NewBuilder(r.Ctx, r.Decl) - if err := sb.Build(schema.OptionFor(ftpe, typable), schema.WithPath(r.bodyPathFor(typable))); err != nil { - return err - } - - for _, d := range sb.PostDeclarations() { - r.AppendPostDecl(d) - } - - return nil -} - func (r *Builder) buildFromFieldMap(ftpe *types.Map, typable ifaces.SwaggerTypable) error { // A Go map is only representable under in=body (object + additionalProperties). // A response header is an OAS v2 SimpleSchema target with no map representation. @@ -235,32 +225,10 @@ func (r *Builder) buildFromFieldMap(ftpe *types.Map, typable ifaces.SwaggerTypab // value's inline props (if any) anchor there. defer r.descendBody("additionalProperties")() valTypable := schema.NewTypable(sch, typable.Level()+1, r.Ctx.SkipExtensions()) - sb := schema.NewBuilder(r.Ctx, r.Decl) - if err := sb.Build( + return schema.Delegate(r.Builder, schema.WithType(ftpe.Elem(), valTypable), schema.WithPath(r.bodyPathFor(valTypable)), - ); err != nil { - return err - } - - for _, d := range sb.PostDeclarations() { - r.AppendPostDecl(d) - } - - return nil -} - -func (r *Builder) buildFromFieldInterface(tpe *types.Interface, typable ifaces.SwaggerTypable) error { - sb := schema.NewBuilder(r.Ctx, r.Decl) - if err := sb.Build(schema.OptionFor(tpe, typable), schema.WithPath(r.bodyPathFor(typable))); err != nil { - return err - } - - for _, d := range sb.PostDeclarations() { - r.AppendPostDecl(d) - } - - return nil + ) } func (r *Builder) buildFromType(otpe types.Type, resp *oaispec.Response, seen map[string]bool) error { @@ -276,6 +244,45 @@ func (r *Builder) buildFromType(otpe types.Type, resp *oaispec.Response, seen ma } } +// namedWrittenRHS reports a declaration whose written right-hand side is itself a NAMED type, +// together with that type. +// +// Only a named right-hand side redirects the build: a struct literal, a slice or a basic type is +// already the shape the response arm should build, and sending those through the sub-builder would +// publish the response type as a definition. +func namedWrittenRHS(ctx *scanner.ScanCtx, o *types.TypeName) (*scanner.EntityDecl, types.Type, bool) { + decl, found := ctx.DeclForType(o.Type()) + if !found { + return nil, nil, false + } + rhs, ok := writtenRHS(decl) + if !ok { + return nil, nil, false + } + if _, isNamed := rhs.(*types.Named); !isNamed { + return nil, nil, false + } + + return decl, rhs, true +} + +// writtenRHS returns the type a declaration was WRITTEN over — `Stamp` in `type StampResp Stamp` — +// as opposed to the fully peeled underlying that `types.Named.Underlying` yields. +// +// The distinction matters wherever a named layer carries meaning: a stdlib recognizer keys on +// `time.Time`, which peeling discards. +func writtenRHS(decl *scanner.EntityDecl) (types.Type, bool) { + if decl == nil || decl.Spec == nil || decl.Pkg == nil { + return nil, false + } + ti, ok := decl.Pkg.TypesInfo.Types[decl.Spec.Type] + if !ok || ti.Type == nil { + return nil, false + } + + return ti.Type, true +} + func (r *Builder) buildNamedType(tpe *types.Named, resp *oaispec.Response, seen map[string]bool) error { o := tpe.Obj() if resolvers.IsAny(o) || resolvers.IsStdError(o) { @@ -283,6 +290,30 @@ func (r *Builder) buildNamedType(tpe *types.Named, resp *oaispec.Response, seen } resolvers.MustNotBeABuiltinType(o) + // Follow the declaration's WRITTEN right-hand side when that is itself a named type, before + // dispatching on the underlying shape. + // + // `Underlying()` peels every named layer at once, so `type Stamp time.Time` arrives here as + // time.Time's STRUCT — read as a response struct whose fields become headers, of which time.Time + // has none, so the response came out with no schema. The schema builder does not have this problem + // because it builds from `Spec.Type`, where the recognizer sees `time.Time` one level in. + // + // Only a NAMED right-hand side redirects: a struct literal, a slice or a basic type is already the + // shape this arm should build, and sending those through the sub-builder would publish the + // response type as a definition. + if decl, rhs, ok := namedWrittenRHS(r.Ctx, o); ok { + var sch oaispec.Schema + typable := schema.NewTypable(&sch, 0, r.Ctx.SkipExtensions()) + if err := schema.DelegateAs(r.Builder, decl, + schema.OptionFor(rhs, typable), schema.WithPath(r.bodyPathFor(typable)), + ); err != nil { + return err + } + resp.WithSchema(&sch) + + return nil + } + switch stpe := o.Type().Underlying().(type) { case *types.Struct: if decl, found := r.Ctx.DeclForType(o.Type()); found { @@ -295,30 +326,60 @@ func (r *Builder) buildNamedType(tpe *types.Named, resp *oaispec.Response, seen var sch oaispec.Schema typable := schema.NewTypable(&sch, 0, r.Ctx.SkipExtensions()) + // The recognizer and the declaration's format are applied HERE rather than by the sub-build, + // and the sub-build is handed the UNDERLYING rather than the declared type — deliberately. + // A `swagger:response` declares a response, not a model: passing the named type would send + // it through the $ref machinery and publish it as a definition, which is the one thing this + // arm must not do. + // + // Both branches used to write into `sch` and return WITHOUT the resp.WithSchema below, so a + // response declared on a named time.Time or a named formatted type carried a description and + // no schema whatsoever. d := decl.Obj() if resolvers.IsStdTime(d) { typable.Typed("string", "date-time") + resp.WithSchema(&sch) + return nil } if sfnm, isf := strfmtFromDoc(r.ParseBlocks(decl.Comments)); isf { - typable.Typed("string", sfnm) + applyDeclFormat(sfnm, tpe.Underlying(), typable) + resp.WithSchema(&sch) + return nil } - sb := schema.NewBuilder(r.Ctx, decl) - sb.InferNames() - if err := sb.Build(schema.OptionFor(tpe.Underlying(), typable), schema.WithPath(r.bodyPathFor(typable))); err != nil { + + if err := schema.DelegateAs(r.Builder, decl, + schema.OptionFor(tpe.Underlying(), typable), schema.WithPath(r.bodyPathFor(typable)), + ); err != nil { return err } resp.WithSchema(&sch) - for _, d := range sb.PostDeclarations() { - r.AppendPostDecl(d) - } + return nil } return fmt.Errorf("responses can only be structs, did you mean for %s to be the response body?: %w", tpe.String(), ErrResponses) } } +// applyDeclFormat writes a declaration's `swagger:strfmt` onto target, honouring the element-driven +// items-vs-whole rule rather than assuming the whole schema. +// +// A byte or rune sequence is string-like and takes the format itself; any other sequence takes it on +// its items, so a `[]string` annotated `email` is a list of email addresses and not one of them. The +// rule itself lives in common, shared with the schema builder's own classifier — this only picks the +// element to hand it. +func applyDeclFormat(format string, underlying types.Type, target ifaces.SwaggerTypable) { + switch u := underlying.(type) { + case *types.Slice: + common.ApplyArrayLikeStrfmt(format, u.Elem(), target) + case *types.Array: + common.ApplyArrayLikeStrfmt(format, u.Elem(), target) + default: + target.Typed("string", format) + } +} + func (r *Builder) buildAlias(tpe *types.Alias, resp *oaispec.Response, seen map[string]bool) error { o := tpe.Obj() if resolvers.IsAny(o) || resolvers.IsStdError(o) { @@ -342,36 +403,28 @@ func (r *Builder) buildAlias(tpe *types.Alias, resp *oaispec.Response, seen map[ } func (r *Builder) buildNamedField(ftpe *types.Named, typable ifaces.SwaggerTypable) error { - decl, found := r.Ctx.DeclForType(ftpe.Obj().Type()) - if !found { - return fmt.Errorf("unable to find package and source file for: %s: %w", ftpe.String(), ErrResponses) - } + o := ftpe.Obj() - d := decl.Obj() - if resolvers.IsStdTime(d) { - typable.Typed("string", "date-time") + // The identity recognizers answer from the object alone and so run before the lookup below. + // This arm had none of them, and the lookup is not a soft gate here: a field typed `error` has no + // package, so resolving its declaring source dereferenced nil and took the whole scan down. + if schema.ApplyStdlibSpecials(o, typable, r.Ctx.SkipExtensions()) { return nil } - if sfnm, isf := strfmtFromDoc(r.ParseBlocks(decl.Comments)); isf { - typable.Typed("string", sfnm) - return nil - } - - sb := schema.NewBuilder(r.Ctx, decl) - sb.InferNames() - if err := sb.Build(schema.OptionFor(decl.ObjType(), typable), schema.WithPath(r.bodyPathFor(typable))); err != nil { - return err - } - - for _, d := range sb.PostDeclarations() { - r.AppendPostDecl(d) + decl, found := r.Ctx.DeclForType(o.Type()) + if !found { + return fmt.Errorf("unable to find package and source file for: %s: %w", ftpe.String(), ErrResponses) } - return nil + // See the parameters builder's twin: the delegation reaches the schema builder's element-aware + // classifiers, which the local shortcuts that used to sit here were not. + return schema.DelegateAs(r.Builder, decl, + schema.OptionFor(decl.ObjType(), typable), schema.WithPath(r.bodyPathFor(typable)), + ) } -func (r *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypable, fld *types.Var, seen map[string]bool) error { +func (r *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypable) error { o := tpe.Obj() if resolvers.IsAny(o) { // e.g. Field interface{} or Field any @@ -380,47 +433,11 @@ func (r *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl return nil // just leave an empty schema } - // TransparentAliases supersedes annotation at use sites — dissolve to the unaliased target via - // the schema sub-builder. - if r.Ctx.TransparentAliases() { - sb := schema.NewBuilder(r.Ctx, r.Decl) - if err := sb.Build(schema.OptionFor(tpe.Rhs(), typable), schema.WithPath(r.bodyPathFor(typable))); err != nil { - return err - } - for _, d := range sb.PostDeclarations() { - r.AppendPostDecl(d) - } - return nil - } - - // Non-body fields are SimpleSchema targets and cannot carry $ref — always expand the alias to - // its unaliased target regardless of annotation. types.Unalias collapses chains in one step. - if typable.In() != inBody { - return r.buildFromField(fld, types.Unalias(tpe), typable, seen) - } - - decl, ok := r.Ctx.GetModel(o.Pkg().Path(), o.Name()) - if !ok { + // Shared with the parameters builder — see schema.BuildFieldAlias. The cross-ref path is the one + // thing genuinely ours: a header anchors at respBase/headers/{h}, not under /schema. + return schema.BuildFieldAlias(r.Builder, tpe, typable, func() error { return fmt.Errorf("can't find source file for aliased type: %v: %w", tpe, ErrResponses) - } - - // Body field: annotation gates first-class identity at the use site. - // See [§alias-handling](./README.md#alias-handling) for the cross-builder rule. - // - // - annotated alias → $ref preserves the alias name; the alias - // gets its own definition via MakeRef's AppendPostDecl side - // effect. - // - unannotated alias → dissolve fully to the unaliased target; - // the alias produces no definition entry. - // - // The mode flag (RefAliases vs Default) only affects the shape of the alias decl's OWN definition - // downstream — it does not change the field-site $ref target, which is gated entirely by - // annotation. - if decl.HasModelAnnotation() { - return r.MakeRef(decl, typable) - } - - return r.buildFromField(fld, types.Unalias(tpe), typable, seen) + }, schema.WithPath(r.bodyPathFor(typable))) } func (r *Builder) buildFromStruct(decl *scanner.EntityDecl, tpe *types.Struct, resp *oaispec.Response, seen map[string]bool) error { @@ -469,7 +486,7 @@ func (r *Builder) buildEmbeddedField(fld *types.Var, decl *scanner.EntityDecl, r // single body, so per-field promotion is meaningless). go-swagger#1635. Other in: values still // promote the embed's fields (#2701). if r.inherited.InSet && r.inherited.In == inBody { - err := r.buildBodyEmbed(fld, resp, seen) + err := r.buildBodyEmbed(fld, resp) r.inherited = saved if err != nil { return err @@ -490,7 +507,7 @@ func (r *Builder) buildEmbeddedField(fld *types.Var, decl *scanner.EntityDecl, r // buildBodyEmbed renders an anonymously-embedded field marked `in: body` as the response body, // exactly like a named `Body Foo` field: the embedded type drives the body schema (a $ref to a // model, or its inline shape) instead of its members becoming response headers (go-swagger#1635). -func (r *Builder) buildBodyEmbed(fld *types.Var, resp *oaispec.Response, seen map[string]bool) error { +func (r *Builder) buildBodyEmbed(fld *types.Var, resp *oaispec.Response) error { var refAttempted bool header := oaispec.Header{} return r.buildFromField(fld, fld.Type(), responseTypable{ @@ -499,7 +516,7 @@ func (r *Builder) buildBodyEmbed(fld *types.Var, resp *oaispec.Response, seen ma response: resp, skipExt: r.Ctx.SkipExtensions(), refAttempted: &refAttempted, - }, seen) + }) } func (r *Builder) processResponseField(fld *types.Var, decl *scanner.EntityDecl, resp *oaispec.Response, seen map[string]bool) error { @@ -603,7 +620,7 @@ func (r *Builder) processResponseField(fld *types.Var, decl *scanner.EntityDecl, response: resp, skipExt: r.Ctx.SkipExtensions(), refAttempted: &refAttempted, - }, seen); err != nil { + }); err != nil { if errors.Is(err, errUnrepresentableHeader) { // The field type has no OAS v2 SimpleSchema representation in this header (non-body) location // (e.g. a map). diff --git a/internal/builders/responses/typable.go b/internal/builders/responses/typable.go index 48080759..30ce0de6 100644 --- a/internal/builders/responses/typable.go +++ b/internal/builders/responses/typable.go @@ -79,8 +79,20 @@ func (ht responseTypable) Schema() *oaispec.Schema { return ht.response.Schema } +// AddExtension writes onto the construct the typable is currently describing — the body schema under +// `in: body`, the header otherwise — mirroring paramTypable. +// +// It used to write onto the RESPONSE in every case, so an extension describing a body's Go type +// (`x-go-type` on a field typed `error`) surfaced as a sibling of `schema` and `description` instead +// of inside the schema it was talking about. func (ht responseTypable) AddExtension(key string, value any) { - ht.response.AddExtension(key, value) + if ht.in == inBody { + ht.Schema().AddExtension(key, value) + + return + } + + ht.header.AddExtension(key, value) } func (ht responseTypable) WithEnum(values ...any) { diff --git a/internal/builders/schema/README.md b/internal/builders/schema/README.md index 6917317c..bbb4b135 100644 --- a/internal/builders/schema/README.md +++ b/internal/builders/schema/README.md @@ -21,6 +21,7 @@ trade-offs, and known quirks live here. - [§embedded](#embedded) — embed routing, struct/interface specials asymmetry - [§embed-depth](#embed-depth) — ambiguous-embed diagnostic mechanism - [§omit](#omit) — `swagger:omit` — the author's pre-filter on promoted fields +- [§json-dash](#json-dash) — what `json:"-"` does, and the two shapes it is confused with - [§method-mangler](#method-mangler) — interface-method JSON-name derivation - [§user-overrides](#user-overrides) — explicit user-driven type/format overrides at decl-site and field-site - [§traceability](#traceability) — `x-go-name` / `x-go-package` / `x-go-type` origin extensions and `EmitXGoType` @@ -66,6 +67,19 @@ exit-validator's role. 4. Dispatch on `s.Decl.ObjType()`: `*types.Named` → `buildFromType(ti.Type, …)`; `*types.Alias` → `buildDeclAlias`; otherwise warn-and-skip. +Inside the `*types.Named` arm, an override classifier runs first, selected by +`Underlying()` kind — struct, basic, array and slice each have an arm. A +`swagger:model` type carrying an override publishes the **full** override schema +on its own definition here; field sites then `$ref` it, and +`buildNamedType`'s `refModel` gate skips the inline classifiers precisely +because this step is assumed to have run. + +That assumption is why the array and slice arms matter: while they were missing, +a `swagger:model` sequence with a `swagger:strfmt` published a definition with +the format silently dropped, and no later stage could recover it. `buildDeclAlias` +applies the same rule on the alias side, so `type ID = [16]byte` and +`type ID [16]byte` publish the same definition. + --- ## §dispatch-table — `buildNamedType`'s underlying-shape table @@ -296,6 +310,86 @@ Default and Ref; the difference shows up downstream in the alias's own definition (Expand structural under Default vs chain `$ref` under Ref). +#### `swagger:strfmt` on the alias runs before the dissolve + +Every row of the table above ends in either a `$ref` or a +dissolve, and a dissolve forgets that an alias was involved at +all. So a `swagger:strfmt` carried by the **alias declaration** +has to be applied *before* that point, or it is lost — which is +what used to happen at every use site. + +`ClassifierAliasStrfmt` (on `common.Builder`, shared with the +parameters and responses builders) is that entry. It runs on the +alias's own comments, dispatching on the alias's underlying kind +so the format lands where the equivalent **named** declaration +would put it: + +| Alias underlying | Where the format lands | Named counterpart | +|---|---|---| +| basic | whole schema | `classifierNamedBasic` | +| struct | whole schema — strfmt replaces the type | `classifierNamedStructStrfmt` | +| slice / array | by element type — see below | `classifierNamedArrayLike` | + +#### Items vs. whole schema, for a sequence + +A format on an array or slice can describe the sequence itself or each of its +elements. The decision is made by the **element type**, in +`common.IsStringLikeSequence`: + +| Element | Meaning | Result | +|---|---|---| +| `byte` / `uint8` | a byte sequence — string-like | format on the **whole schema** | +| `rune` / `int32` | a rune sequence — string-like | format on the **whole schema** | +| anything else | a collection of formatted values | format on the **items** | + +```go +type ID [16]byte // swagger:strfmt uuid → {string, format: uuid} +type Emails []string // swagger:strfmt email → {array, items: {string, format: email}} +``` + +This replaced a two-name allowlist (`byte`, and `bsonobjectid` for arrays only) +that was standing in for the same question — both are formats for a byte +sequence, `bsonobjectid` being a strfmt library type that happens to have an +array underlying. Keying on the element generalises to every such type (`uuid` +over `[16]byte`, `ulid`, …) with no list to extend, and it removes the +array-vs-slice asymmetry the allowlist had. + +go/types cannot tell `rune` from `int32` (rune is an alias), so `[]int32` is +treated alike — harmless, since a *string* format on integer elements was +already a contradiction. + +Note this settles only the mechanical half of the question. A format on a +sequence of some other element type is genuinely ambiguous — whether the author +means the sequence or its members cannot be read off the Go type — and it stays +on the items, as it always has. + +Two ordering constraints, both load-bearing: + +- **Above the `TransparentAliases` return.** The declaration + lookup was historically below it, so that mode dissolved + without ever reading the declaration. Lookup and classifier + both sit above it now, and a not-found declaration is only an + error on the paths that need it to emit a `$ref`. +- **Before the right-hand side is reached.** For an alias over a + recognised stdlib type (`type Stamp = time.Time`), the dissolve + lands on `time.Time` itself, where `applyStdlibSpecials` answers + `date-time`. An author writing `swagger:strfmt date` must beat + that: the annotation is the escape hatch for formats the library + cannot infer, so a recognizer is a default for un-annotated code + and never an override. + +A `swagger:model` alias is the exception, mirroring +`buildNamedType`'s `refModel` gate: it publishes its override on +its own definition and is referenced here, so the inline +classifier is skipped — except under SimpleSchema (`$ref` +illegal) and `TransparentAliases` (no `$ref` emitted), where the +format must inline after all. + +An alias that carries **no** annotation of its own is unaffected: +it dissolves as before, and if it lands on an annotated *named* +type the named machinery applies that type's format, exactly as +it always did. + The same applies inside allOf composition: `swagger:allOf` on an embed governs the *composition* shape (allOf vs flat inline); `swagger:model` on an embedded alias governs the *identity* of @@ -310,6 +404,12 @@ sites the alias always expands to the unaliased target regardless of annotation. The annotation gate has no effect for SimpleSchema because the question "$ref to what" never arises. +A `swagger:strfmt` on the alias still applies, though — the +expansion is a dissolve like any other, so `ClassifierAliasStrfmt` +runs ahead of it in the parameters and responses builders too. +Since `$ref` is illegal here, it runs even when the alias carries +`swagger:model`. + ### Top-level alias parameters and responses When `swagger:parameters` or `swagger:response` is on an alias @@ -443,6 +543,21 @@ same user-classifier-first precedence the rest of the builder uses). ### `scanEmbeddedFields` — embed classification +**Which `fieldDoc` signals an embed consumes.** Exactly five: `Ignored` +(`swagger:ignore`), `JSONName` (`swagger:name`, via `embedNestName`), +`OmitTargets` (`swagger:omit`, via `embedOmitTargets`), `IsAllOfMember` / +`AllOfClass` (`swagger:allOf`), and the `required:` inheritance hint read by +`buildPlainEmbed`. All five describe the *embedding*. + +`StrfmtName` and `TypeOverride` are parsed into the same `fieldDoc` and are +**never read here** — they describe the embedded type, whose shape comes from +its own declaration, not from the site that embeds it. Writing either on an +embed raises `CodeIneffectiveAnnotation` (`warnIneffectiveEmbedAnnotations`) +rather than being dropped in silence: both ARE honoured on a regular field +(`fields.go`), so the same annotation one field over means something, and the +scanner rejects an *unknown* annotation in that same comment — so without the +warning the author gets validation feedback implying it took effect. + Walks `*types.Struct`'s anonymous fields. Three signals decide classification per embed: @@ -619,6 +734,41 @@ contribute an `{}` entry to the outer schema. --- +## §json-dash — `json:"-"` is not a way to hide a promoted field + +The emitted property set must match what `encoding/json` puts on the wire. Two +readings of the `-` tag used to diverge from it. + +**A `-` field is ignored, not shadowing.** encoding/json drops such a field +entirely — it never enters the name set — so re-declaring a promoted field with +`json:"-"` does **not** hide the embedded one, which Go keeps marshalling: + +```go +type Outer struct { Base; Age int32 `json:"-"` } // Base has Age int32 `json:"age"` + +json.Marshal → {"id":1,"name":"n","age":42} // age survives, from Base +``` + +The builder used to delete the promoted property here, which understated the +wire. It no longer does. The intent is real, though — an author writing this +wants the field gone — so the shape raises `scan.shadowed-embed-field`, pointing +at [`swagger:omit`](#omit), which drops it for real. Contrast with a re-declaration +under a *real* name, where Go's depth rule does apply and the outer field wins. + +Tagging the **embed itself** `json:"-"` is a different act and does drop the whole +embed; that always worked. + +**The whole tag must be `-` to ignore.** encoding/json compares the entire tag, +so `json:"-,"` and `json:"-,omitempty"` name the field literally `-`. Splitting on +the comma first conflates the two and dropped a field Go marshals; `jsonTagIgnores` +in `resolvers` now does the whole-tag comparison, and `-` is accepted as a name +from the `json` tag only (no other tag type has an encoding rule to appeal to). + +**Witness.** `fixtures/enhancements/json-tag-fidelity` is differential: the fixture +module marshals its own types and commits the key sets as `wire.golden.json`, and +`TestJSONTagFidelity` asserts the emitted property sets equal them. Neither side +hard-codes an expectation — the oracle is encoding/json itself. + ## §omit — `swagger:omit`, the author's pre-filter on promoted fields Promoting an embed can produce a schema the author never meant, and codescan must not guess which @@ -1367,6 +1517,36 @@ sees `""` ("type unknown") and accepts everything. A shape-constrained keyword on a mismatched scalar model (e.g. `minProperties:` on a `type Foo string`) would therefore be written and never flagged. +The same ordering has a second consequence, on VALUES rather than on +shape gating. `default:`, `example:` and `enum:` are coerced against +`SchemaTypeOf(ps)`, which is `""` at dispatch time, so `ParseDefault` / +`ParseEnumValues` fall back to the raw string: `default: 8080` on a named +int became the string `"8080"`, `enum: 1,2,3` became `["1","2","3"]` — an +enum no validator can satisfy on an integer schema — and a JSON array +literal became a string holding JSON source. + +`RecoerceDeclValues` runs at the same seam as `RecheckSchemaShape` and +re-types those three from their raw form. A value that cannot be read as +the schema's type is **dropped with a warning** rather than emitted at the +wrong type: a document carrying `"notanumber"` on an integer schema is one +no validator accepts, while a document missing a default is merely +incomplete. Field sites always dropped such a value — but silently, which +was the invisible half of the same defect; they now report it too, so the +two sites agree on behaviour AND on reporting. + +For `enum:` the drop is per member (`pruneUncoercibleEnum`), and the +warning names the member: dropping narrows a closed set, which is a real +change to the author's contract, so a count would not be enough to act on. +It fails closed, matching what the `swagger:enum` annotation already does +when a const value does not fit. + +Coercion **towards** string never fails — every literal has a string form +— so a string-typed schema is skipped outright, and no diagnostic can +arise there. Re-coercion is sound precisely +because the fallback preserved the author's text verbatim: a value still +stored as a string is one that was never typed. String-typed schemas are +skipped, since there the fallback and the correct answer coincide. + Field- and items-level dispatch don't have this problem: their target's type is already set when their block is dispatched, so `checkShape` gates correctly inline. @@ -1453,7 +1633,7 @@ annotation of interest. |---|---|---| | `classifierTextMarshal` | `buildFromTextMarshal` end-of-pipe | `swagger:strfmt` | | `classifierNamedTypeOverride` | `buildFromType` named fallback, `buildFromStruct` pre-pass | `swagger:type` | -| `classifierNamedBasic` | `buildNamedBasic` | cascade: `swagger:strfmt → swagger:enum → swagger:default → swagger:type → swagger:alias` (the alias arm doubles as the SimpleSchema-mode primitive-inline branch — see [§simple-schema-mode](#simple-schema-mode)) | +| `classifierNamedBasic` | `buildNamedBasic` | cascade: `swagger:strfmt → swagger:enum → swagger:type`, then the SimpleSchema-mode primitive-inline branch (see [§simple-schema-mode](#simple-schema-mode)). `swagger:default` and `swagger:alias` are deprecated sinks: each raises `validate.deprecated` and falls through. `swagger:default` used to be TERMINAL here, returning handled on a target it never wrote — which published a typeless schema for the declared type | | `classifierNamedArrayLike` | `buildNamedArray` / `buildNamedSlice` | `swagger:strfmt`, `swagger:type` | | `classifierAliasTargetStrfmt` | `buildNamedAllOf` (struct + interface arms) | `swagger:strfmt` | | `classifierStructPreBuildType` | `buildFromStruct` top | `swagger:type` | diff --git a/internal/builders/schema/alias_field.go b/internal/builders/schema/alias_field.go new file mode 100644 index 00000000..d791f794 --- /dev/null +++ b/internal/builders/schema/alias_field.go @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package schema + +import ( + "go/types" + + "github.com/go-openapi/codescan/internal/builders/common" + "github.com/go-openapi/codescan/internal/builders/resolvers" + "github.com/go-openapi/codescan/internal/ifaces" + "github.com/go-openapi/codescan/internal/scanner" +) + +// inBodyLocation is the `in:` value naming a full-Schema parameter or response field. +const inBodyLocation = "body" + +// Delegate runs a schema sub-build for b and hands back whatever it discovered. +// +// The parameters and responses builders resolve most field shapes by deferring to this package, and +// each had written the three-step dance out per shape: construct a sub-builder on the caller's +// context and declaration, build, then drain the post-declaration queue into the caller. Forgetting +// the drain loses a discovered model silently, which is the kind of thing five copies eventually get +// wrong in one of them. +// +// The Options stay with the caller, because that is where the builders genuinely differ — the +// responses builder threads a cross-ref path, and a map value is targeted explicitly rather than +// through OptionFor. +func Delegate(b *common.Builder, opts ...Option) error { + return delegateWith(b, NewBuilder(b.Ctx, b.Decl), opts...) +} + +// DelegateAs is Delegate bound to a RESOLVED declaration rather than the caller's own. +// +// A field whose type resolves to another declaration is built in that declaration's context, so the +// sub-builder takes it and infers names from it. The InferNames call is easy to omit and its absence +// is not obvious in the output, which is reason enough for the two callers to share one spelling. +func DelegateAs(b *common.Builder, decl *scanner.EntityDecl, opts ...Option) error { + sb := NewBuilder(b.Ctx, decl) + sb.InferNames() + + return delegateWith(b, sb, opts...) +} + +func delegateWith(b *common.Builder, sb *Builder, opts ...Option) error { + if err := sb.Build(opts...); err != nil { + return err + } + // Propagate the sub-build's discoveries: a model reached only through this field — no + // swagger:model annotation, no other reference site — arrives in the spec only via this queue. + for _, d := range sb.PostDeclarations() { + b.AppendPostDecl(d) + } + + return nil +} + +// BuildFieldAlias resolves an alias reached as a FIELD of a `swagger:parameters` or +// `swagger:response` struct. +// +// Both builders used to carry their own copy of this, and the copies drifted: the declaration lookup +// sat below the TransparentAliases return in one and not the other, `swagger:type` reached the +// non-body branch and not the body one, and the not-found error fired at different points. Every +// classifier the alias declaration may carry lives in this package, so the resolution belongs here +// and the callers keep only what is genuinely theirs — which types they refuse, and which error +// they wrap a missing source in. +// +// notFound produces the caller's error for an alias whose declaration is not in the scanned set. It +// is consulted only on the path that needs the declaration to emit a `$ref`; a dissolve does not. +// +// extra carries per-caller Options — the responses builder threads a cross-ref path; the parameters +// builder passes none. +func BuildFieldAlias(b *common.Builder, tpe *types.Alias, typable ifaces.SwaggerTypable, + notFound func() error, extra ...Option, +) error { + resolvers.MustHaveRightHandSide(tpe) + + dissolve := func(t types.Type) error { + return Delegate(b, append([]Option{OptionFor(t, typable)}, extra...)...) + } + + // Everything dissolves through the schema builder, which owns the classifier cascade and the + // TransparentAliases rule alike — handing it the ALIAS rather than the right-hand side is what + // lets it read the declaration first. + // + // The single exception is a body field naming a swagger:model alias, which keeps its `$ref` + // identity here. TransparentAliases overrides that, dissolving at every use site by definition. + if typable.In() == inBodyLocation && !b.Ctx.TransparentAliases() { + o := tpe.Obj() + decl, found := b.Ctx.GetModel(o.Pkg().Path(), o.Name()) + if !found { + return notFound() + } + if decl.HasModelAnnotation() { + return b.MakeRef(decl, typable) + } + } + + return dissolve(tpe) +} diff --git a/internal/builders/schema/allof.go b/internal/builders/schema/allof.go index 8f2680f9..872a7cf5 100644 --- a/internal/builders/schema/allof.go +++ b/internal/builders/schema/allof.go @@ -7,8 +7,10 @@ import ( "fmt" "go/ast" "go/types" + "strings" "github.com/go-openapi/codescan/internal/builders/resolvers" + "github.com/go-openapi/codescan/internal/parsers/grammar" "github.com/go-openapi/codescan/internal/scanner" oaispec "github.com/go-openapi/spec" ) @@ -46,6 +48,7 @@ func (s *Builder) scanEmbeddedFields( if fd.Ignored { continue } + s.warnIneffectiveEmbedAnnotations(afld, fd) _, ignore, isString, omitEmpty, err := resolvers.ParseFieldTag(afld, fld.Name(), s.Ctx.NameFromTags()) if err != nil { @@ -102,6 +105,33 @@ func (s *Builder) scanEmbeddedFields( return target, hasAllOf, nil } +// warnIneffectiveEmbedAnnotations reports `swagger:strfmt` / `swagger:type` written in an EMBEDDED +// field's own comment, which no embed arm consults. +// +// Both are honoured on a regular field, so the same annotation in the same syntactic position — a +// field's doc comment — works one line and does nothing the next. Worse, the scanner reads that +// comment and rejects an unknown annotation in it, so the author gets validation feedback implying +// the annotation is meaningful and no feedback at all that it was discarded. +// +// An embed contributes its embedded type's shape; what that shape is comes from that type's own +// declaration. So the annotation belongs there, and the message says so rather than merely refusing. +func (s *Builder) warnIneffectiveEmbedAnnotations(afld *ast.Field, fd fieldDoc) { + var annotations []string + if fd.StrfmtName != "" { + annotations = append(annotations, "swagger:strfmt") + } + if fd.TypeOverride != "" { + annotations = append(annotations, "swagger:type") + } + if len(annotations) == 0 { + return + } + + s.RecordDiagnostic(grammar.Warnf(s.Ctx.PosOf(afld.Pos()), grammar.CodeIneffectiveAnnotation, + "%s on an embedded field has no effect and is ignored; annotate the embedded type's own "+ + "declaration instead", strings.Join(annotations, " and "))) +} + // buildPlainEmbed handles an anonymous embed that carries no `swagger:allOf` annotation, returning // the (possibly newly-assigned) property target. // @@ -202,10 +232,7 @@ func (s *Builder) buildNamedAllOf(ftpe *types.Named, schema *oaispec.Schema) err tgt := NewTypable(schema, 0, s.skipExtensions) tio := ftpe.Obj() - if s.classifierAliasTargetStrfmt(ftpe, tgt) { - return nil - } - if applyStdlibSpecials(tio, tgt, s.skipExtensions) { + if ApplyStdlibSpecials(tio, tgt, s.skipExtensions) { return nil } @@ -214,10 +241,39 @@ func (s *Builder) buildNamedAllOf(ftpe *types.Named, schema *oaispec.Schema) err return fmt.Errorf("can't find source for named allOf member %s: %w", ftpe.String(), ErrSchema) } + // A `swagger:model` member is referenced, and its classifiers ride on its own definition — the + // same gate buildNamedType applies before inlining an override. if decl.HasModelAnnotation() { return s.MakeRef(decl, tgt) } + // The author's classifiers, in the precedence the field dispatch uses: `swagger:type` decides the + // type axis outright, then the shape-aware classifiers. + // + // This arm used to run one shape-BLIND strfmt check and no type override at all, so a member + // carrying `swagger:type` or `swagger:enum` came out as an EMPTY schema (its basic underlying then + // fell to the warn-and-skip default below), and a format on a string sequence landed on the whole + // member instead of on its items. + if handled, recurse := s.classifierNamedTypeOverride( + decl.Comments, tgt, ftpe, s.Ctx.PosOf(tio.Pos()), + ); handled { + if recurse { + return s.buildFromType(ftpe.Underlying(), tgt) + } + + return nil + } + if handled, recurse := s.applyNamedShapeClassifier(decl.Comments, ftpe, tgt); handled { + if recurse != nil { + return recurse() + } + + return nil + } + + // Shape dispatch. Unlike the field arm this INLINES rather than emitting a $ref: a member that is + // not a `swagger:model` has no definition to point at, and publishing one would put types in the + // spec their author never asked to expose. switch utpe := ftpe.Underlying().(type) { case *types.Struct: return s.buildFromStruct(decl, utpe, schema, make(map[string]propOwner)) diff --git a/internal/builders/schema/embedded.go b/internal/builders/schema/embedded.go index a735eb0e..75f36b6c 100644 --- a/internal/builders/schema/embedded.go +++ b/internal/builders/schema/embedded.go @@ -64,7 +64,7 @@ func (s *Builder) buildEmbedded(tpe types.Type, schema *oaispec.Schema, nameByJS // buildNamedEmbedded inlines an embedded named struct or interface into the outer schema. // -// The interface arm runs `applyStdlibSpecials` so `error` etc. recognize cleanly; the struct arm +// The interface arm runs `ApplyStdlibSpecials` so `error` etc. recognize cleanly; the struct arm // does not — the asymmetry is intentional, see README §embedded. // // # Details @@ -95,7 +95,7 @@ func (s *Builder) buildNamedEmbedded(tpe *types.Named, schema *oaispec.Schema, n } o := tpe.Obj() target := NewTypable(schema, 0, s.skipExtensions) - if applyStdlibSpecials(o, target, s.skipExtensions) { + if ApplyStdlibSpecials(o, target, s.skipExtensions) { return nil } @@ -135,7 +135,7 @@ func (s *Builder) processEmbeddedType(fld types.Type, flist []*ast.Field, decl * o := ftpe.Obj() var dummySchema oaispec.Schema ps := NewTypable(&dummySchema, 0, s.skipExtensions) - if applyStdlibSpecials(o, ps, s.skipExtensions) { + if ApplyStdlibSpecials(o, ps, s.skipExtensions) { return false, nil } return s.buildNamedInterface(ftpe, flist, decl, schema, nameByJSON) diff --git a/internal/builders/schema/fields.go b/internal/builders/schema/fields.go index f840207a..f54b261d 100644 --- a/internal/builders/schema/fields.go +++ b/internal/builders/schema/fields.go @@ -271,16 +271,14 @@ func (s *Builder) structFieldCarrier(fld *types.Var, decl *scanner.EntityDecl, t } if ignore { // A `json:"-"` re-declaration does NOT shadow a promoted field in Go — encoding/json ignores the - // field entirely, so the embedded one keeps marshalling. Report it and point at swagger:omit, - // which drops it for real. + // field entirely, so it never enters the name set and the embedded one keeps marshalling. The + // schema says what goes on the wire, so the promoted property stays; the Hint points at + // swagger:omit, which drops it for real. + // + // This used to delete the promoted property, which understated the wire. See + // [§json-dash](./README.md#json-dash). s.warnShadowedByJSONDash(fld, afld, target, nameByJSON) - for jsonName, prior := range nameByJSON { - if prior.goName == fld.Name() { - delete(target.Properties, jsonName) - break - } - } return fieldCarrier{}, false, nil } diff --git a/internal/builders/schema/schema.go b/internal/builders/schema/schema.go index 0be83123..ca630340 100644 --- a/internal/builders/schema/schema.go +++ b/internal/builders/schema/schema.go @@ -106,8 +106,10 @@ func (s *Builder) Build(opts ...Option) error { // The decl-comment block is dispatched before the Go type is resolved onto the schema (see // buildFromDecl), so the inline checkShape ran against an empty type. - // Re-gate now that the type is known: strip validations illegal for the resolved type and warn. + // Re-gate now that the type is known: strip validations illegal for the resolved type and warn, + // and re-type the value keywords that were coerced against nothing. // See [§decl-shape-recheck](./README.md#decl-shape-recheck). + handlers.RecoerceDeclValues(&schema, s.Ctx.PosOf(s.Decl.Spec.Pos()), s.RecordDiagnostic) handlers.RecheckSchemaShape(&schema, s.Ctx.PosOf(s.Decl.Spec.Pos()), s.RecordDiagnostic) s.definitions[defKey] = schema @@ -170,7 +172,7 @@ func (s *Builder) buildFromDecl(schema *oaispec.Schema) error { // chain). // See [§special-types](./README.md#special-types). ps := NewTypable(schema, 0, s.skipExtensions) - if applyStdlibSpecials(s.Decl.Obj(), ps, s.skipExtensions) { + if ApplyStdlibSpecials(s.Decl.Obj(), ps, s.skipExtensions) { return nil } @@ -209,6 +211,18 @@ func (s *Builder) buildFromDecl(schema *oaispec.Schema) error { if s.classifierNamedBasic(s.Decl.Comments, s.Decl.Pkg, tpe, ut, defTgt) { return nil } + case *types.Array: + if handled, _ := s.classifierNamedArrayLike(s.Decl.Comments, defTgt, ut.Elem()); handled { + return nil + } + case *types.Slice: + // Array and slice were missing from this switch, so a swagger:model sequence carrying a + // `swagger:strfmt` published a definition with the format dropped — and nothing downstream + // compensates, because buildNamedType's refModel gate skips the inline classifiers precisely + // on the assumption that the declaration already applied the override. + if handled, _ := s.classifierNamedArrayLike(s.Decl.Comments, defTgt, ut.Elem()); handled { + return nil + } } ti := s.Decl.Pkg.TypesInfo.Types[s.Decl.Spec.Type] resolvers.MustBeAType(ti) // invariant @@ -245,8 +259,11 @@ func (s *Builder) buildDeclAlias(tpe *types.Alias, target ifaces.SwaggerTypable) // `swagger:strfmt` on a Named decl is unaffected — that path is covered by // `classifierNamedStructStrfmt` (struct underlying) and `classifierNamedBasic` (primitive // underlying), both fired from `buildNamedType` after the underlying-kind switch. - if name, ok := s.findAnnotationArg(s.Decl.Comments, grammar.AnnStrfmt); ok { - target.Typed("string", name) + s.warnUnfixableAliasEnum(s.Decl.Comments, tpe, s.Ctx.PosOf(s.Decl.Ident.Pos())) + + // Detection is symmetric with the named decl arm above: the same element-driven items-vs-whole + // rule, so `type ID = [16]byte` and `type ID [16]byte` publish the same definition. + if s.classifierAliasStrfmt(s.Decl.Comments, tpe, target) { return nil } @@ -276,7 +293,7 @@ func (s *Builder) buildDeclAlias(tpe *types.Alias, target ifaces.SwaggerTypable) // The TransparentAliases path at line 156 already gets this right via buildFromType(rhs); Expand // needs the same recognizer call before its Underlying fallthrough. if obj := rhsTypeName(rhs); obj != nil && - applyStdlibSpecials(obj, target, s.skipExtensions) { + ApplyStdlibSpecials(obj, target, s.skipExtensions) { return nil } return s.buildFromType(tpe.Underlying(), target) @@ -299,7 +316,7 @@ func (s *Builder) buildDeclAlias(tpe *types.Alias, target ifaces.SwaggerTypable) // For predeclared `error`, this is also the only safe path: it has no package, so the GetModel // lookup below would nil-panic on Pkg().Path(). // User-defined named types (non-stdlib) fall through to the GetModel + MakeRef chain as before. - if applyStdlibSpecials(ro, target, s.skipExtensions) { + if ApplyStdlibSpecials(ro, target, s.skipExtensions) { return nil } if ro.Pkg() == nil { @@ -321,7 +338,7 @@ func (s *Builder) buildDeclAlias(tpe *types.Alias, target ifaces.SwaggerTypable) return nil } - if applyStdlibSpecials(ro, target, s.skipExtensions) { + if ApplyStdlibSpecials(ro, target, s.skipExtensions) { return nil } @@ -403,16 +420,57 @@ func (s *Builder) buildAlias(tpe *types.Alias, target ifaces.SwaggerTypable) err } o := tpe.Obj() - if applyStdlibSpecials(o, target, s.skipExtensions) { + if ApplyStdlibSpecials(o, target, s.skipExtensions) { return nil } resolvers.MustNotBeABuiltinType(o) + // Look the declaration up BEFORE any dissolve, so the alias's own classifier annotations are + // honoured in all three modes. + // + // The lookup used to sit below the TransparentAliases return, which meant that mode dissolved + // without ever reading the declaration — and a `swagger:strfmt` on the alias was lost. Not found + // is only an error on the paths that need the decl to emit a $ref; TransparentAliases dissolves + // regardless, as it did before. + decl, ok := s.Ctx.GetModel(o.Pkg().Path(), o.Name()) + + // refModel mirrors buildNamedType's gate: a swagger:model alias publishes its override on its OWN + // definition (buildDeclAlias) and is referenced here, so the inline classifier is skipped. + // + // Not under SimpleSchema, where $ref is illegal in OAS v2 and the override must inline; and not + // under TransparentAliases, which never emits the $ref this defers to. + refModel := ok && decl.HasModelAnnotation() && !s.simpleSchema && !s.Ctx.TransparentAliases() + + if ok { + // `swagger:enum` on an alias is unfixable rather than merely unimplemented — report it wherever + // the alias is reached, since that is where the members go missing. + s.warnUnfixableAliasEnum(decl.Comments, tpe, s.Ctx.PosOf(decl.Ident.Pos())) + } + + if ok && !refModel { + // `swagger:type` first, then `swagger:strfmt` — the same precedence the named side applies at + // buildNamedType, where classifierNamedTypeOverride runs ahead of the underlying-kind classifiers + // and rides a co-present format as an advisory hint. + // + // ownType is the alias's right-hand side: that is what `inline` should inline. + if handled, recurse := s.classifierNamedTypeOverride( + decl.Comments, target, tpe.Rhs(), s.Ctx.PosOf(decl.Ident.Pos()), + ); handled { + if recurse { + return s.buildFromType(tpe.Rhs(), target) + } + + return nil + } + if s.classifierAliasStrfmt(decl.Comments, tpe, target) { + return nil + } + } + if s.Ctx.TransparentAliases() { return s.buildFromType(tpe.Rhs(), target) } - decl, ok := s.Ctx.GetModel(o.Pkg().Path(), o.Name()) if !ok { return fmt.Errorf("can't find source file for aliased type: %v: %w", tpe, ErrSchema) } @@ -443,17 +501,15 @@ func (s *Builder) buildNamedType(titpe *types.Named, target ifaces.SwaggerTypabl } tio := titpe.Obj() - if applyStdlibSpecials(tio, target, s.skipExtensions) { + if ApplyStdlibSpecials(tio, target, s.skipExtensions) { return nil } // PkgForType-miss catches types we can't anchor to a scanned package (predeclared `comparable`, // generic type params, compiler-internal shapes). // - // Complementary to the UnsupportedBuiltin guard above; also yields `pkg` for the Basic classifier - // below. - pkg, found := s.Ctx.PkgForType(titpe) - if !found { + // Complementary to the UnsupportedBuiltin guard above. + if _, found := s.Ctx.PkgForType(titpe); !found { return nil } @@ -492,13 +548,23 @@ func (s *Builder) buildNamedType(titpe *types.Named, target ifaces.SwaggerTypabl return s.buildFromType(titpe.Underlying(), target) } + // The shape-aware half of the classifier cascade, shared with the composition arm + // (buildNamedAllOf) so the two cannot answer a `swagger:strfmt` / `swagger:enum` differently. + // Skipped for a swagger:model type, whose overrides ride on its own definition. + if !refModel { + if handled, recurse := s.applyNamedShapeClassifier(cmt, titpe, target); handled { + if recurse != nil { + return recurse() + } + + return nil + } + } + // Underlying-shape table. // See [§dispatch-table](./README.md#dispatch-table). switch utitpe := titpe.Underlying().(type) { case *types.Struct: - if !refModel && s.classifierNamedStructStrfmt(cmt, target) { - return nil - } return s.resolveRefOr(tio, target, nil) case *types.Interface: @@ -509,17 +575,14 @@ func (s *Builder) buildNamedType(titpe *types.Named, target ifaces.SwaggerTypabl s.warnUnsupportedGoType("buildNamedType", tio) return nil } - if !refModel && s.classifierNamedBasic(cmt, pkg, titpe, utitpe, target) { - return nil - } return s.resolveRefOr(tio, target, func() error { return resolvers.SwaggerSchemaForType(utitpe.String(), target) }) case *types.Array: - return s.buildNamedArrayLike(tio, cmt, utitpe.Elem(), target, false, refModel) + return s.buildNamedArrayLike(tio, utitpe.Elem(), target) case *types.Slice: - return s.buildNamedArrayLike(tio, cmt, utitpe.Elem(), target, true, refModel) + return s.buildNamedArrayLike(tio, utitpe.Elem(), target) case *types.Map: return s.resolveRefOr(tio, target, nil) @@ -530,21 +593,11 @@ func (s *Builder) buildNamedType(titpe *types.Named, target ifaces.SwaggerTypabl } } -// buildNamedArrayLike is the unified Array/Slice arm. forSlice toggles the slice-only -// "bsonobjectid" special case in classifierNamedArrayLike. isModel skips the inline override -// classifier so a swagger:model array/slice type is referenced by $ref (its override schema lives -// on its own definition). -func (s *Builder) buildNamedArrayLike(tio *types.TypeName, cmt *ast.CommentGroup, elem types.Type, tgt ifaces.SwaggerTypable, forSlice, isModel bool) error { - if !isModel { - if handled, recurse := s.classifierNamedArrayLike(cmt, tgt, forSlice); handled { - if recurse { - defer s.descend("items")() - return s.buildFromType(elem, tgt.Items()) - } - return nil - } - } - +// buildNamedArrayLike is the unified Array/Slice arm. +// +// The author's classifiers ran in applyNamedShapeClassifier before this point, so what remains is +// the $ref-or-inline decision and the items build. +func (s *Builder) buildNamedArrayLike(tio *types.TypeName, elem types.Type, tgt ifaces.SwaggerTypable) error { return s.resolveRefOr(tio, tgt, func() error { defer s.descend("items")() return s.buildFromType(elem, tgt.Items()) @@ -643,10 +696,10 @@ func hasNamedCore(tpe types.Type) bool { } // rhsTypeName extracts the *types.TypeName from an alias RHS when it is one of the two kinds -// applyStdlibSpecials accepts: *types.Named (the direct stdlib reference, e.g. `Timestamp = +// ApplyStdlibSpecials accepts: *types.Named (the direct stdlib reference, e.g. `Timestamp = // time.Time`) or *types.Alias (the chained reference, e.g. `Wrap = Timestamp`). // -// Returns nil for anonymous and other RHS kinds — applyStdlibSpecials has nothing to recognize on +// Returns nil for anonymous and other RHS kinds — ApplyStdlibSpecials has nothing to recognize on // those. // // Used by buildDeclAlias's Expand branch to consult the stdlib recognizers before walking diff --git a/internal/builders/schema/schema_test.go b/internal/builders/schema/schema_test.go index ce87156f..4c3585e7 100644 --- a/internal/builders/schema/schema_test.go +++ b/internal/builders/schema/schema_test.go @@ -51,7 +51,13 @@ func TestBuilder(t *testing.T) { schema.Description, ) assert.Len(t, schema.Required, 3) - assert.Len(t, schema.Properties, 12) + // 13, not 12: `json:"-,omitempty"` on IgnoredOther names the field literally "-" rather than + // ignoring it — encoding/json compares the WHOLE tag to "-". Its sibling `json:"-"` on Ignored is + // a true ignore and contributes nothing. + assert.Len(t, schema.Properties, 13) + dash, hasDash := schema.Properties["-"] + assert.TrueT(t, hasDash, `json:"-,omitempty" must emit a property named "-"`) + assert.EqualT(t, "IgnoredOther", dash.Extensions["x-go-name"]) scantest.AssertProperty(t, &schema, "integer", "id", "int64", "ID") prop, ok := schema.Properties["id"] @@ -509,6 +515,17 @@ func TestArrayOfPointers(t *testing.T) { scantest.AssertProperty(t, &schema, "array", "cars", "", "Cars") } +// TestOverridingOneIgnore pins the wire-faithful reading of a promoted field re-declared with +// `json:"-"`. +// +// encoding/json ignores a `-` field ENTIRELY: it never enters the name set, so it does not shadow +// the `age` promoted from the embed, which Go still marshals. The schema describes the wire, so +// `age` stays. This test previously asserted 2 properties — the schema understated the wire, and +// the assertion recorded that as the contract. +// +// An author who wants the property gone wants `swagger:omit` on the embed; the scan raises +// `scan.shadowed-embed-field` pointing there. Differential coverage against encoding/json itself +// lives in TestJSONTagFidelity. func TestOverridingOneIgnore(t *testing.T) { ctx := scantest.LoadClassificationPkgsCtx(t) decl := getClassificationModel(ctx, "OverridingOneIgnore") @@ -521,7 +538,8 @@ func TestOverridingOneIgnore(t *testing.T) { scantest.AssertProperty(t, &schema, "integer", "id", "int64", "ID") scantest.AssertProperty(t, &schema, "string", "name", "", "Name") - assert.Len(t, schema.Properties, 2) + scantest.AssertProperty(t, &schema, "integer", "age", "int32", "Age") + assert.Len(t, schema.Properties, 3) } type collectionAssertions struct { @@ -1848,6 +1866,12 @@ func TestParamsShape_DescWithRef_BothModes(t *testing.T) { // The example must travel on the override arm of the allOf compound, never as a sibling of $ref. // The DescWithRef toggle does not change this case — when validations (here, `example`) are // present, the allOf wrap is mandatory regardless of the flag. +// +// The decl-level `example:` / `default:` on Book are incidental to that shape, but they pin a +// second property: an object literal is emitted as real JSON at a DECLARATION site, exactly as the +// field-level one on `Author` already was. They used to differ inside this very expectation — the +// declaration pair came out as escaped strings, because a decl's keywords are coerced before its Go +// type is known. func TestIssue2540(t *testing.T) { // Sub-builder unit tests run without the spec reduce stage, so the definitions key and the $ref // stay fully-qualified. @@ -1856,8 +1880,8 @@ func TestIssue2540(t *testing.T) { "description": "At this moment, a book is only described by its publishing date\nand author.", "type": "object", "title": "Book holds all relevant information about a book.", - "example": "{ \"Published\": 2026, \"Author\": \"Fred\" }", - "default": "{ \"Published\": 1900, \"Author\": \"Unknown\" }", + "example": {"Published": 2026, "Author": "Fred"}, + "default": {"Published": 1900, "Author": "Unknown"}, "properties": { "Author": { "allOf": [ diff --git a/internal/builders/schema/special_types.go b/internal/builders/schema/special_types.go index 719acd61..5c107d49 100644 --- a/internal/builders/schema/special_types.go +++ b/internal/builders/schema/special_types.go @@ -64,7 +64,7 @@ const ( recognizeRawMessage // recognizeStdUUID is an identity match on the go1.27 stdlib uuid.UUID. // - // Safe everywhere, so it lives in the canonical set applied by [applyStdlibSpecials]. + // Safe everywhere, so it lives in the canonical set applied by [ApplyStdlibSpecials]. // See [§special-types](./README.md#special-types). recognizeStdUUID // recognizeUUID is a fuzzy name-only match (case-insensitive "uuid"). @@ -74,15 +74,22 @@ const ( recognizeUUID ) -// applyStdlibSpecials runs the canonical safe set of identity-based recognizers (any / time.Time / +// ApplyStdlibSpecials runs the canonical safe set of identity-based recognizers (any / time.Time / // error / json.RawMessage / go1.27 uuid.UUID). // -// Safe at every call site that handles a *types.TypeName. +// Safe at every call site that handles a *types.TypeName. Exported for the parameters and responses +// builders, which reach it from their own field arms: each used to carry a hand-rolled subset of +// these, differing per function, and the subsets drifted. +// +// Call it BEFORE any declaration lookup. Every recognizer here answers from the object's identity +// alone, so requiring a declaration first subordinates a rule that needs nothing to one that can +// fail — and for the predeclared `error` it always fails, since a predeclared object has no package +// and therefore no declaring source to find. // // # Details // // See [§special-types](./README.md#special-types). -func applyStdlibSpecials(obj *types.TypeName, target ifaces.SwaggerTypable, skipExt bool) bool { +func ApplyStdlibSpecials(obj *types.TypeName, target ifaces.SwaggerTypable, skipExt bool) bool { return applySpecialType(obj, target, skipExt, recognizeAny, recognizeTime, recognizeError, recognizeRawMessage, recognizeStdUUID) } diff --git a/internal/builders/schema/type_override.go b/internal/builders/schema/type_override.go index 78e5a960..80ee7fd1 100644 --- a/internal/builders/schema/type_override.go +++ b/internal/builders/schema/type_override.go @@ -16,6 +16,84 @@ import ( oaispec "github.com/go-openapi/spec" ) +// refuseUnrepresentableOverride reports whether a `swagger:type` base must be IGNORED because the +// location cannot carry what it resolves to. +// +// A non-body parameter or a response header is an OAS-2 SimpleSchema, where `type` is mandatory and +// restricted to {string, number, integer, boolean, array} (plus `file`, formData-only). An override +// resolving to an object has nowhere to go there. +// +// Ignoring beats applying: `type` is required, so the Go-derived type must survive. The alternative +// outcomes were both invalid — a bare `object` base reached the target and was reset to `{}` by +// validateSimpleSchemaOutcome, leaving the parameter untyped, while a type-name reference resolved +// into paramTypable.Schema()'s nil and vanished with no diagnostic at all, the check never firing +// because the illegal value never materialised to be checked. +// +// Side-effect free: it inspects, it does not build. +func (s *Builder) refuseUnrepresentableOverride(arg string, ownType types.Type, pos token.Position) bool { + if !s.simpleSchema { + return false + } + + base, depth := stripArrayPrefixes(arg) + if depth > 0 { + // `[]T` is an array, which SimpleSchema allows — but its items are a SimpleSchema too, so the + // element carries the same restriction. + return s.overrideYieldsObject(base, ownType) + } + if !s.overrideYieldsObject(base, ownType) { + return false + } + + s.RecordDiagnostic(grammar.Warnf(pos, grammar.CodeUnsupportedInSimpleSchema, + "swagger:type %s resolves to an object, which an OAS v2 SimpleSchema (non-body parameter or "+ + "response header, in=%q) cannot carry; the annotation is ignored and the Go type is used "+ + "instead, since SimpleSchema requires a type", arg, s.paramIn)) + + return true +} + +// overrideYieldsObject reports whether a swagger:type base resolves to an object-shaped schema. +// +// The four base kinds are answered without building: the `inline`/`array` keywords inline the +// annotated Go type, a recognised scalar name is never an object except `object` itself, and +// anything else is a reference to a scanned type whose underlying decides. +func (s *Builder) overrideYieldsObject(base string, ownType types.Type) bool { + switch base { + case keywordInline, keywordArray: + return ownType != nil && isObjectLikeUnderlying(ownType) + case keywordFile: + return false // refused earlier, with its own diagnostic + case "object": + return true + } + + // A recognised scalar / Go-builtin / OAS-2 name resolves without touching the scanned packages. + var probe oaispec.Schema + if err := resolvers.SwaggerSchemaForType(base, NewTypable(&probe, 0, true)); err == nil { + return len(probe.Type) > 0 && probe.Type[0] == "object" + } + + // Otherwise a type-name reference: its underlying decides. + decl, found, ambiguous := s.resolveNamedTypeLeaf(base, token.Position{}) + if ambiguous || !found { + return false // unknown name — resolveTypeBase reports it + } + t := declNamedType(decl) + + return t != nil && isObjectLikeUnderlying(t) +} + +// isObjectLikeUnderlying reports whether a Go type renders as a Swagger object. +func isObjectLikeUnderlying(t types.Type) bool { + switch t.Underlying().(type) { + case *types.Struct, *types.Map, *types.Interface: + return true + default: + return false + } +} + // resolveTypeOverride applies a `swagger:type` argument onto tgt, ALWAYS producing an inline schema // (never a $ref). // @@ -41,10 +119,15 @@ import ( // items); // - `array` — deprecated alias of `inline` for collections (warns, // prefer `inline`); -// - `file` — unsupported here (diagnostic; use swagger:file); +// - `file` — valid only on a formData parameter or a response body, both handled by the +// parameters / responses builders; a diagnostic anywhere else; // - any other token — a case-sensitive type-name reference, inlined from a // known definition; unknown → diagnostic. func (s *Builder) resolveTypeOverride(arg string, tgt ifaces.SwaggerTypable, ownType types.Type, pos token.Position) (applied bool) { + if s.refuseUnrepresentableOverride(arg, ownType, pos) { + return false + } + base, depth := stripArrayPrefixes(arg) if depth == 0 { return s.resolveTypeBase(base, tgt, ownType, pos, false) @@ -85,8 +168,13 @@ func (s *Builder) resolveTypeBase(base string, target ifaces.SwaggerTypable, own } return s.inlineGoType(ownType, target) case keywordFile: + // `file` is an OAS v2 type, but only in two places: a formData parameter and a response body. + // Both are owned by the parameters / responses builders, which raise the file signal for this + // spelling and apply it behind their own location gates — so reaching here means the location + // is not one of them. s.RecordDiagnostic(grammar.Warnf(pos, grammar.CodeUnsupportedType, - `swagger:type: "file" is not supported here — use the swagger:file annotation instead`)) + `swagger:type: "file" is only valid on a formData parameter or a response body; ignored here`)) + return false } diff --git a/internal/builders/schema/walker_classifiers.go b/internal/builders/schema/walker_classifiers.go index 1079734c..51095cdb 100644 --- a/internal/builders/schema/walker_classifiers.go +++ b/internal/builders/schema/walker_classifiers.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" + "github.com/go-openapi/codescan/internal/builders/common" "github.com/go-openapi/codescan/internal/builders/resolvers" "github.com/go-openapi/codescan/internal/builders/validations" "github.com/go-openapi/codescan/internal/ifaces" @@ -346,8 +347,23 @@ func (s *Builder) classifierNamedBasic(cg *ast.CommentGroup, pkg *packages.Packa "swagger:enum %s: no matching const values found; enum semantics dropped", enumName)) } - if _, ok := s.findAnnotationArg(cg, grammar.AnnDefaultName); ok { - return true + // swagger:default is DEPRECATED: it is now an empty sink. + // + // It never emitted a `default` into the spec in any placement or form. Worse, this arm used to + // return handled=true on a target it had not written, so a named basic type carrying it published + // a TYPELESS definition — and every property referencing it came out typeless too, silently. + // + // Every place OpenAPI 2.0 admits a default is already served: the `default:` keyword covers the + // Schema, Parameter, Items and Header objects (which is exactly its registered context set), and a + // `default` response-code head in a route's `Responses:` body covers the Responses object. That + // closes the surface, so the annotation has no meaning left to implement. + // + // Emit a deprecation diagnostic and fall through, exactly as the swagger:alias sink below does. + if def := s.findAnnotation(cg, grammar.AnnDefaultName); def != nil { + s.RecordDiagnostic(grammar.Warnf(def.Pos(), grammar.CodeDeprecated, + `swagger:default is deprecated and no longer affects output; use the "default:" keyword `+ + `on the field, parameter, header or type declaration, or a "default:" response code `+ + `in a route's Responses: body`)) } if typeName, ok := s.findAnnotationArg(cg, grammar.AnnType); ok { @@ -380,26 +396,19 @@ func (s *Builder) classifierNamedBasic(cg *ast.CommentGroup, pkg *packages.Packa // classifierNamedArrayLike is the named-type walker shared between `buildNamedArray` and // `buildNamedSlice`. // -// Both have the same classifier surface — `swagger:strfmt` and `swagger:type` — with subtly -// different strfmt fall-throughs (array honors a "bsonobjectid" special case the slice doesn't). -// The boolean `forSlice` switches that arm; the rest is identical. +// Both have the same classifier surface — `swagger:strfmt` and `swagger:type`. elem is the +// sequence's element type, which decides whether a format describes the whole value or its items +// (see [common.ApplyArrayLikeStrfmt]); array and slice are otherwise identical here. // // Returns: // - handled=true, err=nil → caller returns nil // - handled=true, err!=nil → unrecognised swagger:type → caller // should fall through to inline the element type // - handled=false, err=nil → no classifier matched -func (s *Builder) classifierNamedArrayLike(cg *ast.CommentGroup, tgt ifaces.SwaggerTypable, forSlice bool) (handled bool, fallthroughElement bool) { +func (s *Builder) classifierNamedArrayLike(cg *ast.CommentGroup, tgt ifaces.SwaggerTypable, elem types.Type) (handled bool, fallthroughElement bool) { if sfnm, isf := s.findAnnotationArg(cg, grammar.AnnStrfmt); isf { - if sfnm == "byte" { - tgt.Typed("string", sfnm) - return true, false - } - if !forSlice && sfnm == "bsonobjectid" { - tgt.Typed("string", sfnm) - return true, false - } - tgt.Items().Typed("string", sfnm) + common.ApplyArrayLikeStrfmt(sfnm, elem, tgt) + return true, false } @@ -415,10 +424,120 @@ func (s *Builder) classifierNamedArrayLike(cg *ast.CommentGroup, tgt ifaces.Swag return false, false } +// warnUnfixableAliasEnum reports a `swagger:enum` on an alias declaration whose right-hand side is +// not a named type, where the annotation cannot work and never could. +// +// An enum is collected by finding the constants declared WITH the annotated type. A type alias is +// erased by the type-checker, so in +// +// type Unsigned = uint64 +// const Zero Unsigned = 0 +// +// `Zero` is a `uint64` constant indistinguishable from every other `uint64` constant in the package. +// There is nothing to collect, and no amount of plumbing changes that — unlike `swagger:strfmt` and +// `swagger:type`, which merely decorate the emitted schema and were fixed. +// +// An alias to a NAMED enum type is silent here: the named type survives the alias, so the members +// resolve and the annotation works. +// +// See [§enum-values](../../scanner/README.md#enum-values). +func (s *Builder) warnUnfixableAliasEnum(cg *ast.CommentGroup, tpe *types.Alias, pos token.Position) { + ann := s.findAnnotation(cg, grammar.AnnEnum) + if ann == nil { + return + } + if _, named := types.Unalias(tpe.Rhs()).(*types.Named); named { + return + } + + s.RecordDiagnostic(grammar.Warnf(pos, grammar.CodeInvalidEnumOption, + "swagger:enum on an alias to a non-named type cannot collect any member: the type-checker "+ + "erases the alias, so its constants are indistinguishable from any other constant of the "+ + "underlying type. Declare it as a named type (`type %s %s`) instead", + tpe.Obj().Name(), tpe.Rhs().String())) +} + +// classifierAliasStrfmt applies a `swagger:strfmt` carried by an ALIAS declaration. +// +// The implementation is shared with the parameters and responses builders, which have their own +// alias dissolve paths and the same gap. See [common.Builder.ClassifierAliasStrfmt]. +// +// # Details +// +// See [§aliases](./README.md#aliases) — the use-site classifier contract. +func (s *Builder) classifierAliasStrfmt(cg *ast.CommentGroup, tpe *types.Alias, tgt ifaces.SwaggerTypable) bool { + return s.ClassifierAliasStrfmt(cg, tpe, tgt) +} + // classifierAliasTargetStrfmt is the named-type walker fired from `buildNamedAllOf`'s struct branch // — checks the alias's target type's docstring for `swagger:strfmt`. // // On match writes `{string, }` to schema and returns true. +// applyNamedShapeClassifier runs the author's classifier annotations that depend on a named type's +// UNDERLYING shape: `swagger:strfmt` for a struct, `swagger:strfmt` / `swagger:enum` for a basic, +// and the element-driven `swagger:strfmt` / `swagger:type` for an array or slice. +// +// handled reports that the classifiers produced the schema; recurse is non-nil when one of them +// asked for the type to be rebuilt rather than resolved here. +// +// This is the shape-aware half of the cascade, and the reason it is a function rather than three +// lines inside a switch: the composition arm needs the same answers as the field dispatch, and the +// copy it used to keep — `classifierAliasTargetStrfmt` — is shape-BLIND. That copy writes +// `Typed("string", format)` whatever the underlying is, so a `[]string` annotated `email` composed +// into an allOf claimed the member IS an email address rather than a list of them, and a +// `swagger:enum` reached it not at all. +func (s *Builder) applyNamedShapeClassifier( + cg *ast.CommentGroup, titpe *types.Named, tgt ifaces.SwaggerTypable, +) (handled bool, recurse func() error) { + switch utitpe := titpe.Underlying().(type) { + case *types.Struct: + return s.classifierNamedStructStrfmt(cg, tgt), nil + + case *types.Basic: + // A builtin with no Swagger form is left to the caller, which warns and skips it. The guard sits + // ahead of the classifier because that is the order the field dispatch has always applied, and + // moving it would quietly start honouring an annotation on a type that cannot carry one. + if resolvers.UnsupportedBuiltinType(utitpe) { + return false, nil + } + + // PkgForType yields the package the enum's const values are collected from; a miss means the + // type cannot be anchored to a scanned package, so there is nothing to collect. + pkg, found := s.Ctx.PkgForType(titpe) + if !found { + return false, nil + } + + return s.classifierNamedBasic(cg, pkg, titpe, utitpe, tgt), nil + + case *types.Array: + return s.arrayLikeClassifier(cg, tgt, utitpe.Elem()) + + case *types.Slice: + return s.arrayLikeClassifier(cg, tgt, utitpe.Elem()) + + default: + return false, nil + } +} + +// arrayLikeClassifier adapts classifierNamedArrayLike's (handled, fallthroughElement) pair to the +// closure form applyNamedShapeClassifier returns. +func (s *Builder) arrayLikeClassifier( + cg *ast.CommentGroup, tgt ifaces.SwaggerTypable, elem types.Type, +) (bool, func() error) { + handled, fallthroughElement := s.classifierNamedArrayLike(cg, tgt, elem) + if !handled || !fallthroughElement { + return handled, nil + } + + return true, func() error { + defer s.descend("items")() + + return s.buildFromType(elem, tgt.Items()) + } +} + func (s *Builder) classifierAliasTargetStrfmt(tpe types.Type, tgt ifaces.SwaggerTypable) bool { decl, ok := s.Ctx.DeclForType(tpe) if !ok || decl == nil { diff --git a/internal/integration/annotation_noise_test.go b/internal/integration/annotation_noise_test.go new file mode 100644 index 00000000..6a8cb946 --- /dev/null +++ b/internal/integration/annotation_noise_test.go @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "strings" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/scantest" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// A classifier annotation written where nothing consults it must be reported, not silently dropped. +// +// `swagger:strfmt` / `swagger:type` in an EMBEDDED field's own comment are parsed, validated and +// discarded, while the same annotation on a regular field one line away is honoured. That asymmetry +// is what makes the silence a defect rather than a rule — and because the scanner REJECTS an unknown +// annotation in that same comment (TestCoverage_UnknownAnnotation), the author got validation +// feedback implying the annotation was meaningful and nothing saying it had been dropped. +// +// The diagnostic reports the drop; it does not change it. Where such an annotation belongs is the +// embedded type's own declaration, and the message says so. +func TestAnnotationNoise(t *testing.T) { + var diags []codescan.Diagnostic + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/annotation-noise/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + OnDiagnostic: func(d codescan.Diagnostic) { + diags = append(diags, d) + }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + find := func(code, needle string) (codescan.Diagnostic, bool) { + for _, d := range diags { + if string(d.Code) == code && strings.Contains(d.Message, needle) { + return d, true + } + } + + return codescan.Diagnostic{}, false + } + + t.Run("classifiers on an embed are reported as ineffective", func(t *testing.T) { + d, ok := find("scan.ineffective-annotation", "swagger:strfmt and swagger:type") + require.True(t, ok, "both annotations on one allOf embed must be reported together; got %v", diags) + assert.Equal(t, codescan.SeverityWarning, d.Severity) + assert.NotZero(t, d.Pos.Line, "the diagnostic must point at the offending embed") + + _, ok = find("scan.ineffective-annotation", "annotate the embedded type's own declaration") + assert.True(t, ok, "the message must say where the annotation does belong") + + var n int + for _, d := range diags { + if string(d.Code) == "scan.ineffective-annotation" { + n++ + } + } + assert.Equal(t, 2, n, "one report per annotated embed — the allOf one and the plain one") + }) + + t.Run("the annotations are still ignored, not applied", func(t *testing.T) { + // The diagnostic reports the drop; it does not change it. An embed still contributes its + // embedded type's shape, so the composed member is Target's object either way. + host := doc.Definitions["IneffectiveOnAllOf"] + require.Len(t, host.AllOf, 2) + assert.Equal(t, "object{left}", schemaSignature(host.AllOf[0], doc.Definitions, 0)) + + plain := doc.Definitions["IneffectiveOnPlain"] + assert.Equal(t, "object{left,note}", schemaSignature(plain, doc.Definitions, 0)) + }) + + t.Run("the same annotations on a regular field are honoured", func(t *testing.T) { + // The control that makes the asymmetry a defect rather than a rule: identical syntax, identical + // position in the comment, different outcome. + props := doc.Definitions["EffectiveOnField"].Properties + assert.Equal(t, "string/uuid", schemaSignature(props["fmt"], doc.Definitions, 0)) + assert.Equal(t, "string/", schemaSignature(props["typ"], doc.Definitions, 0)) + + for _, d := range diags { + assert.NotContains(t, d.Message, "EffectiveOnField", + "a regular field must not be reported as ineffective") + } + }) + + scantest.CompareOrDumpJSON(t, doc, "enhancements_annotation_noise.json") +} diff --git a/internal/integration/builder_conformance_test.go b/internal/integration/builder_conformance_test.go new file mode 100644 index 00000000..58de66ef --- /dev/null +++ b/internal/integration/builder_conformance_test.go @@ -0,0 +1,320 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/scantest" + oaispec "github.com/go-openapi/spec" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// Cross-builder conformance: the schema, parameters and responses builders must agree. +// +// Each of the three resolves Go types to spec constructs and each carries its own copy of rules the +// others also need. Nothing forces them to agree and nothing detected it when they stopped, so a fix +// verified on one read as complete — which is how `swagger:type` on an alias came to work for a +// model field and a query parameter while silently dropping for a body parameter. This test is the +// detector. +// +// It compares four FULL-SCHEMA positions, where no legitimate difference exists: +// +// a model field · a body parameter · a response body · an allOf member +// +// The first three converge on one field dispatch. The fourth does not: an allOf member is resolved +// by `buildNamedAllOf`, a composition arm with its own copy of the classifier cascade — so it is the +// position most likely to have been left behind by a fix, and adding it immediately showed three +// classifiers missing there rather than the one already on record. +// +// SimpleSchema positions are excluded on purpose. A non-body parameter and a response header have a +// genuinely different legality surface — `type` mandatory and restricted, `$ref` forbidden — which +// is the historical reason the builders grew separate paths at all. Comparing them needs a declared +// projection rather than equality, and mixing the two would bury real drift under expected +// difference. +// +// The subjects carry no hand-written expectations: each is asserted against the model field, whose +// behaviour is pinned by its own witnesses elsewhere. This suite only asks whether the four agree. +func TestBuilderConformance(t *testing.T) { + var diags []codescan.Diagnostic + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/builder-conformance/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + OnDiagnostic: func(d codescan.Diagnostic) { + diags = append(diags, d) + }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + subjects := []struct { + prop string // property on ModelHost + path string // route carrying the body parameter + response string // response whose body carries it + note string + + // paramDropped marks a subject the parameters builder must REFUSE rather than render. + // The reason is location-specific and stated, not emergent: it is the only shape in this + // suite where a full-schema position legitimately produces nothing. + paramDropped string + + // allOf marks a subject also reached as an allOf MEMBER. The members of AllOfHost appear in + // this table's order, so the flagged subjects and the members zip positionally. + allOf bool + }{ + {prop: "fmt", path: "/fmt", response: "respFmt", allOf: true}, + {prop: "fmtAl", path: "/fmt-al", response: "respFmtAl", allOf: true}, + {prop: "typ", path: "/typ", response: "respTyp", allOf: true}, + { + prop: "typAl", path: "/typ-al", response: "respTypAl", allOf: true, + note: "the pair that caught the body-branch gap: swagger:type on an alias", + }, + {prop: "enum", path: "/enum", response: "respEnum", allOf: true}, + {prop: "bytes", path: "/bytes", response: "respBytes", allOf: true}, + {prop: "stamp", path: "/stamp", response: "respStamp", allOf: true}, + {prop: "raw", path: "/raw", response: "respRaw", allOf: true}, + + // Shape subjects: the arms of the field dispatch rather than the classifiers. The classifier + // subjects above reach only the Named and Alias arms; these reach the rest, so a factorization + // of those arms is guarded in all three positions. + {prop: "struct", path: "/struct", response: "respStruct", allOf: true}, + {prop: "iface", path: "/iface", response: "respIface", allOf: true}, + {prop: "mapping", path: "/mapping", response: "respMapping"}, + {prop: "inline", path: "/inline", response: "respInline", note: "slice arm with an inline element"}, + {prop: "ptr", path: "/ptr", response: "respPtr"}, + {prop: "basic", path: "/basic", response: "respBasic"}, + + { + prop: "emails", path: "/emails", response: "respEmails", allOf: true, + note: "named []string + non-special format — the element-driven rule puts it on items", + }, + {prop: "codes", path: "/codes", response: "respCodes", allOf: true, note: "array flavour of the same"}, + + // Stdlib-identity subjects. The classifier subjects above name their stdlib type through an + // alias; these reach it as the NAMED type, which is the arm where each builder carried its own + // subset of the recognizers. + {prop: "stampN", path: "/stamp-n", response: "respStampN", allOf: true, note: "time.Time as the named type"}, + {prop: "rawN", path: "/raw-n", response: "respRawN", allOf: true, note: "json.RawMessage as the named type"}, + {prop: "anyv", path: "/anyv", response: "respAnyV", note: "the predeclared any"}, + { + prop: "errN", path: "/err-n", response: "respErrN", + note: "the predeclared error: nil package, so no declaration exists to look up", + paramDropped: "an error has no meaning as something a client sends", + }, + { + prop: "errAl", path: "/err-al", response: "respErrAl", + note: "the same through an alias — the recognizer only fires after the dissolve", + allOf: true, + paramDropped: "an error has no meaning as something a client sends", + }, + } + + // Cells where the builders are legitimately expected to disagree. Empty: the three full-schema + // positions have no reason to differ, so every divergence found here has been a defect. + // + // The assertion runs in BOTH directions, so a listed cell that starts agreeing fails too and the + // list cannot rot into a stale TODO. + knownBroken := map[string]string{} + + // The allOf MEMBER position keeps its own pin list. Its arm — `buildNamedAllOf` — is not the field + // dispatch the other three converge on, and it runs a different subset of the classifiers again. + // + // Same both-directions assertion: a listed cell that starts agreeing fails too. + knownBrokenAllOf := map[string]string{} + + model := doc.Definitions["ModelHost"].Properties + require.NotEmpty(t, model, "the control host must have properties") + + // AllOfHost embeds the flagged subjects in this table's order, which is what lets the two zip. + var inAllOfOrder []string + for _, s := range subjects { + if s.allOf { + inAllOfOrder = append(inAllOfOrder, s.prop) + } + } + byAllOf := allOfMemberSignatures(t, doc, inAllOfOrder) + + var ledger strings.Builder + fmt.Fprintf(&ledger, "\n%-8s %-26s %-26s %-26s %-26s %s\n", + "SUBJECT", "MODEL FIELD", "BODY PARAM", "RESPONSE BODY", "ALLOF MEMBER", "") + fmt.Fprintf(&ledger, "%s\n", strings.Repeat("-", 128)) + + for _, s := range subjects { + t.Run(s.prop, func(t *testing.T) { + want, ok := model[s.prop] + require.True(t, ok, "missing control property %s", s.prop) + + control := schemaSignature(want, doc.Definitions, 0) + asParam, hasParam := bodyParamSignature(t, doc, s.path) + asResponse := responseBodySignature(t, doc, s.response) + + // The allOf member is a full schema describing the same type, so it is held to the same + // equality — under its own pin list, since it is reached by its own arm. Asserted here, above + // the dropped-parameter branch: whether a PARAMETER refuses the type says nothing about how + // it composes, and returning early would leave that cell displayed but unchecked. + asAllOf, inAllOf := byAllOf[s.prop] + allOfCell, allOfVerdict := "—", "" + if inAllOf { + allOfCell = asAllOf + allOfReason, allOfPinned := knownBrokenAllOf[s.prop] + switch { + case asAllOf == control && allOfPinned: + allOfVerdict = " · ALLOF UNPINNED — remove it" + case asAllOf != control && !allOfPinned: + allOfVerdict = " · ALLOF DIVERGES" + case allOfPinned: + allOfVerdict = " · ALLOF PINNED" + } + if allOfPinned { + assert.NotEqual(t, control, asAllOf, + "%s: allOf member pinned as broken (%s) but it now agrees — remove it", + s.prop, allOfReason) + } else { + assert.Equal(t, control, asAllOf, + "an allOf member must render this shape as a model field does") + } + } + + if s.paramDropped != "" { + // The refusal must be reported, not silent: a parameter vanishing from an operation with no + // word to the author is the failure mode the skip-with-a-diagnostic rule exists to avoid. + assert.False(t, hasParam, + "%s: the parameters builder must drop this (%s), but it emitted %s", + s.prop, s.paramDropped, asParam) + assert.True(t, hasDiagnosticFor(diags, s.prop), + "%s: dropped without a diagnostic naming it", s.prop) + assert.Equal(t, control, asResponse, + "a dropped parameter says nothing about the response, which must still agree") + + fmt.Fprintf(&ledger, "%-8s %-26s %-26s %-26s %-26s %s\n", + s.prop, control, "", asResponse, allOfCell, + "DECLARED — "+s.paramDropped+allOfVerdict) + + return + } + require.True(t, hasParam, "%s: no body parameter on %s", s.prop, s.path) + + paramAgrees := control == asParam + responseAgrees := control == asResponse + agrees := paramAgrees && responseAgrees + reason, pinned := knownBroken[s.prop] + + var verdict string + switch { + case agrees && pinned: + verdict = "UNPINNED — remove it from knownBroken" + case agrees: + verdict = "OK" + case pinned: + verdict = "PINNED(Q39)" + default: + verdict = "DIVERGES" + } + + verdict += allOfVerdict + + if s.note != "" { + verdict += " — " + s.note + } + fmt.Fprintf(&ledger, "%-8s %-26s %-26s %-26s %-26s %s\n", + s.prop, control, asParam, asResponse, allOfCell, verdict) + + if pinned { + assert.False(t, agrees, + "%s: pinned as broken (%s) but the builders now agree — remove it", s.prop, reason) + + return + } + assert.Equal(t, control, asParam, + "the parameters builder must render this shape as the schema builder does") + assert.Equal(t, control, asResponse, + "the responses builder must render this shape as the schema builder does") + }) + } + + t.Log(ledger.String()) + + // The comparison above only asks whether the three builders agree; a wrong answer they all share + // would pass it. The golden makes every subject's emitted spec reviewable on its own. + scantest.CompareOrDumpJSON(t, doc, "enhancements_builder_conformance.json") +} + +// allOfMemberSignatures renders AllOfHost's members and keys them by the subject each one carries. +// +// The mapping is positional, so it is only trustworthy if the shape is exactly what the fixture +// promises: one member per flagged subject, in order, plus a trailing member holding the composing +// struct's own field. Both are asserted here rather than assumed — a member that fails to build +// emits an EMPTY member rather than none (that is the Q40 symptom), so a count that still matches is +// evidence the indices did not shift, and a count that does not tells us the mapping is meaningless +// before any cell is compared. +func allOfMemberSignatures(t *testing.T, doc *oaispec.Swagger, props []string) map[string]string { + t.Helper() + + host, ok := doc.Definitions["AllOfHost"] + require.True(t, ok, "missing AllOfHost") + require.Len(t, host.AllOf, len(props)+1, + "AllOfHost must hold one member per flagged subject plus the own-fields member") + + own := schemaSignature(host.AllOf[len(props)], doc.Definitions, 0) + require.Equal(t, "object{note}", own, + "the trailing member must be the composing struct's own field; the members are misaligned") + + out := make(map[string]string, len(props)) + for i, prop := range props { + out[prop] = schemaSignature(host.AllOf[i], doc.Definitions, 0) + } + + return out +} + +// bodyParamSignature renders the body parameter of the operation on path, reporting whether one was +// emitted at all. A missing parameter is a result rather than a fatality: a subject the builder is +// required to refuse has to be distinguishable from one it silently lost. +func bodyParamSignature(t *testing.T, doc *oaispec.Swagger, path string) (string, bool) { + t.Helper() + + require.NotNil(t, doc.Paths, "fixture must produce paths") + item, ok := doc.Paths.Paths[path] + require.True(t, ok, "missing path %s", path) + require.NotNil(t, item.Post, "missing POST on %s", path) + + for _, p := range item.Post.Parameters { + if p.In != "body" { + continue + } + require.NotNil(t, p.Schema, "body parameter on %s carries no schema", path) + + return schemaSignature(*p.Schema, doc.Definitions, 0), true + } + + return "", false +} + +// hasDiagnosticFor reports whether any diagnostic names the subject's property, which is also the +// Go field name the fixture gives it in every position. +func hasDiagnosticFor(diags []codescan.Diagnostic, prop string) bool { + for _, d := range diags { + if strings.Contains(strings.ToLower(d.Message), strings.ToLower(prop)) { + return true + } + } + + return false +} + +// responseBodySignature renders the body schema of the named response. +func responseBodySignature(t *testing.T, doc *oaispec.Swagger, name string) string { + t.Helper() + + resp, ok := doc.Responses[name] + require.True(t, ok, "missing response %s", name) + require.NotNil(t, resp.Schema, "response %s carries no body schema", name) + + return schemaSignature(*resp.Schema, doc.Definitions, 0) +} diff --git a/internal/integration/coverage_enhancements_test.go b/internal/integration/coverage_enhancements_test.go index 0b5d7b30..89caf462 100644 --- a/internal/integration/coverage_enhancements_test.go +++ b/internal/integration/coverage_enhancements_test.go @@ -4,6 +4,7 @@ package integration_test import ( + "strings" "testing" "github.com/go-openapi/codescan" @@ -186,14 +187,34 @@ func TestCoverage_ResponseEdges(t *testing.T) { } func TestCoverage_NamedBasic(t *testing.T) { + var diags []string doc, err := codescan.Run(&codescan.Options{ - Packages: []string{"./enhancements/named-basic/..."}, - WorkDir: scantest.FixturesDir(), - ScanModels: true, + Packages: []string{"./enhancements/named-basic/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + OnDiagnostic: func(d codescan.Diagnostic) { diags = append(diags, d.String()) }, }) require.NoError(t, err) require.NotNil(t, doc) + // Grade carries the deprecated swagger:default. The annotation is an inert sink, so Grade emits + // exactly what an unannotated named int would — a real definition, referenced by $ref — and the + // deprecation is reported rather than silently swallowed. + require.Contains(t, doc.Definitions, "Grade", "the deprecated annotation must not suppress the definition") + assert.Equal(t, "integer", doc.Definitions["Grade"].Type[0]) + gradeProp := doc.Definitions["User"].Properties["grade"] + assert.Equal(t, "#/definitions/Grade", gradeProp.Ref.String()) + + var sawDeprecation bool + for _, d := range diags { + if strings.Contains(d, "swagger:default is deprecated") { + sawDeprecation = true + + break + } + } + assert.True(t, sawDeprecation, "swagger:default must raise a deprecation diagnostic; got %v", diags) + scantest.CompareOrDumpJSON(t, doc, "enhancements_named_basic.json") } @@ -448,17 +469,42 @@ func TestCoverage_AllHTTPMethods(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_all_http_methods.json") } -// TestCoverage_UnknownAnnotation asserts that scanning a file with an unknown swagger: annotation -// returns a classifier error. +// TestCoverage_UnknownAnnotation asserts that an unknown `swagger:` annotation is skipped and +// reported rather than aborting the scan. +// +// It used to return a classifier error, which made one mistyped keyword in one comment enough to +// produce nothing at all from a whole package graph — the outcome least useful to whoever typed it. +// Skip-and-diagnose is the house rule; the author gets the name and the location, and every other +// annotation in the tree still works. // // This exercises the default branch of typeIndex.detectNodes. func TestCoverage_UnknownAnnotation(t *testing.T) { - _, err := codescan.Run(&codescan.Options{ + var diags []codescan.Diagnostic + doc, err := codescan.Run(&codescan.Options{ Packages: []string{"./enhancements/unknown-annotation/..."}, WorkDir: scantest.FixturesDir(), ScanModels: true, + OnDiagnostic: func(d codescan.Diagnostic) { + diags = append(diags, d) + }, }) - require.Error(t, err) + require.NoError(t, err) + require.NotNil(t, doc) + + var said bool + for _, d := range diags { + if string(d.Code) == "parse.invalid-annotation" && strings.Contains(d.Message, "doesnotexist") { + said = true + assert.Equal(t, codescan.SeverityWarning, d.Severity) + assert.NotZero(t, d.Pos.Line, "the diagnostic must point at the offending comment") + + break + } + } + assert.True(t, said, "the unknown annotation must be named and located; got %v", diags) + + // The scan continued: the type carrying it is emitted as if the comment were prose. + assert.Contains(t, doc.Definitions, "Bogus") } func TestCoverage_NamedStructTags(t *testing.T) { diff --git a/internal/integration/coverage_provenance_test.go b/internal/integration/coverage_provenance_test.go index ee3bb0fe..a373da7d 100644 --- a/internal/integration/coverage_provenance_test.go +++ b/internal/integration/coverage_provenance_test.go @@ -430,6 +430,12 @@ func TestCoverage_ProvenancePatternPropertyPointer(t *testing.T) { // resolved only after path binding, so a body parameter's inner schema is intentionally not drilled // into); a response header anchors at /responses/{name}/headers/{h}; and an in:body response // field's inline struct anchors its properties at /responses/{name}/schema/properties/f. +// +// The array-bodied case is the guard for descendBody: when the responses builder peels its OWN array +// layer, the inline element's properties must land under …/schema/items/… . That threading affects +// cross-ref pointers only and never the emitted spec, so no golden and no schema-comparing suite can +// see it — this anchor is its only detector, and only with an INLINE element (a named one anchors in +// its own definition instead). func TestCoverage_ProvenanceParamsResponses(t *testing.T) { byPointer := map[string]scanner.Provenance{} doc, err := codescan.Run(&codescan.Options{ @@ -444,9 +450,10 @@ func TestCoverage_ProvenanceParamsResponses(t *testing.T) { require.NotNil(t, doc) for _, ptr := range []string{ - "/paths/~1prov/get/parameters/0", // parameter level (stops here) - "/responses/provResp/headers/X-Request-Id", // response header - "/responses/provResp/schema/properties/status", // inline body property + "/paths/~1prov/get/parameters/0", // parameter level (stops here) + "/responses/provResp/headers/X-Request-Id", // response header + "/responses/provResp/schema/properties/status", // inline body property + "/responses/provListResp/schema/items/properties/code", // inline element of an array body } { prov, ok := byPointer[ptr] require.Truef(t, ok, "expected an anchor for %q; got %v", ptr, keysOf(byPointer)) diff --git a/internal/integration/coverage_simple_schema_test.go b/internal/integration/coverage_simple_schema_test.go index 70c211bf..993837e2 100644 --- a/internal/integration/coverage_simple_schema_test.go +++ b/internal/integration/coverage_simple_schema_test.go @@ -4,24 +4,33 @@ package integration_test import ( + "strings" "testing" "github.com/go-openapi/codescan" "github.com/go-openapi/codescan/internal/parsers/grammar" "github.com/go-openapi/codescan/internal/scantest" + oaispec "github.com/go-openapi/spec" "github.com/go-openapi/testify/v2/assert" "github.com/go-openapi/testify/v2/require" ) -// TestCoverage_SimpleSchemaViolation exercises M1's exit validator: a query parameter whose Go type -// resolves to an object-typed SimpleSchema fires CodeUnsupportedInSimpleSchema and the target is -// reset to empty `{}`. +// TestCoverage_SimpleSchemaViolation covers the two ways a non-body parameter can fail to be an +// OAS v2 SimpleSchema. They look alike and have different remedies, so both are pinned here. +// +// 1. The ANNOTATION asks for something the location cannot carry (`swagger:type object`). Since +// `type` is mandatory under SimpleSchema, the override is refused before it is applied and the +// Go-derived type stands, leaving a valid parameter. The diagnostic names the annotation. +// This case used to be honoured and then wiped, which produced an untyped parameter. +// 2. The GO TYPE itself is not representable (a struct). Nothing to refuse, nothing to fall back +// to, so the exit validator wipes the target — honest over lossy. // // Plumbing tested: // - schema.WithSimpleSchema option carries the `in` value to the builder +// - the override gate refuses an object-resolving swagger:type under SimpleSchema // - exit validator detects Type=="object" as a violation // - paramTypable.ResetForViolation wipes the SimpleSchema-shape -// - OnDiagnostic callback fires with the new code +// - OnDiagnostic callback fires with the code in both cases func TestCoverage_SimpleSchemaViolation(t *testing.T) { var got []grammar.Diagnostic doc, err := codescan.Run(&codescan.Options{ @@ -46,16 +55,45 @@ func TestCoverage_SimpleSchemaViolation(t *testing.T) { } assert.True(t, seen, "expected CodeUnsupportedInSimpleSchema diagnostic") - // 2. Target reset. The offending parameter should have an empty - // SimpleSchema (no Type, no Format, no Ref) — honest over lossy. require.Contains(t, doc.Paths.Paths, "/violation") op := doc.Paths.Paths["/violation"].Get require.NotNil(t, op) - require.Len(t, op.Parameters, 1) - bad := op.Parameters[0] - assert.Equal(t, "bad", bad.Name) + require.Len(t, op.Parameters, 2, "the error-typed field is dropped, not described") + + byName := make(map[string]oaispec.Parameter, len(op.Parameters)) + for _, p := range op.Parameters { + byName[p.Name] = p + } + + // Case 1 — the override is refused, the Go type stands. A parameter without a type is not a + // valid SimpleSchema, so keeping `string` is the whole point of refusing. + bad, ok := byName["bad"] + require.True(t, ok, "missing parameter bad") assert.Equal(t, "query", bad.In, "in: query preserved") - assert.Empty(t, bad.Type, "Type should be wiped to empty") - assert.Empty(t, bad.Format, "Format should be wiped to empty") - assert.Empty(t, bad.Ref.String(), "Ref should be wiped to empty") + assert.Equal(t, "string", bad.Type, "the Go-derived type must survive a refused override") + assert.Empty(t, bad.Ref.String(), "Ref is forbidden under SimpleSchema") + + // Case 3 — the error-typed field is gone entirely, and said so. + // + // Under its OWN code, not the SimpleSchema one the other two cases carry. `error` is meaningless + // as an inbound value in every location including `in: body`, so reporting it as a SimpleSchema + // restriction sent the reader to change an `in:` that was never the problem. + _, hasErrored := byName["errored"] + assert.False(t, hasErrored, "an error-typed parameter must be dropped") + var saidSo bool + for _, d := range got { + if d.Code == grammar.CodeUnsupportedGoType && strings.Contains(d.Message, "errored") { + saidSo = true + + break + } + } + assert.True(t, saidSo, "dropping the error-typed parameter must be reported under its own code; got %v", got) + + // Case 2 — nothing to fall back to, so the target is wiped. + unrep, ok := byName["unrepresentable"] + require.True(t, ok, "missing parameter unrepresentable") + assert.Empty(t, unrep.Type, "an unrepresentable Go type is wiped") + assert.Empty(t, unrep.Format, "Format wiped with it") + assert.Empty(t, unrep.Ref.String(), "Ref wiped with it") } diff --git a/internal/integration/default_example_typing_test.go b/internal/integration/default_example_typing_test.go new file mode 100644 index 00000000..65d4dfbf --- /dev/null +++ b/internal/integration/default_example_typing_test.go @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "strings" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/scantest" + oaispec "github.com/go-openapi/spec" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// Witness for the typing of `default:`, `example:` and `enum:` values. +// +// A declaration's comment block is dispatched before its Go type is resolved onto the schema, so +// those three keywords used to be coerced against an empty type and fell back to their raw string +// form — `default: 8080` on a named int became "8080", `enum: 1,2,3` became ["1","2","3"], and a +// JSON array literal became a string holding JSON source. Every other site (field, parameter, +// header, body property) was always correct, because the type is known there when the walk runs. +// +// Each declaration cell is asserted against a field-site control carrying the identical literal, so +// the test states "these must agree" rather than hard-coding a coercion result. +// +// The three keywords are checked together on purpose: they share validations.ParseDefault and the +// same dispatch arms, so a divergence between them is itself a defect. +func TestDefaultExampleTyping(t *testing.T) { + var diags []string + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/default-example-typing/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + OnDiagnostic: func(d codescan.Diagnostic) { diags = append(diags, d.String()) }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + controls := doc.Definitions["FieldControls"].Properties + + // decl definition ↔ the field-site control carrying the same literal. + pairs := []struct { + decl string + control string + }{ + {"DeclInt", "port"}, + {"DeclNumber", "ratio"}, + {"DeclBool", "flag"}, + {"DeclString", "mode"}, + {"DeclIntSlice", "numbers"}, + } + + for _, p := range pairs { + t.Run(p.decl, func(t *testing.T) { + decl, ok := doc.Definitions[p.decl] + require.True(t, ok, "missing definition %s", p.decl) + ctl, ok := controls[p.control] + require.True(t, ok, "missing control property %s", p.control) + + assert.Equal(t, ctl.Default, decl.Default, + "a declaration's default: must be typed exactly as the same literal on a field") + assert.Equal(t, ctl.Example, decl.Example, + "a declaration's example: must be typed exactly as the same literal on a field") + }) + } + + // enum: shares the defect and the fix. An enum of strings on an integer schema is a spec no + // validator can satisfy, so this cell is the most consequential of the three keywords. + t.Run("DeclEnumInt", func(t *testing.T) { + decl := doc.Definitions["DeclEnumInt"] + ctl := controls["grade"] + + assert.Equal(t, ctl.Enum, decl.Enum, "a declaration's enum: members must be typed as on a field") + assert.Equal(t, ctl.Default, decl.Default) + }) + + // Sites that were always correct — pinned so a future change to the recoercion cannot regress + // them by treating every site as if it needed repair. + t.Run("already-correct sites", func(t *testing.T) { + op := doc.Paths.Paths["/typing"].Get + var queryPort *oaispec.Parameter + for i := range op.Parameters { + if op.Parameters[i].Name == "queryPort" { + queryPort = &op.Parameters[i] + } + } + require.NotNil(t, queryPort, "missing queryPort parameter") + assert.Equal(t, controls["port"].Default, queryPort.Default, "a non-body parameter types its own default") + + hdr, ok := doc.Responses["typingResponse"].Headers["X-Rate-Limit"] + require.True(t, ok, "missing response header") + assert.NotNil(t, hdr.Default) + assert.IsType(t, controls["port"].Default, hdr.Default, "a response header types its own default") + }) + + // The OTHER sense of "default": a response, not a value. `default:` is not legal in a response + // block context, so the two mechanisms cannot collide — this pins that they stay apart. + t.Run("default response is not a default value", func(t *testing.T) { + op := doc.Paths.Paths["/typing"].Get + require.NotNil(t, op.Responses.Default, "the default response code must produce responses.default") + assert.Equal(t, "#/responses/errorResponse", op.Responses.Default.Ref.String()) + assert.Nil(t, op.Responses.Default.Schema, "the default RESPONSE carries no default VALUE") + }) + + // A value that cannot be read as the schema's type is DROPPED and reported. Emitting it at the + // wrong type would produce a document no validator accepts; dropping produces an incomplete one, + // which is the lesser harm once the author is told. Decl and field must agree on both. + t.Run("uncoercible values are dropped and reported", func(t *testing.T) { + declBad := doc.Definitions["DeclUncoercible"] + fieldBad := doc.Definitions["FieldUncoercible"].Properties + + assert.Nil(t, declBad.Default, "an uncoercible default must not survive on a declaration") + assert.Nil(t, declBad.Example, "an uncoercible example must not survive on a declaration") + assert.Nil(t, fieldBad["port"].Default, "…nor on a field") + assert.Nil(t, fieldBad["port"].Example, "…nor on a field") + + // The enum keeps its coercible members and loses only the bad one, at both sites. Asserted as a + // property — "two members, none of them a leftover string" — rather than against a hard-coded + // coercion result, so the test survives a change in how integers are represented. + require.Len(t, declBad.Enum, 2, "the two coercible members survive") + for _, m := range declBad.Enum { + _, leftoverString := m.(string) + assert.False(t, leftoverString, "no member may survive as an uncoerced string: %v", m) + } + assert.Equal(t, declBad.Enum, fieldBad["grade"].Enum, "decl and field must prune alike") + + // Each drop is reported, and the enum warning names the offending member — dropping narrows a + // closed set, so a bare count would not be enough to act on. + var dropped int + var namedTheMember bool + for _, d := range diags { + if strings.Contains(d, "cannot be read as") { + dropped++ + } + if strings.Contains(d, `"two"`) { + namedTheMember = true + } + } + assert.GreaterOrEqual(t, dropped, 6, + "default+example+enum member, at both decl and field sites; got %v", diags) + assert.True(t, namedTheMember, "the enum warning must name the dropped member; got %v", diags) + }) + + scantest.CompareOrDumpJSON(t, doc, "enhancements_default_example_typing.json") +} diff --git a/internal/integration/json_tag_fidelity_test.go b/internal/integration/json_tag_fidelity_test.go new file mode 100644 index 00000000..9d29d1e3 --- /dev/null +++ b/internal/integration/json_tag_fidelity_test.go @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/scantest" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// Differential test for `json:"-"` handling: the emitted property set must equal the key set +// `encoding/json` actually puts on the wire. +// +// This corpus is unusual in having an ORACLE — the right answer is not a design choice but whatever +// encoding/json does. The expectations are therefore not written here: the fixture module marshals +// its own types and commits the resulting key sets as `wire.golden.json` (see that package's +// wire_test.go for why the two sides meet at a file rather than an import), and this test compares +// against them. +// +// Two shapes used to diverge: +// +// - a promoted field re-declared with `json:"-"` was deleted, though Go ignores such a field +// entirely — it never shadows the promoted one, which Go still marshals; +// - `json:"-,"`, the escape for a field literally named `-`, was dropped rather than emitted. +func TestJSONTagFidelity(t *testing.T) { + wire := loadWireGolden(t) + + var diags []string + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/json-tag-fidelity/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + OnDiagnostic: func(d codescan.Diagnostic) { diags = append(diags, d.String()) }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + for name, wantKeys := range wire { + t.Run(name, func(t *testing.T) { + def, ok := doc.Definitions[name] + require.True(t, ok, "missing definition %s", name) + + got := make([]string, 0, len(def.Properties)) + for k := range def.Properties { + got = append(got, k) + } + sort.Strings(got) + + assert.Equal(t, wantKeys, got, + "the emitted property set must match what encoding/json marshals") + }) + } + + // The Hint stays: an author writing `json:"-"` over a promoted field usually means "drop it", + // which the schema no longer does for them. swagger:omit is the honest way to say it. + t.Run("shadowed embed still hints", func(t *testing.T) { + var hinted bool + for _, d := range diags { + if strings.Contains(d, "scan.shadowed-embed-field") { + hinted = true + + break + } + } + assert.True(t, hinted, "re-declaring a promoted field with json:\"-\" must still raise the Hint; got %v", diags) + }) + + scantest.CompareOrDumpJSON(t, doc, "enhancements_json_tag_fidelity.json") +} + +// loadWireGolden reads the key sets the fixture module captured from encoding/json. +func loadWireGolden(t *testing.T) map[string][]string { + t.Helper() + + path := filepath.Join(scantest.FixturesDir(), "enhancements", "json-tag-fidelity", "wire.golden.json") + data, err := os.ReadFile(path) + require.NoError(t, err, "wire oracle missing — regenerate with UPDATE_GOLDEN=1 in the fixtures module") + + var wire map[string][]string + require.NoError(t, json.Unmarshal(data, &wire)) + require.NotEmpty(t, wire) + + return wire +} diff --git a/internal/integration/response_named_nonstruct_test.go b/internal/integration/response_named_nonstruct_test.go new file mode 100644 index 00000000..acb1dd25 --- /dev/null +++ b/internal/integration/response_named_nonstruct_test.go @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/scantest" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// A `swagger:response` declared on a NAMED type whose underlying is not a struct must render its +// body as the same type does when reached as a model field. Both are full-schema positions, and a +// declaration should not mean something different depending on which one reads it. +// +// Three of these used to emit a response carrying a description and NO SCHEMA AT ALL: the arm +// short-circuited on the stdlib time recognizer and on the declaration's format, and both branches +// wrote into a local schema and returned without the call that attaches it. +// +// The sub-build is deliberately handed the type's UNDERLYING rather than its declaration. A +// `swagger:response` declares a response, not a model, and passing the named type sends it through +// the $ref machinery and publishes it as a definition — which is what the response-toplevel-example +// and response-edges witnesses exist to prevent. +func TestResponseNamedNonStruct(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/response-named-nonstruct/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + // Cells where the two positions are legitimately expected to differ. Empty: a response body and a + // model field are both full-schema, so every difference found here has been a defect. + // + // The `stamp` cell was pinned until the arm learned to follow the declaration's WRITTEN + // right-hand side. `type Stamp time.Time` is not `time.Time`, so the recognizer keys on identity + // and declines — but `Underlying()` peeled past the `time.Time` layer entirely, where the + // recognizer would have seen it, leaving the response to be read as a struct whose fields become + // headers. time.Time exports none, so the response carried no schema. + knownBroken := map[string]string{} + + props := doc.Definitions["Host"].Properties + require.NotEmpty(t, props, "the control host must have properties") + + var ledger strings.Builder + fmt.Fprintf(&ledger, "\n%-8s %-26s %-26s %s\n", "SUBJECT", "MODEL FIELD", "RESPONSE BODY", "VERDICT") + fmt.Fprintf(&ledger, "%s\n", strings.Repeat("-", 92)) + + for _, c := range []struct{ prop, response string }{ + {"stamp", "stampResp"}, + {"emails", "emailsResp"}, + {"code", "codeResp"}, + {"count", "countResp"}, + } { + t.Run(c.prop, func(t *testing.T) { + field, ok := props[c.prop] + require.True(t, ok, "missing control property %s", c.prop) + control := schemaSignature(field, doc.Definitions, 0) + + resp, ok := doc.Responses[c.response] + require.True(t, ok, "missing response %s", c.response) + + body := "" + if resp.Schema != nil { + body = schemaSignature(*resp.Schema, doc.Definitions, 0) + } + + reason, pinned := knownBroken[c.prop] + verdict := "OK" + switch { + case control == body && pinned: + verdict = "UNPINNED — remove it from knownBroken" + case control != body: + verdict = "PINNED(Q42)" + } + fmt.Fprintf(&ledger, "%-8s %-26s %-26s %s\n", c.prop, control, body, verdict) + + if pinned { + assert.NotEqual(t, control, body, + "%s: pinned as broken (%s) but the two now agree — remove it", c.prop, reason) + + return + } + assert.Equal(t, control, body, + "a response body must render its declaration as a model field does") + }) + } + + t.Log(ledger.String()) + + // The response types must NOT surface as definitions: a swagger:response declares a response. + for _, name := range []string{"StampResp", "EmailsResp", "CodeResp", "CountResp"} { + assert.NotContains(t, doc.Definitions, name, + "a swagger:response declaration must not publish a definition") + } + + scantest.CompareOrDumpJSON(t, doc, "enhancements_response_named_nonstruct.json") +} diff --git a/internal/integration/route_name_shapes_test.go b/internal/integration/route_name_shapes_test.go new file mode 100644 index 00000000..22c34748 --- /dev/null +++ b/internal/integration/route_name_shapes_test.go @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "strings" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/scantest" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// A tag or an operationId of a single character is ordinary in OAS 2.0 — both are free-form strings +// — and used to void the whole `swagger:route` it appeared in. +// +// The failure was not local to the short name, which is why it was so quiet. The tags group is +// optional, so the parse did not stop when `e` failed to match: it fell back to matching with NO +// tags, leaving the operationId pattern to swallow `e listOne`, whose alphabet has no space in it. +// The line then matched nothing at all — and a `swagger:route` that matches nothing is not a +// malformed route, it is simply not a route. Nothing downstream could tell it apart from prose, so +// there was nothing to report and the path just never appeared. +func TestRouteNameShapes(t *testing.T) { + var diags []codescan.Diagnostic + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/route-name-shapes/..."}, + WorkDir: scantest.FixturesDir(), + OnDiagnostic: func(d codescan.Diagnostic) { + diags = append(diags, d) + }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + require.NotNil(t, doc.Paths) + + for _, tc := range []struct { + path string + wantID string + wantTag []string + }{ + {path: "/short-tag", wantID: "listOne", wantTag: []string{"e"}}, + {path: "/short-id", wantID: "l", wantTag: []string{"shapes"}}, + {path: "/short-both", wantID: "l", wantTag: []string{"e"}}, + {path: "/short-id-no-tags", wantID: "q"}, + {path: "/short-among", wantID: "listAmong", wantTag: []string{"a", "shapes"}}, + } { + t.Run(tc.path, func(t *testing.T) { + item, ok := doc.Paths.Paths[tc.path] + require.True(t, ok, "no path %s: the annotation did not parse", tc.path) + require.NotNil(t, item.Get) + assert.Equal(t, tc.wantID, item.Get.ID) + assert.Equal(t, tc.wantTag, item.Get.Tags) + }) + } + + // The negative case: still no path — an operationId of `42` is not one — but no longer silent. + t.Run("unparsed annotation is reported", func(t *testing.T) { + _, ok := doc.Paths.Paths["/unparsed"] + assert.False(t, ok, "an unparsable annotation must not produce a path") + + var said bool + for _, d := range diags { + if string(d.Code) == "scan.unparsed-path-annotation" && strings.Contains(d.Message, "/unparsed") { + said = true + assert.Equal(t, codescan.SeverityWarning, d.Severity) + assert.NotZero(t, d.Pos.Line, "the diagnostic must point at the offending line") + + break + } + } + assert.True(t, said, "an unparsable path annotation must be reported; got %v", diags) + }) + + // The per-path assertions above say the annotations parsed; the golden says what they produced. + scantest.CompareOrDumpJSON(t, doc, "enhancements_route_name_shapes.json") + + // Prose is not an annotation. This package's own doc comment opens a line with `swagger:route` + // while describing the quirk, and warning about it would make the diagnostic useless in exactly + // the files most likely to discuss annotations. + t.Run("prose is not reported", func(t *testing.T) { + for _, d := range diags { + if string(d.Code) != "scan.unparsed-path-annotation" { + continue + } + assert.Contains(t, d.Message, "/unparsed", + "only the genuinely unparsable annotation may be reported, got: %s", d.Message) + } + }) +} diff --git a/internal/integration/strfmt_decl_arraylike_test.go b/internal/integration/strfmt_decl_arraylike_test.go new file mode 100644 index 00000000..e1de71eb --- /dev/null +++ b/internal/integration/strfmt_decl_arraylike_test.go @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import "testing" + +// Witness for the element-driven items-vs-whole rule on a `swagger:strfmt` over an array or slice, +// at BOTH the declaration site and a field site, for named and alias halves alike. +// +// Two things were wrong before. The declaration switch had arms for struct and basic only, so a +// model sequence published its definition with the format dropped — and nothing downstream +// compensates, because the refModel gate skips the inline classifiers on the assumption the +// declaration already applied it. And the items-vs-whole decision was a two-name allowlist +// (`byte`, `bsonobjectid`) that stood in for the real question: both are formats for a byte +// sequence. Keying on the element type generalises to `uuid` over `[16]byte`, to `ulid`, and to +// rune sequences, with no list to extend. +// +// See strfmt_symmetry_harness_test.go and internal/builders/schema/README.md#aliases. +func TestStrfmtDeclArrayLike(t *testing.T) { + ledger := symmetryLedger{ + pkg: "enhancements/strfmt-decl-arraylike", + goldenPrefix: "strfmt_decl_arraylike", + cells: []symmetryCell{ + // Declaration site — a byte sequence takes the format on the schema. + { + namedProp: "IDNamedModeled", aliasProp: "IDAliasModeled", + wantNamed: "string/uuid", + note: "[16]byte is a byte sequence, so uuid describes the whole value", + }, + { + namedProp: "ULIDNamedModeled", aliasProp: "ULIDAliasModeled", + wantNamed: "string/ulid", + note: "generalises past the old byte/bsonobjectid allowlist", + }, + { + namedProp: "RunesNamedModeled", aliasProp: "RunesAliasModeled", + wantNamed: "string/password", + note: "rune sequences are string-like too", + }, + + // Declaration site — a string slice keeps the format on its items. + { + namedProp: "EmailsNamedModeled", aliasProp: "EmailsAliasModeled", + wantNamed: "array", + note: "element is a string, so the format describes each element", + }, + + // Field site — same rule, reached through the inline classifier rather than the decl. + { + definition: "Envelope", namedProp: "fieldIdNamed", aliasProp: "fieldIdAlias", + wantNamed: "string/uuid", + }, + }, + + exceptions: map[string]string{}, + knownBroken: map[string]string{}, + controlBroken: map[string]string{}, + } + + ledger.run(t) +} diff --git a/internal/integration/strfmt_symmetry_composition_test.go b/internal/integration/strfmt_symmetry_composition_test.go new file mode 100644 index 00000000..990fd900 --- /dev/null +++ b/internal/integration/strfmt_symmetry_composition_test.go @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import "testing" + +// F2 of the strfmt dispatch-symmetry matrix: the two composition dispatch sites that bypass +// `buildFromType` — plain struct embed (`buildEmbedded`) and allOf member (`buildAllOf`). +// +// Cells are compared at DEFINITION level: a composition has no enclosing property to inspect. +// +// See strfmt_symmetry_harness_test.go for how the ledger reads. +func TestStrfmtSymmetryComposition(t *testing.T) { + ledger := symmetryLedger{ + pkg: "enhancements/strfmt-symmetry-composition", + goldenPrefix: "strfmt_symmetry_composition", + cells: []symmetryCell{ + // Plain embed: SYMMETRIC, and both halves are wrong the same way. buildNamedEmbedded switches + // on the member's underlying shape and never consults its comments, so the format is dropped on + // both sides — a basic member vanishes entirely, a struct member promotes its properties. Not a + // Q32 asymmetry; the same shared gap Q33 describes for TextMarshaler embeds. Left unasserted + // because what an embed of a formatted type SHOULD produce is an open design question. + { + namedProp: "EmbedBasicNamed", aliasProp: "EmbedBasicAlias", + note: "SHARED GAP: both halves drop the member entirely (buildNamedEmbedded reads no comments) — see Q33", + }, + { + namedProp: "EmbedStructNamed", aliasProp: "EmbedStructAlias", + note: "SHARED GAP: both halves promote left/right and drop the format — see Q33", + }, + + // allOf member: the money row. The named arm runs classifierAliasTargetStrfmt (allof.go:205); + // the alias arm drops straight into buildAlias and dissolves. + { + namedProp: "AllOfBasicNamed", aliasProp: "AllOfBasicAlias", + wantNamed: "allOf[string/isbn+object{note}]", + }, + { + namedProp: "AllOfStructNamed", aliasProp: "AllOfStructAlias", + wantNamed: "allOf[string/duration+object{note}]", + }, + }, + + exceptions: map[string]string{}, + // The allOf alias arm now reads the member's declaration before dissolving, matching the + // classifierAliasTargetStrfmt its named counterpart runs. + knownBroken: map[string]string{}, + controlBroken: map[string]string{}, + } + + ledger.run(t) +} diff --git a/internal/integration/strfmt_symmetry_core_test.go b/internal/integration/strfmt_symmetry_core_test.go new file mode 100644 index 00000000..d84c7b48 --- /dev/null +++ b/internal/integration/strfmt_symmetry_core_test.go @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import "testing" + +// F1 of the strfmt dispatch-symmetry matrix: every cell reachable through `buildFromType` — the +// busiest of `buildAlias`'s four callers (`schema.go:351`). +// +// See strfmt_symmetry_harness_test.go for how the ledger reads. +func TestStrfmtSymmetryCore(t *testing.T) { + ledger := symmetryLedger{ + pkg: "enhancements/strfmt-symmetry-core", + goldenPrefix: "strfmt_symmetry_core", + cells: []symmetryCell{ + {definition: "Envelope", namedProp: "fieldBasicNamed", aliasProp: "fieldBasicAlias", wantNamed: "string/isbn"}, + {definition: "Envelope", namedProp: "fieldStructNamed", aliasProp: "fieldStructAlias", wantNamed: "string/duration"}, + {definition: "Envelope", namedProp: "fieldSliceNamed", aliasProp: "fieldSliceAlias", wantNamed: "string/byte"}, + {definition: "Envelope", namedProp: "fieldArrayNamed", aliasProp: "fieldArrayAlias", wantNamed: "string/bsonobjectid"}, + { + definition: "Envelope", namedProp: "fieldChainNamed", aliasProp: "fieldChainAlias", wantNamed: "string/ssn", + note: "dissolve lands on a NAMED annotated type, so the named machinery still applies the format", + }, + {definition: "Envelope", namedProp: "pointerBasicNamed", aliasProp: "pointerBasicAlias", wantNamed: "string/isbn"}, + {definition: "Envelope", namedProp: "pointerStructNamed", aliasProp: "pointerStructAlias", wantNamed: "string/duration"}, + {definition: "Envelope", namedProp: "sliceElemBasicNamed", aliasProp: "sliceElemBasicAlias", wantNamed: "array"}, + {definition: "Envelope", namedProp: "sliceElemStructNamed", aliasProp: "sliceElemStructAlias", wantNamed: "array"}, + {definition: "Envelope", namedProp: "mapValueBasicNamed", aliasProp: "mapValueBasicAlias", wantNamed: "map"}, + {definition: "Envelope", namedProp: "mapValueStructNamed", aliasProp: "mapValueStructAlias", wantNamed: "map"}, + { + definition: "EnvelopeModeled", namedProp: "modeledBasicNamed", aliasProp: "modeledBasicAlias", wantNamed: "string/isbn", + note: "the model annotation is an accidental workaround: the alias gets its own definition, where buildDeclAlias:248 applies the format", + }, + {definition: "EnvelopeModeled", namedProp: "modeledStructNamed", aliasProp: "modeledStructAlias", wantNamed: "string/duration"}, + }, + + // No cell in F1 has a legitimate reason to differ, and none does: the alias half now reads its + // own declaration before the dissolve, in all three modes. + exceptions: map[string]string{}, + knownBroken: map[string]string{}, + controlBroken: map[string]string{}, + } + + ledger.run(t) +} diff --git a/internal/integration/strfmt_symmetry_harness_test.go b/internal/integration/strfmt_symmetry_harness_test.go new file mode 100644 index 00000000..39585611 --- /dev/null +++ b/internal/integration/strfmt_symmetry_harness_test.go @@ -0,0 +1,274 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "fmt" + "sort" + "strings" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/scantest" + oaispec "github.com/go-openapi/spec" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// Shared harness for the `swagger:strfmt` dispatch-symmetry matrix (Q32). +// +// Each cell is a PAIR of declarations differing only by `=`. The ledger makes two independent +// checks per cell: +// +// - SYMMETRY — does the alias half agree with its named half? The named half is the control, so +// the symmetry check never hand-writes a format. +// - CONTROL — is the named half itself right? Without this, a dispatch site where BOTH halves +// ignore the annotation would report a comfortable "OK". `wantNamed` supplies the expected +// signature; leave it empty to skip the check. +// +// The two checks have separate exception lists, because they are separate defects: `knownBroken` +// is Q32 (alias diverges from named), `controlBroken` is a shared gap (neither half honours the +// annotation). Both fail in BOTH directions — an entry that starts passing is an error too — so +// neither list can rot into a stale TODO. +// +// The four matrix slices live in strfmt_symmetry_{core,composition,simpleschema,stdlib}_test.go. + +// symmetryCell is one named/alias pair observed at one dispatch site. +// +// When definition is set, named/alias name two PROPERTIES of it — the use-site cells, where the +// pair is reached from a field. When definition is empty, they name two DEFINITIONS — the +// composition cells (embed, allOf), where the pair is the composing type itself and there is no +// enclosing property to look at. +type symmetryCell struct { + definition string // definition carrying both properties; "" ⇒ named/alias are definition names + namedProp string // property (or definition) reaching the named half + aliasProp string // property (or definition) reaching the alias half + wantNamed string // expected signature of the named control; "" skips the control check + note string // free-text appended to the ledger verdict; for truths the checks cannot assert + + // signatures overrides the definition/property lookup for locations that are not schemas at all + // — parameters and response headers carry SimpleSchema, not a spec.Schema. When set, definition + // is ignored and namedProp/aliasProp serve only as the cell's label. + signatures func(t *testing.T, doc *oaispec.Swagger) (named, alias string) +} + +// resolve returns the two schemas a cell compares. +func (c symmetryCell) resolve(t *testing.T, defs oaispec.Definitions) (named, alias oaispec.Schema) { + t.Helper() + + if c.definition == "" { + namedDef, okNamed := defs[c.namedProp] + require.True(t, okNamed, "missing definition %s", c.namedProp) + aliasDef, okAlias := defs[c.aliasProp] + require.True(t, okAlias, "missing definition %s", c.aliasProp) + return namedDef, aliasDef + } + + def, ok := defs[c.definition] + require.True(t, ok, "missing definition %s", c.definition) + + namedProp, okNamed := def.Properties[c.namedProp] + require.True(t, okNamed, "missing property %s.%s", c.definition, c.namedProp) + aliasProp, okAlias := def.Properties[c.aliasProp] + require.True(t, okAlias, "missing property %s.%s", c.definition, c.aliasProp) + + return namedProp, aliasProp +} + +// symmetryLedger is one fixture package's matrix, run across all three alias modes. +type symmetryLedger struct { + pkg string // package pattern under fixtures/ + goldenPrefix string // golden file stem; the mode name is appended + cells []symmetryCell + + // exceptions are cells where named and alias SHOULD differ, keyed "/". + exceptions map[string]string + // knownBroken are cells asymmetric because of Q32, keyed "/". The fix's worklist. + knownBroken map[string]string + // controlBroken are cells whose NAMED half is already wrong, keyed "/" — a shared + // gap in the dispatch, not an alias problem. + controlBroken map[string]string +} + +// aliasModes are the three alias-handling modes every matrix runs under. +func aliasModes() []struct { + name string + refAliases bool + transparent bool +} { + return []struct { + name string + refAliases bool + transparent bool + }{ + {"default", false, false}, + {"refaliases", true, false}, + {"transparentaliases", false, true}, + } +} + +// schemaSignature renders a schema's observable shape as a comparable string, resolving `$ref` +// through defs so an inlined schema and a referenced one compare equal when they describe the same +// thing. Depth-bounded: a cyclic $ref yields "" rather than hanging. +func schemaSignature(s oaispec.Schema, defs oaispec.Definitions, depth int) string { + const maxDepth = 8 + if depth > maxDepth { + return "" + } + + if ref := s.Ref.String(); ref != "" { + name := strings.TrimPrefix(ref, "#/definitions/") + target, ok := defs[name] + if !ok { + return "" + } + return schemaSignature(target, defs, depth+1) + } + + if len(s.AllOf) > 0 { + parts := make([]string, 0, len(s.AllOf)) + for _, member := range s.AllOf { + parts = append(parts, schemaSignature(member, defs, depth+1)) + } + return "allOf[" + strings.Join(parts, "+") + "]" + } + + if s.Items != nil && s.Items.Schema != nil { + return "array<" + schemaSignature(*s.Items.Schema, defs, depth+1) + ">" + } + + if s.AdditionalProperties != nil && s.AdditionalProperties.Schema != nil { + return "map<" + schemaSignature(*s.AdditionalProperties.Schema, defs, depth+1) + ">" + } + + if len(s.Properties) > 0 { + keys := make([]string, 0, len(s.Properties)) + for k := range s.Properties { + keys = append(keys, k) + } + sort.Strings(keys) + return "object{" + strings.Join(keys, ",") + "}" + } + + typ := "" + if len(s.Type) > 0 { + typ = strings.Join(s.Type, "|") + } + if typ == "" && s.Format == "" { + return "" + } + return typ + "/" + s.Format +} + +// run scans the fixture package in each alias mode, renders the ledger, and asserts both checks. +func (l symmetryLedger) run(t *testing.T) { + t.Helper() + + for _, mode := range aliasModes() { + t.Run(mode.name, func(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./" + l.pkg + "/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + RefAliases: mode.refAliases, + TransparentAliases: mode.transparent, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + var ledger strings.Builder + fmt.Fprintf(&ledger, "\n%-24s %-34s %-34s %s\n", "CELL", "NAMED (control)", "ALIAS", "VERDICT") + fmt.Fprintf(&ledger, "%s\n", strings.Repeat("-", 120)) + + for _, c := range l.cells { + var namedSig, aliasSig string + if c.signatures != nil { + namedSig, aliasSig = c.signatures(t, doc) + } else { + namedSchema, aliasSchema := c.resolve(t, doc.Definitions) + namedSig = schemaSignature(namedSchema, doc.Definitions, 0) + aliasSig = schemaSignature(aliasSchema, doc.Definitions, 0) + } + + cellID := strings.TrimSuffix(c.namedProp, "Named") + key := mode.name + "/" + cellID + + verdict := l.verdict(key, namedSig, aliasSig, c.wantNamed) + if c.note != "" { + verdict += " — " + c.note + } + fmt.Fprintf(&ledger, "%-24s %-34s %-34s %s\n", cellID, namedSig, aliasSig, verdict) + + l.checkControl(t, key, c.wantNamed, namedSig) + l.checkSymmetry(t, key, namedSig, aliasSig) + } + + t.Log(ledger.String()) + + scantest.CompareOrDumpJSON(t, doc, l.goldenPrefix+"_"+mode.name+".json") + }) + } +} + +// verdict renders the per-cell marker shown in the ledger. +func (l symmetryLedger) verdict(key, namedSig, aliasSig, wantNamed string) string { + controlWrong := wantNamed != "" && namedSig != wantNamed + + switch { + case namedSig == aliasSig && controlWrong: + return "SYMMETRIC but control wrong (want " + wantNamed + ")" + case namedSig == aliasSig: + return "OK" + case l.exceptions[key] != "": + return "EXPECTED-DIFF" + case l.knownBroken[key] != "": + return "BROKEN(Q32)" + default: + return "UNEXPECTED" + } +} + +// checkControl asserts the named half against its expected signature, honouring controlBroken. +func (l symmetryLedger) checkControl(t *testing.T, key, wantNamed, namedSig string) { + t.Helper() + + if wantNamed == "" { + return + } + if reason, isBroken := l.controlBroken[key]; isBroken { + assert.NotEqual(t, wantNamed, namedSig, + "%s: control listed as broken (%s) but is now correct — remove it from controlBroken", key, reason) + return + } + assert.Equal(t, wantNamed, namedSig, "%s: the named control itself is wrong", key) +} + +// checkSymmetry asserts the alias half against its named control, honouring exceptions/knownBroken. +func (l symmetryLedger) checkSymmetry(t *testing.T, key, namedSig, aliasSig string) { + t.Helper() + + if reason, isException := l.exceptions[key]; isException { + assert.NotEqual(t, namedSig, aliasSig, + "%s: listed as a legitimate difference (%s) but the halves now agree — drop the exception", key, reason) + return + } + if reason, isBroken := l.knownBroken[key]; isBroken { + assert.NotEqual(t, namedSig, aliasSig, + "%s: listed as known-broken (%s) but now agrees — remove it from knownBroken", key, reason) + return + } + assert.Equal(t, namedSig, aliasSig, "%s: alias half must match its named control", key) +} + +// simpleSignature renders a SimpleSchema location (parameter, response header) in the same +// vocabulary as schemaSignature, so both kinds of cell read alike in the ledger. +func simpleSignature(typ, format string, items *oaispec.Items) string { + if items != nil { + return "array<" + simpleSignature(items.Type, items.Format, items.Items) + ">" + } + if typ == "" && format == "" { + return "" + } + return typ + "/" + format +} diff --git a/internal/integration/strfmt_symmetry_simpleschema_test.go b/internal/integration/strfmt_symmetry_simpleschema_test.go new file mode 100644 index 00000000..359e38fa --- /dev/null +++ b/internal/integration/strfmt_symmetry_simpleschema_test.go @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "testing" + + oaispec "github.com/go-openapi/spec" + "github.com/go-openapi/testify/v2/require" +) + +// F3 of the strfmt dispatch-symmetry matrix: the SimpleSchema locations — non-body parameters and +// response headers — where OAS v2 forbids `$ref`, so the builder runs with `simpleSchema` set and +// the `refModel` gate flips (`schema.go:475`). +// +// See strfmt_symmetry_harness_test.go for how the ledger reads. +func TestStrfmtSymmetrySimpleSchema(t *testing.T) { + ledger := symmetryLedger{ + pkg: "enhancements/strfmt-symmetry-simpleschema", + goldenPrefix: "strfmt_symmetry_simpleschema", + cells: []symmetryCell{ + { + namedProp: "queryBasicNamed", aliasProp: "queryBasicAlias", + wantNamed: "string/isbn", + signatures: queryParamSignatures("queryBasicNamed", "queryBasicAlias"), + }, + { + namedProp: "querySliceNamed", aliasProp: "querySliceAlias", + wantNamed: "string/byte", + signatures: queryParamSignatures("querySliceNamed", "querySliceAlias"), + }, + { + namedProp: "headerBasicNamed", aliasProp: "headerBasicAlias", + wantNamed: "string/isbn", + signatures: responseHeaderSignatures("headerBasicNamed", "headerBasicAlias"), + }, + { + namedProp: "headerSliceNamed", aliasProp: "headerSliceAlias", + wantNamed: "string/byte", + signatures: responseHeaderSignatures("headerSliceNamed", "headerSliceAlias"), + }, + }, + + exceptions: map[string]string{}, + knownBroken: map[string]string{}, + controlBroken: map[string]string{}, + } + + ledger.run(t) +} + +// operationParams returns the parameters of the fixture's single operation, keyed by name. +func operationParams(t *testing.T, doc *oaispec.Swagger) map[string]oaispec.Parameter { + t.Helper() + + require.NotNil(t, doc.Paths, "fixture must produce paths") + path, ok := doc.Paths.Paths["/simple"] + require.True(t, ok, "missing path /simple") + require.NotNil(t, path.Get, "missing GET operation on /simple") + + out := make(map[string]oaispec.Parameter, len(path.Get.Parameters)) + for _, p := range path.Get.Parameters { + out[p.Name] = p + } + return out +} + +// queryParamSignatures locates two query parameters and renders their SimpleSchema signatures. +func queryParamSignatures(named, alias string) func(*testing.T, *oaispec.Swagger) (string, string) { + return func(t *testing.T, doc *oaispec.Swagger) (string, string) { + t.Helper() + + params := operationParams(t, doc) + namedParam, okNamed := params[named] + require.True(t, okNamed, "missing query parameter %s", named) + aliasParam, okAlias := params[alias] + require.True(t, okAlias, "missing query parameter %s", alias) + + return simpleSignature(namedParam.Type, namedParam.Format, namedParam.Items), + simpleSignature(aliasParam.Type, aliasParam.Format, aliasParam.Items) + } +} + +// responseHeaderSignatures locates two headers on the shared response definition. +// +// The operation's 200 only carries `$ref: #/responses/simpleResponse`; the headers themselves live +// in the top-level responses section. +func responseHeaderSignatures(named, alias string) func(*testing.T, *oaispec.Swagger) (string, string) { + return func(t *testing.T, doc *oaispec.Swagger) (string, string) { + t.Helper() + + resp, ok := doc.Responses["simpleResponse"] + require.True(t, ok, "missing response definition simpleResponse") + + namedHeader, okNamed := resp.Headers[named] + require.True(t, okNamed, "missing response header %s", named) + aliasHeader, okAlias := resp.Headers[alias] + require.True(t, okAlias, "missing response header %s", alias) + + return simpleSignature(namedHeader.Type, namedHeader.Format, namedHeader.Items), + simpleSignature(aliasHeader.Type, aliasHeader.Format, aliasHeader.Items) + } +} diff --git a/internal/integration/strfmt_symmetry_stdlib_test.go b/internal/integration/strfmt_symmetry_stdlib_test.go new file mode 100644 index 00000000..901e5bb9 --- /dev/null +++ b/internal/integration/strfmt_symmetry_stdlib_test.go @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import "testing" + +// F4 of the strfmt dispatch-symmetry matrix: precedence between a user's format annotation and the +// builder's own stdlib recognizers. +// +// The contract is that the AUTHOR ALWAYS WINS. `swagger:strfmt` is the escape hatch for exactly the +// case the library cannot infer — a time.Time may go on the wire as `date`, or as some custom +// format nothing can guess — so a recognizer is a default for un-annotated code, never an override +// of an explicit annotation. +// +// Today only the named half honours that. It never reaches a recognizer at all +// (applyStdlibSpecials is keyed on the declaration's own identity, and `StampNamed` is not +// `time.Time`), so its classifier wins by construction rather than by precedence. The alias half +// dissolves onto the stdlib type itself, the recognizer fires first, and the annotation loses to a +// confidently wrong answer: `date-time` for the time pair, and for json.RawMessage an untyped `{}` +// — the open "any JSON" shape, right as a default and precisely wrong as an override. +// +// See strfmt_symmetry_harness_test.go for how the ledger reads. +func TestStrfmtSymmetryStdlib(t *testing.T) { + ledger := symmetryLedger{ + pkg: "enhancements/strfmt-symmetry-stdlib", + goldenPrefix: "strfmt_symmetry_stdlib", + cells: []symmetryCell{ + { + definition: "Envelope", namedProp: "fieldTimeNamed", aliasProp: "fieldTimeAlias", + wantNamed: "string/date", + note: "alias yields the recognizer's date-time, NOT the author's date", + }, + { + definition: "Envelope", namedProp: "fieldRawNamed", aliasProp: "fieldRawAlias", + wantNamed: "string/byte", + note: "alias yields recognizeRawMessage's untyped open schema, dropping the format AND the type", + }, + }, + + exceptions: map[string]string{}, + knownBroken: map[string]string{}, + controlBroken: map[string]string{}, + } + + ledger.run(t) +} diff --git a/internal/integration/type_override_symmetry_test.go b/internal/integration/type_override_symmetry_test.go new file mode 100644 index 00000000..1d036404 --- /dev/null +++ b/internal/integration/type_override_symmetry_test.go @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "strings" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/scantest" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// `swagger:type` on an alias declaration, against the same annotation on a named one — the second +// half of Q32, after `swagger:strfmt`. +// +// `swagger:type` has a wider surface than strfmt (scalar names, `[]T` prefixes, `inline`, and +// references to other scanned types), so each form is its own pair. It also brought a constraint +// strfmt did not: an OAS v2 SimpleSchema location cannot carry every type. See +// TestTypeOverrideSimpleSchema below. +func TestTypeOverrideSymmetry(t *testing.T) { + ledger := symmetryLedger{ + pkg: "enhancements/type-override-symmetry", + goldenPrefix: "type_override_symmetry", + cells: []symmetryCell{ + {definition: "Envelope", namedProp: "fieldScalarNamed", aliasProp: "fieldScalarAlias", wantNamed: "string/"}, + {definition: "Envelope", namedProp: "fieldArrayNamed", aliasProp: "fieldArrayAlias", wantNamed: "array"}, + { + definition: "Envelope", namedProp: "fieldRefNamed", aliasProp: "fieldRefAlias", + wantNamed: "object{left,right}", + note: "a type-name reference inlines the referenced type's shape", + }, + { + definition: "Envelope", namedProp: "fieldFormattedNamed", aliasProp: "fieldFormattedAlias", + wantNamed: "string/uuid", + note: "swagger:type wins, a co-present swagger:strfmt rides as an advisory format", + }, + {definition: "Envelope", namedProp: "pointerScalarNamed", aliasProp: "pointerScalarAlias", wantNamed: "string/"}, + {definition: "Envelope", namedProp: "sliceElemScalarNamed", aliasProp: "sliceElemScalarAlias", wantNamed: "array"}, + {definition: "Envelope", namedProp: "mapValueScalarNamed", aliasProp: "mapValueScalarAlias", wantNamed: "map"}, + + // The allOf member. The named half used to emit an EMPTY member — the arm ran no type + // classifier, so a `swagger:type` on it was dropped and its basic underlying then fell to the + // warn-and-skip default. It now composes the override exactly as the alias half does. + { + namedProp: "AllOfScalarNamed", aliasProp: "AllOfScalarAlias", + wantNamed: "allOf[string/+object{note}]", + note: "the composition arm now runs the same classifier cascade as the field dispatch", + }, + }, + + exceptions: map[string]string{}, + knownBroken: map[string]string{}, + controlBroken: map[string]string{}, + } + + ledger.run(t) +} + +// TestTypeOverrideSimpleSchema pins the constraint `swagger:type` brings that `swagger:strfmt` did +// not: a non-body parameter and a response header are OAS v2 SimpleSchema locations, where `type` is +// mandatory and restricted — an object has no representation there at all. +// +// A legal override applies as anywhere else; an object-resolving one is refused with a diagnostic +// and the Go-derived type stands, because a parameter without a type is not valid. Both halves of +// each pair must agree, which is the point: the gate lives on the shared path, so the alias and the +// named declaration reach it alike. +func TestTypeOverrideSimpleSchema(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/type-override-symmetry/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + op := doc.Paths.Paths["/type-override"].Get + require.NotNil(t, op) + + params := make(map[string]string, len(op.Parameters)) + for _, p := range op.Parameters { + params[p.Name] = p.Type + } + assert.Equal(t, "string", params["queryScalarNamed"], "a legal override applies to a query parameter") + assert.Equal(t, params["queryScalarNamed"], params["queryScalarAlias"], + "the alias half must reach the same classifier as the named half") + + headers := doc.Responses["typeOverrideResponse"].Headers + assert.Equal(t, "string", headers["X-Named"].Type, "a legal override applies to a response header") + assert.Equal(t, headers["X-Named"].Type, headers["X-Alias"].Type, + "the alias half must reach the same classifier as the named half") +} + +// TestTypeOverrideFileSynonym pins `swagger:type file` as a synonym for `swagger:file`. +// +// `file` is an OAS v2 type name, so the annotation whose job is naming types should name it; +// `swagger:file` is the older, extraneous spelling and is expected to be deprecated, which makes +// `swagger:type file` the preferred one. Synonymy is implemented by raising the same signal the +// legacy annotation raises, so both spellings pass through the SAME location gate — a formData +// parameter and a response body, nowhere else. +func TestTypeOverrideFileSynonym(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/type-override-symmetry/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + op := doc.Paths.Paths["/file-synonym"].Post + require.NotNil(t, op) + byName := make(map[string]string, len(op.Parameters)) + for _, p := range op.Parameters { + byName[p.Name] = p.Type + } + + assert.Equal(t, "file", byName["viaAnnotation"], "the legacy spelling still works") + assert.Equal(t, byName["viaAnnotation"], byName["viaType"], + "swagger:type file must be identical to swagger:file on a formData parameter") + + // The shared gate means the preferred spelling cannot leak `file` anywhere OAS 2.0 forbids it. + assert.Equal(t, "string", byName["queryFile"], + "file is formData-only; elsewhere the override is refused and the Go type stands") + + assert.Equal(t, "file", doc.Responses["fileBodyAnnotation"].Schema.Type[0]) + assert.Equal(t, doc.Responses["fileBodyAnnotation"].Schema.Type[0], + doc.Responses["fileBodyType"].Schema.Type[0], + "swagger:type file must be identical to swagger:file on a response body") +} + +// TestAliasEnumIsUnfixableButLoud closes Q32's last piece. +// +// `swagger:strfmt` and `swagger:type` on an alias were fixable — both merely decorate the emitted +// schema. `swagger:enum` is not: members are collected by finding the constants declared WITH the +// annotated type, and a type alias is erased by the type-checker, so `const Zero Unsigned = 0` is a +// `uint64` constant indistinguishable from every other one. There is nothing to collect. +// +// So the remedy is a diagnostic rather than a propagation — and it must be precise: an alias to a +// NAMED enum type does work, because the named type survives the alias, and must stay silent. +func TestAliasEnumIsUnfixableButLoud(t *testing.T) { + var diags []string + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/type-override-symmetry/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + OnDiagnostic: func(d codescan.Diagnostic) { diags = append(diags, d.String()) }, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + props := doc.Definitions["EnumEnvelope"].Properties + + // The control and the alias-to-named both collect their members. + assert.NotEmpty(t, props["named"].Enum, "a named enum type collects its constants") + assert.Equal(t, props["named"].Enum, props["toNamed"].Enum, + "an alias to a named enum type works — the named type survives the alias") + + // The alias-to-basic collects nothing, which is unavoidable... + assert.Empty(t, props["alias"].Enum, "an alias to a basic type has no collectable constants") + + // ...but must no longer be silent about it. + var warned int + for _, d := range diags { + if strings.Contains(d, "swagger:enum on an alias to a non-named type") { + warned++ + } + } + assert.Positive(t, warned, "the unfixable case must be reported; got %v", diags) + + // Precision matters more than the warning: firing on the alias-to-named case would tell authors + // their working annotation is broken. + for _, d := range diags { + assert.NotContains(t, d, "AliasToNamed", + "an alias to a named enum type works and must not be warned about") + } +} diff --git a/internal/parsers/grammar/README.md b/internal/parsers/grammar/README.md index d4fb69bc..cb61fe3c 100644 --- a/internal/parsers/grammar/README.md +++ b/internal/parsers/grammar/README.md @@ -470,6 +470,12 @@ re-decides. ### `swagger:default` value +The annotation is a DEPRECATED no-op — the builder raises +`validate.deprecated` and ignores it — but the lexer still types its +argument, so existing source keeps parsing. The argument is optional: +requiring it would hard-error on the bare form the annotation was +documented with for years. + `classifyDefaultValue` tries `JSON_VALUE` first (full JSON validation via the stdlib decoder), falling back to `RAW_VALUE`. A leading quote / bracket / brace / sign / digit is the quick diff --git a/internal/parsers/grammar/diagnostic.go b/internal/parsers/grammar/diagnostic.go index 586956ff..ebb16682 100644 --- a/internal/parsers/grammar/diagnostic.go +++ b/internal/parsers/grammar/diagnostic.go @@ -291,6 +291,27 @@ const ( // Warning. // (D7). CodeEmptyOverride Code = "scan.empty-override" + + // CodeUnparsedPathAnnotation fires when a comment line opens with `swagger:route` or + // `swagger:operation` but the rest of the line does not parse as one. + // + // Such a line produces NOTHING: no path, no operation, and — before this code existed — no word to + // the author either, because a route annotation that fails to match is indistinguishable from + // ordinary prose to everything downstream. The route simply is not there, and the first sign of it + // is a missing path in the output. + // Warning. + // (Q43). + CodeUnparsedPathAnnotation Code = "scan.unparsed-path-annotation" + + // CodeIneffectiveAnnotation fires when an annotation is well-formed and recognised, but the + // position it was written in does not consult it — so it is accepted, validated, and discarded. + // + // Currently: `swagger:strfmt` / `swagger:type` in the doc comment of an EMBEDDED field. On a + // regular field both are honoured, which is what makes the silence misleading; an embed + // contributes its type's shape, and what that shape is comes from the embedded type's own + // declaration, never from the embedding site. + // Warning. + CodeIneffectiveAnnotation Code = "scan.ineffective-annotation" ) // Diagnostic is one observation about a comment block. diff --git a/internal/parsers/grammar/parser.go b/internal/parsers/grammar/parser.go index 1e1449e6..b0e7d47e 100644 --- a/internal/parsers/grammar/parser.go +++ b/internal/parsers/grammar/parser.go @@ -540,6 +540,16 @@ func (s *parseState) parseMetaBlock(annIdx int, annTok Token) Block { // --- Classifier family ------------------------------------------------------- +// isStructuralKeyword reports whether a keyword is a field directive rather than part of the +// schema-body grammar. +// +// `in:` places a field (query / path / header / body / formData) and `name:` renames it. Both are +// consumed by the parameters and responses builders directly from the doc text, so they may appear +// alongside any annotation — including a classifier, whose body is otherwise prose-only. +func isStructuralKeyword(name string) bool { + return name == KwIn || name == KwName +} + //nolint:ireturn // stable seam. func (s *parseState) parseClassifierBlock(annIdx int, annTok Token, kind AnnotationKind) Block { base := newBaseBlock(kind, annTok.Pos) @@ -567,6 +577,17 @@ func (s *parseState) parseClassifierBlock(annIdx int, annTok Token, kind Annotat s.emit(Warnf(t.Pos, CodeContextInvalid, "keyword %q not valid under swagger:%s", t.Keyword, kind)) case TokenKeyword: + if isStructuralKeyword(t.Name) { + // `in:` and `name:` are field directives, not schema-body keywords: they say WHERE a field + // goes and what it is CALLED, and the parameters / responses builders read them straight + // from the doc text rather than from this block. + // + // A parameter field may legitimately carry both a classifier annotation and `in:` — indeed + // `in:` is mandatory there — so rejecting it made the canonical file-upload idiom warn + // about its own required directive. The same field without the annotation never warned, + // so this was inconsistent as well as wrong. + continue + } s.emit(Warnf(t.Pos, CodeContextInvalid, "keyword %q not valid under swagger:%s", t.Name, kind)) case TokenOpaqueYaml: @@ -599,10 +620,9 @@ func (s *parseState) parseClassifierBlock(annIdx int, annTok Token, kind Annotat "swagger:strfmt requires a name argument")) } case AnnDefaultName: - if !annTok.HasArg(1) { - s.emit(Errorf(annTok.Pos, CodeMissingRequiredArg, - "swagger:default requires a value argument")) - } + // No arg requirement: swagger:default is a deprecated no-op sink, and demanding a value for a + // value nothing reads would hard-error on the very spelling the annotation was documented with + // (the bare form). Both forms parse; the builder raises the deprecation. case AnnType: // Only the STRUCTURAL shape is checked here: a missing arg, or a malformed token (embedded // spaces, bare `[]`, illegal chars). diff --git a/internal/parsers/parsed_path_content.go b/internal/parsers/parsed_path_content.go index 1acd15ed..c0b7d0f7 100644 --- a/internal/parsers/parsed_path_content.go +++ b/internal/parsers/parsed_path_content.go @@ -33,6 +33,16 @@ type ParsedPathContent struct { // - the cross-ref anchor for the /paths/{path}/{method} node. Invalid when no annotation matched. Pos token.Pos + // UnparsedPos / UnparsedLine record a line recognisable as a path annotation — keyword, method and + // a path — that did not parse as one. Both are zero unless that happened. + // + // Nothing downstream can recover this: a `swagger:route` that fails its regex leaves no trace and + // reads as ordinary prose, so the route goes missing with no diagnostic anywhere. Capturing it + // here is what lets the caller say so — but only when the group produced no route at all, since a + // group may legitimately hold both a good annotation and prose that resembles one. + UnparsedPos token.Pos + UnparsedLine string + // StrippedParams names the path parameters whose inline regex constraint (gorilla/chi style, e.g. // `{id:[0-9]+}`) was stripped to the bare `{id}` template form. // @@ -97,11 +107,11 @@ func stripPathParamRegex(s string) (cleaned string, stripped []string) { } func ParseOperationPathAnnotation(lines []*ast.Comment) (cnt ParsedPathContent) { - return parsePathAnnotation(rxOperation, lines) + return parsePathAnnotation(rxOperation, rxOperationHead, lines) } func ParseRoutePathAnnotation(lines []*ast.Comment) (cnt ParsedPathContent) { - return parsePathAnnotation(rxRoute, lines) + return parsePathAnnotation(rxRoute, rxRouteHead, lines) } // ensureCommentMarker returns line with a leading `// ` prepended unless it already starts with @@ -159,7 +169,7 @@ func stripBlockContinuation(s string) string { return s } -func parsePathAnnotation(annotation *regexp.Regexp, lines []*ast.Comment) (cnt ParsedPathContent) { +func parsePathAnnotation(annotation, head *regexp.Regexp, lines []*ast.Comment) (cnt ParsedPathContent) { const routeTagsIndex = 3 // routeTagsIndex is the regex submatch index where route tags begin. var justMatched bool @@ -193,6 +203,15 @@ func parsePathAnnotation(annotation *regexp.Regexp, lines []*ast.Comment) (cnt P continue } + // The line did not parse. If it still reads as a path annotation — keyword, method, path — it + // was meant to be one, and saying nothing is how a mistyped route goes missing unnoticed. + // Record the first such line; whether it is worth reporting depends on what the rest of the + // group yields, which only the caller knows. + if cnt.UnparsedPos == token.NoPos && head.MatchString(cleaned) { + cnt.UnparsedPos = cmt.Slash + cnt.UnparsedLine = strings.TrimSpace(rxStripComments.ReplaceAllString(line, "")) + } + if cnt.Method == "" { continue } diff --git a/internal/parsers/parsed_path_content_test.go b/internal/parsers/parsed_path_content_test.go index fb11758b..596be665 100644 --- a/internal/parsers/parsed_path_content_test.go +++ b/internal/parsers/parsed_path_content_test.go @@ -53,6 +53,52 @@ func TestParseRoutePathAnnotation(t *testing.T) { wantID: "deletePet", wantTags: []string{"pets", "admin"}, }, + + // One-character names. Both regexes used to require a letter followed by AT LEAST ONE more + // character, and since the tags group is optional the parse did not fail there — it fell back to + // matching with no tags, leaving the operationId pattern to swallow `e listPets`, which its + // alphabet has no space for. The line then matched nothing at all, and a `swagger:route` that + // matches nothing is not a malformed route but simply not a route: the whole annotation vanished + // without a word. Neither restriction has any basis in OAS 2.0. + { + name: "single-character tag", + line: "// swagger:route GET /pets e listPets", + wantMethod: "GET", + wantPath: "/pets", + wantID: "listPets", + wantTags: []string{"e"}, + }, + { + name: "single-character operationId", + line: "// swagger:route GET /pets pets l", + wantMethod: "GET", + wantPath: "/pets", + wantID: "l", + wantTags: []string{"pets"}, + }, + { + name: "single-character operationId, no tags", + line: "// swagger:route GET /pets l", + wantMethod: "GET", + wantPath: "/pets", + wantID: "l", + }, + { + name: "single-character tag and operationId", + line: "// swagger:route GET /pets e l", + wantMethod: "GET", + wantPath: "/pets", + wantID: "l", + wantTags: []string{"e"}, + }, + { + name: "one-character tag among several", + line: "// swagger:route DELETE /pets/{petId} a admin deletePet", + wantMethod: "DELETE", + wantPath: "/pets/{petId}", + wantID: "deletePet", + wantTags: []string{"a", "admin"}, + }, } for _, tc := range tests { @@ -82,6 +128,22 @@ func TestParseOperationPathAnnotation(t *testing.T) { wantID string wantTags []string }{ + { + name: "single-character tag", + line: "// swagger:operation GET /v1/pets e listPets", + wantMethod: "GET", + wantPath: "/v1/pets", + wantID: "listPets", + wantTags: []string{"e"}, + }, + { + name: "single-character operationId", + line: "// swagger:operation GET /v1/pets pets l", + wantMethod: "GET", + wantPath: "/v1/pets", + wantID: "l", + wantTags: []string{"pets"}, + }, { name: "basic operation", line: "// swagger:operation POST /v1/pets pets addPet", diff --git a/internal/parsers/regexprs.go b/internal/parsers/regexprs.go index e4fc2ccd..eb6f9078 100644 --- a/internal/parsers/regexprs.go +++ b/internal/parsers/regexprs.go @@ -30,8 +30,20 @@ const ( rxMethod = "(\\p{L}+)" rxPath = "((?:/[\\p{L}\\p{N}\\p{Pd}\\p{Pc}{}\\-\\.\\?_~%!$&'()*+,;=:@/]*)+/?)" - rxOpTags = "(\\p{L}[\\p{L}\\p{N}\\p{Pd}\\.\\p{Pc}\\p{Zs}]+)" - rxOpID = "((?:\\p{L}[\\p{L}\\p{N}\\p{Pd}\\p{Pc}]+)+)" + + // rxOpTags and rxOpID both accept a name of a SINGLE character: a letter, then zero or more + // further characters. + // + // They required one further character until 2026-08-02, which silently voided the whole + // annotation. The failure is not local to the offending name, because the tags group is optional: + // on `swagger:route GET /pets e listPets` the parse does not stop at `e`, it falls back to matching + // with NO tags, which leaves rxOpID to swallow `e listPets` — and its alphabet has no space. The + // line then matches nothing, and a `swagger:route` matching nothing is not a malformed route, it is + // not a route at all, so there was nothing left to raise a diagnostic about. + // + // OAS 2.0 puts no such floor on either: a tag and an operationId are free-form strings. + rxOpTags = "(\\p{L}[\\p{L}\\p{N}\\p{Pd}\\.\\p{Pc}\\p{Zs}]*)" + rxOpID = "(\\p{L}[\\p{L}\\p{N}\\p{Pd}\\p{Pc}]*)" ) // compile-once regexes; read-only. @@ -79,6 +91,21 @@ var ( rxModelArg = regexp.MustCompile(rxCommentPrefix + `swagger:model\p{Zs}+(\S.*?)\p{Zs}*$`) rxResponseArg = regexp.MustCompile(rxCommentPrefix + `swagger:response\p{Zs}+(\S.*?)\p{Zs}*$`) + // rxRouteHead / rxOperationHead match the HEAD of a path annotation — its full regex up to and + // including the path, with the tags and operationId left off. + // + // They exist to tell "this line is not an annotation" apart from "this line meant to be one and + // did not parse". The full regexes cannot make that distinction: a line that fails them is + // indistinguishable from prose, which is why a malformed route used to disappear in silence. + // + // Matching the keyword alone is NOT enough to tell those apart. Annotations must start the comment + // line, so a doc comment whose sentence happens to begin `swagger:route response lines are …` also + // starts with the keyword — three such lines exist in this repo's own fixtures. Requiring a method + // and a `/`-rooted path costs nothing (a real annotation always has both) and drops every one of + // them, since prose after the keyword does not reach a path. + rxRouteHead = regexp.MustCompile(rxRoutePrefix + `swagger:route\p{Zs}+` + rxMethod + `\p{Zs}*` + rxPath) + rxOperationHead = regexp.MustCompile(rxCommentPrefix + `swagger:operation\p{Zs}+` + rxMethod + `\p{Zs}*` + rxPath) + rxRoute = regexp.MustCompile( rxRoutePrefix + "swagger:route\\p{Zs}*" + diff --git a/internal/scanner/README.md b/internal/scanner/README.md index df83149a..a957d670 100644 --- a/internal/scanner/README.md +++ b/internal/scanner/README.md @@ -403,10 +403,20 @@ An enum cannot be hosted on an **alias to a basic type** (`type Unsigned = uint64`): the checker erases the alias, so `const Zero Unsigned = 0` is indistinguishable from any other `uint64` constant and there is nothing left to match on. The -annotation is a no-op there — as it was before this change, since -the classifier never reaches an alias decl either. An alias to a -*named* enum type (`type Weekday2 = Weekday`) is fine: the -underlying named type survives. +annotation collects nothing there, and now says so: it raises +`parse.invalid-enum-option` naming the declaration and suggesting a +named type. It used to be silent, which left an author with a +correct-looking annotation and no members. + +An alias to a *named* enum type (`type Weekday2 = Weekday`) is +fine — the underlying named type survives — and is deliberately +NOT warned about. + +This is where `swagger:enum` parts company with `swagger:strfmt` +and `swagger:type` on aliases. Those two decorate the emitted +schema and were made to work at alias use sites; this one has no +data to work with, so the remedy is a diagnostic rather than +plumbing. ### Values come from the type-checker, not from the literal diff --git a/internal/scanner/index.go b/internal/scanner/index.go index de4d7d3b..60a6e597 100644 --- a/internal/scanner/index.go +++ b/internal/scanner/index.go @@ -151,6 +151,15 @@ func (a *TypeIndex) emitHintf(code grammar.Code, format string, args ...any) { a.emit(grammar.Hintf(token.Position{}, code, format, args...)) } +// posOf resolves p against pkg's FileSet, tolerating a package that has none. +func posOf(pkg *packages.Package, p token.Pos) token.Position { + if pkg == nil || pkg.Fset == nil || !p.IsValid() { + return token.Position{} + } + + return pkg.Fset.Position(p) +} + func (a *TypeIndex) build(pkgs []*packages.Package) error { for _, pkg := range pkgs { if _, known := a.AllPackages[pkg.PkgPath]; known { @@ -185,7 +194,7 @@ func (a *TypeIndex) processPackage(pkg *packages.Package) error { } func (a *TypeIndex) processFile(pkg *packages.Package, file *ast.File) error { - n, err := a.detectNodes(file) + n, err := a.detectNodes(pkg, file) if err != nil { return err } @@ -195,11 +204,11 @@ func (a *TypeIndex) processFile(pkg *packages.Package, file *ast.File) error { } if n&operationNode != 0 { - a.Operations = a.collectOperationPathAnnotations(file.Comments, a.Operations) + a.Operations = a.collectOperationPathAnnotations(pkg, file.Comments, a.Operations) } if n&routeNode != 0 { - a.Routes = a.collectRoutePathAnnotations(file.Comments, a.Routes) + a.Routes = a.collectRoutePathAnnotations(pkg, file.Comments, a.Routes) } a.processFileDecls(pkg, file, n) @@ -207,10 +216,12 @@ func (a *TypeIndex) processFile(pkg *packages.Package, file *ast.File) error { return nil } -func (a *TypeIndex) collectOperationPathAnnotations(comments []*ast.CommentGroup, dst []parsers.ParsedPathContent) []parsers.ParsedPathContent { +func (a *TypeIndex) collectOperationPathAnnotations(pkg *packages.Package, comments []*ast.CommentGroup, dst []parsers.ParsedPathContent) []parsers.ParsedPathContent { for _, cmts := range comments { pp := parsers.ParseOperationPathAnnotation(cmts.List) if pp.Method == "" { + a.reportUnparsedPathAnnotation(pkg, pp, "swagger:operation") + continue } @@ -225,10 +236,12 @@ func (a *TypeIndex) collectOperationPathAnnotations(comments []*ast.CommentGroup return dst } -func (a *TypeIndex) collectRoutePathAnnotations(comments []*ast.CommentGroup, dst []parsers.ParsedPathContent) []parsers.ParsedPathContent { +func (a *TypeIndex) collectRoutePathAnnotations(pkg *packages.Package, comments []*ast.CommentGroup, dst []parsers.ParsedPathContent) []parsers.ParsedPathContent { for _, cmts := range comments { pp := parsers.ParseRoutePathAnnotation(cmts.List) if pp.Method == "" { + a.reportUnparsedPathAnnotation(pkg, pp, "swagger:route") + continue } @@ -243,6 +256,22 @@ func (a *TypeIndex) collectRoutePathAnnotations(comments []*ast.CommentGroup, ds return dst } +// reportUnparsedPathAnnotation warns about a comment group that opened with a path-annotation +// keyword and yielded no annotation. +// +// Only reached when the group produced nothing, so a group holding a good annotation alongside prose +// that resembles one stays quiet. Without this, the sole symptom of a mistyped `swagger:route` is a +// path missing from the output — the annotation does not fail, it ceases to be an annotation. +func (a *TypeIndex) reportUnparsedPathAnnotation(pkg *packages.Package, pp parsers.ParsedPathContent, keyword string) { + if !pp.UnparsedPos.IsValid() || pkg == nil || pkg.Fset == nil { + return + } + + a.emit(grammar.Warnf(pkg.Fset.Position(pp.UnparsedPos), grammar.CodeUnparsedPathAnnotation, + "%s annotation does not parse and was ignored, so no path is emitted for it: %q; expected `%s METHOD /path [tags] operationID`", + keyword, pp.UnparsedLine, keyword)) +} + func (a *TypeIndex) processFileDecls(pkg *packages.Package, file *ast.File, n node) { for _, dt := range file.Decls { switch fd := dt.(type) { @@ -475,7 +504,7 @@ func (a *TypeIndex) walkImports(pkg *packages.Package) error { // // See [§classifier](./README.md#classifier) — bitmask semantics, struct-annotation exclusivity // rule, and the recognised-but-bitless field-decoration tokens. -func (a *TypeIndex) detectNodes(file *ast.File) (node, error) { +func (a *TypeIndex) detectNodes(pkg *packages.Package, file *ast.File) (node, error) { var n node for _, comments := range file.Comments { var seenStruct string // tracks the struct annotation for this comment group @@ -524,7 +553,12 @@ func (a *TypeIndex) detectNodes(file *ast.File) (node, error) { case "allOf", "omit": case "ignore": default: - return 0, fmt.Errorf("classifier: unknown swagger annotation %q: %w", annotation, ErrScanner) + // An annotation nobody recognises is almost always a typo, and it used to abort the entire + // scan — one mistyped keyword in one comment and a whole package graph produced nothing. + // Skip-and-diagnose is the house rule, and this is the case that most deserves it: the + // author gets the name, the location, and every other annotation in the tree still works. + a.emit(grammar.Warnf(posOf(pkg, cline.Pos()), grammar.CodeInvalidAnnotation, + "unknown swagger annotation %q; the comment is ignored", annotation)) } } } diff --git a/internal/scanner/index_test.go b/internal/scanner/index_test.go index 724dd46e..6f730011 100644 --- a/internal/scanner/index_test.go +++ b/internal/scanner/index_test.go @@ -10,6 +10,7 @@ import ( "slices" "testing" + "github.com/go-openapi/codescan/internal/parsers/grammar" "github.com/go-openapi/testify/v2/assert" "github.com/go-openapi/testify/v2/require" "golang.org/x/tools/go/packages" @@ -55,6 +56,10 @@ func TestShouldAcceptPkg(t *testing.T) { } } +// An annotation nobody recognises is skipped and reported, not fatal. +// +// It used to abort the scan, which made one mistyped keyword in one comment enough to produce +// nothing at all from a whole package graph — the outcome least likely to help whoever typed it. func TestDetectNodes_UnknownAnnotation(t *testing.T) { file := &ast.File{ Comments: []*ast.CommentGroup{ @@ -66,10 +71,16 @@ func TestDetectNodes_UnknownAnnotation(t *testing.T) { }, } - idx := &TypeIndex{} - _, err := idx.detectNodes(file) - require.Error(t, err) - assert.True(t, errors.Is(err, ErrScanner)) + var got []grammar.Diagnostic + idx := &TypeIndex{onDiagnostic: func(d grammar.Diagnostic) { got = append(got, d) }} + n, err := idx.detectNodes(nil, file) + require.NoError(t, err) + assert.EqualT(t, node(0), n, "an unrecognised annotation classifies as nothing") + + require.Len(t, got, 1) + assert.EqualT(t, grammar.CodeInvalidAnnotation, got[0].Code) + assert.EqualT(t, grammar.SeverityWarning, got[0].Severity) + assert.Contains(t, got[0].Message, "bogusAnnotation") } func TestDetectNodes_AllAnnotationTypes(t *testing.T) { @@ -80,7 +91,7 @@ func TestDetectNodes_AllAnnotationTypes(t *testing.T) { }, } idx := &TypeIndex{} - n, err := idx.detectNodes(file) + n, err := idx.detectNodes(nil, file) require.NoError(t, err) assert.True(t, n&metaNode != 0) }) @@ -92,7 +103,7 @@ func TestDetectNodes_AllAnnotationTypes(t *testing.T) { }, } idx := &TypeIndex{} - n, err := idx.detectNodes(file) + n, err := idx.detectNodes(nil, file) require.NoError(t, err) assert.True(t, n&routeNode != 0) }) @@ -104,7 +115,7 @@ func TestDetectNodes_AllAnnotationTypes(t *testing.T) { }, } idx := &TypeIndex{} - n, err := idx.detectNodes(file) + n, err := idx.detectNodes(nil, file) require.NoError(t, err) assert.True(t, n&operationNode != 0) }) @@ -116,7 +127,7 @@ func TestDetectNodes_AllAnnotationTypes(t *testing.T) { }, } idx := &TypeIndex{} - n, err := idx.detectNodes(file) + n, err := idx.detectNodes(nil, file) require.NoError(t, err) assert.True(t, n&modelNode != 0) }) @@ -128,7 +139,7 @@ func TestDetectNodes_AllAnnotationTypes(t *testing.T) { }, } idx := &TypeIndex{} - n, err := idx.detectNodes(file) + n, err := idx.detectNodes(nil, file) require.NoError(t, err) assert.True(t, n¶metersNode != 0) }) @@ -140,7 +151,7 @@ func TestDetectNodes_AllAnnotationTypes(t *testing.T) { }, } idx := &TypeIndex{} - n, err := idx.detectNodes(file) + n, err := idx.detectNodes(nil, file) require.NoError(t, err) assert.True(t, n&responseNode != 0) }) @@ -152,7 +163,7 @@ func TestDetectNodes_AllAnnotationTypes(t *testing.T) { }, } idx := &TypeIndex{} - _, err := idx.detectNodes(file) + _, err := idx.detectNodes(nil, file) require.NoError(t, err) }) @@ -163,7 +174,7 @@ func TestDetectNodes_AllAnnotationTypes(t *testing.T) { }, } idx := &TypeIndex{} - _, err := idx.detectNodes(file) + _, err := idx.detectNodes(nil, file) require.NoError(t, err) }) @@ -175,7 +186,7 @@ func TestDetectNodes_AllAnnotationTypes(t *testing.T) { }, } idx := &TypeIndex{} - _, err := idx.detectNodes(file) + _, err := idx.detectNodes(nil, file) require.NoError(t, err, "annotation %q should be accepted", annotation) } }) @@ -187,7 +198,7 @@ func TestDetectNodes_AllAnnotationTypes(t *testing.T) { }, } idx := &TypeIndex{} - n, err := idx.detectNodes(file) + n, err := idx.detectNodes(nil, file) require.NoError(t, err) assert.EqualT(t, node(0), n) }) @@ -199,7 +210,7 @@ func TestDetectNodes_AllAnnotationTypes(t *testing.T) { }, } idx := &TypeIndex{} - n, err := idx.detectNodes(file) + n, err := idx.detectNodes(nil, file) require.NoError(t, err) assert.True(t, n&metaNode != 0) }) @@ -252,7 +263,7 @@ func TestDetectNodes_StructConflict(t *testing.T) { }, } idx := &TypeIndex{} - _, err := idx.detectNodes(file) + _, err := idx.detectNodes(nil, file) require.Error(t, err) assert.True(t, errors.Is(err, ErrScanner)) }) @@ -269,7 +280,7 @@ func TestDetectNodes_StructConflict(t *testing.T) { }, } idx := &TypeIndex{} - _, err := idx.detectNodes(file) + _, err := idx.detectNodes(nil, file) require.Error(t, err) assert.True(t, errors.Is(err, ErrScanner)) }) @@ -286,7 +297,7 @@ func TestDetectNodes_StructConflict(t *testing.T) { }, } idx := &TypeIndex{} - _, err := idx.detectNodes(file) + _, err := idx.detectNodes(nil, file) require.Error(t, err) assert.True(t, errors.Is(err, ErrScanner)) }) @@ -299,7 +310,7 @@ func TestDetectNodes_StructConflict(t *testing.T) { }, } idx := &TypeIndex{} - n, err := idx.detectNodes(file) + n, err := idx.detectNodes(nil, file) require.NoError(t, err) assert.True(t, n&modelNode != 0) assert.True(t, n&responseNode != 0) diff --git a/internal/scanner/scan_context.go b/internal/scanner/scan_context.go index e8380923..53e173d5 100644 --- a/internal/scanner/scan_context.go +++ b/internal/scanner/scan_context.go @@ -816,9 +816,9 @@ func (s *ScanCtx) DeclForType(t types.Type) (*EntityDecl, bool) { case *types.Pointer: return s.DeclForType(tpe.Elem()) case *types.Named: - return s.FindDecl(tpe.Obj().Pkg().Path(), tpe.Obj().Name()) + return s.declForObj(tpe.Obj()) case *types.Alias: - return s.FindDecl(tpe.Obj().Pkg().Path(), tpe.Obj().Name()) + return s.declForObj(tpe.Obj()) default: s.EmitDiagnostic(grammar.Warnf(token.Position{}, grammar.CodeUnsupportedGoType, "unknown Go type %[1]T (%[1]v); cannot resolve its declaring source", t)) @@ -926,6 +926,21 @@ func (s *ScanCtx) FindEnumValues(pkg *packages.Package, enumName string) (list [ return list, descList, posList, true } +// declForObj resolves a type name's declaring source, tolerating an object that has no package. +// +// A predeclared object (`error`, `any`, `comparable`) is declared by the language rather than by any +// package, so `Pkg()` is nil and there is no source to find. Reading the path off it unguarded is a +// nil dereference, which is what a response body field typed `error` used to be: recognizing such a +// type by identity is the caller's job, and a caller that skipped it crashed here rather than +// degrading. +func (s *ScanCtx) declForObj(obj *types.TypeName) (*EntityDecl, bool) { + if obj == nil || obj.Pkg() == nil { + return nil, false + } + + return s.FindDecl(obj.Pkg().Path(), obj.Name()) +} + // findEnumValue extracts one (value, description) row per name declared by a const spec whose type // is enumName. // diff --git a/internal/scanner/scan_context_test.go b/internal/scanner/scan_context_test.go index 0e631d6d..d8e48f20 100644 --- a/internal/scanner/scan_context_test.go +++ b/internal/scanner/scan_context_test.go @@ -301,6 +301,17 @@ func TestScanCtx_DeclForType(t *testing.T) { _, ok := sctx.DeclForType(types.Typ[types.Int]) assert.False(t, ok) }) + + t.Run("predeclared type returns false rather than panicking", func(t *testing.T) { + // `error` is a *types.Named declared by the language, not by a package, so its object has a nil + // Pkg() and there is no declaring source to find in any package graph. Reading the import path + // off it unguarded is a nil dereference — which a response body field typed `error` used to be. + errType := types.Universe.Lookup("error").Type() + require.IsType(t, (*types.Named)(nil), errType) + + _, ok := sctx.DeclForType(errType) + assert.False(t, ok) + }) } func TestScanCtx_DeclForType_Alias(t *testing.T) {