Skip to content

Commit 6c514fb

Browse files
authored
feat(AIP-215): augment foreign type checking (#1467)
1 parent a14ed3d commit 6c514fb

File tree

4 files changed

+310
-0
lines changed

4 files changed

+310
-0
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
rule:
3+
aip: 215
4+
name: [core, '0215', foreign-type-reference]
5+
summary: API should not reference foreign types outside of the API scope.
6+
permalink: /215/foreign-type-reference
7+
redirect_from:
8+
- /0215/foreign-type-reference
9+
---
10+
11+
# Versioned packages
12+
13+
This rule enforces that none of the fields in an API reference message types in a different
14+
proto package namespace other than well-known common packages.
15+
16+
## Details
17+
18+
This rule examines all fields in an API's messages and complains if the package of the
19+
referenced message type is not the same or from one of the common packages such as
20+
`google.api`, `google.protobuf`, etc.
21+
22+
## Examples
23+
24+
**Incorrect** code for this rule:
25+
26+
```proto
27+
// Incorrect.
28+
package foo.bar;
29+
import "some/other.proto";
30+
31+
message SomeMessage {
32+
some.OtherMessage other_message = 1;
33+
}
34+
```
35+
36+
**Correct** code for this rule:
37+
38+
```proto
39+
// Correct.
40+
package foo.bar;
41+
import "other.proto";
42+
43+
message SomeMessage {
44+
OtherMessage other_message = 1;
45+
}
46+
```
47+
48+
## Known issues
49+
50+
This check only allows subpackage usage within a versioned path, but generates warnings for unversioned subpackage usage.
51+
It also ignores if the referenced package is a "common" package like `google.api`, or if the package path ends in `.type` indicating
52+
the package is an AIP-213 component package.
53+
54+
Examples of foreign type references and their expected results:
55+
56+
| Calling Package | Referenced Package | Result |
57+
| --------------- | ------------------ | ------------ |
58+
| foo.bar | foo.xyz | lint warning |
59+
| foo.v2.bar | foo.v2.xyz | ok |
60+
| foo.bar | foo.type | ok |
61+
| foo.bar | google.api | ok |
62+
63+
## Disabling
64+
65+
If you need to violate this rule, place the comment above the package statement.
66+
Remember to also include an [aip.dev/not-precedent][] comment explaining why.
67+
68+
```proto
69+
// (-- api-linter: core::0215::foreign-type-reference=disabled
70+
// aip.dev/not-precedent: We need to do this because reasons. --)
71+
package foo.bar;
72+
```
73+
74+
[aip-215]: https://aip.dev/215
75+
[aip.dev/not-precedent]: https://aip.dev/not-precedent

rules/aip0215/aip0215.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,6 @@ func AddRules(r lint.RuleRegistry) error {
2424
return r.Register(
2525
215,
2626
versionedPackages,
27+
foreignTypeReference,
2728
)
2829
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package aip0215
16+
17+
import (
18+
"fmt"
19+
"regexp"
20+
"strings"
21+
22+
"github.com/googleapis/api-linter/lint"
23+
"github.com/googleapis/api-linter/rules/internal/utils"
24+
"github.com/jhump/protoreflect/desc"
25+
"google.golang.org/protobuf/types/descriptorpb"
26+
)
27+
28+
var foreignTypeReference = &lint.FieldRule{
29+
Name: lint.NewRuleName(215, "foreign-type-reference"),
30+
OnlyIf: func(fd *desc.FieldDescriptor) bool {
31+
return fd.GetType() == descriptorpb.FieldDescriptorProto_TYPE_MESSAGE
32+
},
33+
LintField: func(fd *desc.FieldDescriptor) []lint.Problem {
34+
curPkg := getNormalizedPackage(fd)
35+
if curPkg == "" {
36+
return nil // Empty or unavailable package.
37+
}
38+
msg := fd.GetMessageType()
39+
if msg == nil {
40+
return nil // Couldn't resolve type.
41+
}
42+
msgPkg := getNormalizedPackage(msg)
43+
if msgPkg == "" {
44+
return nil // Empty or unavailable package.
45+
}
46+
47+
if utils.IsCommonProto(msg.GetFile()) {
48+
return nil // reference to a well known proto package.
49+
}
50+
51+
if strings.HasSuffix(msgPkg, ".type") {
52+
return nil // AIP-213 component type.
53+
}
54+
55+
if curPkg != msgPkg {
56+
return []lint.Problem{{
57+
Message: fmt.Sprintf("foreign type referenced, current field in %q message in %q", curPkg, msgPkg),
58+
Descriptor: fd,
59+
}}
60+
}
61+
62+
return nil
63+
},
64+
}
65+
66+
// Regexp to capture everything up to a versioned segment.
67+
var versionedPrefix = regexp.MustCompile(`^.*\.v[\d]+(p[\d]+)?(alpha|beta|eap|test)?[\d]*`)
68+
69+
// getNormalizedPackage returns a normalized package path.
70+
// If package cannot be resolved it returns the empty string.
71+
// If the package path has a "versioned" segment, the path is truncated to that segment.
72+
func getNormalizedPackage(d desc.Descriptor) string {
73+
f := d.GetFile()
74+
if f == nil {
75+
return ""
76+
}
77+
pkg := f.GetPackage()
78+
if normPkg := versionedPrefix.FindString(pkg); normPkg != "" {
79+
pkg = normPkg
80+
}
81+
return pkg
82+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package aip0215
16+
17+
import (
18+
"testing"
19+
20+
"github.com/googleapis/api-linter/rules/internal/testutils"
21+
)
22+
23+
// Tests our regexp normalizes strings to the expected path.
24+
func TestVersionNormalization(t *testing.T) {
25+
for _, tc := range []struct {
26+
in string
27+
want string
28+
}{
29+
{
30+
in: "",
31+
want: "",
32+
},
33+
{
34+
in: "foo.bar.baz",
35+
want: "",
36+
},
37+
{
38+
// This one's a bit iffy. Should a version be allowed as the first segment?
39+
in: "v1beta",
40+
want: "",
41+
},
42+
{
43+
in: "foo.v3",
44+
want: "foo.v3",
45+
},
46+
{
47+
in: "foo.v99alpha.bar",
48+
want: "foo.v99alpha",
49+
},
50+
{
51+
in: "foo.v2.bar.v2",
52+
want: "foo.v2.bar.v2",
53+
},
54+
} {
55+
got := versionedPrefix.FindString(tc.in)
56+
if got != tc.want {
57+
t.Errorf("mismatch: in %q, got %q, want %q", tc.in, got, tc.want)
58+
}
59+
}
60+
}
61+
62+
func TestForeignTypeReference(t *testing.T) {
63+
64+
for _, tc := range []struct {
65+
description string
66+
CallingPkg string
67+
ReferencePkg string
68+
ReferencedMsg string
69+
problems testutils.Problems
70+
}{
71+
{
72+
description: "same pkg",
73+
CallingPkg: "same",
74+
ReferencePkg: "same",
75+
ReferencedMsg: "OtherMessage",
76+
problems: nil,
77+
},
78+
{
79+
description: "refers to google.api",
80+
CallingPkg: "same",
81+
ReferencePkg: "google.api",
82+
ReferencedMsg: "google.api.OtherMessage",
83+
problems: nil,
84+
},
85+
{
86+
description: "refers to google.protobuf",
87+
CallingPkg: "same",
88+
ReferencePkg: "google.protobuf",
89+
ReferencedMsg: "google.protobuf.OtherMessage",
90+
problems: nil,
91+
},
92+
{
93+
description: "refers to foreign pkg",
94+
CallingPkg: "same",
95+
ReferencePkg: "other",
96+
ReferencedMsg: "other.OtherMessage",
97+
problems: testutils.Problems{{Message: "foreign type referenced"}},
98+
},
99+
{
100+
description: "unversioned subpackage",
101+
CallingPkg: "somepackage",
102+
ReferencePkg: "somepackage.sub",
103+
ReferencedMsg: "somepackage.sub.OtherMessage",
104+
problems: testutils.Problems{{Message: "foreign type referenced"}},
105+
},
106+
{
107+
description: "versioned subpackage",
108+
CallingPkg: "somepackage.v6",
109+
ReferencePkg: "somepackage.v6.sub",
110+
ReferencedMsg: "somepackage.v6.sub.OtherMessage",
111+
problems: nil,
112+
},
113+
{
114+
description: "versioned deep subpackaging",
115+
CallingPkg: "somepackage.v1.abc",
116+
ReferencePkg: "somepackage.v1.lol.xyz",
117+
ReferencedMsg: "somepackage.v1.lol.xyz.OtherMessage",
118+
problems: nil,
119+
},
120+
{
121+
description: "refers to component package",
122+
CallingPkg: "somepackage",
123+
ReferencePkg: "otherpackage.type",
124+
ReferencedMsg: "otherpackage.type.OtherMessage",
125+
problems: nil,
126+
},
127+
} {
128+
t.Run(tc.description, func(t *testing.T) {
129+
files := testutils.ParseProto3Tmpls(t, map[string]string{
130+
"calling.proto": `
131+
package {{.CallingPkg}};
132+
import "ref.proto";
133+
message Caller {
134+
string foo = 1;
135+
{{.ReferencedMsg}} bar = 2;
136+
}
137+
`,
138+
"ref.proto": `
139+
package {{.ReferencePkg}};
140+
message OtherMessage {
141+
int32 baz = 1;
142+
}
143+
`,
144+
}, tc)
145+
file := files["calling.proto"]
146+
field := file.GetMessageTypes()[0].GetFields()[1]
147+
if diff := tc.problems.SetDescriptor(field).Diff(foreignTypeReference.Lint(file)); diff != "" {
148+
t.Error(diff)
149+
}
150+
})
151+
}
152+
}

0 commit comments

Comments
 (0)