Skip to content

Commit 03453e3

Browse files
authored
feat: add expand_slashed_path_patterns flag (#4813)
* feat: add expand_slashed_path_patterns flag * chore: improve comments * chore: improve comments and code polishing * fix: add expand_slashed_path_patterns into bazel * refactor: extract logic to single function * refactor: expandPathPatterns fn don't mutate input * fix: templateToParts camel-cases also path-patterns * fix: inferred path-params not camel-cased * docs: document the new compiler option * fix: failing test * docs: reword documentation about the option
1 parent 830ba27 commit 03453e3

File tree

6 files changed

+241
-18
lines changed

6 files changed

+241
-18
lines changed

docs/docs/mapping/customizing_openapi_output.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,51 @@ Note that path parameters in OpenAPI does not support values with `/`, as discus
539539
so tools as Swagger UI will URL encode any `/` provided as parameter value. A possible workaround for this is to write
540540
a custom post processor for your OAS file to replace any path parameter with `/` into multiple parameters.
541541

542+
#### Expand path parameters containing sub-path segments
543+
544+
Alternative to the above, you can enable the `expand_slashed_path_patterns` compiler option to expand path parameters containing sub-path segments into the URI.
545+
546+
For example, consider:
547+
```protobuf
548+
rpc GetBook(GetBookRequest) returns (Book) {
549+
option (google.api.http) = {
550+
get: "/v1/{name=publishers/*/books/*}"
551+
};
552+
}
553+
```
554+
555+
Where the `GetBook` has a path parameter `name` with a pattern `publishers/*/books/*`. When you enable the `expand_slashed_path_patterns=true` option the path pattern is expanded into the URI and each wildcard in the pattern is transformed into new path parameter. The generated schema for previous protobuf is:
556+
557+
```JSON
558+
{
559+
"/v1/publishers/{publisher}/books/{book}": {
560+
"get": {
561+
"parameters": [
562+
{
563+
"name": "publisher",
564+
"in": "path",
565+
"required": true,
566+
"type": "string",
567+
},
568+
{
569+
"name": "book",
570+
"in": "path",
571+
"required": true,
572+
"type": "string",
573+
}
574+
]
575+
}
576+
}
577+
}
578+
```
579+
580+
The URI is now pretty descriptive and there are two path parameters `publisher` and `book` instead of one `name`. The name of the new parameters is derived from the path segment before the wildcard in the pattern.
581+
582+
Caveats:
583+
584+
- the fact that the original `name` parameter is missing might complicate the usage of the API if you intend to pass in the `name` parameters from the resources,
585+
- when the `expand_slashed_path_patterns` compiler flag is enabled, the [`path_param_name`](#path-parameters) field annotation is ignored.
586+
542587
### Output format
543588

544589
By default the output format is JSON, but it is possible to configure it using the `output_format` option. Allowed values are: `json`, `yaml`. The output format will also change the extension of the output files.
@@ -1043,4 +1088,6 @@ definitions:
10431088
type: string
10441089
```
10451090

1091+
10461092
{% endraw %}
1093+

internal/descriptor/registry.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,18 @@ type Registry struct {
163163

164164
// enableRpcDeprecation whether to process grpc method's deprecated option
165165
enableRpcDeprecation bool
166+
167+
// expandSlashedPathPatterns, if true, for a path parameter carrying a sub-path, described via parameter pattern (i.e.
168+
// the pattern contains forward slashes), this will expand the _pattern_ into the URI and will _replace_ the parameter
169+
// with new path parameters inferred from patterns wildcards.
170+
//
171+
// Example: a Google AIP style path "/v1/{name=projects/*/locations/*}/datasets/{dataset}" with a "name" parameter
172+
// containing sub-path will generate "/v1/projects/{project}/locations/{location}/datasets/{dataset}" path in OpenAPI.
173+
// Note that the original "name" parameter is replaced with "project" and "location" parameters.
174+
//
175+
// This leads to more compliant and readable OpenAPI suitable for documentation, but may complicate client
176+
// implementation if you want to pass the original "name" parameter.
177+
expandSlashedPathPatterns bool
166178
}
167179

168180
type repeatedFieldSeparator struct {
@@ -888,3 +900,11 @@ func (r *Registry) SetEnableRpcDeprecation(enable bool) {
888900
func (r *Registry) GetEnableRpcDeprecation() bool {
889901
return r.enableRpcDeprecation
890902
}
903+
904+
func (r *Registry) SetExpandSlashedPathPatterns(expandSlashedPathPatterns bool) {
905+
r.expandSlashedPathPatterns = expandSlashedPathPatterns
906+
}
907+
908+
func (r *Registry) GetExpandSlashedPathPatterns() bool {
909+
return r.expandSlashedPathPatterns
910+
}

protoc-gen-openapiv2/defs.bzl

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ def _run_proto_gen_openapi(
7575
visibility_restriction_selectors,
7676
use_allof_for_refs,
7777
disable_default_responses,
78-
enable_rpc_deprecation):
78+
enable_rpc_deprecation,
79+
expand_slashed_path_patterns):
7980
args = actions.args()
8081

8182
args.add("--plugin", "protoc-gen-openapiv2=%s" % protoc_gen_openapiv2.path)
@@ -152,6 +153,9 @@ def _run_proto_gen_openapi(
152153
if enable_rpc_deprecation:
153154
args.add("--openapiv2_opt", "enable_rpc_deprecation=true")
154155

156+
if expand_slashed_path_patterns:
157+
args.add("--openapiv2_opt", "expand_slashed_path_patterns=true")
158+
155159
args.add("--openapiv2_opt", "repeated_path_param_separator=%s" % repeated_path_param_separator)
156160

157161
proto_file_infos = _direct_source_infos(proto_info)
@@ -260,6 +264,7 @@ def _proto_gen_openapi_impl(ctx):
260264
use_allof_for_refs = ctx.attr.use_allof_for_refs,
261265
disable_default_responses = ctx.attr.disable_default_responses,
262266
enable_rpc_deprecation = ctx.attr.enable_rpc_deprecation,
267+
expand_slashed_path_patterns = ctx.attr.expand_slashed_path_patterns,
263268
),
264269
),
265270
),
@@ -420,6 +425,13 @@ protoc_gen_openapiv2 = rule(
420425
mandatory = False,
421426
doc = "whether to process grpc method's deprecated option.",
422427
),
428+
"expand_slashed_path_patterns": attr.bool(
429+
default = False,
430+
mandatory = False,
431+
doc = "if set, expands path patterns containing slashes into URI." +
432+
" It also creates a new path parameter for each wildcard in " +
433+
" the path pattern.",
434+
),
423435
"_protoc": attr.label(
424436
default = "@com_google_protobuf//:protoc",
425437
executable = True,

protoc-gen-openapiv2/internal/genopenapi/template.go

Lines changed: 99 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"reflect"
1111
"regexp"
12+
"slices"
1213
"sort"
1314
"strconv"
1415
"strings"
@@ -984,8 +985,17 @@ func resolveFullyQualifiedNameToOpenAPINames(messages []string, namingStrategy s
984985

985986
var canRegexp = regexp.MustCompile("{([a-zA-Z][a-zA-Z0-9_.]*)([^}]*)}")
986987

987-
// templateToParts will split a URL template as defined by https://github.com/googleapis/googleapis/blob/master/google/api/http.proto
988-
// into a string slice with each part as an element of the slice for use by `partsToOpenAPIPath` and `partsToRegexpMap`.
988+
// templateToParts splits a URL template into path segments for use by `partsToOpenAPIPath` and `partsToRegexpMap`.
989+
//
990+
// Parameters:
991+
// - path: The URL template as defined by https://github.com/googleapis/googleapis/blob/master/google/api/http.proto
992+
// - reg: The descriptor registry used to read compiler flags
993+
// - fields: The fields of the request message, only used when `useJSONNamesForFields` is true
994+
// - msgs: The Messages of the service binding, only used when `useJSONNamesForFields` is true
995+
//
996+
// Returns:
997+
//
998+
// The path segments of the URL template.
989999
func templateToParts(path string, reg *descriptor.Registry, fields []*descriptor.Field, msgs []*descriptor.Message) []string {
9901000
// It seems like the right thing to do here is to just use
9911001
// strings.Split(path, "/") but that breaks badly when you hit a url like
@@ -995,31 +1005,26 @@ func templateToParts(path string, reg *descriptor.Registry, fields []*descriptor
9951005
var parts []string
9961006
depth := 0
9971007
buffer := ""
998-
jsonBuffer := ""
9991008
pathLoop:
10001009
for i, char := range path {
10011010
switch char {
10021011
case '{':
10031012
// Push on the stack
10041013
depth++
10051014
buffer += string(char)
1006-
jsonBuffer = ""
1007-
jsonBuffer += string(char)
10081015
case '}':
10091016
if depth == 0 {
10101017
panic("Encountered } without matching { before it.")
10111018
}
10121019
// Pop from the stack
10131020
depth--
1014-
buffer += string(char)
1015-
if reg.GetUseJSONNamesForFields() &&
1016-
len(jsonBuffer) > 1 {
1017-
jsonSnakeCaseName := jsonBuffer[1:]
1018-
jsonCamelCaseName := lowerCamelCase(jsonSnakeCaseName, fields, msgs)
1019-
prev := buffer[:len(buffer)-len(jsonSnakeCaseName)-2]
1020-
buffer = strings.Join([]string{prev, "{", jsonCamelCaseName, "}"}, "")
1021-
jsonBuffer = ""
1021+
if !reg.GetUseJSONNamesForFields() {
1022+
buffer += string(char)
1023+
continue
10221024
}
1025+
paramNameProto := strings.SplitN(buffer[1:], "=", 2)[0]
1026+
paramNameCamelCase := lowerCamelCase(paramNameProto, fields, msgs)
1027+
buffer = strings.Join([]string{"{", paramNameCamelCase, buffer[len(paramNameProto)+1:], "}"}, "")
10231028
case '/':
10241029
if depth == 0 {
10251030
parts = append(parts, buffer)
@@ -1029,7 +1034,6 @@ pathLoop:
10291034
continue
10301035
}
10311036
buffer += string(char)
1032-
jsonBuffer += string(char)
10331037
case ':':
10341038
if depth == 0 {
10351039
// As soon as we find a ":" outside a variable,
@@ -1039,10 +1043,8 @@ pathLoop:
10391043
break pathLoop
10401044
}
10411045
buffer += string(char)
1042-
jsonBuffer += string(char)
10431046
default:
10441047
buffer += string(char)
1045-
jsonBuffer += string(char)
10461048
}
10471049
}
10481050

@@ -1060,6 +1062,7 @@ pathLoop:
10601062
func partsToOpenAPIPath(parts []string, overrides map[string]string) string {
10611063
for index, part := range parts {
10621064
part = canRegexp.ReplaceAllString(part, "{$1}")
1065+
10631066
if override, ok := overrides[part]; ok {
10641067
part = override
10651068
}
@@ -1152,6 +1155,81 @@ func renderServiceTags(services []*descriptor.Service, reg *descriptor.Registry)
11521155
return tags
11531156
}
11541157

1158+
// expandPathPatterns searches the URI parts for path parameters with pattern and when the pattern contains a sub-path,
1159+
// it expands the pattern into the URI parts and adds the new path parameters to the pathParams slice.
1160+
//
1161+
// Parameters:
1162+
// - pathParts: the URI parts parsed from the path template with `templateToParts` function
1163+
// - pathParams: the path parameters of the service binding
1164+
//
1165+
// Returns:
1166+
//
1167+
// The modified pathParts and pathParams slice.
1168+
func expandPathPatterns(pathParts []string, pathParams []descriptor.Parameter, reg *descriptor.Registry) ([]string, []descriptor.Parameter) {
1169+
expandedPathParts := []string{}
1170+
modifiedPathParams := pathParams
1171+
for _, pathPart := range pathParts {
1172+
if !strings.HasPrefix(pathPart, "{") || !strings.HasSuffix(pathPart, "}") {
1173+
expandedPathParts = append(expandedPathParts, pathPart)
1174+
continue
1175+
}
1176+
woBraces := pathPart[1 : len(pathPart)-1]
1177+
paramPattern := strings.SplitN(woBraces, "=", 2)
1178+
if len(paramPattern) != 2 {
1179+
expandedPathParts = append(expandedPathParts, pathPart)
1180+
continue
1181+
}
1182+
paramName := paramPattern[0]
1183+
pattern := paramPattern[1]
1184+
if pattern == "*" {
1185+
expandedPathParts = append(expandedPathParts, pathPart)
1186+
continue
1187+
}
1188+
pathParamIndex := slices.IndexFunc(modifiedPathParams, func(p descriptor.Parameter) bool {
1189+
return p.FieldPath.String() == paramName
1190+
})
1191+
if pathParamIndex == -1 {
1192+
panic(fmt.Sprintf("Path parameter %q not found in path parameters", paramName))
1193+
}
1194+
pathParam := modifiedPathParams[pathParamIndex]
1195+
patternParts := strings.Split(pattern, "/")
1196+
for _, patternPart := range patternParts {
1197+
if patternPart != "*" {
1198+
expandedPathParts = append(expandedPathParts, patternPart)
1199+
continue
1200+
}
1201+
lastPart := expandedPathParts[len(expandedPathParts)-1]
1202+
paramName := strings.TrimSuffix(lastPart, "s")
1203+
if reg.GetUseJSONNamesForFields() {
1204+
paramName = casing.JSONCamelCase(paramName)
1205+
}
1206+
expandedPathParts = append(expandedPathParts, "{"+paramName+"}")
1207+
newParam := descriptor.Parameter{
1208+
Target: &descriptor.Field{
1209+
FieldDescriptorProto: &descriptorpb.FieldDescriptorProto{
1210+
Name: proto.String(paramName),
1211+
},
1212+
Message: pathParam.Target.Message,
1213+
FieldMessage: pathParam.Target.FieldMessage,
1214+
ForcePrefixedName: pathParam.Target.ForcePrefixedName,
1215+
},
1216+
FieldPath: []descriptor.FieldPathComponent{{
1217+
Name: paramName,
1218+
Target: nil,
1219+
}},
1220+
Method: nil,
1221+
}
1222+
modifiedPathParams = append(modifiedPathParams, newParam)
1223+
if pathParamIndex != -1 {
1224+
// the new parameter from the pattern replaces the old path parameter
1225+
modifiedPathParams = append(modifiedPathParams[:pathParamIndex], modifiedPathParams[pathParamIndex+1:]...)
1226+
pathParamIndex = -1
1227+
}
1228+
}
1229+
}
1230+
return expandedPathParts, modifiedPathParams
1231+
}
1232+
11551233
func renderServices(services []*descriptor.Service, paths *openapiPathsObject, reg *descriptor.Registry, requestResponseRefs, customRefs refMap, msgs []*descriptor.Message, defs openapiDefinitionsObject) error {
11561234
// Correctness of svcIdx and methIdx depends on 'services' containing the services in the same order as the 'file.Service' array.
11571235
svcBaseIdx := 0
@@ -1179,11 +1257,15 @@ func renderServices(services []*descriptor.Service, paths *openapiPathsObject, r
11791257
parameters := openapiParametersObject{}
11801258
// split the path template into its parts
11811259
parts := templateToParts(b.PathTmpl.Template, reg, meth.RequestType.Fields, msgs)
1260+
pathParams := b.PathParams
1261+
if reg.GetExpandSlashedPathPatterns() {
1262+
parts, pathParams = expandPathPatterns(parts, pathParams, reg)
1263+
}
11821264
// extract any constraints specified in the path placeholders into ECMA regular expressions
11831265
pathParamRegexpMap := partsToRegexpMap(parts)
11841266
// Keep track of path parameter overrides
11851267
var pathParamNames = make(map[string]string)
1186-
for _, parameter := range b.PathParams {
1268+
for _, parameter := range pathParams {
11871269

11881270
var paramType, paramFormat, desc, collectionFormat string
11891271
var defaultValue interface{}

protoc-gen-openapiv2/internal/genopenapi/template_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4119,6 +4119,61 @@ func TestTemplateToOpenAPIPath(t *testing.T) {
41194119
}
41204120
}
41214121

4122+
func getParameters(names []string) []descriptor.Parameter {
4123+
params := make([]descriptor.Parameter, 0)
4124+
for _, name := range names {
4125+
params = append(params, descriptor.Parameter{
4126+
Target: &descriptor.Field{
4127+
FieldDescriptorProto: &descriptorpb.FieldDescriptorProto{
4128+
Name: proto.String(name),
4129+
},
4130+
Message: &descriptor.Message{},
4131+
FieldMessage: nil,
4132+
ForcePrefixedName: false,
4133+
},
4134+
FieldPath: []descriptor.FieldPathComponent{{
4135+
Name: name,
4136+
Target: nil,
4137+
}},
4138+
Method: nil,
4139+
})
4140+
}
4141+
return params
4142+
}
4143+
4144+
func TestTemplateToOpenAPIPathExpandSlashed(t *testing.T) {
4145+
var tests = []struct {
4146+
input string
4147+
expected string
4148+
pathParams []descriptor.Parameter
4149+
expectedPathParams []string
4150+
useJSONNames bool
4151+
}{
4152+
{"/v1/{name=projects/*/documents/*}:exportResults", "/v1/projects/{project}/documents/{document}:exportResults", getParameters([]string{"name"}), []string{"project", "document"}, true},
4153+
{"/test/{name=*}", "/test/{name}", getParameters([]string{"name"}), []string{"name"}, true},
4154+
{"/test/{name=*}/", "/test/{name}/", getParameters([]string{"name"}), []string{"name"}, true},
4155+
{"/test/{name=test_cases/*}/", "/test/test_cases/{testCase}/", getParameters([]string{"name"}), []string{"testCase"}, true},
4156+
{"/test/{name=test_cases/*}/", "/test/test_cases/{test_case}/", getParameters([]string{"name"}), []string{"test_case"}, false},
4157+
}
4158+
reg := descriptor.NewRegistry()
4159+
reg.SetExpandSlashedPathPatterns(true)
4160+
for _, data := range tests {
4161+
reg.SetUseJSONNamesForFields(data.useJSONNames)
4162+
actualParts, actualParams := templateToExpandedPath(data.input, reg, generateFieldsForJSONReservedName(), generateMsgsForJSONReservedName(), data.pathParams)
4163+
if data.expected != actualParts {
4164+
t.Errorf("Expected templateToOpenAPIPath(%v) = %v, actual: %v", data.input, data.expected, actualParts)
4165+
}
4166+
pathParamsNames := make([]string, 0)
4167+
for _, param := range actualParams {
4168+
pathParamsNames = append(pathParamsNames, param.FieldPath[0].Name)
4169+
}
4170+
if !reflect.DeepEqual(data.expectedPathParams, pathParamsNames) {
4171+
t.Errorf("Expected mutated path params in templateToOpenAPIPath(%v) = %v, actual: %v", data.input, data.expectedPathParams, data.pathParams)
4172+
}
4173+
4174+
}
4175+
}
4176+
41224177
func BenchmarkTemplateToOpenAPIPath(b *testing.B) {
41234178
const input = "/{user.name=prefix1/*/prefix2/*}:customMethod"
41244179

@@ -4232,6 +4287,11 @@ func templateToRegexpMap(path string, reg *descriptor.Registry, fields []*descri
42324287
return partsToRegexpMap(templateToParts(path, reg, fields, msgs))
42334288
}
42344289

4290+
func templateToExpandedPath(path string, reg *descriptor.Registry, fields []*descriptor.Field, msgs []*descriptor.Message, pathParams []descriptor.Parameter) (string, []descriptor.Parameter) {
4291+
pathParts, pathParams := expandPathPatterns(templateToParts(path, reg, fields, msgs), pathParams, reg)
4292+
return partsToOpenAPIPath(pathParts, make(map[string]string)), pathParams
4293+
}
4294+
42354295
func TestFQMNToRegexpMap(t *testing.T) {
42364296
var tests = []struct {
42374297
input string

0 commit comments

Comments
 (0)