Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support oneOfs & anyOfs in operation request & response schema #701

Open
wants to merge 10 commits into
base: dev
Choose a base branch
from
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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is necessary to account for nested anyOfs and oneOfs.

):
# 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 = {}
9 changes: 7 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 @@ -169,10 +171,13 @@ 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 v.type == "object":
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
Loading