Skip to content

Commit cba1159

Browse files
authored
Support x-google-lro (#16028)
1 parent dac6b8e commit cba1159

File tree

3 files changed

+206
-43
lines changed

3 files changed

+206
-43
lines changed

mmv1/openapi_generate/parser.go

Lines changed: 78 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"path/filepath"
2727
"regexp"
2828
"slices"
29+
"strconv"
2930
"strings"
3031

3132
"log"
@@ -92,12 +93,15 @@ func (parser Parser) WriteYaml(filePath string) {
9293
doc, _ := loader.LoadFromFile(filePath)
9394
_ = doc.Validate(ctx)
9495

95-
resourcePaths := findResources(doc)
96+
resources := findResources(doc)
9697
productPath := buildProduct(filePath, parser.Output, doc, header)
9798

9899
log.Printf("Generated product %+v/product.yaml", productPath)
99-
for _, pathArray := range resourcePaths {
100-
resource := buildResource(filePath, pathArray[0], pathArray[1], doc)
100+
for name, resource := range resources {
101+
if resource.create == nil {
102+
continue
103+
}
104+
resource := buildResource(filePath, name, resource, doc)
101105

102106
// marshal method
103107
var yamlContent bytes.Buffer
@@ -130,24 +134,69 @@ func (parser Parser) WriteYaml(filePath string) {
130134
}
131135
}
132136

133-
func findResources(doc *openapi3.T) [][]string {
134-
var resourcePaths [][]string
137+
type resourceOp struct {
138+
path string
139+
async bool
140+
}
135141

136-
pathMap := doc.Paths.Map()
137-
for key, pathValue := range pathMap {
138-
if pathValue.Post == nil {
139-
continue
142+
type resource struct {
143+
// nil if not defined
144+
create, update, delete *resourceOp
145+
}
146+
147+
func anyToBool(a any) bool {
148+
switch v := a.(type) {
149+
case bool:
150+
return v
151+
case string:
152+
if b, err := strconv.ParseBool(v); err == nil {
153+
return b
154+
}
155+
panic(fmt.Sprintf("cannot parse expected boolean value, found string: %q", v))
156+
default:
157+
panic(fmt.Sprintf("unexpected type: %T", v))
158+
}
159+
}
160+
161+
func buildOperation(resourcePath string, op *openapi3.Operation, prefix string) (string, *resourceOp) {
162+
if op == nil {
163+
return "", nil
164+
}
165+
if strings.HasPrefix(op.OperationID, prefix) {
166+
resourceName := strings.Replace(op.OperationID, prefix, "", 1)
167+
async := false
168+
if a, ok := op.Extensions["x-google-lro"]; ok {
169+
async = anyToBool(a)
170+
}
171+
return resourceName, &resourceOp{path: resourcePath, async: async}
172+
}
173+
return "", nil
174+
}
175+
176+
func findResources(doc *openapi3.T) map[string]*resource {
177+
resources := make(map[string]*resource)
178+
getDefault := func(n string) *resource {
179+
r, ok := resources[n]
180+
if !ok {
181+
r = &resource{}
182+
resources[n] = r
140183
}
184+
return r
185+
}
141186

142-
// Not very clever way of identifying create resource methods
143-
if strings.HasPrefix(pathValue.Post.OperationID, "Create") {
144-
resourcePath := key
145-
resourceName := strings.Replace(pathValue.Post.OperationID, "Create", "", 1)
146-
resourcePaths = append(resourcePaths, []string{resourcePath, resourceName})
187+
for key, pathValue := range doc.Paths.Map() {
188+
if name, op := buildOperation(key, pathValue.Post, "Create"); op != nil {
189+
getDefault(name).create = op
190+
}
191+
if name, op := buildOperation(key, pathValue.Delete, "Delete"); op != nil {
192+
getDefault(name).delete = op
193+
}
194+
if name, op := buildOperation(key, pathValue.Patch, "Update"); op != nil {
195+
getDefault(name).update = op
147196
}
148197
}
149198

150-
return resourcePaths
199+
return resources
151200
}
152201

153202
func buildProduct(filePath, output string, root *openapi3.T, header []byte) string {
@@ -229,8 +278,9 @@ func stripVersion(path string) string {
229278
return re.ReplaceAllString(path, "")
230279
}
231280

232-
func buildResource(filePath, resourcePath, resourceName string, root *openapi3.T) api.Resource {
281+
func buildResource(filePath, resourceName string, in *resource, root *openapi3.T) api.Resource {
233282
resource := api.Resource{}
283+
resourcePath := in.create.path
234284

235285
parsedObjects := parseOpenApi(resourcePath, resourceName, root)
236286

@@ -255,14 +305,25 @@ func buildResource(filePath, resourcePath, resourceName string, root *openapi3.T
255305
async := api.NewAsync()
256306
async.Operation.BaseUrl = "{{op_id}}"
257307
async.Result.ResourceInsideResponse = true
308+
// Clear the default, we will attach the right values below
309+
async.Actions = nil
258310
resource.Async = async
311+
if in.create.async {
312+
resource.Async.Actions = append(resource.Async.Actions, "create")
313+
}
259314

260-
if hasUpdate(resourceName, root) {
315+
if in.update != nil {
261316
resource.UpdateVerb = "PATCH"
262317
resource.UpdateMask = true
318+
if in.update.async {
319+
resource.Async.Actions = append(resource.Async.Actions, "update")
320+
}
263321
} else {
264322
resource.Immutable = true
265323
}
324+
if in.delete != nil && in.delete.async {
325+
resource.Async.Actions = append(resource.Async.Actions, "delete")
326+
}
266327

267328
example := r.Examples{}
268329
example.Name = "name_of_example_file"
@@ -279,20 +340,6 @@ func buildResource(filePath, resourcePath, resourceName string, root *openapi3.T
279340
return resource
280341
}
281342

282-
func hasUpdate(resourceName string, root *openapi3.T) bool {
283-
// Create and Update have different paths in the OpenAPI spec, so look
284-
// through all paths to find one that matches the expected operation name
285-
for _, pathValue := range root.Paths.Map() {
286-
if pathValue.Patch == nil {
287-
continue
288-
}
289-
if pathValue.Patch.OperationID == fmt.Sprintf("Update%s", resourceName) {
290-
return true
291-
}
292-
}
293-
return false
294-
}
295-
296343
func parseOpenApi(resourcePath, resourceName string, root *openapi3.T) []any {
297344
returnArray := []any{}
298345
path := root.Paths.Find(resourcePath)

mmv1/openapi_generate/parser_test.go

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,16 @@ func TestMapType(t *testing.T) {
1414
_ = NewOpenapiParser("/fake", "/fake")
1515
ctx := t.Context()
1616
loader := &openapi3.Loader{Context: ctx, IsExternalRefsAllowed: true}
17-
doc, _ := loader.LoadFromData(testData)
18-
_ = doc.Validate(ctx)
17+
doc, err := loader.LoadFromData(testData)
18+
if err != nil {
19+
t.Fatalf("Could not load data %s", err)
20+
}
21+
err = doc.Validate(ctx)
22+
if err != nil {
23+
t.Fatalf("Could not validate data %s", err)
24+
}
1925

20-
petSchema := doc.Paths.Map()["/pets"].Post.Parameters[0].Value.Schema
26+
petSchema := doc.Paths.Map()["/pets"].Post.RequestBody.Value.Content["application/json"].Schema
2127
mmObject := WriteObject("pet", petSchema, propType(petSchema), false)
2228
if mmObject.KeyName == "" || mmObject.Type != "Map" {
2329
t.Error("Failed to parse map type")
@@ -26,3 +32,26 @@ func TestMapType(t *testing.T) {
2632
t.Errorf("Expected 4 properties, found %d", len(mmObject.ValueType.Properties))
2733
}
2834
}
35+
36+
func TestFindResources(t *testing.T) {
37+
ctx := t.Context()
38+
loader := &openapi3.Loader{Context: ctx, IsExternalRefsAllowed: true}
39+
doc, err := loader.LoadFromData(testData)
40+
if err != nil {
41+
t.Fatalf("Could not load data %s", err)
42+
}
43+
err = doc.Validate(ctx)
44+
if err != nil {
45+
t.Fatalf("Could not validate data %s", err)
46+
}
47+
res := findResources(doc)
48+
if len(res) != 2 {
49+
t.Fatalf("Expected 2 resources, found: %d", len(res))
50+
}
51+
if !res["Food"].create.async {
52+
t.Error("Food resource is supposed to be detected as async and is not")
53+
}
54+
if res["Pet"].create.async {
55+
t.Error("Pet resource is not supposed to be detected as async")
56+
}
57+
}

mmv1/openapi_generate/test_data/test_api.yaml

Lines changed: 96 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,18 +41,18 @@ paths:
4141
$ref: "#/components/schemas/Error"
4242
post:
4343
summary: Create a pet
44-
operationId: createPets
44+
operationId: CreatePet
4545
tags:
4646
- pets
4747
parameters:
48-
- name: pet
49-
in: body
50-
required: true
51-
description: The pet to create
52-
schema:
53-
type: object
54-
additionalProperties:
55-
$ref: "#/components/schemas/Pet"
48+
requestBody:
49+
description: "Required. The pets being created"
50+
content:
51+
application/json:
52+
schema:
53+
type: object
54+
additionalProperties:
55+
$ref: "#/components/schemas/Pet"
5656
responses:
5757
201:
5858
description: Null response
@@ -88,9 +88,87 @@ paths:
8888
application/json:
8989
schema:
9090
$ref: "#/components/schemas/Error"
91+
/foods:
92+
get:
93+
summary: List all foods
94+
operationId: listFoods
95+
tags:
96+
- foods
97+
parameters:
98+
- name: limit
99+
in: query
100+
description: How many items to return at one time (max 100)
101+
required: false
102+
schema:
103+
type: integer
104+
format: int32
105+
responses:
106+
200:
107+
description: An paged array of foods
108+
headers:
109+
x-next:
110+
description: A link to the next page of responses
111+
schema:
112+
type: string
113+
content:
114+
application/json:
115+
schema:
116+
$ref: "#/components/schemas/Foods"
117+
default:
118+
description: unexpected error
119+
content:
120+
application/json:
121+
schema:
122+
$ref: "#/components/schemas/Error"
123+
post:
124+
summary: Create a food
125+
operationId: CreateFood
126+
tags:
127+
- foods
128+
x-google-lro: true
129+
requestBody:
130+
description: "Required. The food being created"
131+
content:
132+
application/json:
133+
schema:
134+
$ref: "#/components/schemas/Food"
135+
responses:
136+
default:
137+
description: Successful operation
138+
content:
139+
application/json:
140+
schema:
141+
$ref: "#/components/schemas/CreateFoodOperation"
142+
/foods/{foodId}:
143+
get:
144+
summary: Info for a specific food
145+
operationId: showFoodById
146+
tags:
147+
- foods
148+
parameters:
149+
- name: foodId
150+
in: path
151+
required: true
152+
description: The id of the food to retrieve
153+
schema:
154+
type: string
155+
responses:
156+
200:
157+
description: Expected response to a valid request
158+
content:
159+
application/json:
160+
schema:
161+
$ref: "#/components/schemas/Foods"
162+
default:
163+
description: unexpected error
164+
content:
165+
application/json:
166+
schema:
167+
$ref: "#/components/schemas/Error"
91168
components:
92169
schemas:
93170
Pet:
171+
type: object
94172
required:
95173
- id
96174
- name
@@ -112,10 +190,19 @@ components:
112190
properties:
113191
name:
114192
type: string
193+
Foods:
194+
type: array
195+
items:
196+
$ref: "#/components/schemas/Food"
115197
Pets:
116198
type: array
117199
items:
118200
$ref: "#/components/schemas/Pet"
201+
CreateFoodOperation:
202+
type: object
203+
properties:
204+
name:
205+
type: string
119206
Error:
120207
required:
121208
- code

0 commit comments

Comments
 (0)