diff --git a/docs/doc-site/tutorials/model-definitions.md b/docs/doc-site/tutorials/model-definitions.md index 90fbd9a6..97c63484 100644 --- a/docs/doc-site/tutorials/model-definitions.md +++ b/docs/doc-site/tutorials/model-definitions.md @@ -24,6 +24,19 @@ the JSON-Schema `type` / `format`. Well-known standard-library types resolve automatically — a `time.Time` field, for instance, is published as `{type: string, format: date-time}`. +UUIDs are recognised two different ways, and both publish +`{type: string, format: uuid}`: + +- **by type identity** — the standard-library `uuid.UUID` introduced in Go 1.27, + matched on its import path and name, so nothing else can be mistaken for it; +- **by type name** — any *other* type named `UUID` (case-insensitively) that + marshals as text, which covers `github.com/google/uuid`, `gofrs/uuid`, + `strfmt.UUID` and the like. + +The name-based rule is a heuristic and stays available whichever Go version you +build with. Either way an explicit [`swagger:strfmt`](#swaggerstrfmt) on the type +wins, so you can always overrule the recognition. + {{< example go="concepts/models/models.go" goregion="model" json="concepts/models/testdata/model.json" jsonlabel="#/definitions/Pet" >}} diff --git a/fixtures/goparsing/go127/uuid/doc.go b/fixtures/goparsing/go127/uuid/doc.go new file mode 100644 index 00000000..13430ccc --- /dev/null +++ b/fixtures/goparsing/go127/uuid/doc.go @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package uuidmodels witnesses the identity-based recognizer for the go1.27 stdlib uuid package. +// +// This file is deliberately UNTAGGED while model.go carries `//go:build go1.27`: under an older +// toolchain `import "uuid"` does not compile, and a package left with no buildable files at all +// fails `go build ./...` with "build constraints exclude all Go files". This doc file keeps the +// package loadable while contributing nothing to the emitted spec. +package uuidmodels diff --git a/fixtures/goparsing/go127/uuid/model.go b/fixtures/goparsing/go127/uuid/model.go new file mode 100644 index 00000000..063b55f9 --- /dev/null +++ b/fixtures/goparsing/go127/uuid/model.go @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +//go:build go1.27 + +package uuidmodels + +import "uuid" + +// Order carries the stdlib uuid.UUID in every position the schema builder can reach it from. +// +// swagger:model Order +type Order struct { + // ID is a plain field. + ID uuid.UUID `json:"id"` + + // PtrID is a pointer, peeled before recognition. + PtrID *uuid.UUID `json:"ptrId"` + + // IDs exercises the items chain. + IDs []uuid.UUID `json:"ids"` + + // ByName exercises a map value; the key is a plain string. + ByName map[string]uuid.UUID `json:"byName"` + + // Keyed exercises uuid.UUID as a map KEY: it marshals to a JSON object because the key is a + // TextMarshaler, and the key type itself is not rendered in Swagger 2.0. + Keyed map[uuid.UUID]string `json:"keyed"` + + // Named refs the named type declared below. + Named OrderID `json:"named"` + + // Overridden proves the explicit classifier still beats the recognizer. + // + // swagger:strfmt date + Overridden uuid.UUID `json:"overridden"` +} + +// OrderID is a named type whose underlying type is the stdlib uuid.UUID. +// +// swagger:model OrderID +type OrderID uuid.UUID + +// AliasID is an ALIAS of the stdlib uuid.UUID: it dissolves at use sites. +type AliasID = uuid.UUID + +// Tagged uses the alias. +// +// swagger:model Tagged +type Tagged struct { + // Alias is declared through the alias above. + Alias AliasID `json:"alias"` +} + +// Embedder embeds the stdlib uuid.UUID. +// +// Witness for the embedded arm, which the fuzzy name heuristic never reached (it is caller-gated to +// buildFromTextMarshal). Note the promoted MarshalText makes encoding/json render the WHOLE struct +// as a bare string, dropping Name from the wire — see quirks-open.md Q33. +// +// swagger:model Embedder +type Embedder struct { + uuid.UUID + + // Name is NOT on the wire, whatever the emitted schema says. + Name string `json:"name"` +} diff --git a/fixtures/integration/golden/go127_uuid_spec.json b/fixtures/integration/golden/go127_uuid_spec.json new file mode 100644 index 00000000..77e4d222 --- /dev/null +++ b/fixtures/integration/golden/go127_uuid_spec.json @@ -0,0 +1,92 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Embedder": { + "description": "Witness for the embedded arm, which the fuzzy name heuristic never reached (it is caller-gated to\nbuildFromTextMarshal). Note the promoted MarshalText makes encoding/json render the WHOLE struct\nas a bare string, dropping Name from the wire — see quirks-open.md Q33.", + "type": "object", + "title": "Embedder embeds the stdlib uuid.UUID.", + "properties": { + "name": { + "description": "Name is NOT on the wire, whatever the emitted schema says.", + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/goparsing/go127/uuid" + }, + "Order": { + "type": "object", + "title": "Order carries the stdlib uuid.UUID in every position the schema builder can reach it from.", + "properties": { + "byName": { + "description": "ByName exercises a map value; the key is a plain string.", + "type": "object", + "additionalProperties": { + "type": "string", + "format": "uuid" + }, + "x-go-name": "ByName" + }, + "id": { + "description": "ID is a plain field.", + "type": "string", + "format": "uuid", + "x-go-name": "ID" + }, + "ids": { + "description": "IDs exercises the items chain.", + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "x-go-name": "IDs" + }, + "keyed": { + "description": "Keyed exercises uuid.UUID as a map KEY: it marshals to a JSON object because the key is a\nTextMarshaler, and the key type itself is not rendered in Swagger 2.0.", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "x-go-name": "Keyed" + }, + "named": { + "$ref": "#/definitions/OrderID" + }, + "overridden": { + "description": "Overridden proves the explicit classifier still beats the recognizer.", + "type": "string", + "format": "date", + "x-go-name": "Overridden" + }, + "ptrId": { + "description": "PtrID is a pointer, peeled before recognition.", + "type": "string", + "format": "uuid", + "x-go-name": "PtrID" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/goparsing/go127/uuid" + }, + "OrderID": { + "type": "string", + "format": "uuid", + "title": "OrderID is a named type whose underlying type is the stdlib uuid.UUID.", + "x-go-package": "github.com/go-openapi/codescan/fixtures/goparsing/go127/uuid" + }, + "Tagged": { + "type": "object", + "title": "Tagged uses the alias.", + "properties": { + "alias": { + "description": "Alias is declared through the alias above.", + "type": "string", + "format": "uuid", + "x-go-name": "Alias" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/goparsing/go127/uuid" + } + } +} \ No newline at end of file diff --git a/internal/builders/resolvers/assertions.go b/internal/builders/resolvers/assertions.go index 0df6e57b..4ba5de01 100644 --- a/internal/builders/resolvers/assertions.go +++ b/internal/builders/resolvers/assertions.go @@ -151,6 +151,19 @@ func IsStdTime(o *types.TypeName) bool { return o.Pkg() != nil && o.Pkg().Name() == "time" && o.Name() == "Time" } +// IsStdUUID reports whether o is the go1.27 stdlib [uuid.UUID]. +// +// Identity-based, so it never misfires on the many third-party types also named UUID +// (github.com/google/uuid, gofrs, strfmt, …) — those keep going through the fuzzy +// name heuristic in the schema builder. +// +// Deliberately NOT behind a go1.27 build tag: codescan compares types harvested from +// *scanned* code, never imports uuid itself, and ships as a binary that may be built by +// an older toolchain than the module it scans. +func IsStdUUID(o *types.TypeName) bool { + return o.Pkg() != nil && o.Pkg().Path() == "uuid" && o.Name() == "UUID" +} + func IsStdError(o *types.TypeName) bool { return o.Pkg() == nil && o.Name() == "error" } diff --git a/internal/builders/resolvers/assertions_test.go b/internal/builders/resolvers/assertions_test.go new file mode 100644 index 00000000..bb1ef435 --- /dev/null +++ b/internal/builders/resolvers/assertions_test.go @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package resolvers + +import ( + "go/token" + "go/types" + "testing" + + "github.com/go-openapi/testify/v2/assert" +) + +// TestIsStdUUID covers the go1.27 stdlib uuid.UUID predicate. +// +// Deliberately synthesizes the *types.TypeName instead of scanning a fixture, so the predicate stays +// covered on every supported toolchain — including the ones where `import "uuid"` does not compile. +// The end-to-end wiring is witnessed by the go1.27-tagged integration test instead. +func TestIsStdUUID(t *testing.T) { + // stdlib packages have a bare import path; uuid.UUID is [16]byte. + stdUUID := func() *types.TypeName { + pkg := types.NewPackage("uuid", "uuid") + return types.NewTypeName(token.NoPos, pkg, "UUID", types.NewArray(types.Typ[types.Byte], 16)) + } + + named := func(path, name, typeName string) *types.TypeName { + pkg := types.NewPackage(path, name) + return types.NewTypeName(token.NoPos, pkg, typeName, types.NewArray(types.Typ[types.Byte], 16)) + } + + t.Run("the go1.27 stdlib uuid.UUID is recognized", func(t *testing.T) { + assert.TrueT(t, IsStdUUID(stdUUID())) + }) + + t.Run("third-party UUID types are NOT recognized by identity", func(t *testing.T) { + // These stay on the fuzzy name heuristic in the schema builder; identity must not claim them. + assert.FalseT(t, IsStdUUID(named("github.com/google/uuid", "uuid", "UUID"))) + assert.FalseT(t, IsStdUUID(named("github.com/gofrs/uuid/v5", "uuid", "UUID"))) + assert.FalseT(t, IsStdUUID(named("github.com/go-openapi/strfmt", "strfmt", "UUID"))) + }) + + t.Run("a user package merely named uuid is NOT recognized", func(t *testing.T) { + // Package *name* is uuid, but the import path carries the module prefix — only the stdlib + // gets a bare path. This is why the predicate tests Path(), not Name(). + assert.FalseT(t, IsStdUUID(named("example.com/mymod/internal/uuid", "uuid", "UUID"))) + }) + + t.Run("another type in the stdlib uuid package is NOT recognized", func(t *testing.T) { + assert.FalseT(t, IsStdUUID(named("uuid", "uuid", "Version"))) + }) + + t.Run("the match is case-sensitive", func(t *testing.T) { + assert.FalseT(t, IsStdUUID(named("uuid", "uuid", "Uuid"))) + }) + + t.Run("a builtin (no package) does not panic", func(t *testing.T) { + builtin := types.NewTypeName(token.NoPos, nil, "error", nil) + assert.FalseT(t, IsStdUUID(builtin)) + }) +} diff --git a/internal/builders/schema/README.md b/internal/builders/schema/README.md index 8e75bc33..6917317c 100644 --- a/internal/builders/schema/README.md +++ b/internal/builders/schema/README.md @@ -12,7 +12,7 @@ trade-offs, and known quirks live here. - [§build-entry](#build-entry) — `Build` modes and dispatch entry points - [§dispatch-table](#dispatch-table) — `buildNamedType`'s underlying-shape table - [§dissolve-named](#dissolve-named) — when a named type is unwrapped instead of `$ref`'d -- [§special-types](#special-types) — `applyStdlibSpecials`, `applySpecialType`, UUID heuristic +- [§special-types](#special-types) — `applyStdlibSpecials`, `applySpecialType`, the two UUID recognizers - [§textmarshal-order](#textmarshal-order) — `buildFromTextMarshal` precedence - [§aliases](#aliases) — `TransparentAliases` vs `RefAliases` vs default-expand - [§discovery](#discovery) — `Models` / `ExtraModels` / `discovered`, dedup layers @@ -137,7 +137,7 @@ Fixture: `fixtures/enhancements/generic-instantiation/`. --- -## §special-types — `applyStdlibSpecials`, `applySpecialType`, UUID heuristic +## §special-types — `applyStdlibSpecials`, `applySpecialType`, the two UUID recognizers Three layers, all in `special_types.go`: @@ -147,15 +147,46 @@ Three layers, all in `special_types.go`: `recognizeUUID` which is **fuzzy** (case-insensitive name match). - **`applyStdlibSpecials(obj, target)`** — the canonical safe set - `{recognizeAny, recognizeTime, recognizeError, recognizeRawMessage}`. - All four are identity-based and cannot misfire on user types, - so this helper is **called uniformly at every site** that handles - a `*types.TypeName`. - -- **`recognizeUUID`** — opt-in via `applySpecialType`'s variadic. - Currently used **only by `buildFromTextMarshal`** because the - upstream `IsTextMarshaler` gate guarantees the type renders as - text, making the fuzzy name match safe. + `{recognizeAny, recognizeTime, recognizeError, recognizeRawMessage, + recognizeStdUUID}`. All five are identity-based and cannot misfire + on user types, so this helper is **called uniformly at every site** + that handles a `*types.TypeName`. + +The two UUID recognizers are a **certain/guessed pair**, and the +distinction is the whole reason both exist: + +- **`recognizeStdUUID`** — identity match on the go1.27 stdlib + `uuid.UUID` (`resolvers.IsStdUUID`: import path `uuid`, name `UUID`). + Certain, therefore in the safe set. It is **not** behind a + `//go:build go1.27` tag, on purpose: codescan compares types + harvested from *scanned* code and never imports `uuid` itself, so + tagging it would mean a codescan built by an older toolchain fails + to recognize a go1.27 user type — the inverted matrix. Build tags + live on the fixture and the integration test, which genuinely cannot + compile before go1.27; the predicate keeps toolchain-independent + coverage via `TestIsStdUUID`. + +- **`recognizeUUID`** — fuzzy, case-insensitive name match, the + fallback for `github.com/google/uuid`, `gofrs/uuid`, `strfmt.UUID` + and every other third-party type named UUID. Opt-in via + `applySpecialType`'s variadic and used **only by + `buildFromTextMarshal`**, because the upstream `IsTextMarshaler` + gate guarantees the type renders as text, making the name match + safe. `buildFromTextMarshal` orders identity **before** fuzzy so + the certain match wins. + +Neither beats an explicit `swagger:strfmt` / `swagger:type` — the +classifier runs first, see [§textmarshal-order](#textmarshal-order). + +**Not reached by either: a struct embed.** `uuid.UUID` has an array +underlying type, so `buildNamedEmbedded` falls to its `default` arm +(only the *interface* arm consults `applyStdlibSpecials`) and skips +the embed with a `CodeUnsupportedGoType` warning. That asymmetry is +long-standing and uuid-agnostic — any `strfmt.UUID` / `time.Time` +embed behaves the same, and Go itself renders such a struct as a bare +string via the promoted `MarshalText`. Tracked as Q33; the identity +recognizer deliberately does not change it. Witnessed by +`TestStdlibUUID`'s embed sub-test. The seven call sites of `applyStdlibSpecials` (`buildFromDecl`, `buildDeclAlias` RHS, `buildAlias`, `buildNamedType`, @@ -177,7 +208,7 @@ The function is entered from `buildFromType`'s shortcut 2. route aliases through `buildAlias` (honour `TransparentAliases` / `RefAliases`) 3. type-assert to `*types.Named` (fallback: `{string, ""}`) 4. **classifier (`swagger:strfmt`) — explicit user intent wins** -5. **stdlib trio via `applySpecialType(recognizeError, recognizeTime, recognizeRawMessage, recognizeUUID)`** +5. **stdlib recognizers via `applySpecialType(recognizeError, recognizeTime, recognizeRawMessage, recognizeStdUUID, recognizeUUID)`** — identity before fuzzy 6. `PkgForType`-miss bail (gates only the generic fallback below) 7. **generic fallback** — `{string, ""}` + `x-go-type: pkg.Name` diff --git a/internal/builders/schema/special_types.go b/internal/builders/schema/special_types.go index 11d5b0aa..719acd61 100644 --- a/internal/builders/schema/special_types.go +++ b/internal/builders/schema/special_types.go @@ -39,8 +39,9 @@ func (s *Builder) buildFromTextMarshal(tpe types.Type, target ifaces.SwaggerTypa if s.classifierTextMarshal(typeNamed, target) { return nil } - // Implicit recognizers in priority order. - if applySpecialType(tio, target, s.skipExtensions, recognizeError, recognizeTime, recognizeRawMessage, recognizeUUID) { + // Implicit recognizers in priority order: certain (identity) before guessed (fuzzy name). + if applySpecialType(tio, target, s.skipExtensions, + recognizeError, recognizeTime, recognizeRawMessage, recognizeStdUUID, recognizeUUID) { return nil } // Generic fallback: x-go-type carries pkg.Name, so PkgForType-miss must bail (can't produce the @@ -61,6 +62,11 @@ const ( recognizeAny recognizeError 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]. + // See [§special-types](./README.md#special-types). + recognizeStdUUID // recognizeUUID is a fuzzy name-only match (case-insensitive "uuid"). // // Caller-gated — opt in only where the type is guaranteed to render as text. @@ -69,7 +75,7 @@ const ( ) // applyStdlibSpecials runs the canonical safe set of identity-based recognizers (any / time.Time / -// error / json.RawMessage). +// error / json.RawMessage / go1.27 uuid.UUID). // // Safe at every call site that handles a *types.TypeName. // @@ -78,7 +84,7 @@ const ( // See [§special-types](./README.md#special-types). func applyStdlibSpecials(obj *types.TypeName, target ifaces.SwaggerTypable, skipExt bool) bool { return applySpecialType(obj, target, skipExt, - recognizeAny, recognizeTime, recognizeError, recognizeRawMessage) + recognizeAny, recognizeTime, recognizeError, recognizeRawMessage, recognizeStdUUID) } // applySpecialType iterates wanted recognizers in order and applies the first match to target, @@ -123,6 +129,12 @@ func applySpecialType(obj *types.TypeName, target ifaces.SwaggerTypable, skipExt return true } + case recognizeStdUUID: // identity — go1.27 stdlib uuid.UUID. + if resolvers.IsStdUUID(obj) { + target.Typed("string", "uuid") + return true + } + case recognizeUUID: // fuzzy — see [§special-types](./README.md#special-types). if obj != nil && strings.ToLower(obj.Name()) == "uuid" { target.Typed("string", "uuid") diff --git a/internal/integration/schema_uuid_go127_test.go b/internal/integration/schema_uuid_go127_test.go new file mode 100644 index 00000000..6173a51d --- /dev/null +++ b/internal/integration/schema_uuid_go127_test.go @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +//go:build go1.27 + +package integration_test + +import ( + "path/filepath" + "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" + + oaispec "github.com/go-openapi/spec" +) + +// TestStdlibUUID witnesses end-to-end recognition of the go1.27 stdlib uuid.UUID. +// +// Tagged go1.27 because the fixture cannot compile on an older toolchain ("package uuid is not in +// std"). The recognizer itself is untagged production code — codescan compares types harvested from +// scanned sources and never imports uuid — so the predicate is covered on every supported toolchain +// by TestIsStdUUID in internal/builders/resolvers. +func TestStdlibUUID(t *testing.T) { + fixturesPath := filepath.Join(scantest.FixturesDir(), "goparsing", "go127", "uuid") + var ( + sp *oaispec.Swagger + diagnostics []codescan.Diagnostic + ) + + t.Run("end-to-end source scan should succeed", func(t *testing.T) { + var err error + sp, err = codescan.Run(&codescan.Options{ + WorkDir: fixturesPath, + ScanModels: true, + OnDiagnostic: func(d codescan.Diagnostic) { + diagnostics = append(diagnostics, d) + }, + }) + require.NoError(t, err) + }) + + require.NotNil(t, sp) + + order, ok := sp.Definitions["Order"] + require.TrueT(t, ok) + props := order.Properties + require.NotEmpty(t, props) + + assertUUID := func(t *testing.T, s oaispec.Schema) { + t.Helper() + assert.TrueT(t, s.Type.Contains("string")) + assert.EqualT(t, "uuid", s.Format) + } + + t.Run("a plain uuid.UUID field renders as a formatted string", func(t *testing.T) { + assertUUID(t, props["id"]) + }) + + t.Run("a pointer to uuid.UUID is peeled before recognition", func(t *testing.T) { + assertUUID(t, props["ptrId"]) + }) + + t.Run("a slice of uuid.UUID carries the format on its items", func(t *testing.T) { + ids := props["ids"] + require.TrueT(t, ids.Type.Contains("array")) + require.NotNil(t, ids.Items) + require.NotNil(t, ids.Items.Schema) + assertUUID(t, *ids.Items.Schema) + }) + + t.Run("a map value of uuid.UUID carries the format on additionalProperties", func(t *testing.T) { + byName := props["byName"] + require.TrueT(t, byName.Type.Contains("object")) + require.NotNil(t, byName.AdditionalProperties) + require.NotNil(t, byName.AdditionalProperties.Schema) + assertUUID(t, *byName.AdditionalProperties.Schema) + }) + + t.Run("uuid.UUID as a map KEY still yields an object", func(t *testing.T) { + // The key type is not rendered in Swagger 2.0; what matters is that the map is recognized as + // an object at all, which requires the key to marshal as a JSON string. + keyed := props["keyed"] + assert.TrueT(t, keyed.Type.Contains("object")) + }) + + t.Run("an explicit swagger:strfmt still beats the recognizer", func(t *testing.T) { + overridden := props["overridden"] + assert.TrueT(t, overridden.Type.Contains("string")) + assert.EqualT(t, "date", overridden.Format) + }) + + t.Run("a named type over uuid.UUID renders as a formatted string", func(t *testing.T) { + orderID, ok := sp.Definitions["OrderID"] + require.TrueT(t, ok) + assertUUID(t, orderID) + }) + + t.Run("an alias of uuid.UUID dissolves to a formatted string at the use site", func(t *testing.T) { + tagged, ok := sp.Definitions["Tagged"] + require.TrueT(t, ok) + assertUUID(t, tagged.Properties["alias"]) + }) + + t.Run("an EMBEDDED uuid.UUID is dropped, with a warning — quirks-open.md Q33", func(t *testing.T) { + // Current behaviour, not desired behaviour. uuid.UUID has an array underlying type, so + // buildNamedEmbedded's switch falls to its default arm (only the *interface* arm consults + // applyStdlibSpecials) and skips the embed. Go itself renders the whole struct as a bare + // string via the promoted MarshalText, so "name" is not on the wire either. + // + // This is a pre-existing, uuid-agnostic quirk shared with every strfmt.UUID / time.Time + // embed; the identity recognizer deliberately does not change it. When Q33 is decided, this + // sub-test and the golden move together. + embedder, ok := sp.Definitions["Embedder"] + require.TrueT(t, ok) + assert.TrueT(t, embedder.Type.Contains("object")) + assert.Len(t, embedder.Properties, 1) + assert.Contains(t, embedder.Properties, "name") + + var warned bool + for _, d := range diagnostics { + if d.Severity == codescan.SeverityWarning && strings.Contains(d.Message, "buildNamedEmbedded") { + warned = true + break + } + } + assert.TrueT(t, warned, "the dropped embed must not be silent") + }) + + scantest.CompareOrDumpJSON(t, sp, "go127_uuid_spec.json") +}