Skip to content

Commit 8c0bd2a

Browse files
authored
Merge pull request #129 from fredbi/fix/loader-conformance
Fix/loader conformance
2 parents a4e1397 + 1a54aac commit 8c0bd2a

11 files changed

Lines changed: 169 additions & 32 deletions

File tree

‎internal/builders/resolvers/assertions.go‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,27 @@ func IsOpaqueStream(o *types.TypeName) bool {
228228
return found
229229
}
230230

231+
// IsStdJSONRawMessage reports whether o is [encoding/json.RawMessage], in either of the two shapes a
232+
// toolchain gives it.
233+
//
234+
// go1.27 turned RawMessage into an alias of [encoding/json/jsontext.Value], so unaliasing a field
235+
// written as json.RawMessage now lands on jsontext.Value, and a predicate that reads only
236+
// (encoding/json, RawMessage) stopped matching there: the type fell through to its []byte underlying
237+
// and rendered as an array of uint8 instead of "any JSON". Both names mean raw JSON text, so both
238+
// answer yes.
231239
func IsStdJSONRawMessage(o *types.TypeName) bool {
232-
return o.Pkg() != nil && o.Pkg().Path() == "encoding/json" && o.Name() == "RawMessage"
240+
if o == nil || o.Pkg() == nil {
241+
return false
242+
}
243+
244+
switch o.Pkg().Path() {
245+
case "encoding/json":
246+
return o.Name() == "RawMessage"
247+
case "encoding/json/jsontext":
248+
return o.Name() == "Value"
249+
default:
250+
return false
251+
}
233252
}
234253

235254
func IsAny(o *types.TypeName) bool {

‎internal/builders/resolvers/assertions_test.go‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,45 @@ import (
1111
"github.com/go-openapi/testify/v2/assert"
1212
)
1313

14+
// TestIsStdJSONRawMessage covers both spellings of raw JSON the standard library has had.
15+
//
16+
// Synthesized rather than scanned, for the reason given on [TestIsStdUUID]: the predicate must
17+
// answer for a toolchain other than the one this test is built with. go1.27 turned
18+
// encoding/json.RawMessage into an alias of encoding/json/jsontext.Value, so a scan under go1.26
19+
// reaches the predicate with the named RawMessage and a scan under go1.27 reaches it with
20+
// jsontext.Value — through the same binary.
21+
func TestIsStdJSONRawMessage(t *testing.T) {
22+
byteSlice := types.NewSlice(types.Typ[types.Byte])
23+
24+
named := func(path, name, typeName string) *types.TypeName {
25+
pkg := types.NewPackage(path, name)
26+
return types.NewTypeName(token.NoPos, pkg, typeName, byteSlice)
27+
}
28+
29+
t.Run("encoding/json.RawMessage is recognized", func(t *testing.T) {
30+
assert.TrueT(t, IsStdJSONRawMessage(named("encoding/json", "json", "RawMessage")))
31+
})
32+
33+
t.Run("the jsontext.Value it aliases in go1.27 is recognized", func(t *testing.T) {
34+
assert.TrueT(t, IsStdJSONRawMessage(named("encoding/json/jsontext", "jsontext", "Value")))
35+
})
36+
37+
t.Run("another type in either package is NOT recognized", func(t *testing.T) {
38+
assert.FalseT(t, IsStdJSONRawMessage(named("encoding/json", "json", "Number")))
39+
assert.FalseT(t, IsStdJSONRawMessage(named("encoding/json/jsontext", "jsontext", "Pointer")))
40+
})
41+
42+
t.Run("a third-party type of either name is NOT recognized", func(t *testing.T) {
43+
assert.FalseT(t, IsStdJSONRawMessage(named("github.com/goccy/go-json", "json", "RawMessage")))
44+
assert.FalseT(t, IsStdJSONRawMessage(named("example.com/mymod/jsontext", "jsontext", "Value")))
45+
})
46+
47+
t.Run("a builtin (no package) does not panic", func(t *testing.T) {
48+
builtin := types.NewTypeName(token.NoPos, nil, "error", nil)
49+
assert.FalseT(t, IsStdJSONRawMessage(builtin))
50+
})
51+
}
52+
1453
// TestIsStdUUID covers the go1.27 stdlib uuid.UUID predicate.
1554
//
1655
// Deliberately synthesizes the *types.TypeName instead of scanning a fixture, so the predicate stays

‎internal/builders/responses/responses.go‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -239,10 +239,16 @@ func (r *Builder) buildFromType(otpe types.Type, resp *oaispec.Response, seen ma
239239
}
240240
}
241241

242-
// namedWrittenRHS reports a declaration whose written right-hand side is itself a NAMED type, together with that type.
242+
// namedWrittenRHS reports a declaration whose written right-hand side names a DECLARED type, together with that type.
243243
//
244-
// Only a named right-hand side redirects the build: a struct literal, a slice or a basic type is already the shape the
245-
// response arm should build, and sending those through the sub-builder would publish the response type as a definition.
244+
// Only a declared right-hand side redirects the build: a struct literal, a slice or a basic type is already the shape
245+
// the response arm should build, and sending those through the sub-builder would publish the response type as a
246+
// definition.
247+
//
248+
// An alias counts, since it too names a declared type: go1.27 made `encoding/json.RawMessage` an alias of
249+
// jsontext.Value, so `type DefinedRaw json.RawMessage` writes an alias on the right and a gate reading only
250+
// *types.Named stopped redirecting it — the response fell through to the []byte underlying and came out as an array of
251+
// uint8. The alias itself is handed on, so the schema builder applies the alias policy the declaration asks for.
246252
func namedWrittenRHS(ctx *scanner.ScanCtx, o *types.TypeName) (*scanner.EntityDecl, types.Type, bool) {
247253
decl, found := ctx.DeclForType(o.Type())
248254
if !found {
@@ -252,7 +258,7 @@ func namedWrittenRHS(ctx *scanner.ScanCtx, o *types.TypeName) (*scanner.EntityDe
252258
if !ok {
253259
return nil, nil, false
254260
}
255-
if _, isNamed := rhs.(*types.Named); !isNamed {
261+
if _, isNamed := types.Unalias(rhs).(*types.Named); !isNamed {
256262
return nil, nil, false
257263
}
258264

‎internal/builders/schema/README.md‎

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -214,16 +214,28 @@ distinction is the whole reason both exist:
214214
Neither beats an explicit `swagger:strfmt` / `swagger:type` — the
215215
classifier runs first, see [§textmarshal-order](#textmarshal-order).
216216

217-
**Not reached by either: a struct embed.** `uuid.UUID` has an array
218-
underlying type, so `buildNamedEmbedded` falls to its `default` arm
219-
(only the *interface* arm consults `applyStdlibSpecials`) and skips
220-
the embed with a `CodeUnsupportedGoType` warning. That asymmetry is
221-
long-standing and uuid-agnostic — any `strfmt.UUID` / `time.Time`
222-
embed behaves the same, and Go itself renders such a struct as a bare
223-
string via the promoted `MarshalText`. Tracked as Q33; the identity
224-
recognizer deliberately does not change it. Witnessed by
217+
**A struct embed reaches them through the property path.**
218+
`uuid.UUID` has an array underlying type, so embedding it promotes no
219+
member: `embedPromotes` sends it to `applyFieldCarrier` as an ordinary
220+
property named after the type, and the recognizers run there like on
221+
any other field — `Embedder` carries `UUID: {string, uuid}`. Go's
222+
default marshaller renders the whole struct as a bare string instead,
223+
via the promoted `MarshalText`, which codescan does not model; see
224+
[§embed-marshaller](#embed-marshaller) and Q33. Witnessed by
225225
`TestStdlibUUID`'s embed sub-test.
226226

227+
**`recognizeRawMessage` answers to two names.** go1.27 turned
228+
`encoding/json.RawMessage` into an alias of
229+
`encoding/json/jsontext.Value`, so unaliasing a field written as
230+
`json.RawMessage` now lands on `jsontext.Value`.
231+
`resolvers.IsStdJSONRawMessage` matches both — a predicate reading
232+
only `(encoding/json, RawMessage)` let the type fall through to its
233+
`[]byte` underlying and render as an array of `uint8`. The same
234+
change makes an alias the ordinary shape of a written right-hand side
235+
(`type DefinedRaw json.RawMessage`), which is why the responses
236+
builder's `namedWrittenRHS` unaliases before testing for a declared
237+
type.
238+
227239
The seven call sites of `applyStdlibSpecials` (`buildFromDecl`,
228240
`buildDeclAlias` RHS, `buildAlias`, `buildNamedType`,
229241
`buildNamedEmbedded` interface arm, `processEmbeddedType` named arm,

‎internal/integration/response_specials_test.go‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,14 @@ func TestResponseSpecials(t *testing.T) {
3434
}
3535
})
3636

37-
t.Run("the two spellings agree", func(t *testing.T) {
37+
t.Run("the three spellings agree", func(t *testing.T) {
3838
for defined, aliased := range map[string]string{
3939
"definedStamp": "aliasedStamp",
4040
"definedRaw": "aliasedRaw",
4141
"definedStream": "aliasedStream",
4242
"definedWhole": "aliasedWhole",
43+
"viaAliasStamp": "aliasedStamp",
44+
"viaAliasRaw": "aliasedRaw",
4345
} {
4446
d, a := doc.Responses[defined].Schema, doc.Responses[aliased].Schema
4547
require.NotNil(t, d)
@@ -71,5 +73,21 @@ func TestResponseSpecials(t *testing.T) {
7173
assert.Empty(t, raw.Type)
7274
})
7375

76+
t.Run("a declaration written over an ALIAS reaches the recognizers", func(t *testing.T) {
77+
// `type ViaAliasStamp Stamped` writes an alias on the right, and the redirect that carries the
78+
// named layer to the recognizers used to demand a *types.Named there. go1.27 makes this the
79+
// ordinary case rather than a curiosity: encoding/json.RawMessage became an alias of
80+
// jsontext.Value, so `type DefinedRaw json.RawMessage` takes this path too.
81+
stamp := doc.Responses["viaAliasStamp"].Schema
82+
require.NotNil(t, stamp)
83+
require.Len(t, stamp.Type, 1)
84+
assert.Equal(t, "string", stamp.Type[0])
85+
assert.Equal(t, "date-time", stamp.Format)
86+
87+
raw := doc.Responses["viaAliasRaw"].Schema
88+
require.NotNil(t, raw)
89+
assert.Empty(t, raw.Type)
90+
})
91+
7492
scantest.CompareOrDumpJSON(t, doc, "enhancements_response_specials.json")
7593
}

‎internal/integration/schema_uuid_go127_test.go‎

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -105,29 +105,28 @@ func TestStdlibUUID(t *testing.T) {
105105
assertUUID(t, tagged.Properties["alias"])
106106
})
107107

108-
t.Run("an EMBEDDED uuid.UUID is dropped, with a warning — quirks-open.md Q33", func(t *testing.T) {
109-
// Current behaviour, not desired behaviour. uuid.UUID has an array underlying type, so
110-
// buildNamedEmbedded's switch falls to its default arm (only the *interface* arm consults
111-
// applyStdlibSpecials) and skips the embed. Go itself renders the whole struct as a bare
112-
// string via the promoted MarshalText, so "name" is not on the wire either.
108+
t.Run("an EMBEDDED uuid.UUID becomes a property named after the type", func(t *testing.T) {
109+
// uuid.UUID is an array underneath, so embedding it promotes no member and Go keeps the value
110+
// as an ordinary key named UUID — which is what the schema says, built through the identity
111+
// recognizer like any other member. See embed-basic-underlying for the general rule.
113112
//
114-
// This is a pre-existing, uuid-agnostic quirk shared with every strfmt.UUID / time.Time
115-
// embed; the identity recognizer deliberately does not change it. When Q33 is decided, this
116-
// sub-test and the golden move together.
113+
// The promoted MarshalText makes the DEFAULT marshaller render the whole struct as a bare
114+
// string, dropping "name" from the wire. codescan does not model that, by decision: an embed
115+
// means composition, and a composed model round-trips through a hand-written marshaller. See
116+
// schema/README.md#embed-marshaller.
117117
embedder, ok := sp.Definitions["Embedder"]
118118
require.TrueT(t, ok)
119119
assert.TrueT(t, embedder.Type.Contains("object"))
120-
assert.Len(t, embedder.Properties, 1)
120+
assert.Len(t, embedder.Properties, 2)
121121
assert.Contains(t, embedder.Properties, "name")
122+
require.Contains(t, embedder.Properties, "UUID")
123+
assertUUID(t, embedder.Properties["UUID"])
122124

123-
var warned bool
125+
// The embed is emitted, so nothing about it is skipped or reported.
124126
for _, d := range diagnostics {
125-
if d.Severity == codescan.SeverityWarning && strings.Contains(d.Message, "buildNamedEmbedded") {
126-
warned = true
127-
break
128-
}
127+
assert.FalseT(t, d.Severity == codescan.SeverityWarning && strings.Contains(d.Message, "buildNamedEmbedded"),
128+
"an embed that is emitted must raise no unsupported-type warning: %s", d.Message)
129129
}
130-
assert.TrueT(t, warned, "the dropped embed must not be silent")
131130
})
132131

133132
scantest.CompareOrDumpJSON(t, sp, "go127_uuid_spec.json")

‎testdata/enhancements/response-specials/api.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,6 @@ package response_specials
1818
// 212: aliasedStream
1919
// 213: aliasedWhole
2020
// 214: aliasedFraction
21+
// 220: viaAliasStamp
22+
// 221: viaAliasRaw
2123
func getSpecial() {}

‎testdata/enhancements/response-specials/types.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,19 @@ type AliasedWhole = big.Int
5555

5656
// swagger:response aliasedFraction
5757
type AliasedFraction = big.Rat
58+
59+
// Local-alias spelling — the declaration is written over an alias, which is what go1.27 made of
60+
// json.RawMessage: `type DefinedRaw json.RawMessage` now has an ALIAS on its right-hand side. The
61+
// redirect that carries the named layer to the recognizers has to follow that too.
62+
63+
// Stamped is a local alias of time.Time.
64+
type Stamped = time.Time
65+
66+
// Rawish is a local alias of json.RawMessage.
67+
type Rawish = json.RawMessage
68+
69+
// swagger:response viaAliasStamp
70+
type ViaAliasStamp Stamped
71+
72+
// swagger:response viaAliasRaw
73+
type ViaAliasRaw Rawish

‎testdata/goparsing/go127/uuid/model.go‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,13 @@ type Tagged struct {
5555
// Embedder embeds the stdlib uuid.UUID.
5656
//
5757
// Witness for the embedded arm, which the fuzzy name heuristic never reached (it is caller-gated to
58-
// buildFromTextMarshal). Note the promoted MarshalText makes encoding/json render the WHOLE struct
59-
// as a bare string, dropping Name from the wire — see quirks-open.md Q33.
58+
// buildFromTextMarshal). uuid.UUID is an array underneath, so the embed promotes nothing and Go
59+
// keeps it as an ordinary key named after the type: the schema carries a UUID property, built
60+
// through the identity recognizer like any other member.
61+
//
62+
// The promoted MarshalText makes the DEFAULT marshaller render the whole struct as a bare string,
63+
// dropping Name from the wire. codescan does not model that — an embed means composition here — see
64+
// the schema builder README, section embed-marshaller.
6065
//
6166
// swagger:model Embedder
6267
type Embedder struct {

‎testdata/integration/golden/enhancements_response_specials.json‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@
3535
},
3636
"214": {
3737
"$ref": "#/responses/aliasedFraction"
38+
},
39+
"220": {
40+
"$ref": "#/responses/viaAliasStamp"
41+
},
42+
"221": {
43+
"$ref": "#/responses/viaAliasRaw"
3844
}
3945
}
4046
}
@@ -99,6 +105,17 @@
99105
"type": "integer",
100106
"x-go-type": "math/big.Int"
101107
}
108+
},
109+
"viaAliasRaw": {
110+
"description": "",
111+
"schema": {}
112+
},
113+
"viaAliasStamp": {
114+
"description": "",
115+
"schema": {
116+
"type": "string",
117+
"format": "date-time"
118+
}
102119
}
103120
}
104121
}

0 commit comments

Comments
 (0)