Make openapi specs reproducible
Sort openapi spec to make it possible to put them under the version control not taking care of changed ordering. Change-Id: Ifb999f85a6d77e751cba8587c516c7d2c5c23644
This commit is contained in:
@@ -1107,7 +1107,11 @@ class TypeManager:
|
||||
and (
|
||||
(
|
||||
name in unique_models
|
||||
and unique_models[name].hash_ != model_.reference.hash_
|
||||
and (
|
||||
unique_models[name].hash_ != model_.reference.hash_
|
||||
# maybe we previously decided to rename it
|
||||
or self.refs[unique_models[name]].name != name
|
||||
)
|
||||
)
|
||||
or name == self.root_name
|
||||
)
|
||||
@@ -1131,28 +1135,51 @@ class TypeManager:
|
||||
if new_name not in unique_models:
|
||||
# New name is still unused
|
||||
model_data_type.name = new_name
|
||||
self.refs[model_.reference].name = new_name
|
||||
unique_models[new_name] = model_.reference
|
||||
# rename original model to the same naming scheme
|
||||
other_model = unique_models.get(name)
|
||||
if other_model and other_model.parent:
|
||||
# Try adding parent_name as prefix
|
||||
new_other_name = (
|
||||
"".join(
|
||||
x.title()
|
||||
for x in other_model.parent.name.split("_")
|
||||
)
|
||||
+ name
|
||||
)
|
||||
logging.debug(
|
||||
f"Renaming also {other_model} into {new_other_name} for consistency"
|
||||
)
|
||||
self.refs[other_model].name = new_other_name
|
||||
# other_model.name = new_other_name
|
||||
unique_models[new_other_name] = other_model
|
||||
# Rename all other already processed models with name type and hash matching current model or the other model
|
||||
for ref, some_model in self.refs.items():
|
||||
if (
|
||||
hasattr(some_model, "name")
|
||||
and some_model.name == name
|
||||
):
|
||||
if (
|
||||
ref.type == model_.reference.type
|
||||
and ref.parent
|
||||
and ref.parent.name
|
||||
and (
|
||||
(
|
||||
other_model
|
||||
and ref.hash_ == other_model.hash_
|
||||
)
|
||||
or ref.hash_ == model_.reference.hash_
|
||||
)
|
||||
):
|
||||
new_other_name = (
|
||||
"".join(
|
||||
x.title()
|
||||
for x in ref.parent.name.split("_")
|
||||
)
|
||||
+ name
|
||||
)
|
||||
some_model.name = new_other_name
|
||||
unique_models[new_other_name] = some_model
|
||||
logging.debug(
|
||||
f"Renaming also {some_model} into {new_other_name} for consistency"
|
||||
)
|
||||
|
||||
else:
|
||||
if model_.reference.hash_ == unique_models[new_name].hash_:
|
||||
# not sure whether the new name should be save somewhere to be properly used in cli
|
||||
self.ignored_models.append(model_.reference)
|
||||
if name != self.refs[unique_models[name]].name:
|
||||
logging.debug(
|
||||
f"Found that same model {model_.reference} that we previously renamed to {self.refs[unique_models[name]].name}"
|
||||
)
|
||||
pass
|
||||
# not sure whether the new name should be save
|
||||
# somewhere to be properly used in cli
|
||||
# self.ignored_models.append(model_.reference)
|
||||
elif isinstance(model_data_type, Struct):
|
||||
# This is already an exceptional case (identity.mapping
|
||||
# with remote being oneOf with multiple structs)
|
||||
@@ -1199,8 +1226,9 @@ class TypeManager:
|
||||
# image.metadef.namespace have weird occurences of itself
|
||||
and model_.reference != unique_models[name]
|
||||
):
|
||||
# Ignore duplicated (or more precisely same) model
|
||||
self.ignored_models.append(model_.reference)
|
||||
# We already have literally same model. Do nothing expecting
|
||||
# that filtering in the `get_subtypes` will do the rest.
|
||||
pass
|
||||
elif name:
|
||||
unique_models[name] = model_.reference
|
||||
|
||||
@@ -1209,20 +1237,29 @@ class TypeManager:
|
||||
|
||||
def get_subtypes(self):
|
||||
"""Get all subtypes excluding TLA"""
|
||||
# Need to prevent literaly same objects to be emitted multiple times
|
||||
# what may happen in case of deep nesting
|
||||
emitted: set[str] = set()
|
||||
for k, v in self.refs.items():
|
||||
if (
|
||||
k
|
||||
and isinstance(v, (Enum, Struct, StringEnum))
|
||||
and k.name != self.root_name
|
||||
):
|
||||
yield v
|
||||
key = f"{k.type}:{getattr(v, 'name', '')}:{k.hash_}"
|
||||
if key not in emitted:
|
||||
emitted.add(key)
|
||||
yield v
|
||||
elif (
|
||||
k
|
||||
and k.name != self.root_name
|
||||
and isinstance(v, self.option_type_class)
|
||||
):
|
||||
if isinstance(v.item_type, Enum):
|
||||
yield v.item_type
|
||||
key = f"{v.item_type}:{getattr(v, 'name', '')}:{k.hash_}"
|
||||
if key not in emitted:
|
||||
emitted.add(key)
|
||||
yield v.item_type
|
||||
|
||||
def get_root_data_type(self):
|
||||
"""Get TLA type"""
|
||||
|
@@ -516,10 +516,10 @@ class JsonSchemaParser:
|
||||
) in [x.reference for x in results]:
|
||||
raise NotImplementedError
|
||||
else:
|
||||
logging.info(
|
||||
f"rename {obj.reference.name} to {new_name}"
|
||||
)
|
||||
obj.reference.name = new_name
|
||||
logging.info(
|
||||
f"rename {obj.reference.name} to {new_name}"
|
||||
)
|
||||
results.append(obj)
|
||||
|
||||
return obj
|
||||
|
@@ -83,6 +83,56 @@ def get_referred_type_data(func, name: str):
|
||||
raise RuntimeError("Cannot get module the function was defined in")
|
||||
|
||||
|
||||
def sort_schema(data: Any):
|
||||
def schema_tag_order(item):
|
||||
orders = {
|
||||
"openapi": 0,
|
||||
"info": 1,
|
||||
"jsonSchemaDialect": 2,
|
||||
"servers": 3,
|
||||
"paths": 4,
|
||||
"components": 5,
|
||||
"security": 6,
|
||||
"webhooks": 7,
|
||||
"tags": 8,
|
||||
"externalDocs": 9,
|
||||
}
|
||||
return orders.get(item[0], item[0])
|
||||
|
||||
return {
|
||||
key: sort_data(value)
|
||||
for key, value in sorted(data.items(), key=schema_tag_order)
|
||||
}
|
||||
|
||||
|
||||
def sort_data(data: Any):
|
||||
def get_key(item: Any) -> str:
|
||||
if isinstance(item, dict):
|
||||
return str(item)
|
||||
elif isinstance(item, (str, bool, int, float)):
|
||||
return str(item)
|
||||
elif item is None:
|
||||
return ""
|
||||
else:
|
||||
raise RuntimeError(f"Cannot determine key for {item}")
|
||||
|
||||
if isinstance(data, dict):
|
||||
return {
|
||||
key: sort_data(value)
|
||||
for key, value in sorted(data.items(), key=lambda item: item[0])
|
||||
}
|
||||
elif isinstance(data, list):
|
||||
return [sort_data(item) for item in sorted(data, key=get_key)]
|
||||
elif isinstance(data, tuple):
|
||||
return [sort_data(item) for item in sorted(data, key=get_key)]
|
||||
elif isinstance(data, (str, bool, int, float)):
|
||||
return data
|
||||
elif data is None:
|
||||
return data
|
||||
else:
|
||||
raise RuntimeError(f"Cannot sort {data} [{type(data)}]")
|
||||
|
||||
|
||||
class OpenStackServerSourceBase:
|
||||
# A URL to Operation tag (OpenApi group) mapping. Can be used when first
|
||||
# non parameter path element grouping is not enough
|
||||
@@ -120,7 +170,8 @@ class OpenStackServerSourceBase:
|
||||
with open(path) as fp:
|
||||
spec = yaml.load(fp)
|
||||
|
||||
return SpecSchema(**spec)
|
||||
if spec:
|
||||
return SpecSchema(**spec)
|
||||
|
||||
def dump_openapi(self, spec, path, validate: bool, service_type: str):
|
||||
"""Dump OpenAPI spec into the file"""
|
||||
@@ -131,8 +182,10 @@ class OpenStackServerSourceBase:
|
||||
yaml.indent(mapping=2, sequence=4, offset=2)
|
||||
with open(path, "w") as fp:
|
||||
yaml.dump(
|
||||
spec.model_dump(
|
||||
exclude_none=True, exclude_defaults=True, by_alias=True
|
||||
sort_schema(
|
||||
spec.model_dump(
|
||||
exclude_none=True, exclude_defaults=True, by_alias=True
|
||||
)
|
||||
),
|
||||
fp,
|
||||
)
|
||||
|
169
codegenerator/tests/unit/test_openapi.py
Normal file
169
codegenerator/tests/unit/test_openapi.py
Normal file
@@ -0,0 +1,169 @@
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
#
|
||||
from collections import OrderedDict
|
||||
import json
|
||||
from unittest import TestCase
|
||||
|
||||
from codegenerator.openapi import base
|
||||
|
||||
|
||||
class TestSortSchema(TestCase):
|
||||
def test_sort_dict(self):
|
||||
self.assertEqual(
|
||||
json.dumps({"a": "b", "b": 2, "c": "foo", "d": "bar"}),
|
||||
json.dumps(
|
||||
base.sort_data({"b": 2, "c": "foo", "d": "bar", "a": "b"})
|
||||
),
|
||||
)
|
||||
|
||||
def test_sort_dict_of_dicts(self):
|
||||
self.assertEqual(
|
||||
json.dumps(
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Bar": {"enum": ["1", "2", "4"], "type": "string"},
|
||||
"Foo": {
|
||||
"description": "foo",
|
||||
"properties": {
|
||||
"m": {"type": ["null", "string"]},
|
||||
"z": {"type": "string"},
|
||||
},
|
||||
"type": "object",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
json.dumps(
|
||||
base.sort_data(
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Foo": {
|
||||
"type": "object",
|
||||
"description": "foo",
|
||||
"properties": {
|
||||
"z": {"type": "string"},
|
||||
"m": {"type": ["string", "null"]},
|
||||
},
|
||||
},
|
||||
"Bar": {
|
||||
"type": "string",
|
||||
"enum": ["1", "4", "2"],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def test_sort_spec(self):
|
||||
self.assertEqual(
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "foo"},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Bar": {"type": "string", "enum": ["1", "2", "4"]},
|
||||
"Baz": {"enum": [1, 2.3, "4", True]},
|
||||
"Foo": {
|
||||
"description": "foo",
|
||||
"properties": {
|
||||
"m": {"type": ["null", "string"]},
|
||||
"z": {"type": "string"},
|
||||
},
|
||||
"type": "object",
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
base.sort_data(
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Foo": {
|
||||
"type": "object",
|
||||
"description": "foo",
|
||||
"properties": {
|
||||
"z": {"type": "string"},
|
||||
"m": {"type": ["string", "null"]},
|
||||
},
|
||||
},
|
||||
"Baz": {"enum": [1, 2.3, "4", True]},
|
||||
"Bar": {"type": "string", "enum": ["1", "4", "2"]},
|
||||
}
|
||||
},
|
||||
"info": {"title": "foo"},
|
||||
"openapi": "3.1.0",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def test_order(self):
|
||||
expected = OrderedDict(
|
||||
{
|
||||
"projects_not-tags": OrderedDict(
|
||||
{
|
||||
"in": "query",
|
||||
"name": "not-tags",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"x-openstack": {
|
||||
"openapi": {
|
||||
"schema": {
|
||||
"explode": "false",
|
||||
"items": {
|
||||
"maxLength": 255,
|
||||
"minLength": 1,
|
||||
"pattern": "^[^,/]*$",
|
||||
"type": "string",
|
||||
},
|
||||
"style": "form",
|
||||
"type": "array",
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
unordered = {
|
||||
"projects_not-tags": {
|
||||
"name": "not-tags",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"x-openstack": {
|
||||
"openapi": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 255,
|
||||
"pattern": "^[^,/]*$",
|
||||
},
|
||||
"style": "form",
|
||||
"explode": "false",
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
self.assertEqual(
|
||||
json.dumps(expected), json.dumps(base.sort_data(unordered))
|
||||
)
|
Reference in New Issue
Block a user