Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion linodecli/baked/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ def _parse_response_model(schema, prefix=None, nested_list_depth=0):
)
elif v.type == "object":
attrs += _parse_response_model(v, prefix=pref)
elif v.type == "array" and v.items.type == "object":
elif v.type == "array" and (v.items.type == "object" or v.items.oneOf):
# Parse arrays for objects recursively and increase the nesting depth
attrs += _parse_response_model(
v.items,
Expand Down
60 changes: 59 additions & 1 deletion linodecli/baked/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,51 @@
from openapi3.schemas import Schema


def _schema_richness(schema: Any) -> int:
"""
Estimates how complete a schema definition is, used to decide which
definition to keep when the same property appears in multiple composition
(oneOf/allOf/anyOf) branches.

A branch that nulls a property out (e.g. ``{"type": "object", "nullable":
true}`` with no properties) should never overwrite a branch that fully
defines that property's nested structure.

:param schema: The schema (or raw schema dict) to score.
:return: A non-negative integer; higher means more complete.
"""

def get(attr: str) -> Any:
if isinstance(schema, dict):
return schema.get(attr)
return getattr(schema, attr, None)

score = 0

if get("properties"):
score += 1

if get("oneOf") or get("allOf") or get("anyOf"):
score += 1

items = get("items")
if items is not None:
item_get = (
items.get
if isinstance(items, dict)
else (lambda attr: getattr(items, attr, None))
)
if (
item_get("properties")
or item_get("oneOf")
or item_get("allOf")
or item_get("anyOf")
):
score += 1

return score


def _aggregate_schema_properties(
schema: Schema,
) -> Tuple[Dict[str, Any], Set[str]]:
Expand Down Expand Up @@ -48,7 +93,20 @@ def __inner(
return

# This is a valid option
properties.update(entry.properties)
for key, value in entry.properties.items():
# When the same property is defined in multiple composition
# branches (e.g. a oneOf of interface variants that each define
# `public`, `vpc`, `vlan`, etc.), keep the most complete
# definition instead of letting a later, emptier branch overwrite
# it. Otherwise nested fields like `public.ipv6.ranges.range`
# would be silently dropped when a subsequent branch nulls the
# property out.
if key in properties and _schema_richness(
value
) <= _schema_richness(properties[key]):
continue

properties[key] = value

nonlocal schema_count
schema_count += 1
Expand Down
68 changes: 68 additions & 0 deletions tests/fixtures/operation_oneof_property_overwrite.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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
put:
summary: Update something.
operationId: fooBarPut
description: This is description
requestBody:
description: Some description.
required: True
content:
application/json:
schema:
$ref: '#/components/schemas/Interface'
responses:
'200':
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/Interface'

components:
schemas:
# This schema reproduces the real-world case where a response is a oneOf
# of variants, and every variant defines the SAME set of top-level keys,
# but only fully populates the one relevant to that variant while nulling
# out the others. A naive dict.update() merge lets the last branch
# overwrite the fully-populated definitions from earlier branches.
Interface:
oneOf:
- title: Variant A
type: object
properties:
variant_a:
type: object
properties:
ranges:
type: array
items:
type: object
properties:
range:
type: string
description: The variant A range.
variant_b:
type: object
nullable: true
- title: Variant B
type: object
properties:
variant_a:
type: object
nullable: true
variant_b:
type: object
properties:
label:
type: string
description: The variant B label.

22 changes: 22 additions & 0 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,28 @@ def post_operation_with_one_ofs() -> OpenAPIOperation:
)


@pytest.fixture
def put_operation_with_oneof_property_overwrite() -> OpenAPIOperation:
"""
Creates an OpenAPI operation whose request/response is a oneOf of variants
that each define the same top-level keys, but only fully populate the key
relevant to that variant (nulling the others). Used to verify that
aggregating oneOf branches does not let a later, emptier branch overwrite a
fully-defined property from an earlier branch.
"""

spec = _get_parsed_spec("operation_oneof_property_overwrite.yaml")

path = list(spec.paths.values())[0]

return make_test_operation(
path.extensions.get("linode-cli-command", "default"),
getattr(path, "put"),
"put",
path.parameters,
)


@pytest.fixture
def get_openapi_for_api_components_tests() -> OpenAPI:
"""
Expand Down
19 changes: 19 additions & 0 deletions tests/unit/test_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,25 @@ def test_handle_one_ofs(self, post_operation_with_one_ofs):
assert attr_map[k].datatype == v[0]
assert attr_map[k].description == v[1]

def test_oneof_property_not_overwritten(
self, put_operation_with_oneof_property_overwrite
):
"""
Regression test: when a response is a oneOf of variants that each define
the same top-level keys (fully populating only one per branch and nulling
the rest), aggregating the branches must not let a later, emptier branch
overwrite a fully-defined property from an earlier branch.
"""
model = put_operation_with_oneof_property_overwrite.response_model

attr_paths = {attr.path for attr in model.attrs}

# variant_a is fully defined only in the first branch and nulled in the
# second; its nested field must survive aggregation.
assert "variant_a.ranges.range" in attr_paths
# variant_b is fully defined only in the second branch.
assert "variant_b.label" in attr_paths

def test_fix_json_string_type(self, list_operation_for_response_test):
model = list_operation_for_response_test.response_model
model.rows = ["foo.bar", "type"]
Expand Down