From 9eff0a4d40cd2d2714f37260aa33fe107310f53a Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sat, 1 Aug 2026 12:58:29 +0200 Subject: [PATCH 1/2] fix(scanner): stop dropping negative and computed swagger:enum members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the members of a swagger:enum and the type of the schema carrying them were derived from the literal syntax of the const block. Neither survives that reading intact: a constant's right-hand side is only incidentally a literal, and a value carries no trace of the width it was declared with. Members: - a signed constant (-1) is a unary expression, not a literal, and was skipped, so an enum straddling zero lost half its members; - the non-decimal forms (0x2a, 0b101010, 0o52) and digit separators failed a base-10 parse and reached the spec as a null member; a value above MaxInt64, legal for a uint64 enum, was dropped; - iota, constant expressions, references to earlier members, rune literals, true/false and the raw string form were invisible, since their value is not in their syntax and inside an iota block only the first spec carries a type at all. Members now come from the type-checker, which evaluated them exactly, and membership is decided per name from the constant's own type — the package included, so a same-named imported type is not swallowed. The literal reader survives as the fallback for a package that only partially type-checks, where an annotated enum should still contribute what can be read. Typing: - type and format come from the declared Go type rather than from the first value's Go representation: an int8 enum is {integer, int8}, a float32 one {number, float}. Every width used to collapse, and the declaration order of the const block decided the type — a float enum whose first member was written `= 0` emitted {type: integer} carrying fractional members; - each member is normalised to that type, and one that cannot be represented in it is reported and dropped rather than written against the schema's own type; - a type declared over another named type (type Kind strfmt.UUID) keeps the format of the type it is written over, which Underlying() does not carry. An enum built from iota, booleans or expressions used to emit nothing plus a "no matching const values found" warning; it now emits its values. A rune or byte enum is among those newly emitted, and it emits an integer enum: `const LetterA Letter = 'a'` becomes {integer, int32} with the member 97. That is likely not what its author pictured, and it is still the only faithful answer — a scalar rune is an int32 on the wire, and encoding/json refuses to unmarshal "a" into that field. Declaring the type over string is what changes the wire, and with it the schema. No existing golden changes: the corpus held no enum that was negative, sized, iota-based or strfmt-backed, which is why none of this was visible. Refers to go-swagger/go-swagger#3412. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .claude/CLAUDE.md | 12 + fixtures/bugs/3412/constforms/api.go | 109 +++++++ .../bugs/3412/constforms/foreign/foreign.go | 12 + fixtures/bugs/3412/negative/api.go | 137 +++++++++ fixtures/bugs/3412/simpleschema/api.go | 97 +++++++ fixtures/bugs/3412/strfmtenum/api.go | 64 +++++ .../golden/bugs_3412_constforms.json | 78 +++++ .../golden/bugs_3412_negative.json | 133 +++++++++ .../golden/bugs_3412_simpleschema.json | 177 ++++++++++++ .../golden/bugs_3412_strfmtenum.json | 43 +++ internal/builders/schema/README.md | 101 +++++++ internal/builders/schema/schema.go | 4 +- .../builders/schema/walker_classifiers.go | 191 +++++++++++- internal/builders/validations/README.md | 53 +++- internal/builders/validations/const_values.go | 79 +++++ .../builders/validations/const_values_test.go | 64 +++++ .../integration/coverage_bug_3412_test.go | 272 ++++++++++++++++++ internal/scanner/README.md | 77 +++++ internal/scanner/enum_value.go | 134 ++++++++- internal/scanner/enum_value_test.go | 89 ++++++ internal/scanner/scan_context.go | 110 +++++-- internal/scanner/scan_context_test.go | 72 ++++- 22 files changed, 2043 insertions(+), 65 deletions(-) create mode 100644 fixtures/bugs/3412/constforms/api.go create mode 100644 fixtures/bugs/3412/constforms/foreign/foreign.go create mode 100644 fixtures/bugs/3412/negative/api.go create mode 100644 fixtures/bugs/3412/simpleschema/api.go create mode 100644 fixtures/bugs/3412/strfmtenum/api.go create mode 100644 fixtures/integration/golden/bugs_3412_constforms.json create mode 100644 fixtures/integration/golden/bugs_3412_negative.json create mode 100644 fixtures/integration/golden/bugs_3412_simpleschema.json create mode 100644 fixtures/integration/golden/bugs_3412_strfmtenum.json create mode 100644 internal/builders/validations/const_values.go create mode 100644 internal/builders/validations/const_values_test.go create mode 100644 internal/integration/coverage_bug_3412_test.go create mode 100644 internal/scanner/enum_value_test.go diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 101b92dd..f26dacb6 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -220,6 +220,18 @@ malformed input, the petstore, aliased schemas, go123-specific forms, and cross- (`types.LookupFieldOrMethod`), never against the emitted names. Placed on the embed (plain field names) or on the declaration (dotted embed paths); embeds only. Unresolved / behind-`$ref` targets raise Hints. See `internal/builders/schema/README.md#omit`. +- A `swagger:enum` schema takes its `type`/`format` from the **declared** Go type (`int8` → + `integer/int8`, `float32` → `number/float`), never from the parsed const values, and each member is + normalised to that type — typing from the first value let the const block's declaration order + decide the schema type. Member *values* come from the type-checker + (`TypesInfo.Defs[name].(*types.Const).Val()`), never from the literal syntax, and membership is + decided per name from the constant's own type — which is what makes `iota` blocks (where only the + first spec carries a type and a value) visible at all. So `iota`, expressions (`1 << 3`), + references to earlier members, rune literals (`'a'` → 97), `true`/`false` (identifiers, not + literals), raw/escaped strings, every integer base and above-`MaxInt64` members all resolve + (go-swagger#3412). A literal reader survives only as the degraded-load fallback. See + `internal/scanner/README.md#enum-values`, `internal/builders/schema/README.md#enum-typing` and + `internal/builders/validations/README.md#enum-const-values`. - The scanner works at the AST / `go/types` level — it never executes or compiles scanned code. - Parsers never import builders; they write through the interfaces in `internal/ifaces`. When adding a new annotation, extend the relevant builder's `taggers.go` rather than reaching diff --git a/fixtures/bugs/3412/constforms/api.go b/fixtures/bugs/3412/constforms/api.go new file mode 100644 index 00000000..3fa48520 --- /dev/null +++ b/fixtures/bugs/3412/constforms/api.go @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package constforms exercises the const shapes a `swagger:enum` member can take beyond a plain +// literal — the shapes that are invisible to a reader working on literal syntax alone, and which +// the go-swagger#3412 investigation surfaced one after the other: +// +// - `iota`, where only the first spec of the block carries a type and a value at all and every +// following spec inherits both implicitly; +// - constant expressions and references to earlier members (`1 << 3`, `LevelLow * 2`); +// - rune literals, whose constant is an integer (`'a'` is 97, not the three characters `'a'`); +// - `true` / `false`, which are predeclared identifiers rather than literals — Go has no boolean +// literal token; +// - raw and escaped strings, whose delimiters and escape sequences are part of the syntax, not of +// the value. +// +// Values come from the type-checker, which has already evaluated every one of these exactly. +package constforms + +import "github.com/go-openapi/codescan/fixtures/bugs/3412/constforms/foreign" + +// Weekday is an iota enum. +// +// swagger:enum Weekday +type Weekday int + +const ( + Sunday Weekday = iota + Monday + Tuesday +) + +// ForeignDay is declared HERE but typed with an imported type that happens to be called Weekday +// too. Membership is decided by type identity, not by name, so it is not a member of the enum +// above. +const ForeignDay foreign.Weekday = 13 + +// Level is built from a constant expression and a reference to an earlier member. +// +// swagger:enum Level +type Level int + +const ( + LevelLow Level = 1 << 3 + LevelHigh Level = LevelLow * 2 +) + +// Letter is a rune enum. +// +// swagger:enum Letter +type Letter rune + +const ( + LetterA Letter = 'a' + LetterTab Letter = '\t' +) + +// Toggle is a bool enum. +// +// swagger:enum Toggle +type Toggle bool + +const ( + ToggleOn Toggle = true + ToggleOff Toggle = false +) + +// Label is a string enum in the raw and escaped literal forms. +// +// swagger:enum Label +type Label string + +const ( + LabelRaw Label = `raw` + LabelEscaped Label = "a\tb" +) + +// Byte is a byte enum, whose members are written as rune literals. +// +// swagger:enum Byte +type Byte byte + +const ( + ByteNUL Byte = 0 + ByteX Byte = 'x' +) + +// Settings carries one property per const form. +// +// swagger:model Settings +type Settings struct { + // The day of the week. + Weekday Weekday `json:"weekday"` + + // The level. + Level Level `json:"level"` + + // The letter. + Letter Letter `json:"letter"` + + // The toggle. + Toggle Toggle `json:"toggle"` + + // The label. + Label Label `json:"label"` + + // The byte. + Byte Byte `json:"byte"` +} diff --git a/fixtures/bugs/3412/constforms/foreign/foreign.go b/fixtures/bugs/3412/constforms/foreign/foreign.go new file mode 100644 index 00000000..57873202 --- /dev/null +++ b/fixtures/bugs/3412/constforms/foreign/foreign.go @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package foreign declares a type sharing its name with an enum type of the parent package, so the +// membership test cannot be name-only. +package foreign + +// Weekday is unrelated to constforms.Weekday beyond the name. +type Weekday int + +// TheThirteenth is a member of THIS Weekday, not of the annotated enum next door. +const TheThirteenth Weekday = 13 diff --git a/fixtures/bugs/3412/negative/api.go b/fixtures/bugs/3412/negative/api.go new file mode 100644 index 00000000..0085e8c9 --- /dev/null +++ b/fixtures/bugs/3412/negative/api.go @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package negative reproduces go-swagger issue #3412 ("negative enum values +// are dropped"): a signed constant such as `PanLeft PanDirection = -1` is not +// a literal in the Go grammar — it reaches the AST as a unary expression +// wrapping the literal — so the enum member was silently skipped and only the +// non-negative values survived. +// +// The enum type is consumed from every enum-carrying target — a response +// header, a schema property (in: body) and a non-body parameter (in: query) — +// so the value must survive the per-target coercion, not just the scan. +// +// A float enum covers the FLOAT literal branch, and a non-decimal enum covers +// the base-detected integer forms (hex, binary, octal, digit separators). +package negative + +// swagger:route GET /pan/{pan} pan setPan +// +// Sets the pan direction. +// +// responses: +// +// 200: ControlParams +func SetPan() {} + +// PanDirection is a signed enum: its members straddle zero. +// +// swagger:enum PanDirection +type PanDirection int8 + +const ( + // PanLeft pans to the left. + PanLeft PanDirection = -1 + + // NoPan holds the current position. + NoPan PanDirection = 0 + + // PanRight pans to the right. + PanRight PanDirection = +1 +) + +// Tilt is a signed float enum. +// +// swagger:enum Tilt +type Tilt float64 + +const ( + TiltDown Tilt = -0.5 + TiltFlat Tilt = 0 + TiltUp Tilt = 0.5 +) + +// Mask is an integer enum written in the non-decimal Go literal forms. +// +// swagger:enum Mask +type Mask int64 + +const ( + MaskHex Mask = 0x2a + MaskBinary Mask = 0b101010 + MaskOctal Mask = 0o52 + MaskUnderscore Mask = 1_000 +) + +// Zoom is a float32 enum whose FIRST member is written as an integer literal. +// +// Typing the schema from the first parsed value made this `{type: integer}` with fractional +// members — a schema no validator can satisfy — and moving ZoomNone down the block silently +// changed the emitted type. Both are settled by typing from the declared Go type instead. +// +// swagger:enum Zoom +type Zoom float32 + +const ( + ZoomNone Zoom = 0 + ZoomOut Zoom = -1.5 + ZoomIn Zoom = 1.5 +) + +// Aperture is an unsigned enum spanning the full uint64 range: its top member does not fit an +// int64, the signed parse the scanner tries first. +// +// swagger:enum Aperture +type Aperture uint64 + +const ( + ApertureClosed Aperture = 0 + ApertureOpen Aperture = 18446744073709551615 +) + +// ControlParams carries the enums as response headers. +// +// PTZ control parameters +// +// swagger:response +type ControlParams struct { + // specifies the direction of the pan. -1 is pan left, 0 is no pan, 1 is pan right. + // + // in: header + Pan PanDirection `json:"pan,omitempty"` + + // specifies the tilt angle. + // + // in: header + Tilt Tilt `json:"tilt,omitempty"` +} + +// ControlState carries the enums as schema properties. +// +// swagger:model ControlState +type ControlState struct { + // The pan direction. + Pan PanDirection `json:"pan"` + + // The tilt angle. + Tilt Tilt `json:"tilt"` + + // The active mask. + Mask Mask `json:"mask"` + + // The zoom step. + Zoom Zoom `json:"zoom"` + + // The aperture. + Aperture Aperture `json:"aperture"` +} + +// PanParams carries the enum as a query parameter. +// +// swagger:parameters setPan +type PanParams struct { + // The requested pan direction. + // + // in: query + Pan PanDirection `json:"pan"` +} diff --git a/fixtures/bugs/3412/simpleschema/api.go b/fixtures/bugs/3412/simpleschema/api.go new file mode 100644 index 00000000..a0415f29 --- /dev/null +++ b/fixtures/bugs/3412/simpleschema/api.go @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package simpleschema pins the propagation surface of `swagger:enum`: every place an enum type +// can be consumed must carry the members AND the type/format of the declared Go type. +// +// The SimpleSchema targets are the interesting half. OAS v2 forbids `$ref` on a non-body parameter +// or a response header, so the enum ships inline there through a different builder path than a +// schema property — one per `in:` location, plus the `items` of an array-typed one. A regression in +// any single target is invisible to a definitions-only assertion, which is why this fixture is +// pinned by a whole-spec golden rather than by property lookups. +package simpleschema + +// swagger:route GET /pan/{pan} pan setPan +// +// Sets the pan direction. +// +// responses: +// +// 200: PanResponse +func SetPan() {} + +// swagger:route POST /pan upload uploadPan +// +// Uploads a pan setting. +// +// responses: +// +// 200: PanResponse +func UploadPan() {} + +// PanDirection is a signed int8 enum: negative member (#3412) and a width the members themselves +// cannot carry (they all arrive as int64). +// +// swagger:enum PanDirection +type PanDirection int8 + +const ( + // PanLeft pans to the left. + PanLeft PanDirection = -1 + + // NoPan holds the current position. + NoPan PanDirection = 0 + + // PanRight pans to the right. + PanRight PanDirection = +1 +) + +// PanParams consumes the enum from every non-body parameter location. +// +// swagger:parameters setPan +type PanParams struct { + // in: path + Pan PanDirection `json:"pan"` + + // in: query + Preferred PanDirection `json:"preferred"` + + // in: header + Fallback PanDirection `json:"fallback"` + + // in: query + Allowed []PanDirection `json:"allowed"` +} + +// UploadParams consumes the enum from a form parameter. +// +// swagger:parameters uploadPan +type UploadParams struct { + // in: formData + Requested PanDirection `json:"requested"` +} + +// PanResponse consumes the enum from response headers — scalar and array — and from a body schema. +// +// swagger:response +type PanResponse struct { + // in: header + Applied PanDirection `json:"applied"` + + // in: header + Rejected []PanDirection `json:"rejected"` + + // in: body + Body PanState `json:"body"` +} + +// PanState consumes the enum from a schema property, scalar and array. +// +// swagger:model PanState +type PanState struct { + // The current direction. + Current PanDirection `json:"current"` + + // The directions still available. + Available []PanDirection `json:"available"` +} diff --git a/fixtures/bugs/3412/strfmtenum/api.go b/fixtures/bugs/3412/strfmtenum/api.go new file mode 100644 index 00000000..376c1a38 --- /dev/null +++ b/fixtures/bugs/3412/strfmtenum/api.go @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package strfmtenum covers an enum whose declared type is written OVER another named type rather +// than directly over a basic one. +// +// `type Kind strfmt.UUID` reaches go/types with `string` as its underlying: the intermediate — and +// with it the `uuid` format the strfmt declaration carries — is not in that view. The same type +// without the enum annotation keeps its format, because the ordinary path resolves the declaration's +// right-hand side; the enum arm has to resolve it too, or the annotation silently costs the author +// their format. +package strfmtenum + +import "github.com/go-openapi/strfmt" + +// Kind is an enum over a strfmt type. +// +// swagger:enum Kind +type Kind strfmt.UUID + +const ( + // KindPrimary is the primary kind. + KindPrimary Kind = "0a8bcf1e-0000-0000-0000-000000000000" + + // KindSecondary is the secondary kind. + KindSecondary Kind = "0a8bcf1e-1111-1111-1111-111111111111" +) + +// Contact is an enum two redefinitions away from the strfmt: the walk to the right has to repeat. +// +// swagger:enum Contact +type Contact Address + +// Address is the intermediate redefinition, carrying no annotation of its own. +type Address strfmt.Email + +const ( + // ContactSupport is the support address. + ContactSupport Contact = "support@example.com" +) + +// Plain is an enum straight over a basic type: the control, whose format stays the basic one. +// +// swagger:enum Plain +type Plain string + +const ( + // PlainOn is on. + PlainOn Plain = "on" +) + +// Labels carries the enums as schema properties. +// +// swagger:model Labels +type Labels struct { + // The kind. + Kind Kind `json:"kind"` + + // The contact. + Contact Contact `json:"contact"` + + // The plain label. + Plain Plain `json:"plain"` +} diff --git a/fixtures/integration/golden/bugs_3412_constforms.json b/fixtures/integration/golden/bugs_3412_constforms.json new file mode 100644 index 00000000..1f4c8a6b --- /dev/null +++ b/fixtures/integration/golden/bugs_3412_constforms.json @@ -0,0 +1,78 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Settings": { + "type": "object", + "title": "Settings carries one property per const form.", + "properties": { + "byte": { + "description": "The byte.\n0 ByteNUL\n120 ByteX", + "type": "integer", + "format": "uint8", + "enum": [ + 0, + 120 + ], + "x-go-enum-desc": "0 ByteNUL\n120 ByteX", + "x-go-name": "Byte" + }, + "label": { + "description": "The label.\nraw LabelRaw\na\tb LabelEscaped", + "type": "string", + "enum": [ + "raw", + "a\tb" + ], + "x-go-enum-desc": "raw LabelRaw\na\tb LabelEscaped", + "x-go-name": "Label" + }, + "letter": { + "description": "The letter.\n97 LetterA\n9 LetterTab", + "type": "integer", + "format": "int32", + "enum": [ + 97, + 9 + ], + "x-go-enum-desc": "97 LetterA\n9 LetterTab", + "x-go-name": "Letter" + }, + "level": { + "description": "The level.\n8 LevelLow\n16 LevelHigh", + "type": "integer", + "format": "int64", + "enum": [ + 8, + 16 + ], + "x-go-enum-desc": "8 LevelLow\n16 LevelHigh", + "x-go-name": "Level" + }, + "toggle": { + "description": "The toggle.\ntrue ToggleOn\nfalse ToggleOff", + "type": "boolean", + "enum": [ + true, + false + ], + "x-go-enum-desc": "true ToggleOn\nfalse ToggleOff", + "x-go-name": "Toggle" + }, + "weekday": { + "description": "The day of the week.\n0 Sunday\n1 Monday\n2 Tuesday", + "type": "integer", + "format": "int64", + "enum": [ + 0, + 1, + 2 + ], + "x-go-enum-desc": "0 Sunday\n1 Monday\n2 Tuesday", + "x-go-name": "Weekday" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/bugs/3412/constforms" + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/bugs_3412_negative.json b/fixtures/integration/golden/bugs_3412_negative.json new file mode 100644 index 00000000..4d61e4f9 --- /dev/null +++ b/fixtures/integration/golden/bugs_3412_negative.json @@ -0,0 +1,133 @@ +{ + "swagger": "2.0", + "paths": { + "/pan/{pan}": { + "get": { + "tags": [ + "pan" + ], + "summary": "Sets the pan direction.", + "operationId": "setPan", + "parameters": [ + { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8", + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "x-go-name": "Pan", + "description": "The requested pan direction.\n-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "name": "pan", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ControlParams" + } + } + } + } + }, + "definitions": { + "ControlState": { + "type": "object", + "title": "ControlState carries the enums as schema properties.", + "properties": { + "aperture": { + "description": "The aperture.\n0 ApertureClosed\n18446744073709551615 ApertureOpen", + "type": "integer", + "format": "uint64", + "enum": [ + 0, + 18446744073709551615 + ], + "x-go-enum-desc": "0 ApertureClosed\n18446744073709551615 ApertureOpen", + "x-go-name": "Aperture" + }, + "mask": { + "description": "The active mask.\n42 MaskHex\n42 MaskBinary\n42 MaskOctal\n1000 MaskUnderscore", + "type": "integer", + "format": "int64", + "enum": [ + 42, + 42, + 42, + 1000 + ], + "x-go-enum-desc": "42 MaskHex\n42 MaskBinary\n42 MaskOctal\n1000 MaskUnderscore", + "x-go-name": "Mask" + }, + "pan": { + "description": "The pan direction.\n-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "type": "integer", + "format": "int8", + "enum": [ + -1, + 0, + 1 + ], + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "x-go-name": "Pan" + }, + "tilt": { + "description": "The tilt angle.\n-0.5 TiltDown\n0 TiltFlat\n0.5 TiltUp", + "type": "number", + "format": "double", + "enum": [ + -0.5, + 0, + 0.5 + ], + "x-go-enum-desc": "-0.5 TiltDown\n0 TiltFlat\n0.5 TiltUp", + "x-go-name": "Tilt" + }, + "zoom": { + "description": "The zoom step.\n0 ZoomNone\n-1.5 ZoomOut\n1.5 ZoomIn", + "type": "number", + "format": "float", + "enum": [ + 0, + -1.5, + 1.5 + ], + "x-go-enum-desc": "0 ZoomNone\n-1.5 ZoomOut\n1.5 ZoomIn", + "x-go-name": "Zoom" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/bugs/3412/negative" + } + }, + "responses": { + "ControlParams": { + "description": "ControlParams carries the enums as response headers.\n\nPTZ control parameters", + "headers": { + "pan": { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8", + "description": "specifies the direction of the pan. -1 is pan left, 0 is no pan, 1 is pan right.", + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right." + }, + "tilt": { + "enum": [ + -0.5, + 0, + 0.5 + ], + "type": "number", + "format": "double", + "description": "specifies the tilt angle.", + "x-go-enum-desc": "-0.5 TiltDown\n0 TiltFlat\n0.5 TiltUp" + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/bugs_3412_simpleschema.json b/fixtures/integration/golden/bugs_3412_simpleschema.json new file mode 100644 index 00000000..e34ce4a4 --- /dev/null +++ b/fixtures/integration/golden/bugs_3412_simpleschema.json @@ -0,0 +1,177 @@ +{ + "swagger": "2.0", + "paths": { + "/pan": { + "post": { + "tags": [ + "upload" + ], + "summary": "Uploads a pan setting.", + "operationId": "uploadPan", + "parameters": [ + { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8", + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "x-go-name": "Requested", + "description": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "name": "requested", + "in": "formData" + } + ], + "responses": { + "200": { + "$ref": "#/responses/PanResponse" + } + } + } + }, + "/pan/{pan}": { + "get": { + "tags": [ + "pan" + ], + "summary": "Sets the pan direction.", + "operationId": "setPan", + "parameters": [ + { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8", + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "x-go-name": "Pan", + "description": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "name": "pan", + "in": "path", + "required": true + }, + { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8", + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "x-go-name": "Preferred", + "description": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "name": "preferred", + "in": "query" + }, + { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8", + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "x-go-name": "Fallback", + "description": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "name": "fallback", + "in": "header" + }, + { + "type": "array", + "items": { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8" + }, + "x-go-name": "Allowed", + "name": "allowed", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/PanResponse" + } + } + } + } + }, + "definitions": { + "PanState": { + "type": "object", + "title": "PanState consumes the enum from a schema property, scalar and array.", + "properties": { + "available": { + "description": "The directions still available.", + "type": "array", + "items": { + "type": "integer", + "format": "int8", + "enum": [ + -1, + 0, + 1 + ], + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right." + }, + "x-go-name": "Available" + }, + "current": { + "description": "The current direction.\n-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "type": "integer", + "format": "int8", + "enum": [ + -1, + 0, + 1 + ], + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "x-go-name": "Current" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/bugs/3412/simpleschema" + } + }, + "responses": { + "PanResponse": { + "description": "PanResponse consumes the enum from response headers — scalar and array — and from a body schema.", + "schema": { + "$ref": "#/definitions/PanState" + }, + "headers": { + "applied": { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8", + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right." + }, + "rejected": { + "type": "array", + "items": { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8" + } + } + } + } + } +} \ No newline at end of file diff --git a/fixtures/integration/golden/bugs_3412_strfmtenum.json b/fixtures/integration/golden/bugs_3412_strfmtenum.json new file mode 100644 index 00000000..17e4bc1a --- /dev/null +++ b/fixtures/integration/golden/bugs_3412_strfmtenum.json @@ -0,0 +1,43 @@ +{ + "swagger": "2.0", + "paths": {}, + "definitions": { + "Labels": { + "type": "object", + "title": "Labels carries the enums as schema properties.", + "properties": { + "contact": { + "description": "The contact.\nsupport@example.com ContactSupport is the support address.", + "type": "string", + "format": "email", + "enum": [ + "support@example.com" + ], + "x-go-enum-desc": "support@example.com ContactSupport is the support address.", + "x-go-name": "Contact" + }, + "kind": { + "description": "The kind.\n0a8bcf1e-0000-0000-0000-000000000000 KindPrimary is the primary kind.\n0a8bcf1e-1111-1111-1111-111111111111 KindSecondary is the secondary kind.", + "type": "string", + "format": "uuid", + "enum": [ + "0a8bcf1e-0000-0000-0000-000000000000", + "0a8bcf1e-1111-1111-1111-111111111111" + ], + "x-go-enum-desc": "0a8bcf1e-0000-0000-0000-000000000000 KindPrimary is the primary kind.\n0a8bcf1e-1111-1111-1111-111111111111 KindSecondary is the secondary kind.", + "x-go-name": "Kind" + }, + "plain": { + "description": "The plain label.\non PlainOn is on.", + "type": "string", + "enum": [ + "on" + ], + "x-go-enum-desc": "on PlainOn is on.", + "x-go-name": "Plain" + } + }, + "x-go-package": "github.com/go-openapi/codescan/fixtures/bugs/3412/strfmtenum" + } + } +} \ No newline at end of file diff --git a/internal/builders/schema/README.md b/internal/builders/schema/README.md index 63b1fe02..8e75bc33 100644 --- a/internal/builders/schema/README.md +++ b/internal/builders/schema/README.md @@ -1429,6 +1429,107 @@ annotation of interest. | `classifierNamedStructStrfmt` | `buildNamedStruct` strfmt-first branch | `swagger:strfmt` | | `scanFieldDoc` | field-level FieldWalker (`applyFieldCarrier`) | `swagger:ignore`, `swagger:name`, `swagger:strfmt`, `swagger:type` (with the same single-word filter), `swagger:allOf` | +### Enum typing — the declared Go type wins + +`classifierNamedBasic`'s `swagger:enum` arm delegates to +`applyEnum`, which resolves the schema's `type` / `format` from +`utitpe` — the enum's **declared** underlying basic type — and then +normalises every member to it via +`validations.CoerceConstant`. + +This is deliberate, and it is the opposite of what the code did +before. + +**What the scanner loses is the value's width, not the type.** An +enum member arrives as an `any` holding one of five Go +representations — `int64`, `uint64`, `float64`, `string`, `bool` — +because that is what `go/constant` converts to +(`constant.Int64Val`, `Float64Val`, …). A member of an `int8` enum +and a member of an `int64` one are both an `int64` in that box; +nothing about the box says which. The **declared Go type is still +right there** on the builder side (`utitpe`), and it is the only +place `format: int8` can come from — so the value is the wrong +thing to ask. + +Typing the schema from `reflect.TypeOf(values[0])` — asking the +box — therefore: + +- collapsed `int8` / `uint16` / `float32` enums to + `int64` / `int64` / `double`, while the *same* Go types on a + plain field resolved correctly through + `resolvers.SwaggerSchemaForType`; +- made the emitted type depend on the **declaration order** of the + const block — a `float64` enum whose first member was written + `= 0` (an INT literal) emitted `{type: integer}` while still + carrying its fractional members, a schema no validator can + satisfy, and reordering the block silently changed the output. + +Both call sites (`buildFromDecl`'s named-basic arm and the field +site) pass the declared `*types.Basic`, so definitions, non-body +parameters and response headers all get the same treatment. On the +SimpleSchema targets that means the full set — `in: path` / +`query` / `header` / `formData`, the `items` of an array-typed one, +and response headers including their `items` — all carry the +members with the declared `type` / `format`. + +### The underlying type is not always the whole answer + +"Declared type" means the declaration, not `Underlying()`. The two +differ as soon as a type is written **over another named type**: + +```go +type Kind strfmt.UUID // Underlying() == string. The uuid is gone. +``` + +`strfmt.UUID` carries the `swagger:strfmt uuid` annotation, and +go/types offers no way back to it — `Underlying()` jumps straight +to the bottom `string`. Without the enum annotation this type +still emits `{type: string, format: uuid}`, because the ordinary +declaration path (`buildFromDecl`) resolves `Spec.Type` — the +right-hand side — rather than the underlying. The enum arm typed +from the underlying alone, so **annotating a type as an enum cost +the author their format**, silently. + +`applyEnumType` closes that: for a string-domain enum it first +asks `inheritedStrfmt`, which walks the chain of declarations to +the right (`Spec.Type`, repeatedly) looking for a +`swagger:strfmt`, so an indirection several redefinitions long +resolves like a single one. The walk stops at the basic bottom, at +a declaration the scan cannot see the source of, or at a repeat. +Only `swagger:strfmt` is inherited — a type-*changing* annotation +on an intermediate would contradict the enum's own members rather +than decorate them — and only onto a string, which is all a strfmt +can legally decorate. + +`basicSchemaType` maps that type to the Swagger value domain from +go/types' own kind bits (`IsInteger` / `IsFloat` / `IsString` / +`IsBoolean`) rather than a name table, so `byte` and `rune` need no +special casing and a domain-less type (complex) yields `""` — the +cue to leave values untouched. + +### A `rune` / `byte` enum is an integer enum, and that is correct + +```go +type Letter rune +const LetterA Letter = 'a' // → {type: integer, format: int32, enum: [97]} +``` + +An author who writes character literals usually expects +`enum: ["a"]`, and gets `97`. That surprise is Go's, not ours: a +scalar `rune` is an `int32` on the wire, so +`json.Marshal(Letter('a'))` is `97`, and `encoding/json` **refuses** +to unmarshal `"a"` into that field. A string-typed schema here +would describe a payload the server rejects — the integer is the +only faithful answer, and no rule about enums can change it. + +The author's remedy is a type whose *wire form* is a string: +`type Letter string` with `LetterA Letter = "a"`. Left visible +rather than papered over; the `byte` / `rune` cases in +`fixtures/bugs/3412/constforms` pin the behaviour. + +See [§enum-const-values](../validations/README.md#enum-const-values) +for the coercion rules on the values themselves. + --- ## §quirks — known behavioural caveats diff --git a/internal/builders/schema/schema.go b/internal/builders/schema/schema.go index 2b260a13..0be83123 100644 --- a/internal/builders/schema/schema.go +++ b/internal/builders/schema/schema.go @@ -206,7 +206,7 @@ func (s *Builder) buildFromDecl(schema *oaispec.Schema) error { return nil } case *types.Basic: - if s.classifierNamedBasic(s.Decl.Comments, s.Decl.Pkg, ut, defTgt, tpe.Obj().Name()) { + if s.classifierNamedBasic(s.Decl.Comments, s.Decl.Pkg, tpe, ut, defTgt) { return nil } } @@ -509,7 +509,7 @@ func (s *Builder) buildNamedType(titpe *types.Named, target ifaces.SwaggerTypabl s.warnUnsupportedGoType("buildNamedType", tio) return nil } - if !refModel && s.classifierNamedBasic(cmt, pkg, utitpe, target, tio.Name()) { + if !refModel && s.classifierNamedBasic(cmt, pkg, titpe, utitpe, target) { return nil } return s.resolveRefOr(tio, target, func() error { diff --git a/internal/builders/schema/walker_classifiers.go b/internal/builders/schema/walker_classifiers.go index fd55c30c..1079734c 100644 --- a/internal/builders/schema/walker_classifiers.go +++ b/internal/builders/schema/walker_classifiers.go @@ -7,11 +7,11 @@ import ( "go/ast" "go/token" "go/types" - "reflect" "strconv" "strings" "github.com/go-openapi/codescan/internal/builders/resolvers" + "github.com/go-openapi/codescan/internal/builders/validations" "github.com/go-openapi/codescan/internal/ifaces" "github.com/go-openapi/codescan/internal/parsers/grammar" "github.com/go-openapi/codescan/internal/scanner" @@ -154,22 +154,189 @@ func (s *Builder) recordEnumOrigins(enumPos []token.Pos) { } } -func (s *Builder) classifierNamedBasic(cg *ast.CommentGroup, pkg *packages.Package, utitpe *types.Basic, tgt ifaces.SwaggerTypable, declTypeName string) (resolved bool) { +// applyEnum writes the enum members of enumName onto tgt, together with the type/format of the +// enum's own declared Go type. +// +// Type and format come from utitpe — the DECLARED underlying basic type — never from the values. +// The scanner hands over evaluated constants, so every integer width collapses to int64 and every +// float width to float64 on the way out; typing the schema from the first value made an +// `int8` enum an `int64` one, a `float32` enum a `double`, and — when a float enum's first member +// happened to be written as an integer literal (`= 0`) — produced `{type: integer}` carrying +// fractional members, a schema no validator can satisfy. Each value is then normalised to that +// declared type (go-swagger#3412 follow-up). +// +// Returns false when the enum has no usable member, leaving tgt untouched so the caller can fall +// through to the type-resolution engine. +// +// # Details +// +// See [§enum-typing](./README.md#enum-typing) — why the declared type wins over the parsed values, +// and the two defects that rule settles. +func (s *Builder) applyEnum(pkg *packages.Package, declared *types.Named, utitpe *types.Basic, tgt ifaces.SwaggerTypable, enumName string) (resolved bool) { + enumValues, enumDesces, enumPos, _ := s.Ctx.FindEnumValues(pkg, enumName) + if len(enumValues) == 0 { + return false + } + + schemaType := basicSchemaType(utitpe) + values := make([]any, 0, len(enumValues)) + descs := make([]string, 0, len(enumValues)) + positions := make([]token.Pos, 0, len(enumValues)) + + for i, raw := range enumValues { + value, ok := validations.CoerceConstant(raw, schemaType) + if !ok { + // Unreachable from code that compiles: Go rejects a const whose value does not fit its + // declared type. Reported rather than emitted, so a malformed member can never contradict + // the schema's own type. + s.RecordDiagnostic(grammar.Warnf(s.declPos(), grammar.CodeInvalidEnumOption, + "swagger:enum %s: const value %v is not representable as %s; member dropped", + enumName, raw, utitpe.Name())) + continue + } + + values = append(values, value) + if i < len(enumDesces) { + descs = append(descs, enumDesces[i]) + } + if i < len(enumPos) { + positions = append(positions, enumPos[i]) + } + } + + if len(values) == 0 { + return false + } + + if !s.applyEnumType(declared, utitpe, tgt, schemaType, enumName) { + return false + } + + tgt.WithEnum(values...) + if len(descs) > 0 { + tgt.WithEnumDescription(strings.Join(descs, "\n")) + } + s.recordEnumOrigins(positions) + + return true +} + +// applyEnumType writes the enum's `type` / `format` onto tgt, resolved from the enum's declared Go +// type. +// +// The underlying basic type is the usual answer, but it is not always the whole one: a type +// declared OVER another named type (`type Kind strfmt.UUID`) reaches go/types with `string` as its +// underlying, and everything the intermediate contributed — here the `uuid` format — is gone from +// that view. The same type without the enum annotation keeps its format, because the ordinary path +// resolves the declaration's right-hand side rather than its underlying; inheritedStrfmt is the +// enum arm's equivalent of that, restricted to what a strfmt can legally decorate (a string). +// +// Reports false when the underlying type has no Swagger representation at all (a complex64 enum): +// there is no schema to write members onto, and the caller falls through so the type-resolution +// engine reports the unsupported type. +func (s *Builder) applyEnumType(declared *types.Named, utitpe *types.Basic, tgt ifaces.SwaggerTypable, schemaType, enumName string) bool { + if schemaType == "string" { + if format, ok := s.inheritedStrfmt(declared); ok { + tgt.Typed("string", format) + + return true + } + } + + if err := resolvers.SwaggerSchemaForType(utitpe.Name(), tgt); err != nil { + // e.g. a complex64 enum: no JSON representation, so there is no schema to write the members + // onto. The type-resolution engine reports the unsupported type on the fallthrough. + s.RecordDiagnostic(grammar.Warnf(s.declPos(), grammar.CodeUnsupportedGoType, + "swagger:enum %s: underlying type %s has no Swagger representation: %v", + enumName, utitpe.Name(), err)) + + return false + } + + return true +} + +// inheritedStrfmt returns the strfmt format that declared inherits from the type it is written +// over, walking the chain of declarations to its right. +// +// `type Kind strfmt.UUID` carries no `swagger:strfmt` of its own — the annotation sits on +// `strfmt.UUID`, one declaration to the right — and go/types offers no way back to it, since +// Underlying() jumps straight to the bottom `string`. The AST does: a declaration's Spec.Type is +// the type it was written over, and the walk repeats from there, so an indirection chain several +// redefinitions long resolves like a single one. +// +// The walk stops at the first type that is not itself a named declaration (the basic bottom), at +// one the scan cannot see the source of, or at a repeat — Go forbids a cyclic type declaration, but +// nothing guarantees the AST handed to us came from code that compiles. +// +// Only `swagger:strfmt` is inherited: a type-changing annotation (`swagger:type`) on an intermediate +// would contradict the enum's own members rather than decorate them. +func (s *Builder) inheritedStrfmt(declared *types.Named) (string, bool) { + seen := make(map[types.Type]struct{}) + + for current := types.Type(declared); current != nil; { + if _, dup := seen[current]; dup { + return "", false + } + seen[current] = struct{}{} + + decl, ok := s.Ctx.DeclForType(current) + if !ok || decl == nil || decl.Spec == nil || decl.Pkg == nil { + return "", false + } + + // The enum's own declaration is the first step: its swagger:strfmt (if any) already won in + // classifierNamedBasic's strfmt-first arm, so a match here can only come from further right. + if current != types.Type(declared) { + if format, ok := s.findAnnotationArg(decl.Comments, grammar.AnnStrfmt); ok { + return format, true + } + } + + rhs, ok := decl.Pkg.TypesInfo.Types[decl.Spec.Type] + if !ok { + return "", false + } + + switch rhs.Type.(type) { + case *types.Named, *types.Alias: + current = rhs.Type + default: + return "", false + } + } + + return "", false +} + +// basicSchemaType maps a Go basic type to the Swagger type whose value domain it belongs to. +// +// Driven by go/types' own kind bits rather than a name table, so every integer width (including +// `byte` / `rune`) lands on "integer" without enumerating them. Types with no Swagger value domain +// (complex, unsafe.Pointer) yield "" — the caller's cue to leave values untouched. +func basicSchemaType(utitpe *types.Basic) string { + switch info := utitpe.Info(); { + case info&types.IsInteger != 0: + return "integer" + case info&types.IsFloat != 0: + return "number" + case info&types.IsString != 0: + return "string" + case info&types.IsBoolean != 0: + return "boolean" + default: + return "" + } +} + +func (s *Builder) classifierNamedBasic(cg *ast.CommentGroup, pkg *packages.Package, declared *types.Named, utitpe *types.Basic, tgt ifaces.SwaggerTypable) (resolved bool) { if name, ok := s.findAnnotationArg(cg, grammar.AnnStrfmt); ok { tgt.Typed("string", name) return true } - if enumName, ok := s.enumName(cg, declTypeName); ok { - enumValues, enumDesces, enumPos, _ := s.Ctx.FindEnumValues(pkg, enumName) - if len(enumValues) > 0 { - tgt.WithEnum(enumValues...) - enumTypeName := reflect.TypeOf(enumValues[0]).String() - _ = resolvers.SwaggerSchemaForType(enumTypeName, tgt) - if len(enumDesces) > 0 { - tgt.WithEnumDescription(strings.Join(enumDesces, "\n")) - } - s.recordEnumOrigins(enumPos) + if enumName, ok := s.enumName(cg, declared.Obj().Name()); ok { + if s.applyEnum(pkg, declared, utitpe, tgt, enumName) { return true } // swagger:enum with no matching const values. diff --git a/internal/builders/validations/README.md b/internal/builders/validations/README.md index 6cfac9c4..3191c06a 100644 --- a/internal/builders/validations/README.md +++ b/internal/builders/validations/README.md @@ -11,7 +11,10 @@ items/headers code paths. Its two halves are: - **Value coercion** (`coerce.go`) — turns raw annotation text into the Go value implied by the target schema's `type` + `format`, for keywords whose payload is a primitive literal (`default:`, - `example:`, `enum:`). + `example:`, `enum:`). `const_values.go` is the sibling half of + this concern: it normalises values the scanner read out of a + Go **const block** rather than out of annotation text + (see [§enum-const-values](#enum-const-values)). - **Shape legality** (`shape.go`) — answers "is this keyword legal on a schema of this type?" against the JSON-Schema draft-4 domain rules that Swagger 2.0 inherits. @@ -23,6 +26,7 @@ items/headers code paths. Its two halves are: - [§contract](#contract) — why these helpers live here and not in the grammar - [§coercion-dispatch](#coercion-dispatch) — `CoerceValue` / `ParseDefault` / `ParseEnumValues` routing - [§enum-shapes](#enum-shapes) — JSON-array form vs comma-list form +- [§enum-const-values](#enum-const-values) — `CoerceConstant` and the declared-type rule - [§format-axis](#format-axis) — why `Format` is reserved but not consulted today - [§format-compat](#format-compat) — `IsFormatCompatible` type×format legality - [§type-domain-table](#type-domain-table) — the keyword-vs-type legality table @@ -130,6 +134,53 @@ Per-element coercion is the same `CoerceValue` path as `default:` / `example:`, so type-aware typing applies uniformly across the three keywords. +## §enum-const-values — `CoerceConstant` and the declared-type rule + +`swagger:enum` members do not come from annotation text: the +scanner reads them off a Go const block. It has two readings of +that block, and `CoerceConstant` is what keeps them agreeing. + +The **primary reading** takes the values from the type-checker +(`go/types` constants). There, a constant's kind already follows +its declared type — a `float64`-typed `= 0` is a `constant.Float`, +an `int`-typed `= 42.0` is a `constant.Int` — so `CoerceConstant` +has nothing to do and passes the value through. + +The **degraded reading** (a partially loaded package, where the +type-checker has no value) falls back to literal syntax, and there +the declared type is invisible: + +```go +type Tilt float64 + +const ( + TiltFlat Tilt = 0 // INT literal → int64(0) + TiltDown Tilt = -0.5 // FLOAT literal → float64(-0.5) +) +``` + +One enum, two Go kinds, one declared type. `CoerceConstant` +normalises against the Swagger type resolved from that declared +type, so the degraded reading emits what the primary one would. + +Two rules make this safe: + +- **The declared type wins, always.** The caller + (`schema.applyEnum`) resolves `type` / `format` from the Go + type, never from the values. Before this rule existed, the type + was read off `reflect.TypeOf(values[0])`, which collapsed `int8` + to `int64` and `float32` to `double` — and let the *declaration + order* of the const block decide the schema type, so a float + enum whose first member was written `= 0` emitted + `{type: integer}` carrying fractional members. +- **A non-representable value is never emitted.** `(nil, false)` + tells the caller to drop the member and raise a diagnostic + rather than write something the schema's own `type` forbids. + +An unresolved (empty) Swagger type passes values through +untouched: with no type to normalise against, guessing would be +worse than leaving the scanner's reading intact. + ## §format-axis — `Format` is reserved but not routed `ParseDefault` and `ParseEnumValues` accept a `schemaFormat` diff --git a/internal/builders/validations/const_values.go b/internal/builders/validations/const_values.go new file mode 100644 index 00000000..1b2c6405 --- /dev/null +++ b/internal/builders/validations/const_values.go @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package validations + +import "math" + +// int64 bounds expressed in float64 so a float constant can be range-checked before conversion: +// converting an out-of-range float64 to int64 is implementation-defined in Go, so the check must +// happen first. The upper bound is exclusive — 2^63 is exactly representable as a float64 but is +// one past math.MaxInt64. +const ( + minInt64AsFloat = -9223372036854775808.0 + maxInt64AsFloat = 9223372036854775808.0 +) + +// CoerceConstant normalises a Go constant value — as extracted from an enum's const block — to the +// representation implied by the enum's declared Swagger type. +// +// It is the invariant guard on what reaches an `enum` member: whatever the scanner read, a value +// that contradicts the schema's own `type` is never emitted. The scanner's primary reading takes +// its values from the type-checker, which already normalises a constant's kind to its declared type +// (`TiltFlat Tilt = 0` on a `float64` enum is a Float constant, `Answer Count = 42.0` on an integer +// one is an Int), so on that path this is a no-op. It earns its keep on the degraded reading, which +// falls back to literal syntax — where `= 0` is an int64 whatever type it was declared with. See +// [§enum-const-values](./README.md#enum-const-values). +// +// schemaType is the Swagger type ("integer", "number", "string", "boolean"); any other value — +// including the empty string for a type that did not resolve — passes the value through untouched. +// +// Reports (nil, false) when the value is not representable in the target type. Code that compiles +// cannot produce that (Go rejects `const x IntType = 0.5`), so a caller should treat it as a defect +// worth a diagnostic rather than a routine outcome. +func CoerceConstant(value any, schemaType string) (any, bool) { + switch schemaType { + case "integer": + switch v := value.(type) { + case int64, uint64: + return v, true + case float64: + // An integral float literal is legal Go for an integer constant (`= 42.0`). + if v != math.Trunc(v) || v < minInt64AsFloat || v >= maxInt64AsFloat { + return nil, false + } + return int64(v), true + default: + return nil, false + } + + case "number": + switch v := value.(type) { + case float64: + return v, true + case int64: + return float64(v), true + case uint64: + return float64(v), true + default: + return nil, false + } + + case "string": + if v, ok := value.(string); ok { + return v, true + } + return nil, false + + case "boolean": + if v, ok := value.(bool); ok { + return v, true + } + return nil, false + + default: + // No resolved type to normalise against (or a type with no numeric domain): leave the value + // exactly as the scanner produced it rather than guessing. + return value, true + } +} diff --git a/internal/builders/validations/const_values_test.go b/internal/builders/validations/const_values_test.go new file mode 100644 index 00000000..48c5daa4 --- /dev/null +++ b/internal/builders/validations/const_values_test.go @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package validations_test + +import ( + "math" + "testing" + + "github.com/go-openapi/codescan/internal/builders/validations" + "github.com/go-openapi/testify/v2/assert" +) + +func TestCoerceConstant(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + value any + schemaType string + expected any + expectedOK bool + }{ + // integer targets + {name: "int64 on integer", value: int64(-1), schemaType: "integer", expected: int64(-1), expectedOK: true}, + {name: "uint64 on integer", value: uint64(math.MaxUint64), schemaType: "integer", expected: uint64(math.MaxUint64), expectedOK: true}, + {name: "integral float on integer", value: 42.0, schemaType: "integer", expected: int64(42), expectedOK: true}, + {name: "negative integral float on integer", value: -42.0, schemaType: "integer", expected: int64(-42), expectedOK: true}, + {name: "fractional float on integer", value: 0.5, schemaType: "integer", expectedOK: false}, + {name: "out-of-range float on integer", value: 1e300, schemaType: "integer", expectedOK: false}, + {name: "MaxInt64+1 as float on integer", value: 9223372036854775808.0, schemaType: "integer", expectedOK: false}, + {name: "NaN on integer", value: math.NaN(), schemaType: "integer", expectedOK: false}, + {name: "string on integer", value: "nope", schemaType: "integer", expectedOK: false}, + + // number targets + {name: "float on number", value: -0.5, schemaType: "number", expected: -0.5, expectedOK: true}, + {name: "int64 on number", value: int64(0), schemaType: "number", expected: float64(0), expectedOK: true}, + {name: "uint64 on number", value: uint64(3), schemaType: "number", expected: float64(3), expectedOK: true}, + {name: "string on number", value: "nope", schemaType: "number", expectedOK: false}, + + // string / boolean targets + {name: "string on string", value: "low", schemaType: "string", expected: "low", expectedOK: true}, + {name: "int64 on string", value: int64(1), schemaType: "string", expectedOK: false}, + {name: "bool on boolean", value: true, schemaType: "boolean", expected: true, expectedOK: true}, + {name: "int64 on boolean", value: int64(1), schemaType: "boolean", expectedOK: false}, + + // unresolved target: pass through untouched rather than guess + {name: "int64 on unresolved type", value: int64(7), schemaType: "", expected: int64(7), expectedOK: true}, + {name: "string on object type", value: "raw", schemaType: "object", expected: "raw", expectedOK: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + value, ok := validations.CoerceConstant(tc.value, tc.schemaType) + assert.Equal(t, tc.expectedOK, ok) + if !tc.expectedOK { + assert.Nil(t, value) + + return + } + assert.Equal(t, tc.expected, value) + }) + } +} diff --git a/internal/integration/coverage_bug_3412_test.go b/internal/integration/coverage_bug_3412_test.go new file mode 100644 index 00000000..dc60976d --- /dev/null +++ b/internal/integration/coverage_bug_3412_test.go @@ -0,0 +1,272 @@ +// 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" +) + +// TestCoverage_Bug3412 covers go-swagger issue #3412 ("negative enum values are dropped"): +// `PanLeft PanDirection = -1` is a unary expression in the Go grammar, not a literal, so the +// scanner's BasicLit-only value extraction skipped it and the emitted enum was [0, 1]. +// +// The enum type is consumed from all three enum-carrying targets — response header, schema +// property and non-body parameter — so the negative value must survive each target's coercion, +// not merely the scan. A float enum covers the FLOAT branch, and a non-decimal enum covers the +// base-detected integer forms. +func TestCoverage_Bug3412(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./bugs/3412/negative/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + signedInts := []any{int64(-1), int64(0), int64(1)} + // TiltFlat is written `= 0`, an INT literal, but a constant's kind follows its declared type — + // float64 here — not the shape of the literal, so the member is 0.0 and the enum stays a + // number enum. + signedFloats := []any{-0.5, float64(0), 0.5} + + t.Run("schema property keeps the negative member", func(t *testing.T) { + props := doc.Definitions["ControlState"].Properties + + assert.Equal(t, signedInts, props["pan"].Enum) + assert.Contains(t, props["pan"].Extensions["x-go-enum-desc"], "-1 PanLeft") + + assert.Equal(t, signedFloats, props["tilt"].Enum) + assert.Contains(t, props["tilt"].Extensions["x-go-enum-desc"], "-0.5 TiltDown") + }) + + t.Run("non-body parameter keeps the negative member", func(t *testing.T) { + op := doc.Paths.Paths["/pan/{pan}"].Get + require.NotNil(t, op, "GET /pan/{pan} operation must be present") + require.Len(t, op.Parameters, 1) + param := op.Parameters[0] + require.Equal(t, "query", param.In) + + assert.Equal(t, signedInts, param.Enum) + assert.Contains(t, param.Description, "-1 PanLeft") + }) + + t.Run("response header keeps the negative member", func(t *testing.T) { + headers := doc.Responses["ControlParams"].Headers + + assert.Equal(t, signedInts, headers["pan"].Enum) + assert.Equal(t, signedFloats, headers["tilt"].Enum) + }) + + t.Run("non-decimal integer literals resolve to their value", func(t *testing.T) { + mask := doc.Definitions["ControlState"].Properties["mask"] + + assert.Equal(t, []any{int64(42), int64(42), int64(42), int64(1000)}, mask.Enum, + "hex, binary and octal forms resolve like the decimal one; digit separators are ignored") + }) + + t.Run("an unsigned member above MaxInt64 survives the signed parse", func(t *testing.T) { + aperture := doc.Definitions["ControlState"].Properties["aperture"] + + assert.Equal(t, []any{int64(0), uint64(18446744073709551615)}, aperture.Enum) + }) + + // The enum's type and format come from the DECLARED Go type. They used to be read off the first + // parsed value, which collapsed every width to int64/double and — worse — let the declaration + // order of the const block decide the type of the whole enum. + t.Run("type and format follow the declared Go type", func(t *testing.T) { + props := doc.Definitions["ControlState"].Properties + + for _, tc := range []struct { + property string + goType string + typ string + format string + }{ + {property: "pan", goType: "int8", typ: "integer", format: "int8"}, + {property: "tilt", goType: "float64", typ: "number", format: "double"}, + {property: "mask", goType: "int64", typ: "integer", format: "int64"}, + {property: "zoom", goType: "float32", typ: "number", format: "float"}, + {property: "aperture", goType: "uint64", typ: "integer", format: "uint64"}, + } { + t.Run(tc.goType, func(t *testing.T) { + assert.Equal(t, []string{tc.typ}, []string(props[tc.property].Type)) + assert.Equal(t, tc.format, props[tc.property].Format) + }) + } + }) + + t.Run("an integer literal in a float enum does not make the enum an integer one", func(t *testing.T) { + zoom := doc.Definitions["ControlState"].Properties["zoom"] + + // ZoomNone is `= 0`, written FIRST in the const block. + assert.Equal(t, []string{"number"}, []string(zoom.Type)) + assert.Equal(t, []any{float64(0), -1.5, 1.5}, zoom.Enum) + }) + + t.Run("the declared type reaches the SimpleSchema targets too", func(t *testing.T) { + header := doc.Responses["ControlParams"].Headers["pan"] + assert.Equal(t, "integer", header.Type) + assert.Equal(t, "int8", header.Format) + + param := doc.Paths.Paths["/pan/{pan}"].Get.Parameters[0] + assert.Equal(t, "integer", param.Type) + assert.Equal(t, "int8", param.Format) + }) + + // The subtests above read the properties the fix is about; the golden pins everything else the + // fixture emits alongside them, so a change nobody asserted on still has to be looked at. + scantest.CompareOrDumpJSON(t, doc, "bugs_3412_negative.json") +} + +// TestCoverage_Bug3412_ConstForms covers the const shapes that carry no readable value in their own +// syntax, and which a literal-syntax reader therefore cannot see at all: `iota` (where the implicit +// specs have neither a type nor a value), constant expressions, references to earlier members, rune +// literals, `true`/`false` (identifiers, not literals — Go has no boolean literal token), and the +// raw / escaped string forms. +// +// Values come from the type-checker, which evaluated all of them exactly; the schema type still +// comes from the declared Go type. +func TestCoverage_Bug3412_ConstForms(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./bugs/3412/constforms/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + props := doc.Definitions["Settings"].Properties + + for _, tc := range []struct { + name string + property string + typ string + format string + enum []any + }{ + { + name: "iota block: the implicit specs carry neither type nor value", + property: "weekday", + typ: "integer", format: "int64", + enum: []any{int64(0), int64(1), int64(2)}, + }, + { + name: "constant expression and reference to an earlier member", + property: "level", + typ: "integer", format: "int64", + enum: []any{int64(8), int64(16)}, + }, + { + name: "rune literals are integer constants", + property: "letter", + typ: "integer", format: "int32", + enum: []any{int64('a'), int64('\t')}, + }, + { + name: "true and false are identifiers, not literals", + property: "toggle", + typ: "boolean", format: "", + enum: []any{true, false}, + }, + { + name: "raw and escaped strings lose their delimiters and resolve escapes", + property: "label", + typ: "string", format: "", + enum: []any{"raw", "a\tb"}, + }, + { + name: "a byte enum mixing an integer and a rune literal", + property: "byte", + typ: "integer", format: "uint8", + enum: []any{int64(0), int64('x')}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, []string{tc.typ}, []string(props[tc.property].Type)) + assert.Equal(t, tc.format, props[tc.property].Format) + assert.Equal(t, tc.enum, props[tc.property].Enum) + }) + } + + t.Run("a const of a same-named imported type is not a member", func(t *testing.T) { + // `const ForeignDay foreign.Weekday = 13` sits in the annotated package but belongs to another + // package's Weekday. Membership is type identity, not name. + assert.NotContains(t, props["weekday"].Enum, int64(13)) + }) + + t.Run("the per-value name mapping follows the evaluated value", func(t *testing.T) { + assert.Equal(t, "0 Sunday\n1 Monday\n2 Tuesday", + props["weekday"].Extensions["x-go-enum-desc"]) + assert.Equal(t, "97 LetterA\n9 LetterTab", + props["letter"].Extensions["x-go-enum-desc"]) + }) + + scantest.CompareOrDumpJSON(t, doc, "bugs_3412_constforms.json") +} + +// TestCoverage_Bug3412_SimpleSchema pins the whole propagation surface of a `swagger:enum` type +// with a whole-spec golden. +// +// The enum reaches the spec through several builder paths: a schema property and its array items, +// but also the SimpleSchema targets — `in: path` / `query` / `header` / `formData`, the `items` of +// an array-typed parameter, and response headers with their own `items` — where OAS v2 forbids the +// `$ref` a definition would use, so the members and the declared type/format must be written +// inline. Each is a distinct path, and a per-property assertion in one of them says nothing about +// the others; the golden is what makes a regression in any single target visible. +func TestCoverage_Bug3412_SimpleSchema(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./bugs/3412/simpleschema/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + scantest.CompareOrDumpJSON(t, doc, "bugs_3412_simpleschema.json") +} + +// TestCoverage_Bug3412_StrfmtEnum covers an enum whose declared type is written over another NAMED +// type. `type Kind strfmt.UUID` has `string` as its go/types underlying, so the `uuid` format the +// strfmt declaration carries is absent from the view the enum arm types from — while the very same +// type without the annotation keeps it, since the ordinary path resolves the declaration's +// right-hand side instead. Annotating a type as an enum must not cost the author their format. +func TestCoverage_Bug3412_StrfmtEnum(t *testing.T) { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./bugs/3412/strfmtenum/..."}, + WorkDir: scantest.FixturesDir(), + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + props := doc.Definitions["Labels"].Properties + + t.Run("the format of the type the enum is written over survives", func(t *testing.T) { + assert.Equal(t, []string{"string"}, []string(props["kind"].Type)) + assert.Equal(t, "uuid", props["kind"].Format) + assert.Equal(t, []any{ + "0a8bcf1e-0000-0000-0000-000000000000", + "0a8bcf1e-1111-1111-1111-111111111111", + }, props["kind"].Enum) + }) + + t.Run("an indirection chain resolves like a single redefinition", func(t *testing.T) { + assert.Equal(t, []string{"string"}, []string(props["contact"].Type)) + assert.Equal(t, "email", props["contact"].Format) + assert.Equal(t, []any{"support@example.com"}, props["contact"].Enum) + }) + + t.Run("an enum straight over a basic type keeps the basic format", func(t *testing.T) { + assert.Equal(t, []string{"string"}, []string(props["plain"].Type)) + assert.Empty(t, props["plain"].Format) + assert.Equal(t, []any{"on"}, props["plain"].Enum) + }) + + scantest.CompareOrDumpJSON(t, doc, "bugs_3412_strfmtenum.json") +} diff --git a/internal/scanner/README.md b/internal/scanner/README.md index be5d34fb..df83149a 100644 --- a/internal/scanner/README.md +++ b/internal/scanner/README.md @@ -28,6 +28,8 @@ parameters, responses) consumed by the builder layer. struct-annotation exclusivity - [§after-decl](#after-decl) — `AfterDeclComments` — reading annotations inside / below a declaration +- [§enum-values](#enum-values) — where `swagger:enum` member values + come from, and what the degraded reading can still see - [§clean-godoc](#clean-godoc) — `CleanGoDoc` — filtering godoc syntax out of carried-over title / description prose - [§quirks-open](#quirks-open) — deferred follow-ups @@ -361,6 +363,81 @@ semantics today. Supporting it would mean new builder behaviour, which this scanner-only feature deliberately avoids. Nested/anonymous inline structs are likewise not enriched (only named struct type decls are walked). +## §enum-values — reading `swagger:enum` members + +`FindEnumValues` walks the const declarations of a package and +emits one row per constant whose type is the annotated enum type. +Two decisions shape it. + +### Membership is decided per name, from the type-checker + +The spec's syntactic type (`vs.Type`) is not usable as the +membership test, because inside an `iota` block only the *first* +spec carries a type at all: + +```go +const ( + Sunday Weekday = iota // Type=Weekday Values=[iota] + Monday // Type= Values= + Tuesday // Type= Values= +) +``` + +Monday and Tuesday inherit both implicitly, so a syntactic reader +sees two specs that declare nothing. Membership therefore comes +from `TypesInfo.Defs[name].(*types.Const).Type()` — the type the +checker assigned — which also covers a constant declared without a +written type (`const Extra = StatusOn`). + +The test is **type identity, not name**: the named type must also +come from the package being walked. A constant declared in the +annotated package can perfectly well have an imported type +(`const ForeignDay foreign.Weekday = 13` next to a local `Weekday` +enum), and it is a member of neither. The syntactic reading ruled +that out structurally — a qualified type is a selector expression, +not the bare ident it required — so the package check is what +keeps the type-checked reading from being *wider* than the one it +replaced. + +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. + +### Values come from the type-checker, not from the literal + +A const's right-hand side is only incidentally a literal. It can +be `iota`, an expression (`1 << 3`), a reference to an earlier +member (`Prev * 2`), a rune literal (`'a'`, whose constant is the +integer 97), or `true` / `false` — which are predeclared +*identifiers*, since Go has no boolean literal token. Reading the +value out of the syntax means reimplementing Go's constant +evaluator: iota counting, implicit repetition, and constant +folding. + +`go/types` has already done that, exactly and with arbitrary +precision, so `enumConstantValue` converts the resulting +`constant.Value` by kind (`Int` → `int64`, or `uint64` past +`MaxInt64`; `Float` → `float64`; `String`; `Bool`). A constant with +no JSON representation (complex) or one the checker could not +evaluate is dropped rather than emitted as a null member. + +**The degraded reading.** When the package only partially +type-checked (see `ErrDegradedLoad`), a constant may have no value +in `Defs`. Rather than let an annotated enum vanish, +`enumValue` falls back to the literal syntax — a lone literal, +optionally signed, with rune literals and raw/escaped strings +handled. It is a strict subset: `iota`, expressions and references +are invisible to it by construction, and its values keep the kind +their literal implies rather than the kind of their declared type. +The builder's `validations.CoerceConstant` closes that last gap — +see +[§enum-const-values](../builders/validations/README.md#enum-const-values). + ## §clean-godoc — `CleanGoDoc` `Options.CleanGoDoc` (opt-in, default false) rewrites godoc-specific syntax that diff --git a/internal/scanner/enum_value.go b/internal/scanner/enum_value.go index c8373a21..eb30cbaf 100644 --- a/internal/scanner/enum_value.go +++ b/internal/scanner/enum_value.go @@ -5,28 +5,138 @@ package scanner import ( "go/ast" + "go/constant" + "go/token" "strconv" "strings" ) -// enumBasicLitValue converts the RHS of a `const Foo Kind = "bar"` declaration into its runtime -// value — int64 / float64 / unquoted string — for emission as an enum entry on the Swagger -// schema the scanner is building. +// enumConstantValue converts a type-checked Go constant into its runtime value — int64 / uint64 / +// float64 / string / bool — for emission as an enum entry on the Swagger schema the scanner is +// building. // -// Returns nil when the literal kind is INT or FLOAT but the textual value fails to parse (rare — -// Go's own parser would have caught it upstream, but the safety net is cheap). -func enumBasicLitValue(basicLit *ast.BasicLit) any { - switch basicLit.Kind.String() { - case "INT": - if result, err := strconv.ParseInt(basicLit.Value, 10, 64); err == nil { +// This is the primary reading: go/types has already evaluated the constant exactly, so `iota` +// sequences, expressions (`1 << 3`, `Prev * 2`), references to other constants, rune literals +// (`'a'` → 97), raw/escaped strings and every integer base all arrive resolved. Reading them back +// out of the literal syntax instead would mean reimplementing Go's constant evaluator — see +// [§enum-values](./README.md#enum-values). +// +// Returns ok=false for a constant with no JSON representation (complex) or one the type-checker +// could not evaluate: the caller drops the member rather than emitting a null enum entry. +func enumConstantValue(value constant.Value) (any, bool) { + switch value.Kind() { + case constant.Int: + if i, exact := constant.Int64Val(value); exact { + return i, true + } + // A uint64 above math.MaxInt64 is a legal member of an unsigned enum. + if u, exact := constant.Uint64Val(value); exact { + return u, true + } + + return nil, false + + case constant.Float: + // exact is false whenever the value needs rounding to fit a float64 (1/3, big rationals); + // the rounded value is still the best JSON can carry, so it is kept. + f, _ := constant.Float64Val(value) + + return f, true + + case constant.String: + return constant.StringVal(value), true + + case constant.Bool: + return constant.BoolVal(value), true + + default: // constant.Complex (no JSON representation), constant.Unknown (evaluation failed) + return nil, false + } +} + +// enumValue converts the RHS of a `const Foo Kind = "bar"` declaration into its runtime value by +// reading the literal syntax. +// +// This is the DEGRADED reading, used only when the type-checker has no value for the constant — a +// partially loaded package (see ErrDegradedLoad). It sees a strict subset of what +// [enumConstantValue] sees: a lone literal, optionally signed. `iota`, expressions and references +// to other constants are invisible to it by construction, since their value is not in the syntax. +// +// A signed numeric constant (`-1`, `+2.5`) is not a literal in the Go grammar: it reaches the AST +// as a unary expression wrapping the literal, so the sign is folded back into the literal text +// before parsing. Without this, negative enum members were silently dropped (go-swagger#3412). +// +// Returns nil when the expression is neither a literal nor a signed literal — the caller drops the +// value rather than emitting a null enum member. +func enumValue(expr ast.Expr) any { + switch e := expr.(type) { + case *ast.BasicLit: + return enumLiteralValue(e.Kind, e.Value) + + case *ast.UnaryExpr: + if e.Op != token.SUB && e.Op != token.ADD { + return nil + } + + basicLit, ok := e.X.(*ast.BasicLit) + if !ok || (basicLit.Kind != token.INT && basicLit.Kind != token.FLOAT) { + return nil // a sign only ever applies to a number + } + + return enumLiteralValue(basicLit.Kind, e.Op.String()+basicLit.Value) + + default: + return nil + } +} + +// enumLiteralValue parses the textual form of a literal of the given kind. +// +// Integers are parsed with base detection so the non-decimal Go forms (`0x2a`, `0b101010`, `0o52`) +// and digit separators (`1_000`) resolve to their value like the decimal form does. A value above +// math.MaxInt64 — legal for a `uint64` enum — falls back to an unsigned parse rather than being +// dropped. +// +// A rune literal ('a', '\t') yields its code point, matching the integer type such a constant must +// have. Strings are unquoted rather than trimmed, so escape sequences resolve and the raw +// (backquoted) form loses its delimiters like the interpreted form does. +// +// Returns nil when the textual value fails to parse (rare — Go's own parser would have caught it +// upstream, but the safety net is cheap). +func enumLiteralValue(kind token.Token, value string) any { + switch kind { + case token.INT: + if result, err := strconv.ParseInt(value, 0, 64); err == nil { return result } - case "FLOAT": - if result, err := strconv.ParseFloat(basicLit.Value, 64); err == nil { + if result, err := strconv.ParseUint(value, 0, 64); err == nil { return result } + + case token.FLOAT: + if result, err := strconv.ParseFloat(value, 64); err == nil { + return result + } + + case token.CHAR: + // Drop the opening quote only: UnquoteChar resolves the escape (if any) and stops at the + // closing one, so an escaped quote ('\'') survives where trimming both delimiters would eat + // half of it. + quoted, ok := strings.CutPrefix(value, "'") + if !ok { + return nil + } + if result, _, _, err := strconv.UnquoteChar(quoted, '\''); err == nil { + return int64(result) + } + default: - return strings.Trim(basicLit.Value, "\"") + if result, err := strconv.Unquote(value); err == nil { + return result + } + + return strings.Trim(value, "\"") } + return nil } diff --git a/internal/scanner/enum_value_test.go b/internal/scanner/enum_value_test.go new file mode 100644 index 00000000..ec975fdb --- /dev/null +++ b/internal/scanner/enum_value_test.go @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package scanner + +import ( + "go/constant" + "go/token" + "math" + "testing" + + "github.com/go-openapi/testify/v2/assert" +) + +func TestEnumConstantValue(t *testing.T) { + t.Parallel() + + hugeInt := constant.BinaryOp(constant.MakeUint64(math.MaxUint64), token.MUL, constant.MakeInt64(2)) + + for _, tc := range []struct { + name string + value constant.Value + expected any + expectedOK bool + }{ + {name: "int64", value: constant.MakeInt64(-1), expected: int64(-1), expectedOK: true}, + { + name: "int above MaxInt64 falls back to unsigned", + value: constant.MakeUint64(math.MaxUint64), + expected: uint64(math.MaxUint64), expectedOK: true, + }, + {name: "int beyond uint64 is dropped", value: hugeInt, expectedOK: false}, + {name: "float", value: constant.MakeFloat64(-0.5), expected: -0.5, expectedOK: true}, + { + name: "float needing rounding keeps the rounded value", + value: constant.BinaryOp(constant.MakeInt64(1), token.QUO, constant.MakeInt64(3)), + expected: 1.0 / 3.0, expectedOK: true, + }, + {name: "string", value: constant.MakeString("a\tb"), expected: "a\tb", expectedOK: true}, + {name: "bool", value: constant.MakeBool(true), expected: true, expectedOK: true}, + {name: "complex has no JSON representation", value: constant.ToComplex(constant.MakeInt64(1)), expectedOK: false}, + {name: "unknown (evaluation failed)", value: constant.MakeUnknown(), expectedOK: false}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + value, ok := enumConstantValue(tc.value) + assert.Equal(t, tc.expectedOK, ok) + if !tc.expectedOK { + assert.Nil(t, value) + + return + } + assert.Equal(t, tc.expected, value) + }) + } +} + +// TestEnumLiteralValue covers the degraded reading, used only when the type-checker has no value +// for a constant. The literal forms it must still get right are the ones whose delimiters are not +// part of the value. +func TestEnumLiteralValue(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + kind token.Token + value string + expected any + }{ + {name: "decimal int", kind: token.INT, value: "42", expected: int64(42)}, + {name: "hex int", kind: token.INT, value: "0x2a", expected: int64(42)}, + {name: "int above MaxInt64", kind: token.INT, value: "18446744073709551615", expected: uint64(math.MaxUint64)}, + {name: "float", kind: token.FLOAT, value: "-0.5", expected: -0.5}, + {name: "rune", kind: token.CHAR, value: `'a'`, expected: int64('a')}, + {name: "escaped rune", kind: token.CHAR, value: `'\t'`, expected: int64('\t')}, + {name: "quoted rune", kind: token.CHAR, value: `'\''`, expected: int64('\'')}, + {name: "interpreted string", kind: token.STRING, value: `"low"`, expected: "low"}, + {name: "escaped string", kind: token.STRING, value: `"a\tb"`, expected: "a\tb"}, + {name: "raw string", kind: token.STRING, value: "`raw`", expected: "raw"}, + {name: "unparsable int", kind: token.INT, value: "not-a-number", expected: nil}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tc.expected, enumLiteralValue(tc.kind, tc.value)) + }) + } +} diff --git a/internal/scanner/scan_context.go b/internal/scanner/scan_context.go index 0abeb542..e8380923 100644 --- a/internal/scanner/scan_context.go +++ b/internal/scanner/scan_context.go @@ -911,7 +911,7 @@ func (s *ScanCtx) FindEnumValues(pkg *packages.Package, enumName string) (list [ } for _, spec := range gd.Specs { - values, descriptions, positions := s.findEnumValue(spec, enumName) + values, descriptions, positions := s.findEnumValue(pkg, spec, enumName) if len(values) == 0 { continue } @@ -926,46 +926,39 @@ func (s *ScanCtx) FindEnumValues(pkg *packages.Package, enumName string) (list [ return list, descList, posList, true } -// findEnumValue extracts one (value, description) pair per (name, value) position in a const spec. +// findEnumValue extracts one (value, description) row per name declared by a const spec whose type +// is enumName. // -// For a multi-name spec like `const A, B T = "a", "b"` it emits two rows — A↔"a" and B↔"b" -// — each sharing the spec's doc comment. -// The Go compiler guarantees len(Names) == len(Values) when Values is non-empty, so out-of-parity -// specs are ignored defensively. -func (s *ScanCtx) findEnumValue(spec ast.Spec, enumName string) (values []any, descriptions []string, positions []token.Pos) { +// For a multi-name spec like `const A, B T = "a", "b"` it emits two rows — A↔"a" and B↔"b" — +// each sharing the spec's doc comment. +// +// Membership is decided per NAME, from the type the type-checker assigned to that constant, not +// from the spec's syntactic type: inside an `iota` block only the first spec carries a type at all, +// and every following one inherits it implicitly. +// +// # Details +// +// See [§enum-values](./README.md#enum-values) — why the values come from go/types and what the +// degraded reading can still see. +func (s *ScanCtx) findEnumValue(pkg *packages.Package, spec ast.Spec, enumName string) (values []any, descriptions []string, positions []token.Pos) { vs, ok := spec.(*ast.ValueSpec) if !ok { return nil, nil, nil } - vsIdent, ok := vs.Type.(*ast.Ident) - if !ok { - return nil, nil, nil - } - - if vsIdent.Name != enumName { - return nil, nil, nil - } - - if len(vs.Values) == 0 || len(vs.Values) != len(vs.Names) { - return nil, nil, nil - } - docSuffix := buildEnumDocSuffix(vs.Doc, vs.Names) for i, nameIdent := range vs.Names { - bl, ok := vs.Values[i].(*ast.BasicLit) + value, ok := s.enumMemberValue(pkg, vs, i, nameIdent, enumName) if !ok { continue } - literalValue := enumBasicLitValue(bl) - var desc strings.Builder - fmt.Fprintf(&desc, "%v %s", literalValue, nameIdent.Name) + fmt.Fprintf(&desc, "%v %s", value, nameIdent.Name) desc.WriteString(docSuffix) - values = append(values, literalValue) + values = append(values, value) descriptions = append(descriptions, desc.String()) positions = append(positions, nameIdent.Pos()) } @@ -973,6 +966,73 @@ func (s *ScanCtx) findEnumValue(spec ast.Spec, enumName string) (values []any, d return values, descriptions, positions } +// enumMemberValue resolves the value of the i-th name declared by a const spec, when that constant +// belongs to the enum type enumName. +// +// The type-checker is the source of truth: it has already evaluated the constant exactly, so the +// value arrives resolved whatever shape the source took (`iota`, `1 << 3`, `'a'`, a reference to +// another constant). Membership is read from the constant's own type, which is what makes the +// implicit specs of an `iota` block visible. +// +// The AST reading below it fires only when the type-checker has no constant for the name — a +// partially loaded package, where an annotated enum should still contribute what can be read +// literally rather than vanish. It keeps the pre-types-info preconditions: an explicit type ident +// on the spec, and one value per name. +func (s *ScanCtx) enumMemberValue(pkg *packages.Package, vs *ast.ValueSpec, i int, nameIdent *ast.Ident, enumName string) (any, bool) { + if cst := constObjectFor(pkg, nameIdent); cst != nil { + if !isNamedType(cst.Type(), pkg.PkgPath, enumName) { + return nil, false + } + + return enumConstantValue(cst.Val()) + } + + vsIdent, ok := vs.Type.(*ast.Ident) + if !ok || vsIdent.Name != enumName { + return nil, false + } + + if len(vs.Values) == 0 || len(vs.Values) != len(vs.Names) { + return nil, false + } + + value := enumValue(vs.Values[i]) + + return value, value != nil +} + +// constObjectFor returns the type-checked constant declared by nameIdent, or nil when the package +// carries no type information for it (nil package, no TypesInfo, or a name the type-checker could +// not resolve in a degraded load). +func constObjectFor(pkg *packages.Package, nameIdent *ast.Ident) *types.Const { + if pkg == nil || pkg.TypesInfo == nil { + return nil + } + + cst, _ := pkg.TypesInfo.Defs[nameIdent].(*types.Const) + + return cst +} + +// isNamedType reports whether tpe is the named (possibly aliased) type called name, declared by the +// package at pkgPath. +// +// The package is part of the test because a constant declared in the scanned package may well have +// an IMPORTED type: `const ForeignOne other.Kind = 901`, sitting next to a local `Kind` enum, is a +// member of neither. The syntactic reading this replaces excluded it structurally — a qualified type +// is a selector expression, not the bare ident it required — so dropping the package check would +// silently widen every enum to its same-named neighbours. +func isNamedType(tpe types.Type, pkgPath, name string) bool { + named, ok := types.Unalias(tpe).(*types.Named) + if !ok { + return false + } + + obj := named.Obj() + + return obj.Name() == name && obj.Pkg() != nil && obj.Pkg().Path() == pkgPath +} + // buildEnumDocSuffix renders the shared doc comment as " ..." (with a leading single // space, keeping the per-line leading whitespace that survives TrimPrefix("//")), or the empty // string if there is no doc. diff --git a/internal/scanner/scan_context_test.go b/internal/scanner/scan_context_test.go index 9d1115ea..0e631d6d 100644 --- a/internal/scanner/scan_context_test.go +++ b/internal/scanner/scan_context_test.go @@ -531,7 +531,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { t.Run("non-ValueSpec returns nil", func(t *testing.T) { spec := &ast.ImportSpec{Path: &ast.BasicLit{Value: `"fmt"`}} - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") assert.Nil(t, values) assert.Nil(t, descs) }) @@ -540,7 +540,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { spec := &ast.ValueSpec{ Names: []*ast.Ident{ast.NewIdent("X")}, } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") assert.Nil(t, values) assert.Nil(t, descs) }) @@ -550,7 +550,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { Names: []*ast.Ident{ast.NewIdent("X")}, Type: &ast.SelectorExpr{X: ast.NewIdent("pkg"), Sel: ast.NewIdent("Type")}, } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") assert.Nil(t, values) assert.Nil(t, descs) }) @@ -561,7 +561,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { Type: ast.NewIdent("Bar"), Values: []ast.Expr{&ast.BasicLit{Kind: token.INT, Value: "1"}}, } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") assert.Nil(t, values) assert.Nil(t, descs) }) @@ -571,7 +571,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { Names: []*ast.Ident{ast.NewIdent("X")}, Type: ast.NewIdent("Foo"), } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") assert.Nil(t, values) assert.Nil(t, descs) }) @@ -582,11 +582,57 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { Type: ast.NewIdent("Foo"), Values: []ast.Expr{ast.NewIdent("someFunc")}, } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") assert.Empty(t, values) assert.Empty(t, descs) }) + t.Run("ValueSpec with signed numeric values keeps the sign", func(t *testing.T) { + // go-swagger#3412: `X Foo = -1` is a unary expression, not a BasicLit, and used to be skipped. + spec := &ast.ValueSpec{ + Names: []*ast.Ident{ast.NewIdent("X"), ast.NewIdent("Y"), ast.NewIdent("Z")}, + Type: ast.NewIdent("Foo"), + Values: []ast.Expr{ + &ast.UnaryExpr{Op: token.SUB, X: &ast.BasicLit{Kind: token.INT, Value: "1"}}, + &ast.UnaryExpr{Op: token.ADD, X: &ast.BasicLit{Kind: token.INT, Value: "2"}}, + &ast.UnaryExpr{Op: token.SUB, X: &ast.BasicLit{Kind: token.FLOAT, Value: "0.5"}}, + }, + } + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") + assert.Equal(t, []any{int64(-1), int64(2), -0.5}, values) + assert.Equal(t, []string{"-1 X", "2 Y", "-0.5 Z"}, descs) + }) + + t.Run("ValueSpec with a non-numeric unary value skips that position", func(t *testing.T) { + spec := &ast.ValueSpec{ + Names: []*ast.Ident{ast.NewIdent("X"), ast.NewIdent("Y")}, + Type: ast.NewIdent("Foo"), + Values: []ast.Expr{ + // Neither is legal Go for a constant of a basic type; the guard keeps a malformed AST + // from producing a null enum member. + &ast.UnaryExpr{Op: token.NOT, X: &ast.BasicLit{Kind: token.INT, Value: "1"}}, + &ast.UnaryExpr{Op: token.SUB, X: &ast.BasicLit{Kind: token.STRING, Value: `"a"`}}, + }, + } + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") + assert.Empty(t, values) + assert.Empty(t, descs) + }) + + t.Run("ValueSpec with non-decimal integer values resolves the base", func(t *testing.T) { + spec := &ast.ValueSpec{ + Names: []*ast.Ident{ast.NewIdent("X"), ast.NewIdent("Y"), ast.NewIdent("Z")}, + Type: ast.NewIdent("Foo"), + Values: []ast.Expr{ + &ast.BasicLit{Kind: token.INT, Value: "0x2a"}, + &ast.BasicLit{Kind: token.INT, Value: "0b101010"}, + &ast.BasicLit{Kind: token.INT, Value: "1_000"}, + }, + } + values, _, _ := sctx.findEnumValue(nil, spec, "Foo") + assert.Equal(t, []any{int64(42), int64(42), int64(1000)}, values) + }) + t.Run("ValueSpec with names/values parity mismatch returns nil", func(t *testing.T) { // The Go compiler forbids this, but we guard defensively so a malformed AST (e.g. from tests) // doesn't panic on index access. @@ -595,7 +641,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { Type: ast.NewIdent("Foo"), Values: []ast.Expr{&ast.BasicLit{Kind: token.INT, Value: "42"}}, } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") assert.Nil(t, values) assert.Nil(t, descs) }) @@ -612,7 +658,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { List: []*ast.Comment{{Text: "// shared doc"}}, }, } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") require.Len(t, values, 2) require.Len(t, descs, 2) assert.EqualT(t, "a", values[0]) @@ -633,7 +679,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { }, }, } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") require.Len(t, values, 1) require.Len(t, descs, 1) assert.EqualT(t, "hello", values[0]) @@ -649,7 +695,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { List: []*ast.Comment{{Text: "// PriorityLow is a low-priority level."}}, }, } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") require.Len(t, values, 1) assert.EqualT(t, "low PriorityLow is a low-priority level.", descs[0]) }) @@ -666,7 +712,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { List: []*ast.Comment{{Text: "// ChannelEmail and ChannelSMS share a single spec."}}, }, } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") require.Len(t, values, 2) // Both rows strip leading "ChannelEmail" because it matches one of the names. assert.EqualT(t, "email ChannelEmail and ChannelSMS share a single spec.", descs[0]) @@ -682,7 +728,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { List: []*ast.Comment{{Text: "// The x value."}}, }, } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") require.Len(t, values, 1) assert.EqualT(t, "x X The x value.", descs[0]) }) @@ -693,7 +739,7 @@ func TestScanCtx_findEnumValue_EdgeCases(t *testing.T) { Type: ast.NewIdent("Foo"), Values: []ast.Expr{&ast.BasicLit{Kind: token.INT, Value: "7"}}, } - values, descs, _ := sctx.findEnumValue(spec, "Foo") + values, descs, _ := sctx.findEnumValue(nil, spec, "Foo") require.Len(t, values, 1) intVal, ok := values[0].(int64) require.True(t, ok) From 7afe0e3a8d6cfcc523e4f377e2a84b53466c5714 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sat, 1 Aug 2026 13:50:32 +0200 Subject: [PATCH 2/2] docs(doc-site): give enums their own tutorial page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The enum section under "Model definitions" covered a string const block and little else. Enums get their own page, next to it in the tutorial order: what the scanner collects (any constant expression, iota included), what decides the emitted type and format, the inline form parameters and headers take, and the two shapes that do not work — a rune or byte enum, which emits code points because that is what those types are on the wire, and an alias to a basic type, which the type-checker erases before there is anything left to collect. A new docs/examples/concepts/enums package backs the panes, one region per rule, its fragments regenerated as goldens like the other tutorials. Model definitions keeps its introductory pane and points at the new page; the annotation index and the swagger:enum reference follow. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- docs/doc-site/annotation-index/_index.md | 2 +- .../maintainers/annotations/swagger-enum.md | 20 +- docs/doc-site/tutorials/enumerations.md | 134 ++++++++++++ docs/doc-site/tutorials/model-definitions.md | 5 + docs/examples/concepts/enums/enums.go | 201 ++++++++++++++++++ docs/examples/concepts/enums/enums_test.go | 95 +++++++++ .../concepts/enums/testdata/expressions.json | 18 ++ .../concepts/enums/testdata/full.json | 175 +++++++++++++++ .../concepts/enums/testdata/iota.json | 19 ++ .../concepts/enums/testdata/params.json | 32 +++ .../concepts/enums/testdata/runes.json | 18 ++ .../concepts/enums/testdata/signed.json | 19 ++ .../concepts/enums/testdata/strfmt.json | 18 ++ .../concepts/enums/testdata/width.json | 19 ++ 14 files changed, 772 insertions(+), 3 deletions(-) create mode 100644 docs/doc-site/tutorials/enumerations.md create mode 100644 docs/examples/concepts/enums/enums.go create mode 100644 docs/examples/concepts/enums/enums_test.go create mode 100644 docs/examples/concepts/enums/testdata/expressions.json create mode 100644 docs/examples/concepts/enums/testdata/full.json create mode 100644 docs/examples/concepts/enums/testdata/iota.json create mode 100644 docs/examples/concepts/enums/testdata/params.json create mode 100644 docs/examples/concepts/enums/testdata/runes.json create mode 100644 docs/examples/concepts/enums/testdata/signed.json create mode 100644 docs/examples/concepts/enums/testdata/strfmt.json create mode 100644 docs/examples/concepts/enums/testdata/width.json diff --git a/docs/doc-site/annotation-index/_index.md b/docs/doc-site/annotation-index/_index.md index 83990dc2..f3fad0b9 100644 --- a/docs/doc-site/annotation-index/_index.md +++ b/docs/doc-site/annotation-index/_index.md @@ -17,7 +17,7 @@ tutorial that shows the annotation as runnable Go next to the spec it produces; | `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: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/model-definitions#swaggerenum" %}}) | [reference]({{% relref "/maintainers/annotations/swagger-enum" %}}) | +| `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" %}}) | | `swagger:ignore` | type / field doc | excludes the declaration | [example]({{% relref "/tutorials/model-definitions#swaggerignore" %}}) | [reference]({{% relref "/maintainers/annotations/swagger-ignore" %}}) | | `swagger:meta` | package doc | top-level `info`, `host`, `basePath`, `schemes`, … | [example]({{% relref "/tutorials/document-metadata#swaggermeta" %}}) | [reference]({{% relref "/maintainers/annotations/swagger-meta" %}}) | diff --git a/docs/doc-site/maintainers/annotations/swagger-enum.md b/docs/doc-site/maintainers/annotations/swagger-enum.md index 5a6b5861..efcdf9d6 100644 --- a/docs/doc-site/maintainers/annotations/swagger-enum.md +++ b/docs/doc-site/maintainers/annotations/swagger-enum.md @@ -12,8 +12,24 @@ description: "Marks a named type as an enum and collects its const values." ## What it does -Marks a string-typed (or integer-typed) named type as an enum and -collects the type's `const` declarations. +Marks a named type over a string, integer, number or boolean as an enum +and collects the type's `const` declarations. + +Values come from the Go type-checker, so any constant expression is +collected — `iota` (including the implicit specs, which carry neither a +type nor a value), computed members (`1 << 3`), references to earlier +members, negative values, every integer base, values above `MaxInt64` in +an unsigned enum, rune literals (as code points), `true` / `false`, and +both string forms. The emitted `type` / `format` come from the **declared +Go type**, never from the members, so an `int8` enum is +`{integer, int8}` and reordering the const block cannot change the type. +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` +or `byte` enum emits integers, which is what those types are on the wire. +See [Enumerations]({{% relref "/tutorials/enumerations" %}}). - **Without `swagger:model`** (the default): the values are applied **inline on each model field that references the type** — the property diff --git a/docs/doc-site/tutorials/enumerations.md b/docs/doc-site/tutorials/enumerations.md new file mode 100644 index 00000000..ed13461b --- /dev/null +++ b/docs/doc-site/tutorials/enumerations.md @@ -0,0 +1,134 @@ +--- +title: Enumerations +weight: 12 +description: | + Publish a Go const block as a spec enum — any constant expression, the type + and format taken from the declaration, and the same members inline on + parameters and headers. +--- + +An enum in Go is a named type plus a block of constants. `swagger:enum` turns +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. + +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. + +## swagger:enum + +`swagger:enum ` collects the `const` values declared with that type. A +bare `swagger:enum` on the type declaration works too — the name is inferred +from the declaration it sits on. + +The enum type is emitted **because something points at it**: a model field, a +parameter, a header. On its own it is unreachable, and unreachable types are not +published. Add `swagger:model` to the enum type to make it a first-class +definition (carrying the `enum` array) that fields `$ref` instead — the general +`swagger:model ⇒ definition + $ref` rule. + +Each member's doc comment becomes a line of the `x-go-enum-desc` extension, and +is appended to the property description. Set +[`SkipEnumDescriptions`]({{% relref "/maintainers/options" %}}) to keep the +mapping on the extension only. + +## Any constant expression, not just literals + +The values come from the Go **type-checker**, which has already evaluated the +const block. So the members do not have to be written as literals — anything the +compiler can fold is collected. + +`iota` is the case that matters most, because after the first line there is +nothing left in the source to read: the following specs carry neither a type nor +a value, and inherit both implicitly. + +{{< example go="concepts/enums/enums.go" goregion="iota" + json="concepts/enums/testdata/iota.json" jsonlabel="#/definitions/Schedule" >}} + +Constant expressions and references to earlier members are collected the same +way. + +{{< example go="concepts/enums/enums.go" goregion="expressions" + json="concepts/enums/testdata/expressions.json" jsonlabel="#/definitions/Threshold" >}} + +The same goes for every other constant form Go offers: negative values, the +non-decimal bases (`0x2a`, `0b101010`, `0o52`) and digit separators, values +above `MaxInt64` in an unsigned enum, `true` / `false`, rune literals, and both +the raw and the escaped string forms. + +Negative members are worth a pane of their own, since a signed constant is not a +literal in the Go grammar — it is an expression wrapping one: + +{{< example go="concepts/enums/enums.go" goregion="signed" + json="concepts/enums/testdata/signed.json" jsonlabel="#/definitions/Camera" >}} + +## The type comes from the declaration + +`type` and `format` are read from the Go type you declared, never from the +members. `PanDirection` above is an `int8`, so the property is +`{integer, int8}` — even though every member would fit in a smaller or larger +box. + +This is also what makes the const block **safe to reorder**. `Zoom` is a +`float32` whose first member is written `0`, an integer literal; the schema is a +number enum regardless of which member comes first: + +{{< example go="concepts/enums/enums.go" goregion="width" + json="concepts/enums/testdata/width.json" jsonlabel="#/definitions/Lens" >}} + +A type declared over another **named** type keeps what that type contributed. An +enum written over a string format is still that format: + +{{< example go="concepts/enums/enums.go" goregion="strfmt" + json="concepts/enums/testdata/strfmt.json" jsonlabel="#/definitions/Label" >}} + +## Parameters and headers + +OpenAPI 2.0 forbids a `$ref` on a non-body parameter or a response header, so +there the members and the format are written **inline**. Nothing changes on the +annotation side — the same enum type reaches `in: query`, `path`, `header` and +`formData`, and the `items` of an array-typed one: + +{{< example go="concepts/enums/enums.go" goregion="params" + json="concepts/enums/testdata/params.json" jsonlabel="GET /cameras/search — parameters" + full="concepts/enums/testdata/full.json" >}} + +## Two shapes that do not work + +**A `rune` or `byte` enum emits integers.** It is collected like any other, and +`'a'` reaches the spec as `97`: + +{{< example go="concepts/enums/enums.go" goregion="runes" + json="concepts/enums/testdata/runes.json" jsonlabel="#/definitions/Glyph" >}} + +That is unlikely to be what you pictured, and it is the only faithful answer: a +scalar `rune` is an `int32` on the wire as much as in Go, so `json.Marshal` +writes `97`, and `encoding/json` **refuses** to unmarshal `"a"` back into the +field. A string-typed schema would describe a payload your own server rejects. +If you meant characters, declare the type over `string` +(`type Letter string`, `LetterA Letter = "a"`) — that changes the wire, and the +schema follows. + +**An alias to a basic type cannot host an enum.** + +```go +type Unsigned = uint64 // an alias, not a new type + +// swagger:enum Unsigned // ← collects nothing +const Zero Unsigned = 0 +``` + +The Go type-checker erases the alias, so `Zero` is indistinguishable from any +other `uint64` constant and there is no set of members to collect. Declare a +real type instead (`type Unsigned uint64`). An alias to a *named* enum type is +fine — the named type survives. + +## Where to go next + +- [Validations]({{% relref "/tutorials/validations" %}}) — the other constraints + a property can carry, and the reduced surface parameters accept. +- [Model definitions]({{% relref "/tutorials/model-definitions" %}}) — the rest + of the per-type annotations. +- [`swagger:enum` reference]({{% relref "/maintainers/annotations/swagger-enum" %}}) + — the exhaustive rule. diff --git a/docs/doc-site/tutorials/model-definitions.md b/docs/doc-site/tutorials/model-definitions.md index 4e67700b..90fbd9a6 100644 --- a/docs/doc-site/tutorials/model-definitions.md +++ b/docs/doc-site/tutorials/model-definitions.md @@ -59,6 +59,11 @@ Here the values inline on the referencing field. Add `swagger:model` to the enum type to make it a first-class definition (carrying the `enum` array) that fields `$ref` — again the `swagger:model ⇒ definition + $ref` rule. +Enums have more to them than a string const block: `iota` and computed members, +what decides the emitted `type` / `format`, and the inline form parameters and +headers take. They get their own page — +[Enumerations]({{% relref "/tutorials/enumerations" %}}). + ## swagger:allOf Embedding base types under `swagger:allOf` composes a schema. Each embedded base diff --git a/docs/examples/concepts/enums/enums.go b/docs/examples/concepts/enums/enums.go new file mode 100644 index 00000000..0cebc98a --- /dev/null +++ b/docs/examples/concepts/enums/enums.go @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package enums backs the "Enumerations" tutorial: one Go const block per +// enum-shaping rule, each paired with the fragment the scanner emits from it. +package enums + +import "github.com/go-openapi/strfmt" + +// snippet:iota + +// Weekday is an iota enum: only the first spec carries a type and a value, and +// every following one inherits both implicitly. +// +// swagger:enum Weekday +type Weekday int + +const ( + // Sunday is the first day. + Sunday Weekday = iota + // Monday is the second day. + Monday + // Tuesday is the third day. + Tuesday +) + +// Schedule carries the enum, which is what makes it reachable and so emitted. +// +// swagger:model +type Schedule struct { + // Day the job runs on. + Day Weekday `json:"day"` +} + +// endsnippet:iota + +// snippet:expressions + +// Level is built from a constant expression and from a reference to an earlier +// member — neither of which is a literal. +// +// swagger:enum Level +type Level int + +const ( + // LevelLow is the floor. + LevelLow Level = 1 << 3 + // LevelHigh doubles it. + LevelHigh Level = LevelLow * 2 +) + +// Threshold carries the computed enum. +// +// swagger:model +type Threshold struct { + // Level to alert at. + Level Level `json:"level"` +} + +// endsnippet:expressions + +// snippet:signed + +// PanDirection straddles zero. A signed constant is not a literal in the Go +// grammar — it is an expression wrapping one — so these are the members that +// used to go missing. +// +// swagger:enum PanDirection +type PanDirection int8 + +const ( + // PanLeft pans to the left. + PanLeft PanDirection = -1 + // NoPan holds the current position. + NoPan PanDirection = 0 + // PanRight pans to the right. + PanRight PanDirection = 1 +) + +// Camera carries the signed enum, declared int8. +// +// swagger:model +type Camera struct { + // Pan direction of the camera. + Pan PanDirection `json:"pan"` +} + +// endsnippet:signed + +// snippet:width + +// Zoom is a float32 enum whose FIRST member is written as an integer literal. +// The schema type follows the declared type, so the block can be reordered +// freely. +// +// swagger:enum Zoom +type Zoom float32 + +const ( + // ZoomNone is the neutral step. + ZoomNone Zoom = 0 + // ZoomOut steps back. + ZoomOut Zoom = -1.5 + // ZoomIn steps in. + ZoomIn Zoom = 1.5 +) + +// Lens carries the float enum. +// +// swagger:model +type Lens struct { + // Zoom step of the lens. + Zoom Zoom `json:"zoom"` +} + +// endsnippet:width + +// snippet:strfmt + +// Kind is an enum written over a string format rather than over a plain string: +// the format of the type it is declared over comes with it. +// +// swagger:enum Kind +type Kind strfmt.UUID + +const ( + // KindPrimary is the primary kind. + KindPrimary Kind = "0a8bcf1e-0000-0000-0000-000000000000" + // KindSecondary is the secondary kind. + KindSecondary Kind = "0a8bcf1e-1111-1111-1111-111111111111" +) + +// Label carries the formatted enum. +// +// swagger:model +type Label struct { + // Kind of the label. + Kind Kind `json:"kind"` +} + +// endsnippet:strfmt + +// snippet:runes + +// Letter is a rune enum. A rune is an int32, on the wire as much as in Go, so +// the members are code points — 'a' is 97. +// +// swagger:enum Letter +type Letter rune + +const ( + // LetterA is the first letter. + LetterA Letter = 'a' + // LetterB is the second letter. + LetterB Letter = 'b' +) + +// Glyph carries the rune enum. +// +// swagger:model +type Glyph struct { + // Letter of the glyph. + Letter Letter `json:"letter"` +} + +// endsnippet:runes + +// swagger:route GET /cameras/search cameras search +// +// Searches cameras by pan direction. +// +// responses: +// +// 200: cameraList + +// CameraList is the response body. +// +// swagger:response cameraList +type CameraList struct { + // in: body + Body []Camera `json:"body"` +} + +// snippet:params + +// SearchParams consumes an enum from a non-body parameter, where OpenAPI 2.0 +// forbids a $ref: the members and the format ship inline. +// +// swagger:parameters search +type SearchParams struct { + // Direction to pan while searching. + // + // in: query + Pan PanDirection `json:"pan"` + + // Directions the client accepts. + // + // in: query + Accepted []PanDirection `json:"accepted"` +} + +// endsnippet:params diff --git a/docs/examples/concepts/enums/enums_test.go b/docs/examples/concepts/enums/enums_test.go new file mode 100644 index 00000000..03bb8126 --- /dev/null +++ b/docs/examples/concepts/enums/enums_test.go @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 + +package enums + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/spec" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func examplesRoot(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + require.True(t, ok) + return filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..")) +} + +func scanEnums(t *testing.T) *spec.Swagger { + t.Helper() + doc, err := codescan.Run(&codescan.Options{ + WorkDir: examplesRoot(t), + Packages: []string{"./concepts/enums"}, + ScanModels: true, + }) + require.NoError(t, err) + require.NotNil(t, doc) + return doc +} + +// goldenJSON marshals v and compares it to (or, under UPDATE_GOLDEN, rewrites) +// testdata/.json — the fragment the "Enumerations" tutorial renders +// next to the annotated source. +// +// Regenerate with: UPDATE_GOLDEN=1 go test ./... +func goldenJSON(t *testing.T, feature string, v any) { + t.Helper() + got, err := json.MarshalIndent(v, "", " ") + require.NoError(t, err) + got = append(got, '\n') + + golden := filepath.Join("testdata", feature+".json") + if os.Getenv("UPDATE_GOLDEN") != "" { + require.NoError(t, os.WriteFile(golden, got, 0o600)) + } + want, err := os.ReadFile(golden) + require.NoError(t, err) + assert.JSONEq(t, string(want), string(got)) +} + +func definitionOf(t *testing.T, doc *spec.Swagger, name string) spec.Schema { + t.Helper() + schema, ok := doc.Definitions[name] + require.Truef(t, ok, "definition %q not found", name) + return schema +} + +// TestEnumFragments emits and verifies the golden fragments the tutorial pairs +// with each source region: one per shaping rule, plus the simple-schema +// parameter surface where the enum ships inline. +func TestEnumFragments(t *testing.T) { + doc := scanEnums(t) + + require.NotNil(t, doc.Paths) + search, ok := doc.Paths.Paths["/cameras/search"] + require.True(t, ok, "GET /cameras/search missing") + require.NotNil(t, search.Get) + + goldenJSON(t, "iota", definitionOf(t, doc, "Schedule")) // values the syntax does not carry + goldenJSON(t, "expressions", definitionOf(t, doc, "Threshold")) // computed members + goldenJSON(t, "signed", definitionOf(t, doc, "Camera")) // negative member, int8 width + goldenJSON(t, "width", definitionOf(t, doc, "Lens")) // float32 typed from the declaration + goldenJSON(t, "strfmt", definitionOf(t, doc, "Label")) // format carried over from strfmt.UUID + goldenJSON(t, "runes", definitionOf(t, doc, "Glyph")) // rune members are code points + goldenJSON(t, "params", search.Get.Parameters) // inline on a non-body parameter + + goldenJSON(t, "full", doc) // whole spec for the tutorial's live "SwaggerUI" tab +} + +// TestDeclarationOrderDoesNotDecideTheType pins the rule the tutorial states in +// prose: Zoom's first member is written as an integer literal, and the schema is +// a number enum all the same, because the type comes from the declaration. +func TestDeclarationOrderDoesNotDecideTheType(t *testing.T) { + doc := scanEnums(t) + + zoom := definitionOf(t, doc, "Lens").Properties["zoom"] + assert.Equal(t, []string{"number"}, []string(zoom.Type)) + assert.Equal(t, "float", zoom.Format) +} diff --git a/docs/examples/concepts/enums/testdata/expressions.json b/docs/examples/concepts/enums/testdata/expressions.json new file mode 100644 index 00000000..328d0b17 --- /dev/null +++ b/docs/examples/concepts/enums/testdata/expressions.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "title": "Threshold carries the computed enum.", + "properties": { + "level": { + "description": "Level to alert at.\n8 LevelLow is the floor.\n16 LevelHigh doubles it.", + "type": "integer", + "format": "int64", + "enum": [ + 8, + 16 + ], + "x-go-enum-desc": "8 LevelLow is the floor.\n16 LevelHigh doubles it.", + "x-go-name": "Level" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" +} diff --git a/docs/examples/concepts/enums/testdata/full.json b/docs/examples/concepts/enums/testdata/full.json new file mode 100644 index 00000000..f856933a --- /dev/null +++ b/docs/examples/concepts/enums/testdata/full.json @@ -0,0 +1,175 @@ +{ + "swagger": "2.0", + "paths": { + "/cameras/search": { + "get": { + "tags": [ + "cameras" + ], + "summary": "Searches cameras by pan direction.", + "operationId": "search", + "parameters": [ + { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8", + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "x-go-name": "Pan", + "description": "Direction to pan while searching.\n-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "name": "pan", + "in": "query" + }, + { + "type": "array", + "items": { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8" + }, + "x-go-name": "Accepted", + "description": "Directions the client accepts.", + "name": "accepted", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/cameraList" + } + } + } + } + }, + "definitions": { + "Camera": { + "type": "object", + "title": "Camera carries the signed enum, declared int8.", + "properties": { + "pan": { + "description": "Pan direction of the camera.\n-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "type": "integer", + "format": "int8", + "enum": [ + -1, + 0, + 1 + ], + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "x-go-name": "Pan" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" + }, + "Glyph": { + "type": "object", + "title": "Glyph carries the rune enum.", + "properties": { + "letter": { + "description": "Letter of the glyph.\n97 LetterA is the first letter.\n98 LetterB is the second letter.", + "type": "integer", + "format": "int32", + "enum": [ + 97, + 98 + ], + "x-go-enum-desc": "97 LetterA is the first letter.\n98 LetterB is the second letter.", + "x-go-name": "Letter" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" + }, + "Label": { + "type": "object", + "title": "Label carries the formatted enum.", + "properties": { + "kind": { + "description": "Kind of the label.\n0a8bcf1e-0000-0000-0000-000000000000 KindPrimary is the primary kind.\n0a8bcf1e-1111-1111-1111-111111111111 KindSecondary is the secondary kind.", + "type": "string", + "format": "uuid", + "enum": [ + "0a8bcf1e-0000-0000-0000-000000000000", + "0a8bcf1e-1111-1111-1111-111111111111" + ], + "x-go-enum-desc": "0a8bcf1e-0000-0000-0000-000000000000 KindPrimary is the primary kind.\n0a8bcf1e-1111-1111-1111-111111111111 KindSecondary is the secondary kind.", + "x-go-name": "Kind" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" + }, + "Lens": { + "type": "object", + "title": "Lens carries the float enum.", + "properties": { + "zoom": { + "description": "Zoom step of the lens.\n0 ZoomNone is the neutral step.\n-1.5 ZoomOut steps back.\n1.5 ZoomIn steps in.", + "type": "number", + "format": "float", + "enum": [ + 0, + -1.5, + 1.5 + ], + "x-go-enum-desc": "0 ZoomNone is the neutral step.\n-1.5 ZoomOut steps back.\n1.5 ZoomIn steps in.", + "x-go-name": "Zoom" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" + }, + "Schedule": { + "type": "object", + "title": "Schedule carries the enum, which is what makes it reachable and so emitted.", + "properties": { + "day": { + "description": "Day the job runs on.\n0 Sunday is the first day.\n1 Monday is the second day.\n2 Tuesday is the third day.", + "type": "integer", + "format": "int64", + "enum": [ + 0, + 1, + 2 + ], + "x-go-enum-desc": "0 Sunday is the first day.\n1 Monday is the second day.\n2 Tuesday is the third day.", + "x-go-name": "Day" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" + }, + "Threshold": { + "type": "object", + "title": "Threshold carries the computed enum.", + "properties": { + "level": { + "description": "Level to alert at.\n8 LevelLow is the floor.\n16 LevelHigh doubles it.", + "type": "integer", + "format": "int64", + "enum": [ + 8, + 16 + ], + "x-go-enum-desc": "8 LevelLow is the floor.\n16 LevelHigh doubles it.", + "x-go-name": "Level" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" + } + }, + "responses": { + "cameraList": { + "description": "CameraList is the response body.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Camera" + } + } + } + } +} diff --git a/docs/examples/concepts/enums/testdata/iota.json b/docs/examples/concepts/enums/testdata/iota.json new file mode 100644 index 00000000..3c21f1d4 --- /dev/null +++ b/docs/examples/concepts/enums/testdata/iota.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "title": "Schedule carries the enum, which is what makes it reachable and so emitted.", + "properties": { + "day": { + "description": "Day the job runs on.\n0 Sunday is the first day.\n1 Monday is the second day.\n2 Tuesday is the third day.", + "type": "integer", + "format": "int64", + "enum": [ + 0, + 1, + 2 + ], + "x-go-enum-desc": "0 Sunday is the first day.\n1 Monday is the second day.\n2 Tuesday is the third day.", + "x-go-name": "Day" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" +} diff --git a/docs/examples/concepts/enums/testdata/params.json b/docs/examples/concepts/enums/testdata/params.json new file mode 100644 index 00000000..88f7b172 --- /dev/null +++ b/docs/examples/concepts/enums/testdata/params.json @@ -0,0 +1,32 @@ +[ + { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8", + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "x-go-name": "Pan", + "description": "Direction to pan while searching.\n-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "name": "pan", + "in": "query" + }, + { + "type": "array", + "items": { + "enum": [ + -1, + 0, + 1 + ], + "type": "integer", + "format": "int8" + }, + "x-go-name": "Accepted", + "description": "Directions the client accepts.", + "name": "accepted", + "in": "query" + } +] diff --git a/docs/examples/concepts/enums/testdata/runes.json b/docs/examples/concepts/enums/testdata/runes.json new file mode 100644 index 00000000..edba67df --- /dev/null +++ b/docs/examples/concepts/enums/testdata/runes.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "title": "Glyph carries the rune enum.", + "properties": { + "letter": { + "description": "Letter of the glyph.\n97 LetterA is the first letter.\n98 LetterB is the second letter.", + "type": "integer", + "format": "int32", + "enum": [ + 97, + 98 + ], + "x-go-enum-desc": "97 LetterA is the first letter.\n98 LetterB is the second letter.", + "x-go-name": "Letter" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" +} diff --git a/docs/examples/concepts/enums/testdata/signed.json b/docs/examples/concepts/enums/testdata/signed.json new file mode 100644 index 00000000..f9986ba7 --- /dev/null +++ b/docs/examples/concepts/enums/testdata/signed.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "title": "Camera carries the signed enum, declared int8.", + "properties": { + "pan": { + "description": "Pan direction of the camera.\n-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "type": "integer", + "format": "int8", + "enum": [ + -1, + 0, + 1 + ], + "x-go-enum-desc": "-1 PanLeft pans to the left.\n0 NoPan holds the current position.\n1 PanRight pans to the right.", + "x-go-name": "Pan" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" +} diff --git a/docs/examples/concepts/enums/testdata/strfmt.json b/docs/examples/concepts/enums/testdata/strfmt.json new file mode 100644 index 00000000..27620cb2 --- /dev/null +++ b/docs/examples/concepts/enums/testdata/strfmt.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "title": "Label carries the formatted enum.", + "properties": { + "kind": { + "description": "Kind of the label.\n0a8bcf1e-0000-0000-0000-000000000000 KindPrimary is the primary kind.\n0a8bcf1e-1111-1111-1111-111111111111 KindSecondary is the secondary kind.", + "type": "string", + "format": "uuid", + "enum": [ + "0a8bcf1e-0000-0000-0000-000000000000", + "0a8bcf1e-1111-1111-1111-111111111111" + ], + "x-go-enum-desc": "0a8bcf1e-0000-0000-0000-000000000000 KindPrimary is the primary kind.\n0a8bcf1e-1111-1111-1111-111111111111 KindSecondary is the secondary kind.", + "x-go-name": "Kind" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" +} diff --git a/docs/examples/concepts/enums/testdata/width.json b/docs/examples/concepts/enums/testdata/width.json new file mode 100644 index 00000000..4c13a8a8 --- /dev/null +++ b/docs/examples/concepts/enums/testdata/width.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "title": "Lens carries the float enum.", + "properties": { + "zoom": { + "description": "Zoom step of the lens.\n0 ZoomNone is the neutral step.\n-1.5 ZoomOut steps back.\n1.5 ZoomIn steps in.", + "type": "number", + "format": "float", + "enum": [ + 0, + -1.5, + 1.5 + ], + "x-go-enum-desc": "0 ZoomNone is the neutral step.\n-1.5 ZoomOut steps back.\n1.5 ZoomIn steps in.", + "x-go-name": "Zoom" + } + }, + "x-go-package": "github.com/go-openapi/codescan/docs/examples/concepts/enums" +}