Skip to content

Commit d91e70b

Browse files
Support oneOfs & anyOfs in operation request & response schema (#701)
1 parent defe7aa commit d91e70b

File tree

10 files changed

+331
-67
lines changed

10 files changed

+331
-67
lines changed

linodecli/baked/operation.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@
1818
from openapi3.paths import Operation, Parameter
1919

2020
from linodecli.baked.parsing import simplify_description
21-
from linodecli.baked.request import OpenAPIFilteringRequest, OpenAPIRequest
21+
from linodecli.baked.request import (
22+
OpenAPIFilteringRequest,
23+
OpenAPIRequest,
24+
OpenAPIRequestArg,
25+
)
2226
from linodecli.baked.response import OpenAPIResponse
2327
from linodecli.exit_codes import ExitCodes
2428
from linodecli.output.output_handler import OutputHandler
@@ -415,6 +419,13 @@ def args(self):
415419
"""
416420
return self.request.attrs if self.request else []
417421

422+
@property
423+
def arg_routes(self) -> Dict[str, List[OpenAPIRequestArg]]:
424+
"""
425+
Return a list of attributes from the request schema
426+
"""
427+
return self.request.attr_routes if self.request else []
428+
418429
@staticmethod
419430
def _flatten_url_path(tag: str) -> str:
420431
"""

linodecli/baked/request.py

Lines changed: 86 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
Request details for a CLI Operation
33
"""
44

5+
from openapi3.schemas import Schema
6+
57
from linodecli.baked.parsing import simplify_description
8+
from linodecli.baked.util import _aggregate_schema_properties
69

710

811
class OpenAPIRequestArg:
@@ -134,66 +137,68 @@ def _parse_request_model(schema, prefix=None, parent=None, depth=0):
134137
"""
135138
args = []
136139

137-
if schema.properties is not None:
138-
for k, v in schema.properties.items():
139-
if v.type == "object" and not v.readOnly and v.properties:
140-
# nested objects receive a prefix and are otherwise parsed normally
141-
pref = prefix + "." + k if prefix else k
142-
143-
args += _parse_request_model(
144-
v,
145-
prefix=pref,
140+
properties, required = _aggregate_schema_properties(schema)
141+
142+
if properties is None:
143+
return args
144+
145+
for k, v in properties.items():
146+
if (
147+
v.type == "object"
148+
and not v.readOnly
149+
and len(_aggregate_schema_properties(v)[0]) > 0
150+
):
151+
# nested objects receive a prefix and are otherwise parsed normally
152+
pref = prefix + "." + k if prefix else k
153+
154+
args += _parse_request_model(
155+
v,
156+
prefix=pref,
157+
parent=parent,
158+
# NOTE: We do not increment the depth because dicts do not have
159+
# parent arguments.
160+
depth=depth,
161+
)
162+
elif (
163+
v.type == "array"
164+
and v.items
165+
and v.items.type == "object"
166+
and v.extensions.get("linode-cli-format") != "json"
167+
):
168+
# handle lists of objects as a special case, where each property
169+
# of the object in the list is its own argument
170+
pref = prefix + "." + k if prefix else k
171+
172+
# Support specifying this list as JSON
173+
args.append(
174+
OpenAPIRequestArg(
175+
k,
176+
v.items,
177+
False,
178+
prefix=prefix,
179+
is_parent=True,
146180
parent=parent,
147-
# NOTE: We do not increment the depth because dicts do not have
148-
# parent arguments.
149181
depth=depth,
150182
)
151-
elif (
152-
v.type == "array"
153-
and v.items
154-
and v.items.type == "object"
155-
and v.extensions.get("linode-cli-format") != "json"
156-
):
157-
# handle lists of objects as a special case, where each property
158-
# of the object in the list is its own argument
159-
pref = prefix + "." + k if prefix else k
160-
161-
# Support specifying this list as JSON
162-
args.append(
163-
OpenAPIRequestArg(
164-
k,
165-
v.items,
166-
False,
167-
prefix=prefix,
168-
is_parent=True,
169-
parent=parent,
170-
depth=depth,
171-
)
172-
)
183+
)
173184

174-
args += _parse_request_model(
175-
v.items,
176-
prefix=pref,
177-
parent=pref,
178-
depth=depth + 1,
179-
)
180-
else:
181-
# required fields are defined in the schema above the property, so
182-
# we have to check here if required fields are defined/if this key
183-
# is among them and pass it into the OpenAPIRequestArg class.
184-
required = False
185-
if schema.required:
186-
required = k in schema.required
187-
args.append(
188-
OpenAPIRequestArg(
189-
k,
190-
v,
191-
required,
192-
prefix=prefix,
193-
parent=parent,
194-
depth=depth,
195-
)
185+
args += _parse_request_model(
186+
v.items,
187+
prefix=pref,
188+
parent=pref,
189+
depth=depth + 1,
190+
)
191+
else:
192+
args.append(
193+
OpenAPIRequestArg(
194+
k,
195+
v,
196+
k in required,
197+
prefix=prefix,
198+
parent=parent,
199+
depth=depth,
196200
)
201+
)
197202

198203
return args
199204

@@ -212,15 +217,35 @@ def __init__(self, request):
212217
:type request: openapi3.MediaType
213218
"""
214219
self.required = request.schema.required
220+
215221
schema_override = request.extensions.get("linode-cli-use-schema")
222+
223+
schema = request.schema
224+
216225
if schema_override:
217226
override = type(request)(
218227
request.path, {"schema": schema_override}, request._root
219228
)
220229
override._resolve_references()
221-
self.attrs = _parse_request_model(override.schema)
222-
else:
223-
self.attrs = _parse_request_model(request.schema)
230+
schema = override.schema
231+
232+
self.attrs = _parse_request_model(schema)
233+
234+
# attr_routes stores all attribute routes defined using oneOf.
235+
# For example, config-create uses one of to isolate HTTP, HTTPS, and TCP request attributes
236+
self.attr_routes = {}
237+
238+
if schema.oneOf is not None:
239+
for entry in schema.oneOf:
240+
entry_schema = Schema(schema.path, entry, request._root)
241+
if entry_schema.title is None:
242+
raise ValueError(
243+
f"No title for oneOf entry in {schema.path}"
244+
)
245+
246+
self.attr_routes[entry_schema.title] = _parse_request_model(
247+
entry_schema
248+
)
224249

225250

226251
class OpenAPIFilteringRequest:
@@ -249,3 +274,6 @@ def __init__(self, response_model):
249274

250275
# actually parse out what we can filter by
251276
self.attrs = [c for c in response_model.attrs if c.filterable]
277+
278+
# This doesn't apply since we're building from the response model
279+
self.attr_routes = {}

linodecli/baked/response.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from openapi3.paths import MediaType
66

7+
from linodecli.baked.util import _aggregate_schema_properties
8+
79

810
def _is_paginated(response):
911
"""
@@ -170,10 +172,12 @@ def _parse_response_model(schema, prefix=None, nested_list_depth=0):
170172

171173
attrs = []
172174

173-
if schema.properties is None:
175+
properties, _ = _aggregate_schema_properties(schema)
176+
177+
if properties is None:
174178
return attrs
175179

176-
for k, v in schema.properties.items():
180+
for k, v in properties.items():
177181
pref = prefix + "." + k if prefix else k
178182

179183
if (

linodecli/baked/util.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""
2+
Provides various utility functions for use in baking logic.
3+
"""
4+
5+
from collections import defaultdict
6+
from typing import Any, Dict, Set, Tuple
7+
8+
from openapi3.schemas import Schema
9+
10+
11+
def _aggregate_schema_properties(
12+
schema: Schema,
13+
) -> Tuple[Dict[str, Any], Set[str]]:
14+
"""
15+
Aggregates all properties in the given schema, accounting properties
16+
nested in oneOf and anyOf blocks.
17+
18+
:param schema: The schema to aggregate properties from.
19+
:return: The aggregated properties and a set containing the keys of required properties.
20+
"""
21+
22+
schema_count = 0
23+
properties = {}
24+
required = defaultdict(lambda: 0)
25+
26+
def _handle_schema(_schema: Schema):
27+
if _schema.properties is None:
28+
return
29+
30+
nonlocal schema_count
31+
schema_count += 1
32+
33+
properties.update(dict(_schema.properties))
34+
35+
# Aggregate required keys and their number of usages.
36+
if _schema.required is not None:
37+
for key in _schema.required:
38+
required[key] += 1
39+
40+
_handle_schema(schema)
41+
42+
one_of = schema.oneOf or []
43+
any_of = schema.anyOf or []
44+
45+
for entry in one_of + any_of:
46+
# pylint: disable=protected-access
47+
_handle_schema(Schema(schema.path, entry, schema._root))
48+
49+
return (
50+
properties,
51+
# We only want to mark fields that are required by ALL subschema as required
52+
set(key for key, count in required.items() if count == schema_count),
53+
)

linodecli/help_pages.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,13 @@ def print_help_action(
224224
_help_action_print_filter_args(console, op)
225225
return
226226

227-
if op.args:
228-
_help_action_print_body_args(console, op)
227+
if len(op.arg_routes) > 0:
228+
# This operation uses oneOf so we need to render routes
229+
# instead of the operation-level argument list.
230+
for title, option in op.arg_routes.items():
231+
_help_action_print_body_args(console, op, option, title=title)
232+
elif op.args:
233+
_help_action_print_body_args(console, op, op.args)
229234

230235

231236
def _help_action_print_filter_args(console: Console, op: OpenAPIOperation):
@@ -250,13 +255,15 @@ def _help_action_print_filter_args(console: Console, op: OpenAPIOperation):
250255
def _help_action_print_body_args(
251256
console: Console,
252257
op: OpenAPIOperation,
258+
args: List[OpenAPIRequestArg],
259+
title: Optional[str] = None,
253260
):
254261
"""
255262
Pretty-prints all the body (POST/PUT) arguments for this operation.
256263
"""
257-
console.print("[bold]Arguments:[/]")
264+
console.print(f"[bold]Arguments{f' ({title})' if title else ''}:[/]")
258265

259-
for group in _help_group_arguments(op.args):
266+
for group in _help_group_arguments(args):
260267
for arg in group:
261268
metadata = []
262269

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
openapi: 3.0.1
2+
info:
3+
title: API Specification
4+
version: 1.0.0
5+
servers:
6+
- url: http://localhost/v4
7+
8+
paths:
9+
/foo/bar:
10+
x-linode-cli-command: foo
11+
post:
12+
summary: Do something.
13+
operationId: fooBarPost
14+
description: This is description
15+
requestBody:
16+
description: Some description.
17+
required: True
18+
content:
19+
application/json:
20+
schema:
21+
$ref: '#/components/schemas/Foo'
22+
responses:
23+
'200':
24+
description: Successful response
25+
content:
26+
application/json:
27+
schema:
28+
$ref: '#/components/schemas/Foo'
29+
30+
components:
31+
schemas:
32+
Foo:
33+
oneOf:
34+
- title: Usage 1
35+
type: object
36+
required:
37+
- foobar
38+
- barfoo
39+
properties:
40+
foobar:
41+
type: string
42+
description: Some foobar.
43+
barfoo:
44+
type: integer
45+
description: Some barfoo.
46+
- title: Usage 2
47+
type: object
48+
required:
49+
- foobar
50+
- foofoo
51+
properties:
52+
foobar:
53+
type: string
54+
description: Some foobar.
55+
foofoo:
56+
type: boolean
57+
description: Some foofoo.
58+
barbar:
59+
description: Some barbar.
60+
type: object
61+
anyOf:
62+
- type: object
63+
properties:
64+
foo:
65+
type: string
66+
description: Some foo.
67+
bar:
68+
type: integer
69+
description: Some bar.
70+
- type: object
71+
properties:
72+
baz:
73+
type: boolean
74+
description: Some baz.

0 commit comments

Comments
 (0)