Skip to content

Commit daa7ba0

Browse files
authored
tools: Leave response struct fields out of the schemafields check (#4592)
1 parent 43d331e commit daa7ba0

9 files changed

Lines changed: 193 additions & 63 deletions

File tree

‎CONTRIBUTING.md‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -699,13 +699,19 @@ parameter that `paramcheck` requires to be passed by value. An endpoint is
699699
therefore checked without any extra annotation, and coverage grows as pointer
700700
bodies are converted to by-value ones.
701701

702+
A struct that some method returns describes an API response as well as,
703+
sometimes, a request body, so a field of one that no request body schema has is
704+
not reported: it is usually a field that the server sets and the client only
705+
reads. `-verbose` counts those fields, so that the exemption is visible in the
706+
summary without being a finding.
707+
702708
A run ends with a summary of the state of the tree rather than of the run, so
703709
that a maintainer reads what there is to do rather than what happened:
704710
how many of the methods that take a struct request body were checked, what was
705-
not checked and why (bodies passed by pointer, and operations that the pinned
706-
revision does not document), and what the findings are, counting the ones that
707-
`exceptions.txt` grandfathers alongside the new ones. It also reports what
708-
`-fix` can do about them, including the repairs that the exceptions file is
711+
not checked and why (bodies passed by pointer, and operations that none of the
712+
descriptions the tool loads documents), and what the findings are, counting the
713+
ones that `exceptions.txt` grandfathers alongside the new ones. It also reports
714+
what `-fix` can do about them, including the repairs that the exceptions file is
709715
holding back, and the errors that no mechanical repair settles. A body that is
710716
not a struct has no fields for a schema check to apply to, so it is left out of
711717
those counts and named by `-verbose` instead.

‎tools/schemafields/check.go‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ type stats struct {
102102
usesUnreadable int
103103
fieldsChecked int
104104
fieldsConditional int
105+
// fieldsResponse is the fields that no request body schema has and that are left
106+
// alone because a method returns their struct.
107+
fieldsResponse int
105108
}
106109

107110
type checker struct {
@@ -212,6 +215,10 @@ func (c *checker) run() {
212215
})
213216
}
214217

218+
// checkStruct reports the fields of si that disagree with the request body schemas of the
219+
// operations that send it. A field that no schema has is reported unless a method returns the
220+
// struct: a struct that the API answers with is a model of a response as well, so its
221+
// server-set fields have no place in a request body schema.
215222
func (c *checker) checkStruct(si *structInfo, uses []*structUse) {
216223
for _, f := range si.fields {
217224
c.stats.fieldsChecked++
@@ -238,6 +245,14 @@ func (c *checker) checkStruct(si *structInfo, uses []*structUse) {
238245
}
239246

240247
if !inSchema {
248+
if si.response {
249+
// The struct describes an API response as well as a request body, so
250+
// a field that no request body schema has is usually one the server
251+
// sets, and not a mistake. The summary counts these rather than
252+
// reporting them.
253+
c.stats.fieldsResponse++
254+
continue
255+
}
241256
c.add(&diagnostic{
242257
sev: sevWarn, rule: ruleNotInSchema, file: f.file, line: f.line,
243258
owner: si.name, field: f.goName, info: f,

‎tools/schemafields/check_test.go‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ func TestCheck(t *testing.T) {
7575
}
7676
assertContains(t, err.Error(), "6 schema field issue(s) found")
7777
assertEqual(t, wantFindings, stdout)
78-
assertContains(t, stderr, "checked 17 of 18 methods that take a struct request body (13 body structs, 22 fields)")
78+
// The field of the struct that the operation returns is left alone: the request body
79+
// schema does not have it, but a response may, so it is not a mistake.
80+
assertNotContains(t, stdout, "CommentRequest.URL")
81+
assertContains(t, stderr, "checked 18 of 19 methods that take a struct request body (14 body structs, 24 fields)")
7982
assertContains(t, stderr, "not checked: 1 method whose body is passed by pointer (run paramcheck to convert it)")
8083
assertContains(t, stderr, "not checked: 1 operation use with no JSON request body")
8184
assertContains(t, stderr, "11 findings (5 warn, 6 error)")
@@ -98,7 +101,7 @@ func TestSummary(t *testing.T) {
98101
stats: stats{
99102
files: 209, methods: 7981, methodsWithOps: 1301,
100103
bodyValue: 206, bodyPointer: 62,
101-
structsChecked: 141, fieldsChecked: 618, fieldsConditional: 13,
104+
structsChecked: 141, fieldsChecked: 618, fieldsConditional: 13, fieldsResponse: 34,
102105
usesResolved: 193, usesNotInPlan: 10,
103106
},
104107
byRule: map[string]int{ruleNotInSchema: 1, ruleRequiredOmit: 1, ruleMissingRequiredProp: 1},
@@ -119,8 +122,9 @@ func TestSummary(t *testing.T) {
119122
// The coverage lines are the same whatever the findings are.
120123
const coverage = `checked 206 of 268 methods that take a struct request body (141 body structs, 618 fields)
121124
not checked: 62 methods whose body is passed by pointer (run paramcheck to convert them)
122-
not checked: 10 operation uses that the pinned revision does not document
123-
a newer openapi_commit in openapi_operations.yaml would check them
125+
not checked: 10 operation uses that the descriptions this tool loads do not document
126+
each is documented by a plan this tool does not load, such as an older GHES release,
127+
or by none at all; a newer openapi_commit may not help
124128
`
125129

126130
var buf bytes.Buffer
@@ -147,6 +151,7 @@ func TestSummary(t *testing.T) {
147151
assertContains(t, got, "scanned 209 files, 7981 methods (1301 with //meta:operation)")
148152
assertContains(t, got, "resolved operation uses: 193")
149153
assertContains(t, got, "conditionally required, and left alone: 13 of the 618 fields")
154+
assertContains(t, got, "in no request body schema, and left alone because a method returns the struct: 34 of the 618 fields")
150155
assertContains(t, got, "1 not-in-request-schema: the Go field is not in the request body schema")
151156

152157
// A checkout with nothing to report says so, rather than counting a baseline of nothing.
@@ -187,11 +192,14 @@ func TestCheckGithubFormat(t *testing.T) {
187192
func TestCheckVerbose(t *testing.T) {
188193
t.Parallel()
189194
_, stderr, _ := runOnFixture(t, "-verbose")
190-
assertContains(t, stderr, "no //meta:operation annotation: github/issues.go:54: Untracked (UntrackedRequest)")
195+
assertContains(t, stderr, "no //meta:operation annotation: github/issues.go:72: Untracked (UntrackedRequest)")
191196
// -verbose is the full breakdown of the summary, which the default output leaves out.
192-
assertContains(t, stderr, "scanned 4 files, 18 methods (16 with //meta:operation)")
193-
assertContains(t, stderr, "resolved operation uses: 15")
194-
assertContains(t, stderr, "conditionally required, and left alone: 1 of the 22 fields")
197+
assertContains(t, stderr, "scanned 4 files, 19 methods (17 with //meta:operation)")
198+
assertContains(t, stderr, "resolved operation uses: 16")
199+
assertContains(t, stderr, "conditionally required, and left alone: 1 of the 24 fields")
200+
// The field that no schema has and that the returned struct explains is counted rather
201+
// than reported, so that the exemption is visible.
202+
assertContains(t, stderr, "in no request body schema, and left alone because a method returns the struct: 1 of the 24 fields")
195203
assertContains(t, stderr, "4 required-but-omittable: the schema REQUIRES the property, but the tag lets it be omitted")
196204
// The default output leaves the breakdown out, so that it reads as a verdict.
197205
if _, plain, _ := runOnFixture(t); strings.Contains(plain, "scanned 4 files") {

‎tools/schemafields/exceptions.txt‎

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,43 +11,12 @@
1111
# Run "script/check-schema-fields.sh -fix" to repair the error-severity findings
1212
# that no line here grandfathers. It drops the lines that no finding needs any
1313
# more, so the file can only shrink.
14-
ActionsPermissions.SelectedActionsURL
15-
ActionsPermissionsEnterprise.SelectedActionsURL
16-
ActionsPermissionsRepository.SelectedActionsURL
17-
Blob.NodeID
18-
Blob.SHA
19-
Blob.Size
20-
Blob.URL
21-
CodeSecurityConfiguration.CreatedAt
2214
CodeSecurityConfiguration.Description
23-
CodeSecurityConfiguration.HTMLURL
24-
CodeSecurityConfiguration.ID
25-
CodeSecurityConfiguration.TargetType
26-
CodeSecurityConfiguration.URL
27-
CodeSecurityConfiguration.UpdatedAt
2815
CreateCheckSuiteOptions.HeadBranch
2916
CreateTeamRequest.LDAPDN
30-
CustomProperty.PropertyName
31-
CustomProperty.RequireExplicitValues
32-
CustomProperty.SourceType
33-
CustomProperty.URL
3417
IDPGroupList.Groups
35-
OIDCSubjectClaimCustomTemplate.SubClaimPrefix
3618
OIDCSubjectClaimCustomTemplate.UseDefault
37-
RepositoryRuleset.CreatedAt
38-
RepositoryRuleset.CurrentUserCanBypass
39-
RepositoryRuleset.ID
40-
RepositoryRuleset.Links
41-
RepositoryRuleset.NodeID
42-
RepositoryRuleset.Source
43-
RepositoryRuleset.SourceType
44-
RepositoryRuleset.UpdatedAt
4519
ReviewCustomDeploymentProtectionRuleRequest.Comment
4620
ReviewCustomDeploymentProtectionRuleRequest.State
47-
SCIMEnterpriseGroupAttributes.ID
48-
SCIMEnterpriseGroupAttributes.Meta
49-
SCIMEnterpriseUserAttributes.Groups
50-
SCIMEnterpriseUserAttributes.ID
51-
SCIMEnterpriseUserAttributes.Meta
5221
UpdateCheckRunOptions.Name
5322
UpdateTeamRequest.Name

‎tools/schemafields/main.go‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
// "body" and passed by value, so every by-value body parameter is checked against the request
2424
// body schema of its operation. Coverage grows as pointer bodies are converted, and a
2525
// contributor adding an endpoint is checked without having to do anything extra.
26+
//
27+
// A struct that some method returns describes an API response as well as, sometimes, a request
28+
// body, so a field of one that no request body schema has is left alone rather than reported:
29+
// it is usually a field the server sets. -verbose counts them, and CONTRIBUTING.md has the
30+
// rule.
2631
package main
2732

2833
import (
@@ -415,10 +420,10 @@ func printSummary(w io.Writer, c *checker, o *options, exc *exceptions) {
415420
s.usesNoJSONBody, plural(s.usesNoJSONBody, "use", "uses"))
416421
}
417422
if s.usesNotInPlan > 0 {
418-
fmt.Fprintf(w, " not checked: %v operation %v that the pinned revision does not document\n",
423+
fmt.Fprintf(w, " not checked: %v operation %v that the descriptions this tool loads do not document\n",
419424
s.usesNotInPlan, plural(s.usesNotInPlan, "use", "uses"))
420-
fmt.Fprintf(w, " a newer openapi_commit in %v would check %v\n",
421-
operationsFile, plural(s.usesNotInPlan, "it", "them"))
425+
fmt.Fprint(w, " each is documented by a plan this tool does not load, such as an older GHES release,\n"+
426+
" or by none at all; a newer openapi_commit may not help\n")
422427
}
423428
if s.usesUnreadable > 0 {
424429
fmt.Fprintf(w, " not checked: %v operation %v whose description could not be read\n",
@@ -428,6 +433,8 @@ func printSummary(w io.Writer, c *checker, o *options, exc *exceptions) {
428433
fmt.Fprintf(w, " resolved operation uses: %v\n", s.usesResolved)
429434
fmt.Fprintf(w, " conditionally required, and left alone: %v of the %v fields\n",
430435
s.fieldsConditional, s.fieldsChecked)
436+
fmt.Fprintf(w, " in no request body schema, and left alone because a method returns the struct: %v of the %v fields\n",
437+
s.fieldsResponse, s.fieldsChecked)
431438
}
432439

433440
// A finding the exceptions file does not name is the only kind a change can be told about,

‎tools/schemafields/openapi.go‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -215,16 +215,17 @@ func (d *description) flatten(s *schema, visiting map[string]bool) (*flat, error
215215
}
216216

217217
// bodyReason says why an operation has, or has not, a request body schema to check the fields
218-
// of a request body struct against. The two ways of having none are worth keeping apart: a
219-
// newer pinned revision can check one of them, and nothing can check the other.
218+
// of a request body struct against. The two ways of having none are worth keeping apart: the
219+
// plans this tool loads account for one of them, and for the other they never will.
220220
type bodyReason int
221221

222222
const (
223223
// bodyFound: the operation's application/json request body schema is available.
224224
bodyFound bodyReason = iota
225-
// bodyNotInPlan: the descriptions do not document the operation at all, which is what a
226-
// pinned revision older than the operation looks like. Advancing the pin in
227-
// openapi_operations.yaml makes it checkable.
225+
// bodyNotInPlan: none of the descriptions this tool loads documents the operation. Either
226+
// a plan it does not load has the operation and the three it reads do not, which is what
227+
// an endpoint of an older GHES release looks like, or no plan has it at all. Advancing the
228+
// pin in openapi_operations.yaml helps only when the newer revision documents it too.
228229
bodyNotInPlan
229230
// bodyNoJSONBody: the operation is documented but has no application/json object request
230231
// body, so it has no fields to check.
@@ -286,9 +287,9 @@ func (d *description) requestSchema(op *opRef) (*flat, bodyReason, error) {
286287
type descriptions struct{ files []*description }
287288

288289
// requestSchema returns the request body schema from the first plan that documents the
289-
// operation, so GHEC-only and GHES-only operations resolve as well. When no plan has one, it
290-
// reports whether any plan documents the operation at all: an operation no plan documents is
291-
// the one a newer pinned revision would check.
290+
// operation, so GHEC-only and GHES-only operations resolve as well. When none of them has one,
291+
// it reports which kind of gap this is: an operation a plan documents without an
292+
// application/json body, or one that none of the plans this tool loads documents.
292293
func (ds *descriptions) requestSchema(op *opRef) (*flat, bodyReason, error) {
293294
reason := bodyNotInPlan
294295
for _, d := range ds.files {

‎tools/schemafields/scan.go‎

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ type structInfo struct {
9393
file string
9494
line int
9595
fields []*fieldInfo
96+
// response is set when some method of the package returns the struct, which makes
97+
// its fields the fields of an API response as well as, sometimes, of a request body.
98+
response bool
9699
}
97100

98101
// field returns the field with the given JSON name, if the struct has one.
@@ -128,12 +131,19 @@ type repoInfo struct {
128131
structs map[string]*structInfo
129132
methods []*methodInfo
130133
files int
134+
// resultTypes names every type that a method returns, whether or not this package
135+
// declares it. scanRepo settles the ones that do onto structInfo once every file
136+
// has been read.
137+
resultTypes map[string]bool
131138
}
132139

133140
// scanRepo parses the Go sources under <repo>/github and returns the structs and the
134141
// methods that take a body.
135142
func scanRepo(repo string) (*repoInfo, error) {
136-
info := &repoInfo{structs: map[string]*structInfo{}}
143+
info := &repoInfo{
144+
structs: map[string]*structInfo{},
145+
resultTypes: map[string]bool{},
146+
}
137147
fset := token.NewFileSet()
138148
dir := filepath.Join(repo, "github")
139149

@@ -189,6 +199,9 @@ func scanRepo(repo string) (*repoInfo, error) {
189199
m.bodyType, m.bodyPtr = body, ptr
190200
}
191201
info.methods = append(info.methods, m)
202+
for _, name := range resultTypeNames(d.Type.Results) {
203+
info.resultTypes[name] = true
204+
}
192205
}
193206
}
194207
return nil
@@ -197,8 +210,11 @@ func scanRepo(repo string) (*repoInfo, error) {
197210
return nil, err
198211
}
199212

200-
// A field's type is only known to be a struct once every file has been read.
213+
// A field's type is only known to be a struct once every file has been read, and the
214+
// same goes for a result type: the method that returns it can be read before the
215+
// declaration of the struct it names.
201216
for _, si := range info.structs {
217+
si.response = info.resultTypes[si.name]
202218
for _, f := range si.fields {
203219
if f.typeName != "" {
204220
_, f.isStruct = info.structs[f.typeName]
@@ -251,6 +267,36 @@ func findBodyParam(fn *ast.FuncDecl) (name string, pointer bool) {
251267
return "", false
252268
}
253269

270+
// resultTypeNames returns the names of the types that a result list mentions. A name from
271+
// another package (io.ReadCloser, *http.Response) names no struct this repository declares,
272+
// so collecting it costs nothing.
273+
func resultTypeNames(results *ast.FieldList) []string {
274+
if results == nil {
275+
return nil
276+
}
277+
var names []string
278+
for _, r := range results.List {
279+
names = append(names, typeNames(r.Type)...)
280+
}
281+
return names
282+
}
283+
284+
// typeNames returns the names that t mentions, looking through the pointers and slices that
285+
// a result is built from.
286+
func typeNames(t ast.Expr) []string {
287+
switch e := t.(type) {
288+
case *ast.Ident:
289+
return []string{e.Name}
290+
case *ast.StarExpr:
291+
return typeNames(e.X)
292+
case *ast.ArrayType:
293+
return typeNames(e.Elt)
294+
case *ast.ParenExpr:
295+
return typeNames(e.X)
296+
}
297+
return nil
298+
}
299+
254300
// collectStruct returns the exported fields of a struct declaration.
255301
func collectStruct(fset *token.FileSet, file string, ts *ast.TypeSpec, st *ast.StructType) *structInfo {
256302
si := &structInfo{

0 commit comments

Comments
 (0)