From d53056dbae4d5f8310d83b37d47103293f4dd091 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sat, 1 Aug 2026 19:47:56 +0200 Subject: [PATCH 01/27] test(schema): map swagger:strfmt handling across every alias dispatch site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `swagger:strfmt` is honoured on a named type declaration and dropped on an alias one. The defect is not confined to aliases: it is an inconsistency in which dispatch sites consult a declaration's comments. The named arms do it through five classifiers; the alias arms do it in two places, each added when a specific context was reported. This is the corpus that pins the current behaviour, ahead of any fix. Four fixture packages, one per dispatch family, each scanned in all three alias modes: - core — the sites reachable through `buildFromType`: field, pointer, slice and array element, map value, plus the model-annotation gate - composition — plain struct embed and allOf member - simpleschema — non-body parameters and response headers, where OAS v2 forbids `$ref` - stdlib — a format annotation over a type the builder also recognizes Every cell is a pair of declarations differing only by the `=`, so the named half is the control and the ledger compares the two halves rather than a hand-written expectation. 56 of 69 cells diverge; each is listed with the dispatch site responsible. The lists assert in both directions, so a cell that starts agreeing fails too and the record cannot drift from the code. Three findings the pairwise comparison alone would have missed. The alias half does not merely lose a format keyword — a struct emits an object, a byte slice an array of integers, a `json.RawMessage` an untyped schema. An alias over an already-annotated named type still resolves, which places the defect precisely at "the alias's own comments are never read". And a plain embed drops the format on both halves, so that cell is a shared gap rather than an asymmetry, recorded as a witness and left unasserted. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../strfmt-symmetry-composition/types.go | 145 +++++++++ .../strfmt-symmetry-core/types.go | 237 +++++++++++++++ .../strfmt-symmetry-simpleschema/types.go | 99 ++++++ .../strfmt-symmetry-stdlib/types.go | 62 ++++ .../strfmt_symmetry_composition_default.json | 178 +++++++++++ ...trfmt_symmetry_composition_refaliases.json | 178 +++++++++++ ...mmetry_composition_transparentaliases.json | 178 +++++++++++ .../golden/strfmt_symmetry_core_default.json | 229 ++++++++++++++ .../strfmt_symmetry_core_refaliases.json | 229 ++++++++++++++ ...rfmt_symmetry_core_transparentaliases.json | 231 ++++++++++++++ .../strfmt_symmetry_simpleschema_default.json | 83 +++++ ...rfmt_symmetry_simpleschema_refaliases.json | 83 +++++ ...metry_simpleschema_transparentaliases.json | 83 +++++ .../strfmt_symmetry_stdlib_default.json | 35 +++ .../strfmt_symmetry_stdlib_refaliases.json | 35 +++ ...mt_symmetry_stdlib_transparentaliases.json | 35 +++ .../strfmt_symmetry_composition_test.go | 55 ++++ .../integration/strfmt_symmetry_core_test.go | 62 ++++ .../strfmt_symmetry_harness_test.go | 286 ++++++++++++++++++ .../strfmt_symmetry_simpleschema_test.go | 108 +++++++ .../strfmt_symmetry_stdlib_test.go | 50 +++ 21 files changed, 2681 insertions(+) create mode 100644 fixtures/enhancements/strfmt-symmetry-composition/types.go create mode 100644 fixtures/enhancements/strfmt-symmetry-core/types.go create mode 100644 fixtures/enhancements/strfmt-symmetry-simpleschema/types.go create mode 100644 fixtures/enhancements/strfmt-symmetry-stdlib/types.go create mode 100644 fixtures/integration/golden/strfmt_symmetry_composition_default.json create mode 100644 fixtures/integration/golden/strfmt_symmetry_composition_refaliases.json create mode 100644 fixtures/integration/golden/strfmt_symmetry_composition_transparentaliases.json create mode 100644 fixtures/integration/golden/strfmt_symmetry_core_default.json create mode 100644 fixtures/integration/golden/strfmt_symmetry_core_refaliases.json create mode 100644 fixtures/integration/golden/strfmt_symmetry_core_transparentaliases.json create mode 100644 fixtures/integration/golden/strfmt_symmetry_simpleschema_default.json create mode 100644 fixtures/integration/golden/strfmt_symmetry_simpleschema_refaliases.json create mode 100644 fixtures/integration/golden/strfmt_symmetry_simpleschema_transparentaliases.json create mode 100644 fixtures/integration/golden/strfmt_symmetry_stdlib_default.json create mode 100644 fixtures/integration/golden/strfmt_symmetry_stdlib_refaliases.json create mode 100644 fixtures/integration/golden/strfmt_symmetry_stdlib_transparentaliases.json create mode 100644 internal/integration/strfmt_symmetry_composition_test.go create mode 100644 internal/integration/strfmt_symmetry_core_test.go create mode 100644 internal/integration/strfmt_symmetry_harness_test.go create mode 100644 internal/integration/strfmt_symmetry_simpleschema_test.go create mode 100644 internal/integration/strfmt_symmetry_stdlib_test.go 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/integration/golden/strfmt_symmetry_composition_default.json b/fixtures/integration/golden/strfmt_symmetry_composition_default.json new file mode 100644 index 00000000..288204c1 --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_composition_default.json @@ -0,0 +1,178 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "AllOfBasicAlias": { + "title": "AllOfBasicAlias composes the basic 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/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": [ + { + "$ref": "#/definitions/PlainTarget" + }, + { + "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..288204c1 --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_composition_refaliases.json @@ -0,0 +1,178 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "AllOfBasicAlias": { + "title": "AllOfBasicAlias composes the basic 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/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": [ + { + "$ref": "#/definitions/PlainTarget" + }, + { + "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..288204c1 --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_composition_transparentaliases.json @@ -0,0 +1,178 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "AllOfBasicAlias": { + "title": "AllOfBasicAlias composes the basic 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/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": [ + { + "$ref": "#/definitions/PlainTarget" + }, + { + "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..62945bda --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_core_default.json @@ -0,0 +1,229 @@ +{ + "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": "array", + "items": { + "type": "integer", + "format": "uint8" + }, + "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", + "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": "array", + "items": { + "type": "integer", + "format": "uint8" + }, + "x-go-name": "FieldSliceAlias" + }, + "fieldSliceNamed": { + "description": "FieldSliceNamed is the slice pair, named half.", + "type": "string", + "format": "byte", + "x-go-name": "FieldSliceNamed" + }, + "fieldStructAlias": { + "$ref": "#/definitions/PlainTarget" + }, + "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" + }, + "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": { + "$ref": "#/definitions/PlainTarget" + }, + "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", + "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": { + "$ref": "#/definitions/PlainTarget" + }, + "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" + }, + "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": { + "$ref": "#/definitions/PlainTarget" + }, + "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" + }, + "PlainTarget": { + "description": "It carries no format annotation of its own, so any format on the pair's members\ncan only come from the pair's own declaration.", + "type": "object", + "title": "PlainTarget is an unannotated struct used as the RHS of the struct-kind pair.", + "properties": { + "left": { + "description": "Left is a plain field, present so the struct has observable content when\nthe format is NOT applied.", + "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-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..62945bda --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_core_refaliases.json @@ -0,0 +1,229 @@ +{ + "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": "array", + "items": { + "type": "integer", + "format": "uint8" + }, + "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", + "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": "array", + "items": { + "type": "integer", + "format": "uint8" + }, + "x-go-name": "FieldSliceAlias" + }, + "fieldSliceNamed": { + "description": "FieldSliceNamed is the slice pair, named half.", + "type": "string", + "format": "byte", + "x-go-name": "FieldSliceNamed" + }, + "fieldStructAlias": { + "$ref": "#/definitions/PlainTarget" + }, + "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" + }, + "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": { + "$ref": "#/definitions/PlainTarget" + }, + "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", + "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": { + "$ref": "#/definitions/PlainTarget" + }, + "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" + }, + "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": { + "$ref": "#/definitions/PlainTarget" + }, + "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" + }, + "PlainTarget": { + "description": "It carries no format annotation of its own, so any format on the pair's members\ncan only come from the pair's own declaration.", + "type": "object", + "title": "PlainTarget is an unannotated struct used as the RHS of the struct-kind pair.", + "properties": { + "left": { + "description": "Left is a plain field, present so the struct has observable content when\nthe format is NOT applied.", + "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-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..3e042043 --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_core_transparentaliases.json @@ -0,0 +1,231 @@ +{ + "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": "array", + "items": { + "type": "integer", + "format": "uint8" + }, + "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", + "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": "array", + "items": { + "type": "integer", + "format": "uint8" + }, + "x-go-name": "FieldSliceAlias" + }, + "fieldSliceNamed": { + "description": "FieldSliceNamed is the slice pair, named half.", + "type": "string", + "format": "byte", + "x-go-name": "FieldSliceNamed" + }, + "fieldStructAlias": { + "$ref": "#/definitions/PlainTarget" + }, + "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" + }, + "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": { + "$ref": "#/definitions/PlainTarget" + }, + "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", + "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": { + "$ref": "#/definitions/PlainTarget" + }, + "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" + }, + "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": { + "$ref": "#/definitions/PlainTarget" + }, + "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", + "x-go-name": "ModeledBasicAlias" + }, + "modeledBasicNamed": { + "$ref": "#/definitions/ModeledBasicNamed" + }, + "modeledStructAlias": { + "$ref": "#/definitions/PlainTarget" + }, + "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" + }, + "PlainTarget": { + "description": "It carries no format annotation of its own, so any format on the pair's members\ncan only come from the pair's own declaration.", + "type": "object", + "title": "PlainTarget is an unannotated struct used as the RHS of the struct-kind pair.", + "properties": { + "left": { + "description": "Left is a plain field, present so the struct has observable content when\nthe format is NOT applied.", + "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-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..4e9a7e22 --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_simpleschema_default.json @@ -0,0 +1,83 @@ +{ + "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", + "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": "array", + "items": { + "type": "integer", + "format": "uint8" + }, + "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", + "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": "array", + "items": { + "type": "integer", + "format": "uint8" + }, + "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..4e9a7e22 --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_simpleschema_refaliases.json @@ -0,0 +1,83 @@ +{ + "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", + "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": "array", + "items": { + "type": "integer", + "format": "uint8" + }, + "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", + "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": "array", + "items": { + "type": "integer", + "format": "uint8" + }, + "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..4e9a7e22 --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_simpleschema_transparentaliases.json @@ -0,0 +1,83 @@ +{ + "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", + "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": "array", + "items": { + "type": "integer", + "format": "uint8" + }, + "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", + "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": "array", + "items": { + "type": "integer", + "format": "uint8" + }, + "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..13531de4 --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_stdlib_default.json @@ -0,0 +1,35 @@ +{ + "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.", + "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-time", + "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..13531de4 --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_stdlib_refaliases.json @@ -0,0 +1,35 @@ +{ + "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.", + "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-time", + "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..13531de4 --- /dev/null +++ b/fixtures/integration/golden/strfmt_symmetry_stdlib_transparentaliases.json @@ -0,0 +1,35 @@ +{ + "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.", + "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-time", + "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/internal/integration/strfmt_symmetry_composition_test.go b/internal/integration/strfmt_symmetry_composition_test.go new file mode 100644 index 00000000..ca427afc --- /dev/null +++ b/internal/integration/strfmt_symmetry_composition_test.go @@ -0,0 +1,55 @@ +// 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{}, + knownBroken: forEveryMode( + "buildAllOf's alias arm (allof.go:185) calls buildAlias directly, bypassing the "+ + "classifierAliasTargetStrfmt that its named counterpart buildNamedAllOf runs at allof.go:205", + "AllOfBasic", "AllOfStruct", + ), + 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..96bf53d6 --- /dev/null +++ b/internal/integration/strfmt_symmetry_core_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" + +// 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) { + // The use-site dispatch never reads the alias declaration's comments: buildAlias fetches the decl + // only to test HasModelAnnotation(), then dissolves to Rhs(). + const dissolve = "buildAlias dissolves to Rhs() without consulting the alias decl's comments (schema.go:399-430)" + + // Under TransparentAliases the early return at schema.go:411 precedes the decl lookup entirely, so + // not even a model annotation can compensate. + const transparent = "TransparentAliases returns at schema.go:411 before the decl lookup; the model annotation cannot compensate" + + broken := forEveryMode(dissolve, + "fieldBasic", "fieldStruct", "fieldSlice", "fieldArray", + "pointerBasic", "pointerStruct", + "sliceElemBasic", "sliceElemStruct", + "mapValueBasic", "mapValueStruct", + ) + broken["transparentaliases/modeledBasic"] = transparent + broken["transparentaliases/modeledStruct"] = transparent + + 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: every observed difference is the bug. + exceptions: map[string]string{}, + knownBroken: broken, + 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..474c5ae9 --- /dev/null +++ b/internal/integration/strfmt_symmetry_harness_test.go @@ -0,0 +1,286 @@ +// 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 +} + +// forEveryMode assigns reason to cell for all three alias modes, the common case when a defect is +// mode-independent. +func forEveryMode(reason string, cells ...string) map[string]string { + out := make(map[string]string, len(cells)*len(aliasModes())) + for _, mode := range aliasModes() { + for _, cell := range cells { + out[mode.name+"/"+cell] = reason + } + } + return out +} diff --git a/internal/integration/strfmt_symmetry_simpleschema_test.go b/internal/integration/strfmt_symmetry_simpleschema_test.go new file mode 100644 index 00000000..eb5540e9 --- /dev/null +++ b/internal/integration/strfmt_symmetry_simpleschema_test.go @@ -0,0 +1,108 @@ +// 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: forEveryMode( + "the SimpleSchema path reaches the pair through buildFromType too, so buildAlias dissolves "+ + "before any classifier sees the alias decl's comments (schema.go:399-430)", + "queryBasic", "querySlice", "headerBasic", "headerSlice", + ), + 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..c7762d72 --- /dev/null +++ b/internal/integration/strfmt_symmetry_stdlib_test.go @@ -0,0 +1,50 @@ +// 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) { + const recognizerWins = "the dissolve reaches the stdlib type itself, so applyStdlibSpecials answers " + + "before any classifier sees the alias decl's annotation" + + 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: forEveryMode(recognizerWins, "fieldTime", "fieldRaw"), + controlBroken: map[string]string{}, + } + + ledger.run(t) +} From 1b0e7b2f79616f545e36c2835d92fa277d6b71b3 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sat, 1 Aug 2026 20:39:57 +0200 Subject: [PATCH 02/27] fix(schema): honour swagger:strfmt on an alias declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A format annotation was honoured on a named type declaration and dropped on an alias one, at every use site: struct field, pointer, slice and array element, map value, allOf member, non-body parameter, response header. The alias half did not merely lose the format — a struct emitted an object, a byte slice an array of integers, an alias over `json.RawMessage` an untyped schema. Each builder looked the alias declaration up only to decide whether to emit a `$ref`, then dissolved to the right-hand side. Past that point nothing recalls an alias was involved, so the annotation had no reader. Two ordering rules make it observable again: - the declaration lookup moves above the `TransparentAliases` early return, which used to dissolve without reading the declaration at all; - the classifier runs before the right-hand side is reached, so an author's format beats a recognizer. `swagger:strfmt date` on an alias of `time.Time` now yields `date`, not the recognizer's `date-time` — the annotation exists for formats the library cannot infer, so a recognizer is a default for un-annotated code, never an override. The classifier dispatches on the alias's underlying kind so the format lands where the equivalent named declaration puts it, including the whole-schema `byte` and `bsonobjectid` specials that apply to slices and arrays instead of their items. It lives on the shared builder: the parameters and responses builders have their own dissolve paths and the same gap, and a header cannot carry a `$ref` at all, so for those the format has to inline even when the alias is a model. Declaration sites are untouched, as are aliases carrying no annotation; one that dissolves onto an annotated named type still picks that type's format up as before. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../strfmt_symmetry_composition_default.json | 6 +- ...trfmt_symmetry_composition_refaliases.json | 6 +- ...mmetry_composition_transparentaliases.json | 6 +- .../golden/strfmt_symmetry_core_default.json | 57 ++++++--------- .../strfmt_symmetry_core_refaliases.json | 57 ++++++--------- ...rfmt_symmetry_core_transparentaliases.json | 63 ++++++++--------- .../strfmt_symmetry_simpleschema_default.json | 16 ++--- ...rfmt_symmetry_simpleschema_refaliases.json | 16 ++--- ...metry_simpleschema_transparentaliases.json | 16 ++--- .../strfmt_symmetry_stdlib_default.json | 4 +- .../strfmt_symmetry_stdlib_refaliases.json | 4 +- ...mt_symmetry_stdlib_transparentaliases.json | 4 +- internal/builders/common/builder.go | 69 +++++++++++++++++++ internal/builders/parameters/parameters.go | 15 +++- internal/builders/responses/responses.go | 14 +++- internal/builders/schema/README.md | 54 +++++++++++++++ internal/builders/schema/schema.go | 21 +++++- .../builders/schema/walker_classifiers.go | 24 ++++--- .../strfmt_symmetry_composition_test.go | 8 +-- .../integration/strfmt_symmetry_core_test.go | 22 +----- .../strfmt_symmetry_harness_test.go | 12 ---- .../strfmt_symmetry_simpleschema_test.go | 8 +-- .../strfmt_symmetry_stdlib_test.go | 5 +- 23 files changed, 304 insertions(+), 203 deletions(-) diff --git a/fixtures/integration/golden/strfmt_symmetry_composition_default.json b/fixtures/integration/golden/strfmt_symmetry_composition_default.json index 288204c1..e606c16c 100644 --- a/fixtures/integration/golden/strfmt_symmetry_composition_default.json +++ b/fixtures/integration/golden/strfmt_symmetry_composition_default.json @@ -6,7 +6,8 @@ "title": "AllOfBasicAlias composes the basic pair's alias half as an allOf member.", "allOf": [ { - "type": "string" + "type": "string", + "format": "isbn" }, { "type": "object", @@ -45,7 +46,8 @@ "title": "AllOfStructAlias composes the struct pair's alias half as an allOf member.", "allOf": [ { - "$ref": "#/definitions/PlainTarget" + "type": "string", + "format": "duration" }, { "type": "object", diff --git a/fixtures/integration/golden/strfmt_symmetry_composition_refaliases.json b/fixtures/integration/golden/strfmt_symmetry_composition_refaliases.json index 288204c1..e606c16c 100644 --- a/fixtures/integration/golden/strfmt_symmetry_composition_refaliases.json +++ b/fixtures/integration/golden/strfmt_symmetry_composition_refaliases.json @@ -6,7 +6,8 @@ "title": "AllOfBasicAlias composes the basic pair's alias half as an allOf member.", "allOf": [ { - "type": "string" + "type": "string", + "format": "isbn" }, { "type": "object", @@ -45,7 +46,8 @@ "title": "AllOfStructAlias composes the struct pair's alias half as an allOf member.", "allOf": [ { - "$ref": "#/definitions/PlainTarget" + "type": "string", + "format": "duration" }, { "type": "object", diff --git a/fixtures/integration/golden/strfmt_symmetry_composition_transparentaliases.json b/fixtures/integration/golden/strfmt_symmetry_composition_transparentaliases.json index 288204c1..e606c16c 100644 --- a/fixtures/integration/golden/strfmt_symmetry_composition_transparentaliases.json +++ b/fixtures/integration/golden/strfmt_symmetry_composition_transparentaliases.json @@ -6,7 +6,8 @@ "title": "AllOfBasicAlias composes the basic pair's alias half as an allOf member.", "allOf": [ { - "type": "string" + "type": "string", + "format": "isbn" }, { "type": "object", @@ -45,7 +46,8 @@ "title": "AllOfStructAlias composes the struct pair's alias half as an allOf member.", "allOf": [ { - "$ref": "#/definitions/PlainTarget" + "type": "string", + "format": "duration" }, { "type": "object", diff --git a/fixtures/integration/golden/strfmt_symmetry_core_default.json b/fixtures/integration/golden/strfmt_symmetry_core_default.json index 62945bda..1034c077 100644 --- a/fixtures/integration/golden/strfmt_symmetry_core_default.json +++ b/fixtures/integration/golden/strfmt_symmetry_core_default.json @@ -8,11 +8,8 @@ "properties": { "fieldArrayAlias": { "description": "FieldArrayAlias is the array pair, alias half.", - "type": "array", - "items": { - "type": "integer", - "format": "uint8" - }, + "type": "string", + "format": "bsonobjectid", "x-go-name": "FieldArrayAlias" }, "fieldArrayNamed": { @@ -24,6 +21,7 @@ "fieldBasicAlias": { "description": "FieldBasicAlias is the basic pair, alias half, in plain field position.", "type": "string", + "format": "isbn", "x-go-name": "FieldBasicAlias" }, "fieldBasicNamed": { @@ -43,11 +41,8 @@ }, "fieldSliceAlias": { "description": "FieldSliceAlias is the slice pair, alias half.", - "type": "array", - "items": { - "type": "integer", - "format": "uint8" - }, + "type": "string", + "format": "byte", "x-go-name": "FieldSliceAlias" }, "fieldSliceNamed": { @@ -57,7 +52,10 @@ "x-go-name": "FieldSliceNamed" }, "fieldStructAlias": { - "$ref": "#/definitions/PlainTarget" + "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.", @@ -69,7 +67,8 @@ "description": "MapValueBasicAlias reaches the basic pair's alias half as a map value.", "type": "object", "additionalProperties": { - "type": "string" + "type": "string", + "format": "isbn" }, "x-go-name": "MapValueBasicAlias" }, @@ -86,7 +85,8 @@ "description": "MapValueStructAlias reaches the struct pair's alias half as a map value.", "type": "object", "additionalProperties": { - "$ref": "#/definitions/PlainTarget" + "type": "string", + "format": "duration" }, "x-go-name": "MapValueStructAlias" }, @@ -102,6 +102,7 @@ "pointerBasicAlias": { "description": "PointerBasicAlias reaches the basic pair's alias half through a pointer.", "type": "string", + "format": "isbn", "x-go-name": "PointerBasicAlias" }, "pointerBasicNamed": { @@ -111,7 +112,10 @@ "x-go-name": "PointerBasicNamed" }, "pointerStructAlias": { - "$ref": "#/definitions/PlainTarget" + "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.", @@ -123,7 +127,8 @@ "description": "SliceElemBasicAlias reaches the basic pair's alias half as a slice element.", "type": "array", "items": { - "type": "string" + "type": "string", + "format": "isbn" }, "x-go-name": "SliceElemBasicAlias" }, @@ -140,7 +145,8 @@ "description": "SliceElemStructAlias reaches the struct pair's alias half as a slice element.", "type": "array", "items": { - "$ref": "#/definitions/PlainTarget" + "type": "string", + "format": "duration" }, "x-go-name": "SliceElemStructAlias" }, @@ -199,25 +205,6 @@ "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" }, - "PlainTarget": { - "description": "It carries no format annotation of its own, so any format on the pair's members\ncan only come from the pair's own declaration.", - "type": "object", - "title": "PlainTarget is an unannotated struct used as the RHS of the struct-kind pair.", - "properties": { - "left": { - "description": "Left is a plain field, present so the struct has observable content when\nthe format is NOT applied.", - "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-core" - }, "StrfmtChainNamed": { "description": "It carries no annotation itself: the format must be inherited from\nBaseFormatted, one declaration to the right (inheritedStrfmt).", "type": "string", diff --git a/fixtures/integration/golden/strfmt_symmetry_core_refaliases.json b/fixtures/integration/golden/strfmt_symmetry_core_refaliases.json index 62945bda..1034c077 100644 --- a/fixtures/integration/golden/strfmt_symmetry_core_refaliases.json +++ b/fixtures/integration/golden/strfmt_symmetry_core_refaliases.json @@ -8,11 +8,8 @@ "properties": { "fieldArrayAlias": { "description": "FieldArrayAlias is the array pair, alias half.", - "type": "array", - "items": { - "type": "integer", - "format": "uint8" - }, + "type": "string", + "format": "bsonobjectid", "x-go-name": "FieldArrayAlias" }, "fieldArrayNamed": { @@ -24,6 +21,7 @@ "fieldBasicAlias": { "description": "FieldBasicAlias is the basic pair, alias half, in plain field position.", "type": "string", + "format": "isbn", "x-go-name": "FieldBasicAlias" }, "fieldBasicNamed": { @@ -43,11 +41,8 @@ }, "fieldSliceAlias": { "description": "FieldSliceAlias is the slice pair, alias half.", - "type": "array", - "items": { - "type": "integer", - "format": "uint8" - }, + "type": "string", + "format": "byte", "x-go-name": "FieldSliceAlias" }, "fieldSliceNamed": { @@ -57,7 +52,10 @@ "x-go-name": "FieldSliceNamed" }, "fieldStructAlias": { - "$ref": "#/definitions/PlainTarget" + "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.", @@ -69,7 +67,8 @@ "description": "MapValueBasicAlias reaches the basic pair's alias half as a map value.", "type": "object", "additionalProperties": { - "type": "string" + "type": "string", + "format": "isbn" }, "x-go-name": "MapValueBasicAlias" }, @@ -86,7 +85,8 @@ "description": "MapValueStructAlias reaches the struct pair's alias half as a map value.", "type": "object", "additionalProperties": { - "$ref": "#/definitions/PlainTarget" + "type": "string", + "format": "duration" }, "x-go-name": "MapValueStructAlias" }, @@ -102,6 +102,7 @@ "pointerBasicAlias": { "description": "PointerBasicAlias reaches the basic pair's alias half through a pointer.", "type": "string", + "format": "isbn", "x-go-name": "PointerBasicAlias" }, "pointerBasicNamed": { @@ -111,7 +112,10 @@ "x-go-name": "PointerBasicNamed" }, "pointerStructAlias": { - "$ref": "#/definitions/PlainTarget" + "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.", @@ -123,7 +127,8 @@ "description": "SliceElemBasicAlias reaches the basic pair's alias half as a slice element.", "type": "array", "items": { - "type": "string" + "type": "string", + "format": "isbn" }, "x-go-name": "SliceElemBasicAlias" }, @@ -140,7 +145,8 @@ "description": "SliceElemStructAlias reaches the struct pair's alias half as a slice element.", "type": "array", "items": { - "$ref": "#/definitions/PlainTarget" + "type": "string", + "format": "duration" }, "x-go-name": "SliceElemStructAlias" }, @@ -199,25 +205,6 @@ "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" }, - "PlainTarget": { - "description": "It carries no format annotation of its own, so any format on the pair's members\ncan only come from the pair's own declaration.", - "type": "object", - "title": "PlainTarget is an unannotated struct used as the RHS of the struct-kind pair.", - "properties": { - "left": { - "description": "Left is a plain field, present so the struct has observable content when\nthe format is NOT applied.", - "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-core" - }, "StrfmtChainNamed": { "description": "It carries no annotation itself: the format must be inherited from\nBaseFormatted, one declaration to the right (inheritedStrfmt).", "type": "string", diff --git a/fixtures/integration/golden/strfmt_symmetry_core_transparentaliases.json b/fixtures/integration/golden/strfmt_symmetry_core_transparentaliases.json index 3e042043..ae3987da 100644 --- a/fixtures/integration/golden/strfmt_symmetry_core_transparentaliases.json +++ b/fixtures/integration/golden/strfmt_symmetry_core_transparentaliases.json @@ -8,11 +8,8 @@ "properties": { "fieldArrayAlias": { "description": "FieldArrayAlias is the array pair, alias half.", - "type": "array", - "items": { - "type": "integer", - "format": "uint8" - }, + "type": "string", + "format": "bsonobjectid", "x-go-name": "FieldArrayAlias" }, "fieldArrayNamed": { @@ -24,6 +21,7 @@ "fieldBasicAlias": { "description": "FieldBasicAlias is the basic pair, alias half, in plain field position.", "type": "string", + "format": "isbn", "x-go-name": "FieldBasicAlias" }, "fieldBasicNamed": { @@ -43,11 +41,8 @@ }, "fieldSliceAlias": { "description": "FieldSliceAlias is the slice pair, alias half.", - "type": "array", - "items": { - "type": "integer", - "format": "uint8" - }, + "type": "string", + "format": "byte", "x-go-name": "FieldSliceAlias" }, "fieldSliceNamed": { @@ -57,7 +52,10 @@ "x-go-name": "FieldSliceNamed" }, "fieldStructAlias": { - "$ref": "#/definitions/PlainTarget" + "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.", @@ -69,7 +67,8 @@ "description": "MapValueBasicAlias reaches the basic pair's alias half as a map value.", "type": "object", "additionalProperties": { - "type": "string" + "type": "string", + "format": "isbn" }, "x-go-name": "MapValueBasicAlias" }, @@ -86,7 +85,8 @@ "description": "MapValueStructAlias reaches the struct pair's alias half as a map value.", "type": "object", "additionalProperties": { - "$ref": "#/definitions/PlainTarget" + "type": "string", + "format": "duration" }, "x-go-name": "MapValueStructAlias" }, @@ -102,6 +102,7 @@ "pointerBasicAlias": { "description": "PointerBasicAlias reaches the basic pair's alias half through a pointer.", "type": "string", + "format": "isbn", "x-go-name": "PointerBasicAlias" }, "pointerBasicNamed": { @@ -111,7 +112,10 @@ "x-go-name": "PointerBasicNamed" }, "pointerStructAlias": { - "$ref": "#/definitions/PlainTarget" + "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.", @@ -123,7 +127,8 @@ "description": "SliceElemBasicAlias reaches the basic pair's alias half as a slice element.", "type": "array", "items": { - "type": "string" + "type": "string", + "format": "isbn" }, "x-go-name": "SliceElemBasicAlias" }, @@ -140,7 +145,8 @@ "description": "SliceElemStructAlias reaches the struct pair's alias half as a slice element.", "type": "array", "items": { - "$ref": "#/definitions/PlainTarget" + "type": "string", + "format": "duration" }, "x-go-name": "SliceElemStructAlias" }, @@ -163,13 +169,17 @@ "modeledBasicAlias": { "description": "ModeledBasicAlias is the annotated basic pair, alias half.", "type": "string", + "format": "isbn", "x-go-name": "ModeledBasicAlias" }, "modeledBasicNamed": { "$ref": "#/definitions/ModeledBasicNamed" }, "modeledStructAlias": { - "$ref": "#/definitions/PlainTarget" + "description": "ModeledStructAlias is the annotated struct pair, alias half.", + "type": "string", + "format": "duration", + "x-go-name": "ModeledStructAlias" }, "modeledStructNamed": { "$ref": "#/definitions/ModeledStructNamed" @@ -201,25 +211,6 @@ "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" }, - "PlainTarget": { - "description": "It carries no format annotation of its own, so any format on the pair's members\ncan only come from the pair's own declaration.", - "type": "object", - "title": "PlainTarget is an unannotated struct used as the RHS of the struct-kind pair.", - "properties": { - "left": { - "description": "Left is a plain field, present so the struct has observable content when\nthe format is NOT applied.", - "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-core" - }, "StrfmtChainNamed": { "description": "It carries no annotation itself: the format must be inherited from\nBaseFormatted, one declaration to the right (inheritedStrfmt).", "type": "string", diff --git a/fixtures/integration/golden/strfmt_symmetry_simpleschema_default.json b/fixtures/integration/golden/strfmt_symmetry_simpleschema_default.json index 4e9a7e22..fba3c87a 100644 --- a/fixtures/integration/golden/strfmt_symmetry_simpleschema_default.json +++ b/fixtures/integration/golden/strfmt_symmetry_simpleschema_default.json @@ -18,6 +18,7 @@ }, { "type": "string", + "format": "isbn", "x-go-name": "QueryBasicAlias", "description": "QueryBasicAlias is the basic pair's alias half in query position.", "name": "queryBasicAlias", @@ -32,11 +33,8 @@ "in": "query" }, { - "type": "array", - "items": { - "type": "integer", - "format": "uint8" - }, + "type": "string", + "format": "byte", "x-go-name": "QuerySliceAlias", "description": "QuerySliceAlias is the slice pair's alias half in query position.", "name": "querySliceAlias", @@ -57,6 +55,7 @@ "headers": { "headerBasicAlias": { "type": "string", + "format": "isbn", "description": "HeaderBasicAlias is the basic pair's alias half in header position." }, "headerBasicNamed": { @@ -65,11 +64,8 @@ "description": "HeaderBasicNamed is the basic pair's named half in header position." }, "headerSliceAlias": { - "type": "array", - "items": { - "type": "integer", - "format": "uint8" - }, + "type": "string", + "format": "byte", "description": "HeaderSliceAlias is the slice pair's alias half in header position." }, "headerSliceNamed": { diff --git a/fixtures/integration/golden/strfmt_symmetry_simpleschema_refaliases.json b/fixtures/integration/golden/strfmt_symmetry_simpleschema_refaliases.json index 4e9a7e22..fba3c87a 100644 --- a/fixtures/integration/golden/strfmt_symmetry_simpleschema_refaliases.json +++ b/fixtures/integration/golden/strfmt_symmetry_simpleschema_refaliases.json @@ -18,6 +18,7 @@ }, { "type": "string", + "format": "isbn", "x-go-name": "QueryBasicAlias", "description": "QueryBasicAlias is the basic pair's alias half in query position.", "name": "queryBasicAlias", @@ -32,11 +33,8 @@ "in": "query" }, { - "type": "array", - "items": { - "type": "integer", - "format": "uint8" - }, + "type": "string", + "format": "byte", "x-go-name": "QuerySliceAlias", "description": "QuerySliceAlias is the slice pair's alias half in query position.", "name": "querySliceAlias", @@ -57,6 +55,7 @@ "headers": { "headerBasicAlias": { "type": "string", + "format": "isbn", "description": "HeaderBasicAlias is the basic pair's alias half in header position." }, "headerBasicNamed": { @@ -65,11 +64,8 @@ "description": "HeaderBasicNamed is the basic pair's named half in header position." }, "headerSliceAlias": { - "type": "array", - "items": { - "type": "integer", - "format": "uint8" - }, + "type": "string", + "format": "byte", "description": "HeaderSliceAlias is the slice pair's alias half in header position." }, "headerSliceNamed": { diff --git a/fixtures/integration/golden/strfmt_symmetry_simpleschema_transparentaliases.json b/fixtures/integration/golden/strfmt_symmetry_simpleschema_transparentaliases.json index 4e9a7e22..fba3c87a 100644 --- a/fixtures/integration/golden/strfmt_symmetry_simpleschema_transparentaliases.json +++ b/fixtures/integration/golden/strfmt_symmetry_simpleschema_transparentaliases.json @@ -18,6 +18,7 @@ }, { "type": "string", + "format": "isbn", "x-go-name": "QueryBasicAlias", "description": "QueryBasicAlias is the basic pair's alias half in query position.", "name": "queryBasicAlias", @@ -32,11 +33,8 @@ "in": "query" }, { - "type": "array", - "items": { - "type": "integer", - "format": "uint8" - }, + "type": "string", + "format": "byte", "x-go-name": "QuerySliceAlias", "description": "QuerySliceAlias is the slice pair's alias half in query position.", "name": "querySliceAlias", @@ -57,6 +55,7 @@ "headers": { "headerBasicAlias": { "type": "string", + "format": "isbn", "description": "HeaderBasicAlias is the basic pair's alias half in header position." }, "headerBasicNamed": { @@ -65,11 +64,8 @@ "description": "HeaderBasicNamed is the basic pair's named half in header position." }, "headerSliceAlias": { - "type": "array", - "items": { - "type": "integer", - "format": "uint8" - }, + "type": "string", + "format": "byte", "description": "HeaderSliceAlias is the slice pair's alias half in header position." }, "headerSliceNamed": { diff --git a/fixtures/integration/golden/strfmt_symmetry_stdlib_default.json b/fixtures/integration/golden/strfmt_symmetry_stdlib_default.json index 13531de4..d3539116 100644 --- a/fixtures/integration/golden/strfmt_symmetry_stdlib_default.json +++ b/fixtures/integration/golden/strfmt_symmetry_stdlib_default.json @@ -8,6 +8,8 @@ "properties": { "fieldRawAlias": { "description": "FieldRawAlias is the raw-message pair's alias half.", + "type": "string", + "format": "byte", "x-go-name": "FieldRawAlias" }, "fieldRawNamed": { @@ -19,7 +21,7 @@ "fieldTimeAlias": { "description": "FieldTimeAlias is the time pair's alias half.", "type": "string", - "format": "date-time", + "format": "date", "x-go-name": "FieldTimeAlias" }, "fieldTimeNamed": { diff --git a/fixtures/integration/golden/strfmt_symmetry_stdlib_refaliases.json b/fixtures/integration/golden/strfmt_symmetry_stdlib_refaliases.json index 13531de4..d3539116 100644 --- a/fixtures/integration/golden/strfmt_symmetry_stdlib_refaliases.json +++ b/fixtures/integration/golden/strfmt_symmetry_stdlib_refaliases.json @@ -8,6 +8,8 @@ "properties": { "fieldRawAlias": { "description": "FieldRawAlias is the raw-message pair's alias half.", + "type": "string", + "format": "byte", "x-go-name": "FieldRawAlias" }, "fieldRawNamed": { @@ -19,7 +21,7 @@ "fieldTimeAlias": { "description": "FieldTimeAlias is the time pair's alias half.", "type": "string", - "format": "date-time", + "format": "date", "x-go-name": "FieldTimeAlias" }, "fieldTimeNamed": { diff --git a/fixtures/integration/golden/strfmt_symmetry_stdlib_transparentaliases.json b/fixtures/integration/golden/strfmt_symmetry_stdlib_transparentaliases.json index 13531de4..d3539116 100644 --- a/fixtures/integration/golden/strfmt_symmetry_stdlib_transparentaliases.json +++ b/fixtures/integration/golden/strfmt_symmetry_stdlib_transparentaliases.json @@ -8,6 +8,8 @@ "properties": { "fieldRawAlias": { "description": "FieldRawAlias is the raw-message pair's alias half.", + "type": "string", + "format": "byte", "x-go-name": "FieldRawAlias" }, "fieldRawNamed": { @@ -19,7 +21,7 @@ "fieldTimeAlias": { "description": "FieldTimeAlias is the time pair's alias half.", "type": "string", - "format": "date-time", + "format": "date", "x-go-name": "FieldTimeAlias" }, "fieldTimeNamed": { diff --git a/internal/builders/common/builder.go b/internal/builders/common/builder.go index 03f064a0..eb1cfec8 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,70 @@ 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 +} + +// ApplyArrayLikeStrfmt writes a `swagger:strfmt` format onto an array/slice target. +// +// Two formats describe the WHOLE schema rather than its element: `byte` (a base64 string, not an +// array of integers) and — arrays only — `bsonobjectid`. Every other format decorates the items. +func ApplyArrayLikeStrfmt(format string, tgt ifaces.SwaggerTypable, forSlice bool) { + if format == "byte" || (!forSlice && format == "bsonobjectid") { + 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 tpe.Underlying().(type) { + case *types.Array: + ApplyArrayLikeStrfmt(format, tgt, false) + case *types.Slice: + ApplyArrayLikeStrfmt(format, tgt, true) + default: + tgt.Typed("string", format) + } + + return true +} diff --git a/internal/builders/parameters/parameters.go b/internal/builders/parameters/parameters.go index e2666a39..de1a2545 100644 --- a/internal/builders/parameters/parameters.go +++ b/internal/builders/parameters/parameters.go @@ -434,6 +434,20 @@ func (p *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl resolvers.MustNotBeABuiltinType(o) resolvers.MustHaveRightHandSide(tpe) + // Look the declaration up BEFORE any dissolve, so the alias's own `swagger:strfmt` is honoured in + // every mode and every parameter location. The lookup used to sit below the TransparentAliases + // return, which dissolved without ever reading the declaration. + decl, ok := p.Ctx.GetModel(o.Pkg().Path(), o.Name()) + + // A body parameter typed as a swagger:model alias keeps its $ref identity, and the format rides + // on the alias's own definition — mirroring the schema builder's refModel gate. Everywhere else + // the alias dissolves, so the format has to be applied here or it is lost. + refModel := ok && decl.HasModelAnnotation() && typable.In() == inBody && !p.Ctx.TransparentAliases() + + if ok && !refModel && p.ClassifierAliasStrfmt(decl.Comments, tpe, typable) { + return nil + } + // TransparentAliases supersedes annotation at use sites — dissolve to the unaliased target via // the schema sub-builder. if p.Ctx.TransparentAliases() { @@ -447,7 +461,6 @@ func (p *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl return nil } - decl, ok := p.Ctx.GetModel(o.Pkg().Path(), o.Name()) if !ok { return fmt.Errorf("can't find source file for aliased type: %v -> %v: %w", tpe, tpe.Rhs(), ErrParameters) } diff --git a/internal/builders/responses/responses.go b/internal/builders/responses/responses.go index 504c414f..e7419c67 100644 --- a/internal/builders/responses/responses.go +++ b/internal/builders/responses/responses.go @@ -380,6 +380,19 @@ func (r *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl return nil // just leave an empty schema } + // Look the declaration up BEFORE any dissolve, so the alias's own `swagger:strfmt` is honoured on + // headers as well as bodies. The lookup used to sit below both dissolve paths, so a header typed + // as an annotated alias lost its format. + decl, ok := r.Ctx.GetModel(o.Pkg().Path(), o.Name()) + + // A body field typed as a swagger:model alias keeps its $ref identity and carries the format on + // the alias's own definition. A header cannot $ref at all, so the format must be applied here. + refModel := ok && decl.HasModelAnnotation() && typable.In() == inBody && !r.Ctx.TransparentAliases() + + if ok && !refModel && r.ClassifierAliasStrfmt(decl.Comments, tpe, typable) { + return nil + } + // TransparentAliases supersedes annotation at use sites — dissolve to the unaliased target via // the schema sub-builder. if r.Ctx.TransparentAliases() { @@ -399,7 +412,6 @@ func (r *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl return r.buildFromField(fld, types.Unalias(tpe), typable, seen) } - decl, ok := r.Ctx.GetModel(o.Pkg().Path(), o.Name()) if !ok { return fmt.Errorf("can't find source file for aliased type: %v: %w", tpe, ErrResponses) } diff --git a/internal/builders/schema/README.md b/internal/builders/schema/README.md index 6917317c..297d1162 100644 --- a/internal/builders/schema/README.md +++ b/internal/builders/schema/README.md @@ -296,6 +296,54 @@ 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 | `byte` → whole schema; else items | `classifierNamedArrayLike` | +| array | `byte` / `bsonobjectid` → whole schema; else items | `classifierNamedArrayLike` | + +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 +358,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 diff --git a/internal/builders/schema/schema.go b/internal/builders/schema/schema.go index 0be83123..59d2a084 100644 --- a/internal/builders/schema/schema.go +++ b/internal/builders/schema/schema.go @@ -408,11 +408,30 @@ func (s *Builder) buildAlias(tpe *types.Alias, target ifaces.SwaggerTypable) err } 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 && !refModel && 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) } diff --git a/internal/builders/schema/walker_classifiers.go b/internal/builders/schema/walker_classifiers.go index 1079734c..028a7e74 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" @@ -391,15 +392,8 @@ func (s *Builder) classifierNamedBasic(cg *ast.CommentGroup, pkg *packages.Packa // - handled=false, err=nil → no classifier matched func (s *Builder) classifierNamedArrayLike(cg *ast.CommentGroup, tgt ifaces.SwaggerTypable, forSlice bool) (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, tgt, forSlice) + return true, false } @@ -415,6 +409,18 @@ func (s *Builder) classifierNamedArrayLike(cg *ast.CommentGroup, tgt ifaces.Swag return false, false } +// 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`. // diff --git a/internal/integration/strfmt_symmetry_composition_test.go b/internal/integration/strfmt_symmetry_composition_test.go index ca427afc..990fd900 100644 --- a/internal/integration/strfmt_symmetry_composition_test.go +++ b/internal/integration/strfmt_symmetry_composition_test.go @@ -43,11 +43,9 @@ func TestStrfmtSymmetryComposition(t *testing.T) { }, exceptions: map[string]string{}, - knownBroken: forEveryMode( - "buildAllOf's alias arm (allof.go:185) calls buildAlias directly, bypassing the "+ - "classifierAliasTargetStrfmt that its named counterpart buildNamedAllOf runs at allof.go:205", - "AllOfBasic", "AllOfStruct", - ), + // 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{}, } diff --git a/internal/integration/strfmt_symmetry_core_test.go b/internal/integration/strfmt_symmetry_core_test.go index 96bf53d6..d84c7b48 100644 --- a/internal/integration/strfmt_symmetry_core_test.go +++ b/internal/integration/strfmt_symmetry_core_test.go @@ -10,23 +10,6 @@ import "testing" // // See strfmt_symmetry_harness_test.go for how the ledger reads. func TestStrfmtSymmetryCore(t *testing.T) { - // The use-site dispatch never reads the alias declaration's comments: buildAlias fetches the decl - // only to test HasModelAnnotation(), then dissolves to Rhs(). - const dissolve = "buildAlias dissolves to Rhs() without consulting the alias decl's comments (schema.go:399-430)" - - // Under TransparentAliases the early return at schema.go:411 precedes the decl lookup entirely, so - // not even a model annotation can compensate. - const transparent = "TransparentAliases returns at schema.go:411 before the decl lookup; the model annotation cannot compensate" - - broken := forEveryMode(dissolve, - "fieldBasic", "fieldStruct", "fieldSlice", "fieldArray", - "pointerBasic", "pointerStruct", - "sliceElemBasic", "sliceElemStruct", - "mapValueBasic", "mapValueStruct", - ) - broken["transparentaliases/modeledBasic"] = transparent - broken["transparentaliases/modeledStruct"] = transparent - ledger := symmetryLedger{ pkg: "enhancements/strfmt-symmetry-core", goldenPrefix: "strfmt_symmetry_core", @@ -52,9 +35,10 @@ func TestStrfmtSymmetryCore(t *testing.T) { {definition: "EnvelopeModeled", namedProp: "modeledStructNamed", aliasProp: "modeledStructAlias", wantNamed: "string/duration"}, }, - // No cell in F1 has a legitimate reason to differ: every observed difference is the bug. + // 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: broken, + knownBroken: map[string]string{}, controlBroken: map[string]string{}, } diff --git a/internal/integration/strfmt_symmetry_harness_test.go b/internal/integration/strfmt_symmetry_harness_test.go index 474c5ae9..39585611 100644 --- a/internal/integration/strfmt_symmetry_harness_test.go +++ b/internal/integration/strfmt_symmetry_harness_test.go @@ -272,15 +272,3 @@ func simpleSignature(typ, format string, items *oaispec.Items) string { } return typ + "/" + format } - -// forEveryMode assigns reason to cell for all three alias modes, the common case when a defect is -// mode-independent. -func forEveryMode(reason string, cells ...string) map[string]string { - out := make(map[string]string, len(cells)*len(aliasModes())) - for _, mode := range aliasModes() { - for _, cell := range cells { - out[mode.name+"/"+cell] = reason - } - } - return out -} diff --git a/internal/integration/strfmt_symmetry_simpleschema_test.go b/internal/integration/strfmt_symmetry_simpleschema_test.go index eb5540e9..359e38fa 100644 --- a/internal/integration/strfmt_symmetry_simpleschema_test.go +++ b/internal/integration/strfmt_symmetry_simpleschema_test.go @@ -42,12 +42,8 @@ func TestStrfmtSymmetrySimpleSchema(t *testing.T) { }, }, - exceptions: map[string]string{}, - knownBroken: forEveryMode( - "the SimpleSchema path reaches the pair through buildFromType too, so buildAlias dissolves "+ - "before any classifier sees the alias decl's comments (schema.go:399-430)", - "queryBasic", "querySlice", "headerBasic", "headerSlice", - ), + exceptions: map[string]string{}, + knownBroken: map[string]string{}, controlBroken: map[string]string{}, } diff --git a/internal/integration/strfmt_symmetry_stdlib_test.go b/internal/integration/strfmt_symmetry_stdlib_test.go index c7762d72..901e5bb9 100644 --- a/internal/integration/strfmt_symmetry_stdlib_test.go +++ b/internal/integration/strfmt_symmetry_stdlib_test.go @@ -22,9 +22,6 @@ import "testing" // // See strfmt_symmetry_harness_test.go for how the ledger reads. func TestStrfmtSymmetryStdlib(t *testing.T) { - const recognizerWins = "the dissolve reaches the stdlib type itself, so applyStdlibSpecials answers " + - "before any classifier sees the alias decl's annotation" - ledger := symmetryLedger{ pkg: "enhancements/strfmt-symmetry-stdlib", goldenPrefix: "strfmt_symmetry_stdlib", @@ -42,7 +39,7 @@ func TestStrfmtSymmetryStdlib(t *testing.T) { }, exceptions: map[string]string{}, - knownBroken: forEveryMode(recognizerWins, "fieldTime", "fieldRaw"), + knownBroken: map[string]string{}, controlBroken: map[string]string{}, } From 9d92874dc0cc213d6aa19c49d41564f53ed0e2fa Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sat, 1 Aug 2026 23:40:41 +0200 Subject: [PATCH 03/27] fix(schema): decide a sequence's strfmt placement by its element type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `swagger:strfmt` on an array or slice can describe the sequence itself or each of its elements. That choice was a two-name allowlist — `byte`, plus `bsonobjectid` for arrays only — and every other format went to the items. Both names are formats for a byte sequence, `bsonobjectid` being a strfmt library type that happens to have an array underlying, so the list was standing in for a question about the element type. Ask that question instead: type ID [16]byte // swagger:strfmt uuid → {string, format: uuid} type Emails []string // swagger:strfmt email → {array, items: {string, format: email}} A byte or rune sequence is string-like and takes the format on the schema; anything else takes it on the items. This generalises to formats no list ever mentioned — `uuid` over a `[16]byte` id, `ulid` — and removes the array-vs-slice asymmetry, which had no reason to exist. `rune` and `int32` are the same type to go/types, so `[]int32` is treated alike; a string format on integer elements was already a contradiction. Only the mechanical half of the question is settled. A format on a sequence of some other element type is genuinely ambiguous and stays on the items. Separately, the declaration dispatch grew the array and slice arms it never had. A model sequence carrying a format published its definition with the format dropped, and nothing downstream compensated: the field site references such a type by `$ref` and deliberately skips the inline classifiers, on the assumption the declaration already applied the override. Alias declarations get the same element-driven rule, so the two spellings publish the same definition. One existing witness moves: a `[64]byte` signature annotated `password` emitted an array of 64 password strings and now emits a password string. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- fixtures/enhancements/strfmt-arrays/types.go | 8 +- .../strfmt-decl-arraylike/types.go | 101 ++++++++++++++++++ .../golden/enhancements_strfmt_arrays.json | 7 +- .../golden/strfmt_decl_arraylike_default.json | 79 ++++++++++++++ .../strfmt_decl_arraylike_refaliases.json | 79 ++++++++++++++ ...fmt_decl_arraylike_transparentaliases.json | 79 ++++++++++++++ internal/builders/common/builder.go | 47 ++++++-- internal/builders/schema/README.md | 49 ++++++++- internal/builders/schema/schema.go | 29 +++-- .../builders/schema/walker_classifiers.go | 10 +- .../integration/strfmt_decl_arraylike_test.go | 62 +++++++++++ 11 files changed, 520 insertions(+), 30 deletions(-) create mode 100644 fixtures/enhancements/strfmt-decl-arraylike/types.go create mode 100644 fixtures/integration/golden/strfmt_decl_arraylike_default.json create mode 100644 fixtures/integration/golden/strfmt_decl_arraylike_refaliases.json create mode 100644 fixtures/integration/golden/strfmt_decl_arraylike_transparentaliases.json create mode 100644 internal/integration/strfmt_decl_arraylike_test.go 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/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/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/internal/builders/common/builder.go b/internal/builders/common/builder.go index eb1cfec8..c31f46b0 100644 --- a/internal/builders/common/builder.go +++ b/internal/builders/common/builder.go @@ -318,12 +318,43 @@ func (s *Builder) FindAnnotationArg(cg *ast.CommentGroup, kind grammar.Annotatio return "", false } -// ApplyArrayLikeStrfmt writes a `swagger:strfmt` format onto an array/slice target. +// 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}} // -// Two formats describe the WHOLE schema rather than its element: `byte` (a base64 string, not an -// array of integers) and — arrays only — `bsonobjectid`. Every other format decorates the items. -func ApplyArrayLikeStrfmt(format string, tgt ifaces.SwaggerTypable, forSlice bool) { - if format == "byte" || (!forSlice && format == "bsonobjectid") { +// 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 @@ -348,11 +379,11 @@ func (s *Builder) ClassifierAliasStrfmt(cg *ast.CommentGroup, tpe *types.Alias, return false } - switch tpe.Underlying().(type) { + switch ut := tpe.Underlying().(type) { case *types.Array: - ApplyArrayLikeStrfmt(format, tgt, false) + ApplyArrayLikeStrfmt(format, ut.Elem(), tgt) case *types.Slice: - ApplyArrayLikeStrfmt(format, tgt, true) + ApplyArrayLikeStrfmt(format, ut.Elem(), tgt) default: tgt.Typed("string", format) } diff --git a/internal/builders/schema/README.md b/internal/builders/schema/README.md index 297d1162..1197dd26 100644 --- a/internal/builders/schema/README.md +++ b/internal/builders/schema/README.md @@ -66,6 +66,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 @@ -314,8 +327,40 @@ would put it: |---|---|---| | basic | whole schema | `classifierNamedBasic` | | struct | whole schema — strfmt replaces the type | `classifierNamedStructStrfmt` | -| slice | `byte` → whole schema; else items | `classifierNamedArrayLike` | -| array | `byte` / `bsonobjectid` → whole schema; else items | `classifierNamedArrayLike` | +| 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: diff --git a/internal/builders/schema/schema.go b/internal/builders/schema/schema.go index 59d2a084..4fc4ba13 100644 --- a/internal/builders/schema/schema.go +++ b/internal/builders/schema/schema.go @@ -209,6 +209,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 +257,9 @@ 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) + // 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 } @@ -536,9 +549,9 @@ func (s *Builder) buildNamedType(titpe *types.Named, target ifaces.SwaggerTypabl }) case *types.Array: - return s.buildNamedArrayLike(tio, cmt, utitpe.Elem(), target, false, refModel) + return s.buildNamedArrayLike(tio, cmt, utitpe.Elem(), target, refModel) case *types.Slice: - return s.buildNamedArrayLike(tio, cmt, utitpe.Elem(), target, true, refModel) + return s.buildNamedArrayLike(tio, cmt, utitpe.Elem(), target, refModel) case *types.Map: return s.resolveRefOr(tio, target, nil) @@ -549,13 +562,13 @@ 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 +// buildNamedArrayLike is the unified Array/Slice arm. elem drives the items-vs-whole decision for +// a `swagger:strfmt` (see common.ApplyArrayLikeStrfmt). 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 { +func (s *Builder) buildNamedArrayLike(tio *types.TypeName, cmt *ast.CommentGroup, elem types.Type, tgt ifaces.SwaggerTypable, isModel bool) error { if !isModel { - if handled, recurse := s.classifierNamedArrayLike(cmt, tgt, forSlice); handled { + if handled, recurse := s.classifierNamedArrayLike(cmt, tgt, elem); handled { if recurse { defer s.descend("items")() return s.buildFromType(elem, tgt.Items()) diff --git a/internal/builders/schema/walker_classifiers.go b/internal/builders/schema/walker_classifiers.go index 028a7e74..72066e78 100644 --- a/internal/builders/schema/walker_classifiers.go +++ b/internal/builders/schema/walker_classifiers.go @@ -381,18 +381,18 @@ 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 { - common.ApplyArrayLikeStrfmt(sfnm, tgt, forSlice) + common.ApplyArrayLikeStrfmt(sfnm, elem, tgt) return true, false } 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) +} From 4de3a6e37ba2a10406420b6495a43a096def2221 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 11:06:54 +0200 Subject: [PATCH 04/27] fix(schema): retire swagger:default, which never emitted a default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The annotation produced no `default` in the spec in any placement or form. On a named basic type it was worse than inert: the classifier returned "handled" on a target it had never written, so the declared type published a schema with no type at all, and every property referencing it came out typeless. Silently, and regardless of whether an argument was given. It is now an empty sink, following the precedent set by swagger:alias — parsed, reported as `validate.deprecated`, and otherwise ignored. An annotated type emits exactly what it would emit unannotated. Retiring it rather than implementing it, because every place OpenAPI 2.0 admits a default is already served. The `default:` keyword covers the Schema, Parameter, Items and Header objects — which is precisely its registered context set — and a `default` response code in a route's `Responses:` body covers the Responses object. That closes the surface; there was no meaning left to give the annotation, and no usage to preserve: the sole occurrence in the corpus was a fixture written for branch coverage, whose own comment described the empty schema as the expected result. The value argument becomes optional. Requiring it hard-errored on the bare form the annotation had been documented with for years, which is an unkind way to greet a deprecation. Doc site: the reference page, the tutorial section, the annotation index and the context matrix all described an annotation that had never done anything. They now carry the deprecation and point at the keyword; the tutorial gains the default-response spelling, which is the one sense of "default" the keyword does not cover. The published example is removed rather than rewritten — it demonstrated the bare form on a `var`, a placement no builder ever reads. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- docs/doc-site/annotation-index/_index.md | 2 +- .../maintainers/annotations/_index.md | 11 ++- .../annotations/swagger-default.md | 77 ++++++++++++++----- .../tutorials/examples-and-defaults.md | 18 +++-- docs/examples/concepts/examples/examples.go | 10 --- fixtures/enhancements/named-basic/types.go | 14 +++- .../golden/enhancements_named_basic.json | 9 ++- internal/builders/schema/README.md | 2 +- .../builders/schema/walker_classifiers.go | 19 ++++- .../integration/coverage_enhancements_test.go | 27 ++++++- internal/parsers/grammar/README.md | 6 ++ internal/parsers/grammar/parser.go | 7 +- 12 files changed, 146 insertions(+), 56 deletions(-) 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-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/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/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/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/internal/builders/schema/README.md b/internal/builders/schema/README.md index 1197dd26..ceb3eda8 100644 --- a/internal/builders/schema/README.md +++ b/internal/builders/schema/README.md @@ -1552,7 +1552,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/walker_classifiers.go b/internal/builders/schema/walker_classifiers.go index 72066e78..ffbe60e8 100644 --- a/internal/builders/schema/walker_classifiers.go +++ b/internal/builders/schema/walker_classifiers.go @@ -347,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 { diff --git a/internal/integration/coverage_enhancements_test.go b/internal/integration/coverage_enhancements_test.go index 0b5d7b30..12b7b43e 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") } 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/parser.go b/internal/parsers/grammar/parser.go index 1e1449e6..78e62c47 100644 --- a/internal/parsers/grammar/parser.go +++ b/internal/parsers/grammar/parser.go @@ -599,10 +599,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). From b72a43d285c1fd4eb31f8df7b7fd58bffdaf80a3 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 12:02:00 +0200 Subject: [PATCH 05/27] fix(schema): type default, example and enum values on a declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A declaration's comment block is dispatched before its Go type is resolved onto the schema, so `default:`, `example:` and `enum:` were coerced against an empty type and fell back to the author's raw text: // swagger:model Port // default: 8080 // enum: 1,2,3 type Port int → {"type":"integer", "default":"8080", "enum":["1","2","3"]} The same keywords on a field, a parameter, a header or a body property were always correct — there the type is known when the walk runs, and the identical handler coerces properly. The enum row is the consequential one: an enum of strings on an integer schema is a document no validator can satisfy. A JSON array fared worse than a scalar, arriving as a string holding JSON source rather than as a value of the wrong type. `RecoerceDeclValues` re-types the three from their raw form, at the same "the type is known now" seam where `RecheckSchemaShape` already re-gates the validation keywords for exactly this reason. Re-coercion is sound because the fallback preserved the 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. The three keywords travel together deliberately — they share `validations.ParseDefault` and the same dispatch arms, so a divergence between them would itself be a defect. The witness pairs each declaration cell with a field-site control carrying the identical literal, and asserts they agree rather than hard-coding a coercion result. Also pinned: the default RESPONSE, which is a wholly separate mechanism from a default VALUE. `default:` is not a legal keyword in a response block context, so the two cannot collide, and the fixture keeps both senses side by side. A model-level object literal now reaches the spec as real JSON, matching what a field-level one already did — a difference that had been sitting inside a single test's expectation, unnoticed. Doc site: `swagger:enum` the annotation and `enum:` the keyword produce the same spec keyword from opposite directions, which is easy to trip over. The tutorial gains a side-by-side comparison and both reference pages cross-link it. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../maintainers/annotations/swagger-enum.md | 8 + .../schema-validations-and-decorators.md | 5 + docs/doc-site/tutorials/enumerations.md | 31 +++ .../default-example-typing/types.go | 203 ++++++++++++++++ .../enhancements_default_example_typing.json | 229 ++++++++++++++++++ internal/builders/handlers/dispatch_schema.go | 50 ++++ internal/builders/schema/README.md | 14 ++ internal/builders/schema/schema.go | 4 +- internal/builders/schema/schema_test.go | 10 +- .../default_example_typing_test.go | 105 ++++++++ 10 files changed, 656 insertions(+), 3 deletions(-) create mode 100644 fixtures/enhancements/default-example-typing/types.go create mode 100644 fixtures/integration/golden/enhancements_default_example_typing.json create mode 100644 internal/integration/default_example_typing_test.go diff --git a/docs/doc-site/maintainers/annotations/swagger-enum.md b/docs/doc-site/maintainers/annotations/swagger-enum.md index efcdf9d6..7040381a 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 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/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/fixtures/enhancements/default-example-typing/types.go b/fixtures/enhancements/default-example-typing/types.go new file mode 100644 index 00000000..ca0746ce --- /dev/null +++ b/fixtures/enhancements/default-example-typing/types.go @@ -0,0 +1,203 @@ +// 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 + +// 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/integration/golden/enhancements_default_example_typing.json b/fixtures/integration/golden/enhancements_default_example_typing.json new file mode 100644 index 00000000..455d832f --- /dev/null +++ b/fixtures/integration/golden/enhancements_default_example_typing.json @@ -0,0 +1,229 @@ +{ + "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" + }, + "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" + } + }, + "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/internal/builders/handlers/dispatch_schema.go b/internal/builders/handlers/dispatch_schema.go index da0aeffe..f2cf5c31 100644 --- a/internal/builders/handlers/dispatch_schema.go +++ b/internal/builders/handlers/dispatch_schema.go @@ -503,6 +503,56 @@ 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) { + if sch == nil || len(sch.Type) == 0 { + return + } + typ := sch.Type[0] + if typ == "string" { + return + } + + if raw, ok := sch.Default.(string); ok { + if v, err := validations.ParseDefault(raw, typ, sch.Format); err == nil { + sch.Default = v + } + } + if raw, ok := sch.Example.(string); ok { + if v, err := validations.ParseDefault(raw, typ, sch.Format); err == nil { + sch.Example = v + } + } + + // Enum members were coerced individually against the empty type, so each is independently a + // string. Re-coerce per member and leave any that resists — a member that will not parse as the + // schema's type is the author's error to see, not ours to drop. + 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 + } + } +} + 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/schema/README.md b/internal/builders/schema/README.md index ceb3eda8..a969cc62 100644 --- a/internal/builders/schema/README.md +++ b/internal/builders/schema/README.md @@ -1466,6 +1466,20 @@ 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. 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. diff --git a/internal/builders/schema/schema.go b/internal/builders/schema/schema.go index 4fc4ba13..3c382bcf 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) handlers.RecheckSchemaShape(&schema, s.Ctx.PosOf(s.Decl.Spec.Pos()), s.RecordDiagnostic) s.definitions[defKey] = schema diff --git a/internal/builders/schema/schema_test.go b/internal/builders/schema/schema_test.go index ce87156f..d93aa9db 100644 --- a/internal/builders/schema/schema_test.go +++ b/internal/builders/schema/schema_test.go @@ -1848,6 +1848,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 +1862,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/integration/default_example_typing_test.go b/internal/integration/default_example_typing_test.go new file mode 100644 index 00000000..ac65d9e0 --- /dev/null +++ b/internal/integration/default_example_typing_test.go @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "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) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/default-example-typing/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + 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") + }) + + scantest.CompareOrDumpJSON(t, doc, "enhancements_default_example_typing.json") +} From 23a9d1ebac59dca4a5c16853d45f60dc7841f01c Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 12:19:59 +0200 Subject: [PATCH 06/27] fix(schema): report values that cannot be read as their schema type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `default:`, `example:` or `enum:` value that does not fit the schema it sits on was handled three different silent ways. On a declaration the raw string survived, so `default: notanumber` reached the document as `"notanumber"` on an integer schema — output no validator accepts. On a field the value was dropped instead, discarding the author's intent without a word. And an `enum:` produced a mixed array, `[1, "two", 3]`, at both sites. All three now warn, and all three drop. Dropping beats keeping: a value of the wrong type yields a document that fails validation, whereas omitting it yields one that is merely incomplete — the lesser harm, once the author is told which value went and why. Declaration and field sites agree on behaviour and on reporting, which they did not before. For an enum the drop is per member and the warning names it. An enum is a closed set, so removing a member changes what the API accepts; a count would not be enough to act on. It fails closed, as 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. That also makes the existing skip in the decl re-coercion a statement about correctness rather than an optimisation. Parameters and headers already reported these failures through their error sink; only the schema paths were silent. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../default-example-typing/types.go | 32 ++++++++ .../enhancements_default_example_typing.json | 34 ++++++++ internal/builders/handlers/dispatch_schema.go | 81 +++++++++++++++++-- internal/builders/schema/README.md | 18 ++++- internal/builders/schema/schema.go | 2 +- .../default_example_typing_test.go | 48 ++++++++++- 6 files changed, 205 insertions(+), 10 deletions(-) diff --git a/fixtures/enhancements/default-example-typing/types.go b/fixtures/enhancements/default-example-typing/types.go index ca0746ce..4bd8101a 100644 --- a/fixtures/enhancements/default-example-typing/types.go +++ b/fixtures/enhancements/default-example-typing/types.go @@ -76,6 +76,38 @@ type DeclIntSlice []int // 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. diff --git a/fixtures/integration/golden/enhancements_default_example_typing.json b/fixtures/integration/golden/enhancements_default_example_typing.json index 455d832f..ed4606db 100644 --- a/fixtures/integration/golden/enhancements_default_example_typing.json +++ b/fixtures/integration/golden/enhancements_default_example_typing.json @@ -118,6 +118,17 @@ "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", @@ -184,6 +195,29 @@ } }, "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": { diff --git a/internal/builders/handlers/dispatch_schema.go b/internal/builders/handlers/dispatch_schema.go index f2cf5c31..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 { @@ -519,29 +529,38 @@ func checkShape(p grammar.Property, ps *oaispec.Schema, diag func(grammar.Diagno // 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) { +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 { - if v, err := validations.ParseDefault(raw, typ, sch.Format); err == nil { + 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 { - if v, err := validations.ParseDefault(raw, typ, sch.Format); err == nil { + 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 and leave any that resists — a member that will not parse as the - // schema's type is the author's error to see, not ours to drop. + // string; re-coerce per member. for i, member := range sch.Enum { raw, ok := member.(string) if !ok { @@ -551,6 +570,58 @@ func RecoerceDeclValues(sch *oaispec.Schema) { 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)) { diff --git a/internal/builders/schema/README.md b/internal/builders/schema/README.md index a969cc62..2099d8e8 100644 --- a/internal/builders/schema/README.md +++ b/internal/builders/schema/README.md @@ -1475,7 +1475,23 @@ 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. Re-coercion is sound precisely +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. diff --git a/internal/builders/schema/schema.go b/internal/builders/schema/schema.go index 3c382bcf..d1d77eef 100644 --- a/internal/builders/schema/schema.go +++ b/internal/builders/schema/schema.go @@ -109,7 +109,7 @@ func (s *Builder) Build(opts ...Option) error { // 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) + 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 diff --git a/internal/integration/default_example_typing_test.go b/internal/integration/default_example_typing_test.go index ac65d9e0..65d4dfbf 100644 --- a/internal/integration/default_example_typing_test.go +++ b/internal/integration/default_example_typing_test.go @@ -4,6 +4,7 @@ package integration_test import ( + "strings" "testing" "github.com/go-openapi/codescan" @@ -27,10 +28,12 @@ import ( // 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, + 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) @@ -101,5 +104,44 @@ func TestDefaultExampleTyping(t *testing.T) { 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") } From 53747caa69d30a9307810f30390782c89b2045be Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 15:15:40 +0200 Subject: [PATCH 07/27] fix(schema): emit the properties encoding/json actually marshals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two readings of the `json:"-"` tag diverged from the wire, in opposite directions. A promoted field re-declared with `json:"-"` was deleted from the schema. Go does not hide it: a `-` field is ignored entirely, never enters the name set, and so shadows nothing — the embedded field keeps marshalling. The schema understated what the API returns. The property stays now, and the existing hint continues to point at `swagger:omit`, which drops it for real; the intent behind the tag is real even though the mechanism does not work. The `json:"-,"` escape, which names a field literally `-`, was dropped instead of emitted. encoding/json compares the whole tag to `-`, so a trailing comma names rather than skips; splitting on the comma first conflated the two. `-` is now accepted as a name from the json tag alone — no other tag type has an encoding rule to appeal to. Both were pinned as contracts. `TestOverridingOneIgnore` asserted two properties where the wire carries three, and the corpus has carried `IgnoredOther string` under `json:"-,omitempty"` since long before this, its name recording the assumption rather than the behaviour. A fixture comment likewise credited Go's depth rule for shadowing a `-` re-declaration, which it does not do. All three now say what Go does. The witness is differential, because this corpus has an oracle: the right answer is not a design choice but whatever encoding/json produces. The fixture module marshals its own types and commits the resulting key sets; the integration test scans the same types and asserts the property sets match. Neither side writes an expectation down. Controls for the shapes that were already correct — a re-declaration under a real name, an ignore with nothing to shadow, and an embed tagged `-` — travel with them, so a future change cannot quietly trade one for another. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../default-allof-embeds-override/api.go | 18 ++- .../enhancements/json-tag-fidelity/types.go | 95 +++++++++++++ .../json-tag-fidelity/wire.golden.json | 24 ++++ .../json-tag-fidelity/wire_test.go | 80 +++++++++++ .../classification/models/nomodel.go | 7 +- .../golden/enhancements_embed_override.json | 5 +- .../enhancements_embed_override_allof.json | 2 +- .../enhancements_json_tag_fidelity.json | 127 ++++++++++++++++++ .../golden/enhancements_swagger_omit.json | 3 + internal/builders/resolvers/resolvers.go | 34 ++++- internal/builders/schema/README.md | 36 +++++ internal/builders/schema/fields.go | 14 +- internal/builders/schema/schema_test.go | 22 ++- .../integration/json_tag_fidelity_test.go | 93 +++++++++++++ 14 files changed, 535 insertions(+), 25 deletions(-) create mode 100644 fixtures/enhancements/json-tag-fidelity/types.go create mode 100644 fixtures/enhancements/json-tag-fidelity/wire.golden.json create mode 100644 fixtures/enhancements/json-tag-fidelity/wire_test.go create mode 100644 fixtures/integration/golden/enhancements_json_tag_fidelity.json create mode 100644 internal/integration/json_tag_fidelity_test.go 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/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/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/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_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/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/schema/README.md b/internal/builders/schema/README.md index 2099d8e8..9d424fa4 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` @@ -718,6 +719,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 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_test.go b/internal/builders/schema/schema_test.go index d93aa9db..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 { 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 +} From 5ad1df18ef3cf80dadc4d196b55bf3618463848f Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 17:25:31 +0200 Subject: [PATCH 08/27] fix(schema): honour swagger:type on an alias, and accept file where OAS 2.0 does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the alias half of the classifier story that swagger:strfmt started. A `swagger:type` on an alias declaration was dropped at every use site — struct field, pointer, slice and array element, map value, non-body parameter, response header — so the annotation decorated nothing while the same annotation on a named declaration worked. Every form was affected: scalar names, `[]T` prefixes, and references to other scanned types. The parameters and responses builders needed the fix too, for the third time in this series. Both unaliased the field before delegating, throwing the declaration away before any classifier could read it; they now hand the alias itself to the schema sub-builder, whose OptionFor already selects SimpleSchema mode from the target's location. Which matters, because swagger:type brought a constraint swagger:strfmt did not: a non-body parameter and a response header are SimpleSchema locations, and an object has no representation there. That case was silently broken. A bare `object` argument reached the target and was wiped by the exit validator, leaving a parameter with no type at all; a type-name reference resolved into the nil Schema a non-body parameter exposes and vanished without even a diagnostic, the validator never firing because the illegal value never materialised to be checked. Since `type` is mandatory under SimpleSchema, an override that cannot be represented is now refused before it is applied, with a diagnostic naming the annotation, and the Go-derived type stands. The parameter stays valid. `file` is no longer rejected as a swagger:type argument. It is an OAS v2 type name like any other, so the annotation that names types should name it, and `swagger:file` is the extraneous spelling — the preference is now the other way round. Rather than add a second location rule, the argument raises the same signal the older annotation raises, so both spellings pass through one gate: a formData parameter and a response body, nothing else. The SimpleSchema witness previously asserted that an offending parameter is wiped to `{}`. It now covers both remedies, which had been conflated: an override the location cannot honour is refused so the Go type survives, while a Go type with no SimpleSchema form has nothing to fall back on and is still wiped. One known difference is pinned rather than fixed: an allOf member honours no type classifier, so the named half emits an empty member there. That breaks both halves and is tracked separately. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../maintainers/annotations/swagger-file.md | 6 + .../maintainers/annotations/swagger-type.md | 12 +- .../simple-schema-violation/api.go | 41 ++- .../type-override-symmetry/types.go | 256 +++++++++++++++++ .../type_override_symmetry_default.json | 261 +++++++++++++++++ .../type_override_symmetry_refaliases.json | 261 +++++++++++++++++ ..._override_symmetry_transparentaliases.json | 263 ++++++++++++++++++ internal/builders/parameters/doc_signals.go | 7 + internal/builders/parameters/parameters.go | 21 +- internal/builders/responses/doc_signals.go | 10 +- internal/builders/responses/responses.go | 22 +- internal/builders/schema/schema.go | 20 +- internal/builders/schema/type_override.go | 92 +++++- .../coverage_simple_schema_test.go | 44 ++- .../strfmt_symmetry_harness_test.go | 14 + .../type_override_symmetry_test.go | 132 +++++++++ 16 files changed, 1423 insertions(+), 39 deletions(-) create mode 100644 fixtures/enhancements/type-override-symmetry/types.go create mode 100644 fixtures/integration/golden/type_override_symmetry_default.json create mode 100644 fixtures/integration/golden/type_override_symmetry_refaliases.json create mode 100644 fixtures/integration/golden/type_override_symmetry_transparentaliases.json create mode 100644 internal/integration/type_override_symmetry_test.go 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/fixtures/enhancements/simple-schema-violation/api.go b/fixtures/enhancements/simple-schema-violation/api.go index 0ff8d178..65b7caa1 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,19 @@ 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"` } // DoViolation handles the violating route. diff --git a/fixtures/enhancements/type-override-symmetry/types.go b/fixtures/enhancements/type-override-symmetry/types.go new file mode 100644 index 00000000..6364eb0c --- /dev/null +++ b/fixtures/enhancements/type-override-symmetry/types.go @@ -0,0 +1,256 @@ +// 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() {} + +// 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/integration/golden/type_override_symmetry_default.json b/fixtures/integration/golden/type_override_symmetry_default.json new file mode 100644 index 00000000..00547ffa --- /dev/null +++ b/fixtures/integration/golden/type_override_symmetry_default.json @@ -0,0 +1,261 @@ +{ + "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": "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" + }, + "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..00547ffa --- /dev/null +++ b/fixtures/integration/golden/type_override_symmetry_refaliases.json @@ -0,0 +1,261 @@ +{ + "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": "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" + }, + "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..e7ed8656 --- /dev/null +++ b/fixtures/integration/golden/type_override_symmetry_transparentaliases.json @@ -0,0 +1,263 @@ +{ + "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": "integer", + "format": "int64", + "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": "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" + }, + "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": "integer", + "format": "int64", + "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/parameters/doc_signals.go b/internal/builders/parameters/doc_signals.go index 026a0f1c..3afffc79 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 + } } } } diff --git a/internal/builders/parameters/parameters.go b/internal/builders/parameters/parameters.go index de1a2545..7fcd828f 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. // @@ -465,11 +468,23 @@ func (p *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl 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 + // Non-body parameters are SimpleSchema targets and cannot carry $ref, so the alias always expands // to its unaliased target regardless of annotation. - // Walking through every alias layer (types.Unalias) dissolves chains fully in one step. + // + // Hand the ALIAS to the schema sub-builder rather than unaliasing here: it owns the classifier + // cascade (`swagger:type`, `swagger:strfmt`) that the alias declaration may carry, and OptionFor + // selects SimpleSchema mode from the target's location so the legality gate applies. Unaliasing + // first threw the declaration away before anything could read it. if typable.In() != inBody { - return p.buildFromField(fld, types.Unalias(tpe), typable, seen) + 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 } // Body field: annotation gates first-class identity at the use site. 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 e7419c67..cd3ba66f 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 @@ -406,10 +409,23 @@ func (r *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl 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. + // Non-body fields are SimpleSchema targets and cannot carry $ref, so the alias always expands to + // its unaliased target regardless of annotation. + // + // Hand the ALIAS to the schema sub-builder rather than unaliasing here: it owns the classifier + // cascade (`swagger:type`, `swagger:strfmt`) the alias declaration may carry, and OptionFor selects + // SimpleSchema mode from the target's location so the legality gate applies. Unaliasing first threw + // the declaration away before anything could read it. if typable.In() != inBody { - return r.buildFromField(fld, types.Unalias(tpe), typable, seen) + 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 } if !ok { diff --git a/internal/builders/schema/schema.go b/internal/builders/schema/schema.go index d1d77eef..756a5e4d 100644 --- a/internal/builders/schema/schema.go +++ b/internal/builders/schema/schema.go @@ -439,8 +439,24 @@ func (s *Builder) buildAlias(tpe *types.Alias, target ifaces.SwaggerTypable) err // under TransparentAliases, which never emits the $ref this defers to. refModel := ok && decl.HasModelAnnotation() && !s.simpleSchema && !s.Ctx.TransparentAliases() - if ok && !refModel && s.classifierAliasStrfmt(decl.Comments, tpe, target) { - return nil + 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() { 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/integration/coverage_simple_schema_test.go b/internal/integration/coverage_simple_schema_test.go index 70c211bf..70a36337 100644 --- a/internal/integration/coverage_simple_schema_test.go +++ b/internal/integration/coverage_simple_schema_test.go @@ -9,19 +9,27 @@ import ( "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 +54,28 @@ 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) + + 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 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/strfmt_symmetry_harness_test.go b/internal/integration/strfmt_symmetry_harness_test.go index 39585611..406abd64 100644 --- a/internal/integration/strfmt_symmetry_harness_test.go +++ b/internal/integration/strfmt_symmetry_harness_test.go @@ -272,3 +272,17 @@ func simpleSignature(typ, format string, items *oaispec.Items) string { } return typ + "/" + format } + +// forEveryMode assigns reason to each named cell in all three alias modes, the common case when a +// defect is mode-independent. +func forEveryMode(reason string, cells ...string) map[string]string { + modes := aliasModes() + out := make(map[string]string, len(cells)*len(modes)) + for _, mode := range modes { + for _, cell := range cells { + out[mode.name+"/"+cell] = reason + } + } + + return out +} diff --git a/internal/integration/type_override_symmetry_test.go b/internal/integration/type_override_symmetry_test.go new file mode 100644 index 00000000..ae0b9cb9 --- /dev/null +++ b/internal/integration/type_override_symmetry_test.go @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "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 drops the override on BOTH halves, and the named half emits an empty + // member. A shared gap rather than an alias asymmetry, so it is deferred to its own quirk + // rather than folded in here — pinned as a known difference until then. + { + namedProp: "AllOfScalarNamed", aliasProp: "AllOfScalarAlias", + note: "Q40: buildNamedAllOf runs no type classifier — named emits an EMPTY member, alias dissolves", + }, + }, + + exceptions: map[string]string{}, + knownBroken: forEveryMode( + "Q40 — the allOf member path honours no classifier; deferred, tracked separately", + "AllOfScalar", + ), + 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") +} From 9de2313ff330fe95eb8dc629b20b683287b58a2e Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 17:35:26 +0200 Subject: [PATCH 09/27] fix(grammar): stop rejecting in: and name: beside a classifier annotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A classifier body is prose-only, so the parser warned on every keyword token it found there. `in:` and `name:` are not schema-body keywords, though: they say where a field goes and what it is called, and the parameters and responses builders read them straight from the doc text. A parameter field may carry both a classifier and `in:` — indeed `in:` is mandatory there — so the canonical file-upload idiom warned about its own required directive: // in: formData // // swagger:file MyFormFile *bytes.Buffer → warning: keyword "in" not valid under swagger:file The same field without the annotation never warned, so the check was inconsistent as well as wrong. It fired for `swagger:strfmt`, `swagger:type` and `swagger:file` alike — any classifier next to a field directive. Every keyword context-invalid diagnostic in the entire fixture corpus was an instance of this. After the fix there are none, which is why the malformed-input fixture gains a `maximum:` under the same annotation: a schema keyword there is genuinely invalid, and the TUI's column-translation test needs a diagnostic that lands exactly on a keyword. Its previous subject was the spurious `in:`. No golden moves: the rejected keywords were dropped, and dropping them silently produces what dropping them loudly did. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../internal/ux/model_diagmarks_test.go | 17 ++++++++++----- .../classification/operations/noparams.go | 2 ++ internal/parsers/grammar/parser.go | 21 +++++++++++++++++++ 3 files changed, 35 insertions(+), 5 deletions(-) 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/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/internal/parsers/grammar/parser.go b/internal/parsers/grammar/parser.go index 78e62c47..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: From ed9897cb2a13981ea040e5c8d9c4f43c46823743 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 17:43:46 +0200 Subject: [PATCH 10/27] fix(schema): report a swagger:enum an alias can never host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last of the classifier annotations that an alias declaration dropped silently. `swagger:strfmt` and `swagger:type` were fixable: both decorate the emitted schema, and nothing about aliasing fights them. This one is not. An enum's members are found by collecting 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 plumbing that would change it, so the remedy is to say so rather than to propagate. The diagnostic names the declaration and suggests the named form. It fires wherever the alias is reached, since that is where the members go missing, and it is deliberately silent for an alias to a NAMED enum type — that case works, because the named type survives the alias, and warning about it would tell authors their working annotation is broken. The witness pins all three: the named control, the alias that cannot work, and the alias to a named type that can. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../maintainers/annotations/swagger-enum.md | 4 +- .../type-override-symmetry/types.go | 51 +++++++++++++++++++ .../type_override_symmetry_default.json | 35 +++++++++++++ .../type_override_symmetry_refaliases.json | 35 +++++++++++++ ..._override_symmetry_transparentaliases.json | 35 +++++++++++++ internal/builders/schema/schema.go | 8 +++ .../builders/schema/walker_classifiers.go | 33 ++++++++++++ .../type_override_symmetry_test.go | 48 +++++++++++++++++ internal/scanner/README.md | 18 +++++-- 9 files changed, 262 insertions(+), 5 deletions(-) diff --git a/docs/doc-site/maintainers/annotations/swagger-enum.md b/docs/doc-site/maintainers/annotations/swagger-enum.md index 7040381a..ba60d334 100644 --- a/docs/doc-site/maintainers/annotations/swagger-enum.md +++ b/docs/doc-site/maintainers/annotations/swagger-enum.md @@ -35,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/fixtures/enhancements/type-override-symmetry/types.go b/fixtures/enhancements/type-override-symmetry/types.go index 6364eb0c..06a14795 100644 --- a/fixtures/enhancements/type-override-symmetry/types.go +++ b/fixtures/enhancements/type-override-symmetry/types.go @@ -246,6 +246,57 @@ type FileBodyType struct { // 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 diff --git a/fixtures/integration/golden/type_override_symmetry_default.json b/fixtures/integration/golden/type_override_symmetry_default.json index 00547ffa..23d5e69d 100644 --- a/fixtures/integration/golden/type_override_symmetry_default.json +++ b/fixtures/integration/golden/type_override_symmetry_default.json @@ -107,6 +107,41 @@ ], "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.", diff --git a/fixtures/integration/golden/type_override_symmetry_refaliases.json b/fixtures/integration/golden/type_override_symmetry_refaliases.json index 00547ffa..23d5e69d 100644 --- a/fixtures/integration/golden/type_override_symmetry_refaliases.json +++ b/fixtures/integration/golden/type_override_symmetry_refaliases.json @@ -107,6 +107,41 @@ ], "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.", diff --git a/fixtures/integration/golden/type_override_symmetry_transparentaliases.json b/fixtures/integration/golden/type_override_symmetry_transparentaliases.json index e7ed8656..1a418821 100644 --- a/fixtures/integration/golden/type_override_symmetry_transparentaliases.json +++ b/fixtures/integration/golden/type_override_symmetry_transparentaliases.json @@ -108,6 +108,41 @@ ], "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.", diff --git a/internal/builders/schema/schema.go b/internal/builders/schema/schema.go index 756a5e4d..bc71e9f5 100644 --- a/internal/builders/schema/schema.go +++ b/internal/builders/schema/schema.go @@ -259,6 +259,8 @@ 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. + 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) { @@ -439,6 +441,12 @@ func (s *Builder) buildAlias(tpe *types.Alias, target ifaces.SwaggerTypable) err // 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 diff --git a/internal/builders/schema/walker_classifiers.go b/internal/builders/schema/walker_classifiers.go index ffbe60e8..6b972ed7 100644 --- a/internal/builders/schema/walker_classifiers.go +++ b/internal/builders/schema/walker_classifiers.go @@ -424,6 +424,39 @@ 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 diff --git a/internal/integration/type_override_symmetry_test.go b/internal/integration/type_override_symmetry_test.go index ae0b9cb9..0de835c3 100644 --- a/internal/integration/type_override_symmetry_test.go +++ b/internal/integration/type_override_symmetry_test.go @@ -4,6 +4,7 @@ package integration_test import ( + "strings" "testing" "github.com/go-openapi/codescan" @@ -130,3 +131,50 @@ func TestTypeOverrideFileSynonym(t *testing.T) { 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/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 From 4779aa0980c475f093873103824a228e325bc1a9 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 17:58:32 +0200 Subject: [PATCH 11/27] fix(schema): honour an alias declaration in body positions, and detect the next drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `swagger:type` fix reached the non-body branch of both alias field handlers and not the body one, so the annotation worked on a model field and a query parameter while dropping on a body parameter and a response body. Both branches end in the same dissolve, and unaliasing there discards the declaration before any classifier can read it; the body branch now hands the alias to the schema sub-builder as the non-body branch already did. Notably `swagger:strfmt` on the same alias was unaffected, because its classifier sits above the branch. Two annotations fixed together, diverging one branch apart, is the shape of the problem rather than an instance of it. So this also adds the detector. The schema, parameters and responses builders each resolve Go types to spec constructs and each carries its own copy of rules the others need; nothing forced them to agree and nothing noticed when they stopped, which is why a fix verified on one path read as complete. The suite takes one Go shape reached from three full-schema positions — a model field, a body parameter, a response body — and asserts the three agree. It carries no expectations of its own: each subject is compared against the model field, whose behaviour is pinned by its own witnesses, so the suite only ever asks whether the builders still say the same thing. Two choices in it are load-bearing. The subjects are the field's type directly rather than nested in a struct: nesting delegates everything to the schema sub-builder, where the three agree trivially and the suite reports a comfortable all-clear, while the divergences live in the short-circuits that fire when a field is itself a named or alias type. And SimpleSchema positions are excluded, because a non-body parameter and a response header have a genuinely different legality surface — which is the reason the builders grew separate paths to begin with. Comparing those needs a declared projection, not equality, and mixing them in would bury real drift under expected difference. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../enhancements/builder-conformance/types.go | 316 ++++++++++++++++++ internal/builders/parameters/parameters.go | 14 +- internal/builders/responses/responses.go | 12 +- .../integration/builder_conformance_test.go | 133 ++++++++ 4 files changed, 473 insertions(+), 2 deletions(-) create mode 100644 fixtures/enhancements/builder-conformance/types.go create mode 100644 internal/integration/builder_conformance_test.go diff --git a/fixtures/enhancements/builder-conformance/types.go b/fixtures/enhancements/builder-conformance/types.go new file mode 100644 index 00000000..8adb70e0 --- /dev/null +++ b/fixtures/enhancements/builder-conformance/types.go @@ -0,0 +1,316 @@ +// 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 three FULL-SCHEMA positions, where no legitimate +// difference exists and the three must agree exactly: +// +// - a model field (schema builder) +// - a body parameter (parameters builder) +// - a response body (responses builder) +// +// 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 + +// 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"` +} + +// 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() {} diff --git a/internal/builders/parameters/parameters.go b/internal/builders/parameters/parameters.go index 7fcd828f..9287eea4 100644 --- a/internal/builders/parameters/parameters.go +++ b/internal/builders/parameters/parameters.go @@ -503,7 +503,19 @@ func (p *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl return p.MakeRef(decl, typable) } - return p.buildFromField(fld, types.Unalias(tpe), typable, seen) + // Dissolve through the schema sub-builder, handing it the ALIAS. Unaliasing here would discard the + // declaration before its classifier annotations could be read — the same defect the non-body + // branch above had, one branch over, which is how `swagger:type` on an alias kept working for a + // query parameter and not for a body one. + 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) buildFromStruct(decl *scanner.EntityDecl, tpe *types.Struct, op *oaispec.Operation, seen map[string]oaispec.Parameter) error { diff --git a/internal/builders/responses/responses.go b/internal/builders/responses/responses.go index cd3ba66f..02ba3072 100644 --- a/internal/builders/responses/responses.go +++ b/internal/builders/responses/responses.go @@ -448,7 +448,17 @@ func (r *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl return r.MakeRef(decl, typable) } - return r.buildFromField(fld, types.Unalias(tpe), typable, seen) + // Dissolve through the schema sub-builder, handing it the ALIAS — see the parameters builder's + // twin. Unaliasing here discarded the declaration before its classifiers could be read. + 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) buildFromStruct(decl *scanner.EntityDecl, tpe *types.Struct, resp *oaispec.Response, seen map[string]bool) error { diff --git a/internal/integration/builder_conformance_test.go b/internal/integration/builder_conformance_test.go new file mode 100644 index 00000000..2600bf36 --- /dev/null +++ b/internal/integration/builder_conformance_test.go @@ -0,0 +1,133 @@ +// 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 three FULL-SCHEMA positions, where no legitimate difference exists: +// +// a model field · a body parameter · a response body +// +// 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 three agree. +func TestBuilderConformance(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/builder-conformance/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + 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 + }{ + {prop: "fmt", path: "/fmt", response: "respFmt"}, + {prop: "fmtAl", path: "/fmt-al", response: "respFmtAl"}, + {prop: "typ", path: "/typ", response: "respTyp"}, + { + prop: "typAl", path: "/typ-al", response: "respTypAl", + note: "the pair that caught the body-branch gap: swagger:type on an alias", + }, + {prop: "enum", path: "/enum", response: "respEnum"}, + {prop: "bytes", path: "/bytes", response: "respBytes"}, + {prop: "stamp", path: "/stamp", response: "respStamp"}, + {prop: "raw", path: "/raw", response: "respRaw"}, + } + + model := doc.Definitions["ModelHost"].Properties + require.NotEmpty(t, model, "the control host must have properties") + + var ledger strings.Builder + fmt.Fprintf(&ledger, "\n%-8s %-26s %-26s %-26s %s\n", + "SUBJECT", "MODEL FIELD", "BODY PARAM", "RESPONSE BODY", "") + fmt.Fprintf(&ledger, "%s\n", strings.Repeat("-", 100)) + + 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 := bodyParamSignature(t, doc, s.path) + asResponse := responseBodySignature(t, doc, s.response) + + verdict := "OK" + if control != asParam || control != asResponse { + verdict = "DIVERGES" + } + if s.note != "" { + verdict += " — " + s.note + } + fmt.Fprintf(&ledger, "%-8s %-26s %-26s %-26s %s\n", s.prop, control, asParam, asResponse, verdict) + + 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()) +} + +// bodyParamSignature renders the body parameter of the operation on path. +func bodyParamSignature(t *testing.T, doc *oaispec.Swagger, path string) string { + 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) + } + t.Fatalf("no body parameter on %s", path) + + return "" +} + +// 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) +} From 3407a1411e83338d98fa880009a074851b87e08c Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 18:16:39 +0200 Subject: [PATCH 12/27] refact(builders): share one alias-field resolution across parameters and responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two builders each carried their own walk for an alias reached as a field, and the copies drifted three times: 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 a missing source errored at different points. Both walks ended in the same delegation to the schema builder, which is where every classifier an alias declaration can carry already lives. So the resolution moves there and the callers keep only what is genuinely theirs: which types they refuse, the error they wrap a missing source in, and — for responses — the cross-ref path a header anchors under. 158 lines of duplicated control flow become 13. Most of the walk turned out to be re-implementing what it delegated to. The schema builder already applies the classifiers, already honours the TransparentAliases rule, and already dissolves; handing it the alias rather than the right-hand side is the whole of what the callers needed to do. What remains is one genuine exception — a body field naming a `swagger:model` alias keeps its `$ref` identity at the use site. One behavioural difference goes with the duplication. Under TransparentAliases a `swagger:type` on an alias was honoured for a model field and dropped for a query parameter and a response header, because the schema builder reads the declaration before dissolving and the hand-written copies did not. The named half emits the overridden type in every mode, so the alias half now does too. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- ..._override_symmetry_transparentaliases.json | 6 +- internal/builders/parameters/parameters.go | 88 ++----------------- internal/builders/responses/responses.go | 83 ++--------------- internal/builders/schema/alias_field.go | 70 +++++++++++++++ 4 files changed, 85 insertions(+), 162 deletions(-) create mode 100644 internal/builders/schema/alias_field.go diff --git a/fixtures/integration/golden/type_override_symmetry_transparentaliases.json b/fixtures/integration/golden/type_override_symmetry_transparentaliases.json index 1a418821..23d5e69d 100644 --- a/fixtures/integration/golden/type_override_symmetry_transparentaliases.json +++ b/fixtures/integration/golden/type_override_symmetry_transparentaliases.json @@ -55,8 +55,7 @@ "in": "query" }, { - "type": "integer", - "format": "int64", + "type": "string", "x-go-name": "QueryScalarAlias", "description": "QueryScalarAlias is the scalar pair's alias half in query position.", "name": "queryScalarAlias", @@ -284,8 +283,7 @@ "description": "TypeResponse carries the pairs on response headers.", "headers": { "X-Alias": { - "type": "integer", - "format": "int64", + "type": "string", "description": "HeaderScalarAlias is the scalar pair's alias half in header position." }, "X-Named": { diff --git a/internal/builders/parameters/parameters.go b/internal/builders/parameters/parameters.go index 9287eea4..dd4f020e 100644 --- a/internal/builders/parameters/parameters.go +++ b/internal/builders/parameters/parameters.go @@ -315,7 +315,7 @@ func (p *Builder) buildFromField(fld *types.Var, tpe types.Type, typable ifaces. 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) } @@ -423,7 +423,7 @@ func (p *Builder) buildNamedField(ftpe *types.Named, typable ifaces.SwaggerTypab return nil } -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 @@ -435,87 +435,13 @@ func (p *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl return fmt.Errorf("%s type not supported in the context of a parameter definition: %w", o.Name(), ErrParameters) } resolvers.MustNotBeABuiltinType(o) - resolvers.MustHaveRightHandSide(tpe) - - // Look the declaration up BEFORE any dissolve, so the alias's own `swagger:strfmt` is honoured in - // every mode and every parameter location. The lookup used to sit below the TransparentAliases - // return, which dissolved without ever reading the declaration. - decl, ok := p.Ctx.GetModel(o.Pkg().Path(), o.Name()) - - // A body parameter typed as a swagger:model alias keeps its $ref identity, and the format rides - // on the alias's own definition — mirroring the schema builder's refModel gate. Everywhere else - // the alias dissolves, so the format has to be applied here or it is lost. - refModel := ok && decl.HasModelAnnotation() && typable.In() == inBody && !p.Ctx.TransparentAliases() - - if ok && !refModel && p.ClassifierAliasStrfmt(decl.Comments, tpe, typable) { - return nil - } - - // 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 - } - 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, so the alias always expands - // to its unaliased target regardless of annotation. - // - // Hand the ALIAS to the schema sub-builder rather than unaliasing here: it owns the classifier - // cascade (`swagger:type`, `swagger:strfmt`) that the alias declaration may carry, and OptionFor - // selects SimpleSchema mode from the target's location so the legality gate applies. Unaliasing - // first threw the declaration away before anything could read it. - if typable.In() != inBody { - 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 - } - - // 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) - } - - // Dissolve through the schema sub-builder, handing it the ALIAS. Unaliasing here would discard the - // declaration before its classifier annotations could be read — the same defect the non-body - // branch above had, one branch over, which is how `swagger:type` on an alias kept working for a - // query parameter and not for a body one. - 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) buildFromStruct(decl *scanner.EntityDecl, tpe *types.Struct, op *oaispec.Operation, seen map[string]oaispec.Parameter) error { diff --git a/internal/builders/responses/responses.go b/internal/builders/responses/responses.go index 02ba3072..43d0c642 100644 --- a/internal/builders/responses/responses.go +++ b/internal/builders/responses/responses.go @@ -197,7 +197,7 @@ func (r *Builder) buildFromField(fld *types.Var, tpe types.Type, typable ifaces. 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) } @@ -374,7 +374,7 @@ func (r *Builder) buildNamedField(ftpe *types.Named, typable ifaces.SwaggerTypab return nil } -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 @@ -383,82 +383,11 @@ func (r *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypabl return nil // just leave an empty schema } - // Look the declaration up BEFORE any dissolve, so the alias's own `swagger:strfmt` is honoured on - // headers as well as bodies. The lookup used to sit below both dissolve paths, so a header typed - // as an annotated alias lost its format. - decl, ok := r.Ctx.GetModel(o.Pkg().Path(), o.Name()) - - // A body field typed as a swagger:model alias keeps its $ref identity and carries the format on - // the alias's own definition. A header cannot $ref at all, so the format must be applied here. - refModel := ok && decl.HasModelAnnotation() && typable.In() == inBody && !r.Ctx.TransparentAliases() - - if ok && !refModel && r.ClassifierAliasStrfmt(decl.Comments, tpe, typable) { - return nil - } - - // 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, so the alias always expands to - // its unaliased target regardless of annotation. - // - // Hand the ALIAS to the schema sub-builder rather than unaliasing here: it owns the classifier - // cascade (`swagger:type`, `swagger:strfmt`) the alias declaration may carry, and OptionFor selects - // SimpleSchema mode from the target's location so the legality gate applies. Unaliasing first threw - // the declaration away before anything could read it. - if typable.In() != inBody { - 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 - } - - 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) - } - - // Dissolve through the schema sub-builder, handing it the ALIAS — see the parameters builder's - // twin. Unaliasing here discarded the declaration before its classifiers could be read. - 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 + }, schema.WithPath(r.bodyPathFor(typable))) } func (r *Builder) buildFromStruct(decl *scanner.EntityDecl, tpe *types.Struct, resp *oaispec.Response, seen map[string]bool) error { diff --git a/internal/builders/schema/alias_field.go b/internal/builders/schema/alias_field.go new file mode 100644 index 00000000..7fdb767e --- /dev/null +++ b/internal/builders/schema/alias_field.go @@ -0,0 +1,70 @@ +// 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" +) + +// inBodyLocation is the `in:` value naming a full-Schema parameter or response field. +const inBodyLocation = "body" + +// 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 { + sb := NewBuilder(b.Ctx, b.Decl) + opts := make([]Option, 0, len(extra)+1) + opts = append(opts, OptionFor(t, typable)) + opts = append(opts, extra...) + if err := sb.Build(opts...); err != nil { + return err + } + for _, d := range sb.PostDeclarations() { + b.AppendPostDecl(d) + } + + return nil + } + + // 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) +} From 0fd15d4d1f2fcf09d244b80029cf565c8ac65717 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 18:32:57 +0200 Subject: [PATCH 13/27] test(builders): cover the field-dispatch arms and the response items anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends two harnesses ahead of factoring the duplicated field dispatch out of the parameters and responses builders, because neither could see the change. The conformance suite's subjects were all named or alias types, which reach only two arms of the dispatch. It gains struct, interface, map, pointer, plain basic, and a slice with an inline element, so the arms about to be moved are compared in all three positions rather than guarded by goldens alone. The second gap was the dangerous one. The responses builder threads `descendBody("items")` through its array arms, and that affects cross-ref provenance pointers only — never the emitted spec. No golden can see it, and neither can a suite that compares schemas, so dropping those two lines would have passed everything. The provenance corpus gains an array-bodied response whose element is inline, which pins /responses/{name}/schema/items/properties/code and fails without the threading. Inline is the operative word: a named element anchors in its own definition instead, where the threading is unobservable, so a witness built from one would prove nothing. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../enhancements/builder-conformance/types.go | 196 ++++++++++++++++++ .../provenance-params-responses/api.go | 33 +++ .../integration/builder_conformance_test.go | 10 + .../integration/coverage_provenance_test.go | 13 +- 4 files changed, 249 insertions(+), 3 deletions(-) diff --git a/fixtures/enhancements/builder-conformance/types.go b/fixtures/enhancements/builder-conformance/types.go index 8adb70e0..cde49ff9 100644 --- a/fixtures/enhancements/builder-conformance/types.go +++ b/fixtures/enhancements/builder-conformance/types.go @@ -85,6 +85,25 @@ type StampAlias = time.Time // RawAlias is an alias to the open "any JSON" stdlib type. type RawAlias = json.RawMessage +// --- 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. + +// 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. // @@ -113,6 +132,27 @@ type ModelHost struct { // 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"` } // ParamsFmt reaches FmtNamed as a body parameter. @@ -314,3 +354,159 @@ func HandlerStamp() {} // // 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() {} 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/internal/integration/builder_conformance_test.go b/internal/integration/builder_conformance_test.go index 2600bf36..060bf7f7 100644 --- a/internal/integration/builder_conformance_test.go +++ b/internal/integration/builder_conformance_test.go @@ -61,6 +61,16 @@ func TestBuilderConformance(t *testing.T) { {prop: "bytes", path: "/bytes", response: "respBytes"}, {prop: "stamp", path: "/stamp", response: "respStamp"}, {prop: "raw", path: "/raw", response: "respRaw"}, + + // 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"}, + {prop: "iface", path: "/iface", response: "respIface"}, + {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"}, } model := doc.Definitions["ModelHost"].Properties 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)) From 3848dac95d3170227a2d4c75184f5fdc0c13d23d Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 18:50:34 +0200 Subject: [PATCH 14/27] refact(builders): share the schema sub-build the field dispatch repeats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every field shape the parameters and responses builders do not handle themselves is resolved by deferring to the schema builder, and each had written the same three steps out per shape: construct a sub-builder, build, drain its post-declaration queue. Nine copies between them, differing only in the Options. The drain is the part worth centralising. A model reached solely through one field — no annotation, no other reference — arrives in the spec through that queue and nowhere else, so omitting the loop loses it silently. That is not hypothetical: the loop went missing from the parameters map arm during an earlier factor-out and needed a dedicated fixture to find. Two spellings, because there are two shapes: `Delegate` builds in the caller's own declaration context, `DelegateAs` in a resolved one and infers names from it. The Options stay at the call sites, since 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. `buildFromFieldStruct` and `buildFromFieldInterface` disappear entirely; both were a delegation with no shape-specific content, and the dispatch arm now says so directly. The field dispatch switch itself stays duplicated. Sharing it needs callbacks for the named arm, the alias arm and the responses builder's items descend, which is more machinery than the two lines of difference it would remove. No golden moves and no provenance anchor moves. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- internal/builders/parameters/parameters.go | 62 ++------------------ internal/builders/responses/responses.go | 67 ++++------------------ internal/builders/schema/alias_field.go | 54 +++++++++++++---- 3 files changed, 59 insertions(+), 124 deletions(-) diff --git a/internal/builders/parameters/parameters.go b/internal/builders/parameters/parameters.go index dd4f020e..b8e67750 100644 --- a/internal/builders/parameters/parameters.go +++ b/internal/builders/parameters/parameters.go @@ -301,11 +301,11 @@ func (p *Builder) buildFromField(fld *types.Var, tpe types.Type, typable ifaces. 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) 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) case *types.Slice: @@ -321,18 +321,6 @@ func (p *Builder) buildFromField(fld *types.Var, tpe types.Type, typable ifaces. } } -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: @@ -350,38 +338,10 @@ 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 { @@ -410,17 +370,7 @@ func (p *Builder) buildNamedField(ftpe *types.Named, typable ifaces.SwaggerTypab 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) - } - - return nil + return schema.DelegateAs(p.Builder, decl, schema.OptionFor(decl.ObjType(), typable)) } func (p *Builder) buildFieldAlias(tpe *types.Alias, typable ifaces.SwaggerTypable) error { diff --git a/internal/builders/responses/responses.go b/internal/builders/responses/responses.go index 43d0c642..e34b005a 100644 --- a/internal/builders/responses/responses.go +++ b/internal/builders/responses/responses.go @@ -181,11 +181,11 @@ func (r *Builder) buildFromField(fld *types.Var, tpe types.Type, typable ifaces. 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) 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) @@ -203,19 +203,6 @@ func (r *Builder) buildFromField(fld *types.Var, tpe types.Type, typable ifaces. } } -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. @@ -238,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 { @@ -307,15 +272,13 @@ func (r *Builder) buildNamedType(tpe *types.Named, resp *oaispec.Response, seen typable.Typed("string", sfnm) 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) @@ -361,17 +324,9 @@ func (r *Builder) buildNamedField(ftpe *types.Named, typable ifaces.SwaggerTypab 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) - } - - return nil + 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) error { diff --git a/internal/builders/schema/alias_field.go b/internal/builders/schema/alias_field.go index 7fdb767e..d791f794 100644 --- a/internal/builders/schema/alias_field.go +++ b/internal/builders/schema/alias_field.go @@ -9,11 +9,52 @@ import ( "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. // @@ -35,18 +76,7 @@ func BuildFieldAlias(b *common.Builder, tpe *types.Alias, typable ifaces.Swagger resolvers.MustHaveRightHandSide(tpe) dissolve := func(t types.Type) error { - sb := NewBuilder(b.Ctx, b.Decl) - opts := make([]Option, 0, len(extra)+1) - opts = append(opts, OptionFor(t, typable)) - opts = append(opts, extra...) - if err := sb.Build(opts...); err != nil { - return err - } - for _, d := range sb.PostDeclarations() { - b.AppendPostDecl(d) - } - - return nil + return Delegate(b, append([]Option{OptionFor(t, typable)}, extra...)...) } // Everything dissolves through the schema builder, which owns the classifier cascade and the From fc29d3ce877e2ee5d84b4087cc5362a517ca0f9d Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 19:28:30 +0200 Subject: [PATCH 15/27] fix(parameters): drop an error-typed parameter instead of failing the scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An `error` field in a `swagger:parameters` struct aborted the whole build. The guard is right that an error has no meaning as a parameter — describing it as `{type: string}`, which the schema builder would, tells a client to send something it should never send — but refusing the entire spec over one field is not. A struct shared between a parameter set and a response could not be scanned at all, though losing the field on the parameter side is exactly what its author would expect. It now skips with a located diagnostic, which is the house rule and which its own sibling two arms down already followed for a Go type with no SimpleSchema form (go-swagger#2804); this guard predates that and never got the same treatment. The conformance suite gains a golden. Comparing the three builders against each other only asks whether they agree, so an answer all three get wrong would pass silently; the golden makes every subject's emitted spec reviewable on its own. It also gains its first pinned divergence, with the assertion running in both directions so the list cannot rot: a named `[]string` carrying a non-special format is rendered by the schema builder as an array of formatted elements and by the other two as a single formatted string. They short-circuit on a local helper that writes the format onto the whole schema unconditionally, which predates the element-driven rule the schema builder applies — so the same Go type is described to a client as a list of email addresses in one position and as one email address in another. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../enhancements/builder-conformance/types.go | 75 ++ .../simple-schema-violation/api.go | 10 + .../enhancements_builder_conformance.json | 653 ++++++++++++++++++ internal/builders/parameters/parameters.go | 10 +- .../integration/builder_conformance_test.go | 50 +- .../coverage_simple_schema_test.go | 16 +- 6 files changed, 810 insertions(+), 4 deletions(-) create mode 100644 fixtures/integration/golden/enhancements_builder_conformance.json diff --git a/fixtures/enhancements/builder-conformance/types.go b/fixtures/enhancements/builder-conformance/types.go index cde49ff9..63496ab4 100644 --- a/fixtures/enhancements/builder-conformance/types.go +++ b/fixtures/enhancements/builder-conformance/types.go @@ -92,6 +92,25 @@ type RawAlias = json.RawMessage // 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. @@ -153,6 +172,12 @@ type ModelHost struct { // 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"` } // ParamsFmt reaches FmtNamed as a body parameter. @@ -510,3 +535,53 @@ type RespInline struct { // // 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() {} diff --git a/fixtures/enhancements/simple-schema-violation/api.go b/fixtures/enhancements/simple-schema-violation/api.go index 65b7caa1..67248351 100644 --- a/fixtures/enhancements/simple-schema-violation/api.go +++ b/fixtures/enhancements/simple-schema-violation/api.go @@ -43,6 +43,16 @@ type ViolatingParams struct { 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/integration/golden/enhancements_builder_conformance.json b/fixtures/integration/golden/enhancements_builder_conformance.json new file mode 100644 index 00000000..6fa8c7a4 --- /dev/null +++ b/fixtures/integration/golden/enhancements_builder_conformance.json @@ -0,0 +1,653 @@ +{ + "swagger": "2.0", + "paths": { + "/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": "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": "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" + } + } + } + }, + "/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" + } + } + } + }, + "/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" + } + } + } + }, + "/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": { + "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": { + "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" + }, + "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" + }, + "stamp": { + "description": "Stamp is the stdlib-alias subject.", + "type": "string", + "format": "date-time", + "x-go-name": "Stamp" + }, + "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": { + "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": "string", + "format": "email" + } + }, + "respEmails": { + "description": "RespEmails reaches the emails subject as a response body.", + "schema": { + "type": "string", + "format": "email" + } + }, + "respEnum": { + "description": "RespEnum reaches EnumNamed as a response body.", + "schema": { + "type": "integer", + "format": "uint64" + } + }, + "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": {} + }, + "respStamp": { + "description": "RespStamp reaches StampAlias 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/internal/builders/parameters/parameters.go b/internal/builders/parameters/parameters.go index b8e67750..8e4e7e43 100644 --- a/internal/builders/parameters/parameters.go +++ b/internal/builders/parameters/parameters.go @@ -351,7 +351,15 @@ func (p *Builder) buildNamedField(ftpe *types.Named, typable ifaces.SwaggerTypab return nil } if resolvers.IsStdError(o) { - return fmt.Errorf("%s type not supported in the context of a parameter definition: %w", o.Name(), ErrParameters) + // 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. + return errUnrepresentableParam } resolvers.MustNotBeABuiltinType(o) diff --git a/internal/integration/builder_conformance_test.go b/internal/integration/builder_conformance_test.go index 060bf7f7..5b5a996c 100644 --- a/internal/integration/builder_conformance_test.go +++ b/internal/integration/builder_conformance_test.go @@ -71,6 +71,30 @@ func TestBuilderConformance(t *testing.T) { {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"}, + + // PINNED DIVERGENCES — see knownBroken below. + { + prop: "emails", path: "/emails", response: "respEmails", + note: "named []string + non-special format: schema puts it on items, the other two on the whole schema", + }, + { + prop: "codes", path: "/codes", response: "respCodes", + note: "array flavour of the same", + }, + } + + // Cells where the builders are known to disagree today. Each entry is the fix's worklist, and the + // assertion runs in BOTH directions: a listed cell that starts agreeing fails too, so the list + // cannot rot into a stale TODO. + // + // The parameters and responses builders short-circuit `buildNamedField` on a local + // `strfmtFromDoc` helper that writes `Typed("string", format)` unconditionally. It predates the + // element-driven items-vs-whole rule the schema builder applies, so a format on a `[]string` + // lands on the whole schema instead of on its items — the emitted spec says "one email" where the + // Go type is a list of them. + knownBroken := map[string]string{ + "emails": "strfmtFromDoc short-circuit bypasses the element-driven rule (Q39 tier 3)", + "codes": "strfmtFromDoc short-circuit bypasses the element-driven rule (Q39 tier 3)", } model := doc.Definitions["ModelHost"].Properties @@ -90,8 +114,20 @@ func TestBuilderConformance(t *testing.T) { asParam := bodyParamSignature(t, doc, s.path) asResponse := responseBodySignature(t, doc, s.response) - verdict := "OK" - if control != asParam || control != asResponse { + 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" } if s.note != "" { @@ -99,6 +135,12 @@ func TestBuilderConformance(t *testing.T) { } fmt.Fprintf(&ledger, "%-8s %-26s %-26s %-26s %s\n", s.prop, control, asParam, asResponse, 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, @@ -107,6 +149,10 @@ func TestBuilderConformance(t *testing.T) { } 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") } // bodyParamSignature renders the body parameter of the operation on path. diff --git a/internal/integration/coverage_simple_schema_test.go b/internal/integration/coverage_simple_schema_test.go index 70a36337..010d280c 100644 --- a/internal/integration/coverage_simple_schema_test.go +++ b/internal/integration/coverage_simple_schema_test.go @@ -4,6 +4,7 @@ package integration_test import ( + "strings" "testing" "github.com/go-openapi/codescan" @@ -57,7 +58,7 @@ func TestCoverage_SimpleSchemaViolation(t *testing.T) { require.Contains(t, doc.Paths.Paths, "/violation") op := doc.Paths.Paths["/violation"].Get require.NotNil(t, op) - require.Len(t, op.Parameters, 2) + 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 { @@ -72,6 +73,19 @@ func TestCoverage_SimpleSchemaViolation(t *testing.T) { 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. + _, 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.CodeUnsupportedInSimpleSchema && strings.Contains(d.Message, "errored") { + saidSo = true + + break + } + } + assert.True(t, saidSo, "dropping the error-typed parameter must be reported; 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") From 7b7f0f57875a23b76ec6a03529cbbf08ce3f0e3e Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 19:44:41 +0200 Subject: [PATCH 16/27] fix(builders): let the field dispatch reach the element-aware format rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parameters and responses builders short-circuited a named field on a local helper that wrote `Typed("string", format)` unconditionally. That predates the element-driven items-vs-whole rule the schema builder applies, so a format on a `[]string` landed on the whole schema rather than on its items: the same Go type was described to a client as a list of email addresses in one position and as a single email address in another. Both short-circuits are gone — the format one because it was wrong, the `time.Time` one because the delegation immediately below already recognises it. The conformance suite's two pinned cells are unpinned and now agree, and no golden moved, which is the evidence that removing them changed nothing except the divergence. Removing the consumers left a `seen` map threading through the field dispatch and reaching nothing, so it goes too, along with the helper that is now unreferenced in the parameters builder. One related defect is deliberately NOT addressed here. The same two shortcuts appear again on the response DECLARATION arm, where they are worse: both write into a local schema and return without the call that attaches it, so a `swagger:response` on a named `time.Time` emits a response with no schema at all. Removing them there is not enough on its own — that arm also passes the underlying type rather than the declared one, so the recognizer and the declaration's own classifiers are bypassed, and correcting that moves five other tests. It wants its own change. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../enhancements_builder_conformance.json | 28 +++++++++++++----- internal/builders/parameters/doc_signals.go | 16 ---------- internal/builders/parameters/parameters.go | 28 +++++++----------- internal/builders/responses/responses.go | 29 +++++++------------ .../integration/builder_conformance_test.go | 25 +++++----------- 5 files changed, 48 insertions(+), 78 deletions(-) diff --git a/fixtures/integration/golden/enhancements_builder_conformance.json b/fixtures/integration/golden/enhancements_builder_conformance.json index 6fa8c7a4..3f384c8c 100644 --- a/fixtures/integration/golden/enhancements_builder_conformance.json +++ b/fixtures/integration/golden/enhancements_builder_conformance.json @@ -61,8 +61,11 @@ "name": "body", "in": "body", "schema": { - "type": "string", - "format": "email" + "type": "array", + "items": { + "type": "string", + "format": "email" + } } } ], @@ -85,8 +88,11 @@ "name": "body", "in": "body", "schema": { - "type": "string", - "format": "email" + "type": "array", + "items": { + "type": "string", + "format": "email" + } } } ], @@ -551,15 +557,21 @@ "respCodes": { "description": "RespCodes reaches the codes subject as a response body.", "schema": { - "type": "string", - "format": "email" + "type": "array", + "items": { + "type": "string", + "format": "email" + } } }, "respEmails": { "description": "RespEmails reaches the emails subject as a response body.", "schema": { - "type": "string", - "format": "email" + "type": "array", + "items": { + "type": "string", + "format": "email" + } } }, "respEnum": { diff --git a/internal/builders/parameters/doc_signals.go b/internal/builders/parameters/doc_signals.go index 3afffc79..08c99d1c 100644 --- a/internal/builders/parameters/doc_signals.go +++ b/internal/builders/parameters/doc_signals.go @@ -80,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/parameters.go b/internal/builders/parameters/parameters.go index 8e4e7e43..5f79e0dd 100644 --- a/internal/builders/parameters/parameters.go +++ b/internal/builders/parameters/parameters.go @@ -296,20 +296,20 @@ 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 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 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: @@ -368,16 +368,10 @@ func (p *Builder) buildNamedField(ftpe *types.Named, typable ifaces.SwaggerTypab 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") - return nil - } - - if sfnm, isf := strfmtFromDoc(p.ParseBlocks(decl.Comments)); isf { - typable.Typed("string", sfnm) - 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)) } @@ -539,7 +533,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", "") @@ -549,7 +543,7 @@ 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 err := p.buildFromField(fld, fld.Type(), pty); err != nil { if 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). @@ -650,7 +644,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/responses/responses.go b/internal/builders/responses/responses.go index e34b005a..d8500392 100644 --- a/internal/builders/responses/responses.go +++ b/internal/builders/responses/responses.go @@ -176,22 +176,22 @@ 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 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 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: @@ -313,17 +313,8 @@ func (r *Builder) buildNamedField(ftpe *types.Named, typable ifaces.SwaggerTypab return fmt.Errorf("unable to find package and source file for: %s: %w", ftpe.String(), ErrResponses) } - d := decl.Obj() - if resolvers.IsStdTime(d) { - typable.Typed("string", "date-time") - return nil - } - - if sfnm, isf := strfmtFromDoc(r.ParseBlocks(decl.Comments)); isf { - typable.Typed("string", sfnm) - 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)), ) @@ -391,7 +382,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 @@ -412,7 +403,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{ @@ -421,7 +412,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 { @@ -525,7 +516,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/integration/builder_conformance_test.go b/internal/integration/builder_conformance_test.go index 5b5a996c..4c4890f2 100644 --- a/internal/integration/builder_conformance_test.go +++ b/internal/integration/builder_conformance_test.go @@ -72,30 +72,19 @@ func TestBuilderConformance(t *testing.T) { {prop: "ptr", path: "/ptr", response: "respPtr"}, {prop: "basic", path: "/basic", response: "respBasic"}, - // PINNED DIVERGENCES — see knownBroken below. { prop: "emails", path: "/emails", response: "respEmails", - note: "named []string + non-special format: schema puts it on items, the other two on the whole schema", - }, - { - prop: "codes", path: "/codes", response: "respCodes", - note: "array flavour of the same", + note: "named []string + non-special format — the element-driven rule puts it on items", }, + {prop: "codes", path: "/codes", response: "respCodes", note: "array flavour of the same"}, } - // Cells where the builders are known to disagree today. Each entry is the fix's worklist, and the - // assertion runs in BOTH directions: a listed cell that starts agreeing fails too, so the list - // cannot rot into a stale TODO. + // 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 parameters and responses builders short-circuit `buildNamedField` on a local - // `strfmtFromDoc` helper that writes `Typed("string", format)` unconditionally. It predates the - // element-driven items-vs-whole rule the schema builder applies, so a format on a `[]string` - // lands on the whole schema instead of on its items — the emitted spec says "one email" where the - // Go type is a list of them. - knownBroken := map[string]string{ - "emails": "strfmtFromDoc short-circuit bypasses the element-driven rule (Q39 tier 3)", - "codes": "strfmtFromDoc short-circuit bypasses the element-driven rule (Q39 tier 3)", - } + // 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{} model := doc.Definitions["ModelHost"].Properties require.NotEmpty(t, model, "the control host must have properties") From 1a353da639c3ad5c8f1817a7bac39025821fc77e Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 19:55:09 +0200 Subject: [PATCH 17/27] fix(responses): attach the schema a named non-struct response builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `swagger:response` declared on a named type whose underlying is not a struct came out carrying a description and nothing else. The arm short-circuits 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 — so the schema was built and then dropped. The format branch was wrong as well as unattached: it wrote the format onto the whole schema, where a `[]string` annotated `email` is a list of email addresses rather than one of them. It now goes through the shared element-driven rule. Handing the sub-build the type's underlying rather than its declaration turns out to be deliberate, and worth stating since it reads like the defect: a `swagger:response` declares a response, not a model, so passing the named type would send it through the $ref machinery and publish it as a definition. Two existing witnesses exist to prevent precisely that, and they caught the attempt. The new witness pairs each subject with the same type reached as a model field — both are full-schema positions, so a declaration should not mean different things depending on which one reads it — and asserts that none of the response types becomes a definition. One cell stays pinned. `type Stamp time.Time` is not `time.Time`, so the recognizer declines, correctly. The model side resolves it anyway because it builds from the declaration's written right-hand side, where the recognizer fires one level in; this arm peels to the fully underlying struct instead and reads the response as a struct whose fields become headers, of which time.Time has none. That is a different defect and wants its own change. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../response-named-nonstruct/types.go | 111 +++++++++++++++ ...enhancements_response_named_nonstruct.json | 128 ++++++++++++++++++ internal/builders/responses/responses.go | 34 ++++- .../response_named_nonstruct_test.go | 105 ++++++++++++++ 4 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 fixtures/enhancements/response-named-nonstruct/types.go create mode 100644 fixtures/integration/golden/enhancements_response_named_nonstruct.json create mode 100644 internal/integration/response_named_nonstruct_test.go 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/integration/golden/enhancements_response_named_nonstruct.json b/fixtures/integration/golden/enhancements_response_named_nonstruct.json new file mode 100644 index 00000000..61d9787f --- /dev/null +++ b/fixtures/integration/golden/enhancements_response_named_nonstruct.json @@ -0,0 +1,128 @@ +{ + "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." + } + } +} \ No newline at end of file diff --git a/internal/builders/responses/responses.go b/internal/builders/responses/responses.go index d8500392..fa011ad7 100644 --- a/internal/builders/responses/responses.go +++ b/internal/builders/responses/responses.go @@ -263,15 +263,29 @@ 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 } + if err := schema.DelegateAs(r.Builder, decl, schema.OptionFor(tpe.Underlying(), typable), schema.WithPath(r.bodyPathFor(typable)), ); err != nil { @@ -285,6 +299,24 @@ func (r *Builder) buildNamedType(tpe *types.Named, resp *oaispec.Response, seen } } +// 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) { diff --git a/internal/integration/response_named_nonstruct_test.go b/internal/integration/response_named_nonstruct_test.go new file mode 100644 index 00000000..e6565282 --- /dev/null +++ b/internal/integration/response_named_nonstruct_test.go @@ -0,0 +1,105 @@ +// 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) + + // Known-remaining divergence, asserted in both directions so it cannot rot. + // + // `type Stamp time.Time` is not `time.Time` — the recognizer keys on identity, so it correctly + // declines. The model side still resolves it, because buildFromDecl builds from the declaration's + // WRITTEN right-hand side (`time.Time`, a named type) and the recognizer fires one level in. This + // arm uses `Underlying()` instead, which peels past that layer to time.Time's struct, so the + // response is read as a struct whose fields become headers — and time.Time exports none. + knownBroken := map[string]string{ + "stamp": "Underlying() peels past the declaration's named RHS; the model side uses Spec.Type", + } + + 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") +} From 7b3f3c14d07275c833e47c3eb791aaafbf1406d0 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 20:10:35 +0200 Subject: [PATCH 18/27] fix(responses): follow a declaration's written right-hand side, not its underlying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `type Stamp time.Time` declared as a response emitted no schema. `Underlying()` peels every named layer at once, so the arm received time.Time's STRUCT and read it as a response struct whose fields become headers — and time.Time exports none. The recognizer never got a chance: it keys on identity, and the `time.Time` layer it would have matched had already been peeled away. The schema builder does not have this problem because it builds from `Spec.Type`, the type a declaration was WRITTEN over, where the recognizer sees `time.Time` one level in. The responses arm now does the same, and the response body renders as the model field does — a `$ref` to the same definition. 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, which a `swagger:response` must never do. The witness cell that was pinned for this is unpinned, and its list is now empty: a response body and a model field are both full-schema positions, so every difference found between them has been a defect. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- ...enhancements_response_named_nonstruct.json | 5 +- internal/builders/responses/responses.go | 63 +++++++++++++++++++ .../response_named_nonstruct_test.go | 17 +++-- 3 files changed, 75 insertions(+), 10 deletions(-) diff --git a/fixtures/integration/golden/enhancements_response_named_nonstruct.json b/fixtures/integration/golden/enhancements_response_named_nonstruct.json index 61d9787f..f69099b8 100644 --- a/fixtures/integration/golden/enhancements_response_named_nonstruct.json +++ b/fixtures/integration/golden/enhancements_response_named_nonstruct.json @@ -122,7 +122,10 @@ } }, "stampResp": { - "description": "StampResp declares the response on the named time.Time." + "description": "StampResp declares the response on the named time.Time.", + "schema": { + "$ref": "#/definitions/Stamp" + } } } } \ No newline at end of file diff --git a/internal/builders/responses/responses.go b/internal/builders/responses/responses.go index fa011ad7..a645b231 100644 --- a/internal/builders/responses/responses.go +++ b/internal/builders/responses/responses.go @@ -244,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) { @@ -251,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 { diff --git a/internal/integration/response_named_nonstruct_test.go b/internal/integration/response_named_nonstruct_test.go index e6565282..acb1dd25 100644 --- a/internal/integration/response_named_nonstruct_test.go +++ b/internal/integration/response_named_nonstruct_test.go @@ -35,16 +35,15 @@ func TestResponseNamedNonStruct(t *testing.T) { require.NoError(t, err) require.NotNil(t, doc) - // Known-remaining divergence, asserted in both directions so it cannot rot. + // 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. // - // `type Stamp time.Time` is not `time.Time` — the recognizer keys on identity, so it correctly - // declines. The model side still resolves it, because buildFromDecl builds from the declaration's - // WRITTEN right-hand side (`time.Time`, a named type) and the recognizer fires one level in. This - // arm uses `Underlying()` instead, which peels past that layer to time.Time's struct, so the - // response is read as a struct whose fields become headers — and time.Time exports none. - knownBroken := map[string]string{ - "stamp": "Underlying() peels past the declaration's named RHS; the model side uses Spec.Type", - } + // 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") From 7ff038a503aa3c1ae3d2e3682805cd4da3ba3279 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 21:04:20 +0200 Subject: [PATCH 19/27] fix(builders): run the canonical stdlib recognizers in the field arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema builder applies one identity-recognizer set (any / time.Time / error / json.RawMessage / uuid) uniformly. The parameters and responses builders carried a hand-rolled subset per function, differing per function, and each subset ran AFTER the declaration lookup it should precede. A recognizer answers from a type's identity alone, so ordering it behind a lookup subordinates a rule that needs nothing to one that can fail. For the predeclared `error` it always fails: a predeclared object has no package, so no declaring source exists to find in any package graph. The responses field arm had no recognizer at all and went straight to the lookup, where reading the import path off a nil package took the whole scan down — a response body field typed `error` was a panic, not a degradation. The refusal on the parameters side was also spelling-dependent. An alias's object is the alias's own name, so a rule keyed on the object applied to `error` and not to `type Wrapped = error`: the bare spelling was dropped, the aliased one emitted, for the same Go type. It now resolves through the alias, and the alias arm skips the field where it used to abort the scan. Both parameters arms report the drop under their own reason rather than borrowing the SimpleSchema one. `error` is meaningless as an inbound value in every location including `in: body`, so a message naming a location sent the reader to change an `in:` that was never the problem. Coverage came first and is the reason the rest is visible: the conformance suite reached the stdlib types only through aliases, leaving the named arm — the one carrying the subsets — untested. It now reaches `time.Time`, `json.RawMessage`, `any` and `error` as named types in all three positions, with `error` as the suite's one declared difference: a response renders it, a parameter must refuse it and say so. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../enhancements/builder-conformance/types.go | 166 ++++++++++++++++++ .../enhancements_builder_conformance.json | 147 ++++++++++++++++ internal/builders/parameters/errors.go | 24 ++- internal/builders/parameters/parameters.go | 49 ++++-- internal/builders/resolvers/assertions.go | 11 ++ internal/builders/responses/responses.go | 11 +- internal/builders/schema/allof.go | 2 +- internal/builders/schema/embedded.go | 6 +- internal/builders/schema/schema.go | 16 +- internal/builders/schema/special_types.go | 15 +- .../integration/builder_conformance_test.go | 69 +++++++- .../coverage_simple_schema_test.go | 8 +- internal/scanner/scan_context.go | 19 +- internal/scanner/scan_context_test.go | 11 ++ 14 files changed, 508 insertions(+), 46 deletions(-) diff --git a/fixtures/enhancements/builder-conformance/types.go b/fixtures/enhancements/builder-conformance/types.go index 63496ab4..c89a6339 100644 --- a/fixtures/enhancements/builder-conformance/types.go +++ b/fixtures/enhancements/builder-conformance/types.go @@ -85,6 +85,25 @@ 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 @@ -178,6 +197,21 @@ type ModelHost struct { // 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"` } // ParamsFmt reaches FmtNamed as a body parameter. @@ -585,3 +619,135 @@ type RespCodes struct { // // 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/integration/golden/enhancements_builder_conformance.json b/fixtures/integration/golden/enhancements_builder_conformance.json index 3f384c8c..db37f1f3 100644 --- a/fixtures/integration/golden/enhancements_builder_conformance.json +++ b/fixtures/integration/golden/enhancements_builder_conformance.json @@ -1,6 +1,27 @@ { "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": [ @@ -132,6 +153,32 @@ } } }, + "/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": [ @@ -306,6 +353,27 @@ } } }, + "/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": [ @@ -330,6 +398,30 @@ } } }, + "/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": [ @@ -406,6 +498,10 @@ "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", @@ -447,6 +543,18 @@ "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", @@ -492,12 +600,22 @@ "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" }, @@ -540,6 +658,10 @@ } }, "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": { @@ -581,6 +703,20 @@ "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": { @@ -636,6 +772,10 @@ "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": { @@ -643,6 +783,13 @@ "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": { 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 5f79e0dd..909f564f 100644 --- a/internal/builders/parameters/parameters.go +++ b/internal/builders/parameters/parameters.go @@ -346,11 +346,7 @@ func (p *Builder) buildFromFieldMap(ftpe *types.Map, typable ifaces.SwaggerTypab 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) { + 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 @@ -359,10 +355,20 @@ func (p *Builder) buildNamedField(ftpe *types.Named, typable ifaces.SwaggerTypab // 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. - return errUnrepresentableParam + // + // 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) + // 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 + } + 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) @@ -383,8 +389,13 @@ 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) @@ -544,11 +555,12 @@ func (p *Builder) resolveParamType(signals fieldDocSignals, fld *types.Var, name // A compatible swagger:strfmt then rides as a supplementary format back in processParamField. default: if err := p.buildFromField(fld, fld.Type(), pty); err != nil { - if errors.Is(err, errUnrepresentableParam) { + // 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( @@ -557,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 } } 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/responses/responses.go b/internal/builders/responses/responses.go index a645b231..747c3b24 100644 --- a/internal/builders/responses/responses.go +++ b/internal/builders/responses/responses.go @@ -403,7 +403,16 @@ 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()) + o := ftpe.Obj() + + // 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 + } + + 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) } diff --git a/internal/builders/schema/allof.go b/internal/builders/schema/allof.go index 8f2680f9..0f4c630f 100644 --- a/internal/builders/schema/allof.go +++ b/internal/builders/schema/allof.go @@ -205,7 +205,7 @@ func (s *Builder) buildNamedAllOf(ftpe *types.Named, schema *oaispec.Schema) err if s.classifierAliasTargetStrfmt(ftpe, tgt) { return nil } - if applyStdlibSpecials(tio, tgt, s.skipExtensions) { + if ApplyStdlibSpecials(tio, tgt, s.skipExtensions) { return nil } 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/schema.go b/internal/builders/schema/schema.go index bc71e9f5..531d6b1c 100644 --- a/internal/builders/schema/schema.go +++ b/internal/builders/schema/schema.go @@ -172,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 } @@ -293,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) @@ -316,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 { @@ -338,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 } @@ -420,7 +420,7 @@ 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) @@ -501,7 +501,7 @@ 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 } @@ -701,10 +701,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/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/integration/builder_conformance_test.go b/internal/integration/builder_conformance_test.go index 4c4890f2..a7d4b717 100644 --- a/internal/integration/builder_conformance_test.go +++ b/internal/integration/builder_conformance_test.go @@ -36,10 +36,14 @@ import ( // 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 three 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) @@ -49,6 +53,11 @@ func TestBuilderConformance(t *testing.T) { 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 }{ {prop: "fmt", path: "/fmt", response: "respFmt"}, {prop: "fmtAl", path: "/fmt-al", response: "respFmtAl"}, @@ -77,6 +86,23 @@ func TestBuilderConformance(t *testing.T) { note: "named []string + non-special format — the element-driven rule puts it on items", }, {prop: "codes", path: "/codes", response: "respCodes", 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", note: "time.Time as the named type"}, + {prop: "rawN", path: "/raw-n", response: "respRawN", 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", + 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 @@ -100,9 +126,27 @@ func TestBuilderConformance(t *testing.T) { require.True(t, ok, "missing control property %s", s.prop) control := schemaSignature(want, doc.Definitions, 0) - asParam := bodyParamSignature(t, doc, s.path) + asParam, hasParam := bodyParamSignature(t, doc, s.path) asResponse := responseBodySignature(t, doc, s.response) + 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 %s\n", + s.prop, control, "", asResponse, "DECLARED — "+s.paramDropped) + + return + } + require.True(t, hasParam, "%s: no body parameter on %s", s.prop, s.path) + paramAgrees := control == asParam responseAgrees := control == asResponse agrees := paramAgrees && responseAgrees @@ -144,8 +188,10 @@ func TestBuilderConformance(t *testing.T) { scantest.CompareOrDumpJSON(t, doc, "enhancements_builder_conformance.json") } -// bodyParamSignature renders the body parameter of the operation on path. -func bodyParamSignature(t *testing.T, doc *oaispec.Swagger, path string) string { +// 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") @@ -159,11 +205,22 @@ func bodyParamSignature(t *testing.T, doc *oaispec.Swagger, path string) string } require.NotNil(t, p.Schema, "body parameter on %s carries no schema", path) - return schemaSignature(*p.Schema, doc.Definitions, 0) + 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 + } } - t.Fatalf("no body parameter on %s", path) - return "" + return false } // responseBodySignature renders the body schema of the named response. diff --git a/internal/integration/coverage_simple_schema_test.go b/internal/integration/coverage_simple_schema_test.go index 010d280c..993837e2 100644 --- a/internal/integration/coverage_simple_schema_test.go +++ b/internal/integration/coverage_simple_schema_test.go @@ -74,17 +74,21 @@ func TestCoverage_SimpleSchemaViolation(t *testing.T) { 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.CodeUnsupportedInSimpleSchema && strings.Contains(d.Message, "errored") { + 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; got %v", got) + 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"] 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) { From 361e2f2937e7c3602802fd8c4830edb665af9280 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 21:42:11 +0200 Subject: [PATCH 20/27] fix(responses): write a body's vendor extensions into its schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit responseTypable.AddExtension wrote onto the response in every case, so an extension describing the body's Go type surfaced as a sibling of `schema` and `description` instead of inside the schema it was talking about — a response body typed `error` came out as {schema: {type: string}, x-go-type: error}. The parameters typable has routed body-to-schema all along; this is the same rule, kept privately by each builder, that only one of them applied. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../golden/enhancements_builder_conformance.json | 12 ++++++------ internal/builders/responses/typable.go | 14 +++++++++++++- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/fixtures/integration/golden/enhancements_builder_conformance.json b/fixtures/integration/golden/enhancements_builder_conformance.json index db37f1f3..6506780b 100644 --- a/fixtures/integration/golden/enhancements_builder_conformance.json +++ b/fixtures/integration/golden/enhancements_builder_conformance.json @@ -706,16 +706,16 @@ "respErrAl": { "description": "RespErrAl reaches the aliased error as a response body.", "schema": { - "type": "string" - }, - "x-go-type": "error" + "type": "string", + "x-go-type": "error" + } }, "respErrN": { "description": "RespErrN reaches the predeclared error as a response body.", "schema": { - "type": "string" - }, - "x-go-type": "error" + "type": "string", + "x-go-type": "error" + } }, "respFmt": { "description": "RespFmt reaches FmtNamed as a response body.", 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) { From 6ce3bd4ca43b31c9fb27a481eebe6811b0b83ed2 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 23:13:47 +0200 Subject: [PATCH 21/27] fix(parsers): accept a one-character tag or operationId in a route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both names had to be a letter followed by at least one more character, so a single-character tag or operationId did not match. OAS 2.0 puts no such floor on either — both are free-form strings, and a one-letter tag is ordinary. The annotation did not merely lose the short name, it disappeared whole. The tags group is optional, so `swagger:route GET /pets e listPets` did not fail at `e`: it fell back to matching with no tags, leaving the operationId pattern to swallow `e listPets`, 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 not a route, so the path simply never appeared and nothing anywhere said why. Applies to swagger:operation identically; both share the two patterns. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../enhancements/route-name-shapes/api.go | 67 ++++++++++++++++ .../enhancements_route_name_shapes.json | 76 +++++++++++++++++++ .../integration/route_name_shapes_test.go | 55 ++++++++++++++ internal/parsers/parsed_path_content_test.go | 62 +++++++++++++++ internal/parsers/regexprs.go | 16 +++- 5 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 fixtures/enhancements/route-name-shapes/api.go create mode 100644 fixtures/integration/golden/enhancements_route_name_shapes.json create mode 100644 internal/integration/route_name_shapes_test.go diff --git a/fixtures/enhancements/route-name-shapes/api.go b/fixtures/enhancements/route-name-shapes/api.go new file mode 100644 index 00000000..b2cc0cb0 --- /dev/null +++ b/fixtures/enhancements/route-name-shapes/api.go @@ -0,0 +1,67 @@ +// 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() {} + +// EmptyResp is the shared response body. +// +// swagger:response emptyResp +type EmptyResp struct { + // in: body + Body string `json:"body"` +} 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/internal/integration/route_name_shapes_test.go b/internal/integration/route_name_shapes_test.go new file mode 100644 index 00000000..9538e1a4 --- /dev/null +++ b/internal/integration/route_name_shapes_test.go @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "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) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./enhancements/route-name-shapes/..."}, + WorkDir: scantest.FixturesDir(), + }) + 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 per-path assertions above say the annotations parsed; the golden says what they produced. + scantest.CompareOrDumpJSON(t, doc, "enhancements_route_name_shapes.json") +} 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..043a0fab 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. From e6660371acf451a7f607cee4b9331ee45f17b3bb Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 23:14:34 +0200 Subject: [PATCH 22/27] feat(scanner): report a path annotation that does not parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `swagger:route` or `swagger:operation` line that fails its regex produced nothing and said nothing. The failure mode is worse than an error: the annotation does not become malformed, it stops being an annotation, so it is indistinguishable from prose to everything downstream and the first sign of trouble is a path missing from the output. Every regex change to these patterns has carried that risk, and the path-parameter regex strip already carries a comment saying so. The line is now reported under scan.unparsed-path-annotation, with its position and the offending text. Telling a failed annotation from prose needs more than the keyword. Since annotations must start the comment line, a doc comment whose sentence opens `swagger:route response lines are …` starts with it too — three such lines exist in this repo's own fixtures. The check therefore matches the annotation HEAD: keyword, method, and a /-rooted path. A real annotation always has all three; prose after the keyword never reaches a path. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../enhancements/route-name-shapes/api.go | 11 ++++++ .../integration/route_name_shapes_test.go | 36 +++++++++++++++++++ internal/parsers/grammar/diagnostic.go | 11 ++++++ internal/parsers/parsed_path_content.go | 25 +++++++++++-- internal/parsers/regexprs.go | 15 ++++++++ internal/scanner/index.go | 28 ++++++++++++--- 6 files changed, 119 insertions(+), 7 deletions(-) diff --git a/fixtures/enhancements/route-name-shapes/api.go b/fixtures/enhancements/route-name-shapes/api.go index b2cc0cb0..d2b5aff1 100644 --- a/fixtures/enhancements/route-name-shapes/api.go +++ b/fixtures/enhancements/route-name-shapes/api.go @@ -58,6 +58,17 @@ func HandlerShortIDNoTags() {} // 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 diff --git a/internal/integration/route_name_shapes_test.go b/internal/integration/route_name_shapes_test.go index 9538e1a4..22c34748 100644 --- a/internal/integration/route_name_shapes_test.go +++ b/internal/integration/route_name_shapes_test.go @@ -4,6 +4,7 @@ package integration_test import ( + "strings" "testing" "github.com/go-openapi/codescan" @@ -22,9 +23,13 @@ import ( // 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) @@ -50,6 +55,37 @@ func TestRouteNameShapes(t *testing.T) { }) } + // 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/parsers/grammar/diagnostic.go b/internal/parsers/grammar/diagnostic.go index 586956ff..b4b9f880 100644 --- a/internal/parsers/grammar/diagnostic.go +++ b/internal/parsers/grammar/diagnostic.go @@ -291,6 +291,17 @@ 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" ) // Diagnostic is one observation about a comment block. 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/regexprs.go b/internal/parsers/regexprs.go index 043a0fab..eb6f9078 100644 --- a/internal/parsers/regexprs.go +++ b/internal/parsers/regexprs.go @@ -91,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/index.go b/internal/scanner/index.go index de4d7d3b..a9c686e8 100644 --- a/internal/scanner/index.go +++ b/internal/scanner/index.go @@ -195,11 +195,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 +207,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 +227,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 +247,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) { From 728b8f67a5734d50dc794da13c30dba23d9a8474 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 23:25:29 +0200 Subject: [PATCH 23/27] test(builders): compare an allOf member against the other full-schema positions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conformance suite compared a model field, a body parameter and a response body — three positions that converge on one field dispatch. An allOf member does not: it is resolved by buildNamedAllOf, a composition arm carrying its own copy of the classifier cascade, which makes it the position most likely to be left behind by a fix applied to the others. AllOfHost reaches every embeddable subject as a member of ONE allOf, in the subject table's order, with the composing struct's own field in a trailing member. One allOf rather than one host per subject: members resolving side by side also witness that no member's classifier leaks into its neighbours. The positional mapping is asserted rather than assumed — member count and the trailing member's shape — since a member that fails to build emits an empty member rather than none, and a count that still matches is what shows the indices did not shift. The position starts with three pinned cells against the one already on record: swagger:type is dropped there, swagger:enum likewise (a basic underlying then falls to the arm's warn-and-skip default and the member comes out empty), and a format on a string sequence lands on the whole member instead of on its items, the arm's strfmt classifier predating the element-driven rule. Subjects Go cannot embed under a usable name — a map, a slice of an inline struct, and the pointer/basic/predeclared arms — sit the position out. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../enhancements/builder-conformance/types.go | 78 +++++++++- .../enhancements_builder_conformance.json | 76 ++++++++++ .../integration/builder_conformance_test.go | 138 +++++++++++++++--- 3 files changed, 266 insertions(+), 26 deletions(-) diff --git a/fixtures/enhancements/builder-conformance/types.go b/fixtures/enhancements/builder-conformance/types.go index c89a6339..02104514 100644 --- a/fixtures/enhancements/builder-conformance/types.go +++ b/fixtures/enhancements/builder-conformance/types.go @@ -12,12 +12,16 @@ // // # What is compared // -// One Go shape reached from three FULL-SCHEMA positions, where no legitimate -// difference exists and the three must agree exactly: +// 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) +// - 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 @@ -214,6 +218,74 @@ type ModelHost struct { 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 diff --git a/fixtures/integration/golden/enhancements_builder_conformance.json b/fixtures/integration/golden/enhancements_builder_conformance.json index 6506780b..3faf5ae8 100644 --- a/fixtures/integration/golden/enhancements_builder_conformance.json +++ b/fixtures/integration/golden/enhancements_builder_conformance.json @@ -493,6 +493,82 @@ } }, "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", + "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": "string", + "format": "email" + }, + { + "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", diff --git a/internal/integration/builder_conformance_test.go b/internal/integration/builder_conformance_test.go index a7d4b717..7f9006bf 100644 --- a/internal/integration/builder_conformance_test.go +++ b/internal/integration/builder_conformance_test.go @@ -23,9 +23,14 @@ import ( // model field and a query parameter while silently dropping for a body parameter. This test is the // detector. // -// It compares three FULL-SCHEMA positions, where no legitimate difference exists: +// It compares four FULL-SCHEMA positions, where no legitimate difference exists: // -// a model field · a body parameter · a response body +// 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 @@ -34,7 +39,7 @@ import ( // 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 three agree. +// 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{ @@ -58,40 +63,44 @@ func TestBuilderConformance(t *testing.T) { // 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"}, - {prop: "fmtAl", path: "/fmt-al", response: "respFmtAl"}, - {prop: "typ", path: "/typ", response: "respTyp"}, + {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", + 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"}, - {prop: "bytes", path: "/bytes", response: "respBytes"}, - {prop: "stamp", path: "/stamp", response: "respStamp"}, - {prop: "raw", path: "/raw", response: "respRaw"}, + {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"}, - {prop: "iface", path: "/iface", response: "respIface"}, + {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", + 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", note: "array flavour of the same"}, + {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", note: "time.Time as the named type"}, - {prop: "rawN", path: "/raw-n", response: "respRawN", note: "json.RawMessage as the named type"}, + {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", @@ -101,6 +110,7 @@ func TestBuilderConformance(t *testing.T) { { 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", }, } @@ -112,13 +122,35 @@ func TestBuilderConformance(t *testing.T) { // 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{ + "typ": "Q40 — buildNamedAllOf runs no swagger:type classifier, so the member comes out empty", + "enum": "Q40 family — no enum classifier either; a basic underlying then falls to the " + + "warn-and-skip default and the member comes out empty", + "emails": "Q34 family — the arm's strfmt classifier predates the element-driven rule, so the " + + "format lands on the whole member instead of on its items", + "codes": "Q34 family — array flavour of the same", + } + 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 %s\n", - "SUBJECT", "MODEL FIELD", "BODY PARAM", "RESPONSE BODY", "") - fmt.Fprintf(&ledger, "%s\n", strings.Repeat("-", 100)) + 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) { @@ -129,6 +161,33 @@ func TestBuilderConformance(t *testing.T) { 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. @@ -140,8 +199,9 @@ func TestBuilderConformance(t *testing.T) { assert.Equal(t, control, asResponse, "a dropped parameter says nothing about the response, which must still agree") - fmt.Fprintf(&ledger, "%-8s %-26s %-26s %-26s %s\n", - s.prop, control, "", asResponse, "DECLARED — "+s.paramDropped) + fmt.Fprintf(&ledger, "%-8s %-26s %-26s %-26s %-26s %s\n", + s.prop, control, "", asResponse, allOfCell, + "DECLARED — "+s.paramDropped+allOfVerdict) return } @@ -163,10 +223,14 @@ func TestBuilderConformance(t *testing.T) { default: verdict = "DIVERGES" } + + verdict += allOfVerdict + if s.note != "" { verdict += " — " + s.note } - fmt.Fprintf(&ledger, "%-8s %-26s %-26s %-26s %s\n", s.prop, control, asParam, asResponse, verdict) + fmt.Fprintf(&ledger, "%-8s %-26s %-26s %-26s %-26s %s\n", + s.prop, control, asParam, asResponse, allOfCell, verdict) if pinned { assert.False(t, agrees, @@ -188,6 +252,34 @@ func TestBuilderConformance(t *testing.T) { 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. From e7e4e5c04534b5a5374c5046e87121952ec98ab2 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 23:45:05 +0200 Subject: [PATCH 24/27] fix(scanner): skip an unrecognised annotation instead of failing the scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An annotation nobody recognises is almost always a typo, and it aborted the whole scan: one mistyped keyword in one comment and an entire package graph produced nothing — the outcome least useful to whoever typed it. It is now reported under parse.invalid-annotation, with the name and the location, and classified as nothing so the comment reads as prose. Every other annotation in the tree still works. Skip-and-diagnose is the house rule, and this is the case that most deserves it. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../enhancements/unknown-annotation/types.go | 12 +++-- .../integration/coverage_enhancements_test.go | 33 +++++++++++-- internal/scanner/index.go | 20 ++++++-- internal/scanner/index_test.go | 49 ++++++++++++------- 4 files changed, 85 insertions(+), 29 deletions(-) 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/internal/integration/coverage_enhancements_test.go b/internal/integration/coverage_enhancements_test.go index 12b7b43e..89caf462 100644 --- a/internal/integration/coverage_enhancements_test.go +++ b/internal/integration/coverage_enhancements_test.go @@ -469,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/scanner/index.go b/internal/scanner/index.go index a9c686e8..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 } @@ -495,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 @@ -544,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) From 1973da5751a56d38c15ddd4540022305f2fbcb18 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 23:45:06 +0200 Subject: [PATCH 25/27] feat(schema): report a classifier annotation on an embed as ineffective MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `swagger:strfmt` and `swagger:type` in an embedded field's own comment are parsed into the field doc and then never read: no embed arm consults either. On a regular field both are honoured, so the same annotation in the same syntactic position works one line and does nothing the next. The silence was the worst part. The scanner reads that comment and rejects an unknown annotation in it, so the author got validation feedback implying the annotation was meaningful and no feedback at all that it had been discarded. They are still ignored — this reports the drop, it does not change it. An embed contributes its embedded type's shape, and what that shape is comes from that type's own declaration, so the message names that as the place the annotation belongs rather than merely refusing it. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../enhancements/annotation-noise/types.go | 62 +++++++++++++ .../golden/enhancements_annotation_noise.json | 85 +++++++++++++++++ internal/builders/schema/allof.go | 30 ++++++ internal/integration/annotation_noise_test.go | 92 +++++++++++++++++++ internal/parsers/grammar/diagnostic.go | 10 ++ 5 files changed, 279 insertions(+) create mode 100644 fixtures/enhancements/annotation-noise/types.go create mode 100644 fixtures/integration/golden/enhancements_annotation_noise.json create mode 100644 internal/integration/annotation_noise_test.go 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/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/internal/builders/schema/allof.go b/internal/builders/schema/allof.go index 0f4c630f..3c82860d 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. // 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/parsers/grammar/diagnostic.go b/internal/parsers/grammar/diagnostic.go index b4b9f880..ebb16682 100644 --- a/internal/parsers/grammar/diagnostic.go +++ b/internal/parsers/grammar/diagnostic.go @@ -302,6 +302,16 @@ const ( // 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. From 3fa98eef696ef09b0c07b69e89b21c34f96584a9 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 23:46:22 +0200 Subject: [PATCH 26/27] doc: say that a classifier annotation on an embed is ignored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An embed contributes the shape of the type it embeds, and what that shape is comes from that type's own declaration. `swagger:strfmt` and `swagger:type` written in an embedded field's doc comment do nothing — which is easy to get wrong, because both are honoured on an ordinary field one line away. Covers the annotation reference for swagger:allOf, the allOf composition how-to, and the schema builder's own contract doc, which now names the five fieldDoc signals an embed actually consumes. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../maintainers/annotations/swagger-allof.md | 35 +++++++++++++++++++ .../composing-embeds-with-allof.md | 28 +++++++++++++++ internal/builders/schema/README.md | 15 ++++++++ 3 files changed, 78 insertions(+) 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/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/internal/builders/schema/README.md b/internal/builders/schema/README.md index 9d424fa4..bbb4b135 100644 --- a/internal/builders/schema/README.md +++ b/internal/builders/schema/README.md @@ -543,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: From 2c350a56e6e5cf3bc28986763664be033e664049 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 23:57:51 +0200 Subject: [PATCH 27/27] fix(schema): run the classifier cascade on an allOf member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An allOf member is a full schema describing one type, exactly as a model field is, but the composition arm resolving it kept its own copy of the classifier cascade — one shape-BLIND strfmt check and nothing else. Three annotations were wrong there: - swagger:type was dropped, and a basic underlying then fell to the arm's warn-and-skip default, so the member came out as an empty schema; - swagger:enum likewise; - swagger:strfmt on a string sequence landed on the whole member, so a composed []string annotated `email` asserted the value IS an email address rather than a list of them. The shape-aware classifiers already existed, scattered through buildNamedType's switch. They move to applyNamedShapeClassifier, which both arms now call, so neither can answer one of these differently again. The arm keeps its own $ref-or-inline policy. Delegating wholesale was tried and reverted: buildNamedType publishes a definition for a non-model struct, which would put types in the spec their author never asked to expose — the default-allof-embeds fixture documents inline-for-non-model as deliberate. The classifiers are the shared part; the reference policy is not. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../enhancements_builder_conformance.json | 28 ++++++-- .../type_override_symmetry_default.json | 4 +- .../type_override_symmetry_refaliases.json | 4 +- ..._override_symmetry_transparentaliases.json | 4 +- internal/builders/schema/allof.go | 32 ++++++++- internal/builders/schema/schema.go | 49 +++++++------- .../builders/schema/walker_classifiers.go | 65 +++++++++++++++++++ .../integration/builder_conformance_test.go | 9 +-- .../strfmt_symmetry_harness_test.go | 14 ---- .../type_override_symmetry_test.go | 16 ++--- 10 files changed, 155 insertions(+), 70 deletions(-) diff --git a/fixtures/integration/golden/enhancements_builder_conformance.json b/fixtures/integration/golden/enhancements_builder_conformance.json index 3faf5ae8..bc13b7e8 100644 --- a/fixtures/integration/golden/enhancements_builder_conformance.json +++ b/fixtures/integration/golden/enhancements_builder_conformance.json @@ -505,11 +505,21 @@ "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" @@ -540,12 +550,18 @@ } }, { - "type": "string", - "format": "email" + "type": "array", + "items": { + "type": "string", + "format": "email" + } }, { - "type": "string", - "format": "email" + "type": "array", + "items": { + "type": "string", + "format": "email" + } }, { "type": "string", diff --git a/fixtures/integration/golden/type_override_symmetry_default.json b/fixtures/integration/golden/type_override_symmetry_default.json index 23d5e69d..820468f5 100644 --- a/fixtures/integration/golden/type_override_symmetry_default.json +++ b/fixtures/integration/golden/type_override_symmetry_default.json @@ -93,7 +93,9 @@ "AllOfScalarNamed": { "title": "AllOfScalarNamed composes the scalar pair's named half as an allOf member.", "allOf": [ - {}, + { + "type": "string" + }, { "type": "object", "properties": { diff --git a/fixtures/integration/golden/type_override_symmetry_refaliases.json b/fixtures/integration/golden/type_override_symmetry_refaliases.json index 23d5e69d..820468f5 100644 --- a/fixtures/integration/golden/type_override_symmetry_refaliases.json +++ b/fixtures/integration/golden/type_override_symmetry_refaliases.json @@ -93,7 +93,9 @@ "AllOfScalarNamed": { "title": "AllOfScalarNamed composes the scalar pair's named half as an allOf member.", "allOf": [ - {}, + { + "type": "string" + }, { "type": "object", "properties": { diff --git a/fixtures/integration/golden/type_override_symmetry_transparentaliases.json b/fixtures/integration/golden/type_override_symmetry_transparentaliases.json index 23d5e69d..820468f5 100644 --- a/fixtures/integration/golden/type_override_symmetry_transparentaliases.json +++ b/fixtures/integration/golden/type_override_symmetry_transparentaliases.json @@ -93,7 +93,9 @@ "AllOfScalarNamed": { "title": "AllOfScalarNamed composes the scalar pair's named half as an allOf member.", "allOf": [ - {}, + { + "type": "string" + }, { "type": "object", "properties": { diff --git a/internal/builders/schema/allof.go b/internal/builders/schema/allof.go index 3c82860d..872a7cf5 100644 --- a/internal/builders/schema/allof.go +++ b/internal/builders/schema/allof.go @@ -232,9 +232,6 @@ 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) { return nil } @@ -244,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/schema.go b/internal/builders/schema/schema.go index 531d6b1c..ca630340 100644 --- a/internal/builders/schema/schema.go +++ b/internal/builders/schema/schema.go @@ -508,10 +508,8 @@ func (s *Builder) buildNamedType(titpe *types.Named, target ifaces.SwaggerTypabl // 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 } @@ -550,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: @@ -567,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, refModel) + return s.buildNamedArrayLike(tio, utitpe.Elem(), target) case *types.Slice: - return s.buildNamedArrayLike(tio, cmt, utitpe.Elem(), target, refModel) + return s.buildNamedArrayLike(tio, utitpe.Elem(), target) case *types.Map: return s.resolveRefOr(tio, target, nil) @@ -588,21 +593,11 @@ func (s *Builder) buildNamedType(titpe *types.Named, target ifaces.SwaggerTypabl } } -// buildNamedArrayLike is the unified Array/Slice arm. elem drives the items-vs-whole decision for -// a `swagger:strfmt` (see common.ApplyArrayLikeStrfmt). 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, isModel bool) error { - if !isModel { - if handled, recurse := s.classifierNamedArrayLike(cmt, tgt, elem); 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()) diff --git a/internal/builders/schema/walker_classifiers.go b/internal/builders/schema/walker_classifiers.go index 6b972ed7..51095cdb 100644 --- a/internal/builders/schema/walker_classifiers.go +++ b/internal/builders/schema/walker_classifiers.go @@ -473,6 +473,71 @@ func (s *Builder) classifierAliasStrfmt(cg *ast.CommentGroup, tpe *types.Alias, // — 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/builder_conformance_test.go b/internal/integration/builder_conformance_test.go index 7f9006bf..58de66ef 100644 --- a/internal/integration/builder_conformance_test.go +++ b/internal/integration/builder_conformance_test.go @@ -126,14 +126,7 @@ func TestBuilderConformance(t *testing.T) { // 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{ - "typ": "Q40 — buildNamedAllOf runs no swagger:type classifier, so the member comes out empty", - "enum": "Q40 family — no enum classifier either; a basic underlying then falls to the " + - "warn-and-skip default and the member comes out empty", - "emails": "Q34 family — the arm's strfmt classifier predates the element-driven rule, so the " + - "format lands on the whole member instead of on its items", - "codes": "Q34 family — array flavour of the same", - } + knownBrokenAllOf := map[string]string{} model := doc.Definitions["ModelHost"].Properties require.NotEmpty(t, model, "the control host must have properties") diff --git a/internal/integration/strfmt_symmetry_harness_test.go b/internal/integration/strfmt_symmetry_harness_test.go index 406abd64..39585611 100644 --- a/internal/integration/strfmt_symmetry_harness_test.go +++ b/internal/integration/strfmt_symmetry_harness_test.go @@ -272,17 +272,3 @@ func simpleSignature(typ, format string, items *oaispec.Items) string { } return typ + "/" + format } - -// forEveryMode assigns reason to each named cell in all three alias modes, the common case when a -// defect is mode-independent. -func forEveryMode(reason string, cells ...string) map[string]string { - modes := aliasModes() - out := make(map[string]string, len(cells)*len(modes)) - for _, mode := range modes { - for _, cell := range cells { - out[mode.name+"/"+cell] = reason - } - } - - return out -} diff --git a/internal/integration/type_override_symmetry_test.go b/internal/integration/type_override_symmetry_test.go index 0de835c3..1d036404 100644 --- a/internal/integration/type_override_symmetry_test.go +++ b/internal/integration/type_override_symmetry_test.go @@ -41,20 +41,18 @@ func TestTypeOverrideSymmetry(t *testing.T) { {definition: "Envelope", namedProp: "sliceElemScalarNamed", aliasProp: "sliceElemScalarAlias", wantNamed: "array"}, {definition: "Envelope", namedProp: "mapValueScalarNamed", aliasProp: "mapValueScalarAlias", wantNamed: "map"}, - // The allOf member drops the override on BOTH halves, and the named half emits an empty - // member. A shared gap rather than an alias asymmetry, so it is deferred to its own quirk - // rather than folded in here — pinned as a known difference until then. + // 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", - note: "Q40: buildNamedAllOf runs no type classifier — named emits an EMPTY member, alias dissolves", + wantNamed: "allOf[string/+object{note}]", + note: "the composition arm now runs the same classifier cascade as the field dispatch", }, }, - exceptions: map[string]string{}, - knownBroken: forEveryMode( - "Q40 — the allOf member path honours no classifier; deferred, tracked separately", - "AllOfScalar", - ), + exceptions: map[string]string{}, + knownBroken: map[string]string{}, controlBroken: map[string]string{}, }