Skip to content

Commit

Permalink
Support oneOfs & anyOfs in operation request & response schema (#701)
Browse files Browse the repository at this point in the history
  • Loading branch information
lgarber-akamai authored Dec 30, 2024
1 parent defe7aa commit d91e70b
Show file tree
Hide file tree
Showing 10 changed files with 331 additions and 67 deletions.
13 changes: 12 additions & 1 deletion linodecli/baked/operation.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@
from openapi3.paths import Operation, Parameter

from linodecli.baked.parsing import simplify_description
from linodecli.baked.request import OpenAPIFilteringRequest, OpenAPIRequest
from linodecli.baked.request import (
OpenAPIFilteringRequest,
OpenAPIRequest,
OpenAPIRequestArg,
)
from linodecli.baked.response import OpenAPIResponse
from linodecli.exit_codes import ExitCodes
from linodecli.output.output_handler import OutputHandler
Expand Down Expand Up @@ -415,6 +419,13 @@ def args(self):
"""
return self.request.attrs if self.request else []

@property
def arg_routes(self) -> Dict[str, List[OpenAPIRequestArg]]:
"""
Return a list of attributes from the request schema
"""
return self.request.attr_routes if self.request else []

@staticmethod
def _flatten_url_path(tag: str) -> str:
"""
Expand Down
144 changes: 86 additions & 58 deletions linodecli/baked/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
Request details for a CLI Operation
"""

from openapi3.schemas import Schema

from linodecli.baked.parsing import simplify_description
from linodecli.baked.util import _aggregate_schema_properties


class OpenAPIRequestArg:
Expand Down Expand Up @@ -134,66 +137,68 @@ def _parse_request_model(schema, prefix=None, parent=None, depth=0):
"""
args = []

if schema.properties is not None:
for k, v in schema.properties.items():
if v.type == "object" and not v.readOnly and v.properties:
# nested objects receive a prefix and are otherwise parsed normally
pref = prefix + "." + k if prefix else k

args += _parse_request_model(
v,
prefix=pref,
properties, required = _aggregate_schema_properties(schema)

if properties is None:
return args

for k, v in properties.items():
if (
v.type == "object"
and not v.readOnly
and len(_aggregate_schema_properties(v)[0]) > 0
):
# nested objects receive a prefix and are otherwise parsed normally
pref = prefix + "." + k if prefix else k

args += _parse_request_model(
v,
prefix=pref,
parent=parent,
# NOTE: We do not increment the depth because dicts do not have
# parent arguments.
depth=depth,
)
elif (
v.type == "array"
and v.items
and v.items.type == "object"
and v.extensions.get("linode-cli-format") != "json"
):
# handle lists of objects as a special case, where each property
# of the object in the list is its own argument
pref = prefix + "." + k if prefix else k

# Support specifying this list as JSON
args.append(
OpenAPIRequestArg(
k,
v.items,
False,
prefix=prefix,
is_parent=True,
parent=parent,
# NOTE: We do not increment the depth because dicts do not have
# parent arguments.
depth=depth,
)
elif (
v.type == "array"
and v.items
and v.items.type == "object"
and v.extensions.get("linode-cli-format") != "json"
):
# handle lists of objects as a special case, where each property
# of the object in the list is its own argument
pref = prefix + "." + k if prefix else k

# Support specifying this list as JSON
args.append(
OpenAPIRequestArg(
k,
v.items,
False,
prefix=prefix,
is_parent=True,
parent=parent,
depth=depth,
)
)
)

args += _parse_request_model(
v.items,
prefix=pref,
parent=pref,
depth=depth + 1,
)
else:
# required fields are defined in the schema above the property, so
# we have to check here if required fields are defined/if this key
# is among them and pass it into the OpenAPIRequestArg class.
required = False
if schema.required:
required = k in schema.required
args.append(
OpenAPIRequestArg(
k,
v,
required,
prefix=prefix,
parent=parent,
depth=depth,
)
args += _parse_request_model(
v.items,
prefix=pref,
parent=pref,
depth=depth + 1,
)
else:
args.append(
OpenAPIRequestArg(
k,
v,
k in required,
prefix=prefix,
parent=parent,
depth=depth,
)
)

return args

Expand All @@ -212,15 +217,35 @@ def __init__(self, request):
:type request: openapi3.MediaType
"""
self.required = request.schema.required

schema_override = request.extensions.get("linode-cli-use-schema")

schema = request.schema

if schema_override:
override = type(request)(
request.path, {"schema": schema_override}, request._root
)
override._resolve_references()
self.attrs = _parse_request_model(override.schema)
else:
self.attrs = _parse_request_model(request.schema)
schema = override.schema

self.attrs = _parse_request_model(schema)

# attr_routes stores all attribute routes defined using oneOf.
# For example, config-create uses one of to isolate HTTP, HTTPS, and TCP request attributes
self.attr_routes = {}

if schema.oneOf is not None:
for entry in schema.oneOf:
entry_schema = Schema(schema.path, entry, request._root)
if entry_schema.title is None:
raise ValueError(
f"No title for oneOf entry in {schema.path}"
)

self.attr_routes[entry_schema.title] = _parse_request_model(
entry_schema
)


class OpenAPIFilteringRequest:
Expand Down Expand Up @@ -249,3 +274,6 @@ def __init__(self, response_model):

# actually parse out what we can filter by
self.attrs = [c for c in response_model.attrs if c.filterable]

# This doesn't apply since we're building from the response model
self.attr_routes = {}
8 changes: 6 additions & 2 deletions linodecli/baked/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from openapi3.paths import MediaType

from linodecli.baked.util import _aggregate_schema_properties


def _is_paginated(response):
"""
Expand Down Expand Up @@ -170,10 +172,12 @@ def _parse_response_model(schema, prefix=None, nested_list_depth=0):

attrs = []

if schema.properties is None:
properties, _ = _aggregate_schema_properties(schema)

if properties is None:
return attrs

for k, v in schema.properties.items():
for k, v in properties.items():
pref = prefix + "." + k if prefix else k

if (
Expand Down
53 changes: 53 additions & 0 deletions linodecli/baked/util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""
Provides various utility functions for use in baking logic.
"""

from collections import defaultdict
from typing import Any, Dict, Set, Tuple

from openapi3.schemas import Schema


def _aggregate_schema_properties(
schema: Schema,
) -> Tuple[Dict[str, Any], Set[str]]:
"""
Aggregates all properties in the given schema, accounting properties
nested in oneOf and anyOf blocks.
:param schema: The schema to aggregate properties from.
:return: The aggregated properties and a set containing the keys of required properties.
"""

schema_count = 0
properties = {}
required = defaultdict(lambda: 0)

def _handle_schema(_schema: Schema):
if _schema.properties is None:
return

nonlocal schema_count
schema_count += 1

properties.update(dict(_schema.properties))

# Aggregate required keys and their number of usages.
if _schema.required is not None:
for key in _schema.required:
required[key] += 1

_handle_schema(schema)

one_of = schema.oneOf or []
any_of = schema.anyOf or []

for entry in one_of + any_of:
# pylint: disable=protected-access
_handle_schema(Schema(schema.path, entry, schema._root))

return (
properties,
# We only want to mark fields that are required by ALL subschema as required
set(key for key, count in required.items() if count == schema_count),
)
15 changes: 11 additions & 4 deletions linodecli/help_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,13 @@ def print_help_action(
_help_action_print_filter_args(console, op)
return

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


def _help_action_print_filter_args(console: Console, op: OpenAPIOperation):
Expand All @@ -250,13 +255,15 @@ def _help_action_print_filter_args(console: Console, op: OpenAPIOperation):
def _help_action_print_body_args(
console: Console,
op: OpenAPIOperation,
args: List[OpenAPIRequestArg],
title: Optional[str] = None,
):
"""
Pretty-prints all the body (POST/PUT) arguments for this operation.
"""
console.print("[bold]Arguments:[/]")
console.print(f"[bold]Arguments{f' ({title})' if title else ''}:[/]")

for group in _help_group_arguments(op.args):
for group in _help_group_arguments(args):
for arg in group:
metadata = []

Expand Down
74 changes: 74 additions & 0 deletions tests/fixtures/operation_with_one_ofs.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
openapi: 3.0.1
info:
title: API Specification
version: 1.0.0
servers:
- url: http://localhost/v4

paths:
/foo/bar:
x-linode-cli-command: foo
post:
summary: Do something.
operationId: fooBarPost
description: This is description
requestBody:
description: Some description.
required: True
content:
application/json:
schema:
$ref: '#/components/schemas/Foo'
responses:
'200':
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/Foo'

components:
schemas:
Foo:
oneOf:
- title: Usage 1
type: object
required:
- foobar
- barfoo
properties:
foobar:
type: string
description: Some foobar.
barfoo:
type: integer
description: Some barfoo.
- title: Usage 2
type: object
required:
- foobar
- foofoo
properties:
foobar:
type: string
description: Some foobar.
foofoo:
type: boolean
description: Some foofoo.
barbar:
description: Some barbar.
type: object
anyOf:
- type: object
properties:
foo:
type: string
description: Some foo.
bar:
type: integer
description: Some bar.
- type: object
properties:
baz:
type: boolean
description: Some baz.
Loading

0 comments on commit d91e70b

Please sign in to comment.