From b7a9774e087a4ff5f928d4709e2a8271455a2c14 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 22 Dec 2021 16:39:48 +0100 Subject: [PATCH 001/125] using dataclasses describe the base types using dataclasses, use the data class annotations for parsing all unit tests pass --- openapi3/components.py | 41 ++--- openapi3/example.py | 20 +- openapi3/general.py | 26 +-- openapi3/info.py | 61 +++---- openapi3/object_base.py | 391 ++++++++++++++++------------------------ openapi3/openapi.py | 92 +++++----- openapi3/paths.py | 333 +++++++++++++++------------------- openapi3/schemas.py | 193 ++++++++------------ openapi3/security.py | 28 +-- openapi3/servers.py | 35 ++-- openapi3/tag.py | 17 +- 11 files changed, 516 insertions(+), 721 deletions(-) diff --git a/openapi3/components.py b/openapi3/components.py index f489550..39c8a28 100644 --- a/openapi3/components.py +++ b/openapi3/components.py @@ -1,6 +1,9 @@ -from .object_base import ObjectBase +import dataclasses +from typing import Union +from .object_base import ObjectBase, Map +@dataclasses.dataclass(init=False) class Components(ObjectBase): """ A `Components Object`_ holds a reusable set of different aspects of the OAS @@ -8,29 +11,15 @@ class Components(ObjectBase): .. _Components Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#componentsObject """ + __slots__ = ['schemas', 'responses', 'parameters', 'examples', 'headers', + 'requestBodies', 'securitySchemes', 'links', 'callback'] - __slots__ = [ - "schemas", - "responses", - "parameters", - "examples", - "headers", - "requestBodies", - "securitySchemes", - "links", - "callback", - ] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.examples = self._get("examples", ["Example", "Reference"], is_map=True) - self.parameters = self._get("parameters", ["Parameter", "Reference"], is_map=True) - self.requestBodies = self._get("requestBody", ["RequestBody", "Reference"], is_map=True) - self.responses = self._get("responses", ["Response", "Reference"], is_map=True) - self.schemas = self._get("schemas", ["Schema", "Reference"], is_map=True) - self.securitySchemes = self._get("securitySchemes", ["SecurityScheme", "Reference"], is_map=True) - # self.headers = self._get('headers', ['Header', 'Reference'], is_map=True) - self.links = self._get("links", ["Link", "Reference"], is_map=True) - # self.callbacks = self._get('callbacks', ['Callback', 'Reference'], is_map=True) + examples: Map[str, Union['Example', 'Reference']] + parameters: Map[str, Union['Parameter', 'Reference']] + requestBodies: Map[str, Union['RequestBody', 'Reference']] + responses: Map[str, Union['Response', 'Reference']] + schemas: Map[str, Union['Schema', 'Reference']] + securitySchemes: Map[str, Union['SecurityScheme', 'Reference']] + # headers: ['Header', 'Reference'], is_map=True + links: Map[str, Union['Link', 'Reference']] + # callbacks: ['Callback', 'Reference'], is_map=True diff --git a/openapi3/example.py b/openapi3/example.py index ea9a959..d9f0158 100644 --- a/openapi3/example.py +++ b/openapi3/example.py @@ -1,6 +1,9 @@ -from .object_base import ObjectBase +import dataclasses +from typing import Union +from .object_base import ObjectBase +@dataclasses.dataclass(init=False) class Example(ObjectBase): """ A `Example Object`_ holds a reusable set of different aspects of the OAS @@ -8,14 +11,9 @@ class Example(ObjectBase): .. _Example Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#exampleObject """ + __slots__ = ['summary', 'description', 'value', 'externalValue'] - __slots__ = ["summary", "description", "value", "externalValue"] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.summary = self._get("summary", str) - self.description = self._get("description", str) - self.value = self._get("value", ["Reference", dict, str]) # 'any' type - self.externalValue = self._get("externalValue", str) + summary: str + description: str + value: Union['Reference', dict, str] # 'any' type + externalValue: str diff --git a/openapi3/general.py b/openapi3/general.py index dd453a6..56beef7 100644 --- a/openapi3/general.py +++ b/openapi3/general.py @@ -1,6 +1,7 @@ +import dataclasses from .object_base import ObjectBase - +@dataclasses.dataclass(init=False) class ExternalDocumentation(ObjectBase): """ An `External Documentation Object`_ references external resources for extended @@ -8,25 +9,24 @@ class ExternalDocumentation(ObjectBase): .. _External Documentation Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#externalDocumentationObject """ + __slos__ = ['description', 'url'] + required_fields = 'url' - __slos__ = ["description", "url"] - required_fields = "url" - - def _parse_data(self): - self.description = self._get("description", str) - self.url = self._get("url", str) - + description: str + url: str +@dataclasses.dataclass(init=False) class Reference(ObjectBase): """ A `Reference Object`_ designates a reference to another node in the specification. .. _Reference Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#referenceObject """ - # can't start a variable name with a $ - __slots__ = ["ref"] - required_fields = ["$ref"] + __slots__ = ['ref'] + required_fields = ['$ref'] + + ref: str def _parse_data(self): self.ref = self._get("$ref", str) @@ -38,6 +38,6 @@ def can_parse(cls, dct): in __slots__ (since that's not a valid python variable name) """ # TODO - can a reference object have spec extensions? - cleaned_keys = [k for k in dct.keys() if not k.startswith("x-")] + cleaned_keys = [k for k in dct.keys() if not k.startswith('x-')] - return len(cleaned_keys) == 1 and "$ref" in dct + return len(cleaned_keys) == 1 and '$ref' in dct diff --git a/openapi3/info.py b/openapi3/info.py index 4bc332f..379b4dc 100644 --- a/openapi3/info.py +++ b/openapi3/info.py @@ -1,60 +1,49 @@ +import dataclasses +from typing import ForwardRef from .object_base import ObjectBase - +@dataclasses.dataclass(init=False) class Info(ObjectBase): """ An OpenAPI Info object, as defined in `the spec`_. .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#infoObject """ - - __slots__ = ["title", "description", "termsOfService", "contact", "license", "version"] - required_fields = ["title", "version"] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.contact = self._get("contact", "Contact") - self.description = self._get("description", str) - self.license = self._get("license", "License") - self.termsOfService = self._get("termsOfService", str) - self.title = self._get("title", str) - self.version = self._get("version", str) - - + __slots__ = ['title', 'description', 'termsOfService', 'contact', + 'license', 'version'] + required_fields = ['title', 'version'] + + contact: ForwardRef('Contact') + description: str + license: ForwardRef('License') + termsOfService: str + title: str + version: str + +@dataclasses.dataclass(init=False) class Contact(ObjectBase): """ Contact object belonging to an Info object, as described `here`_ .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#contactObject """ + __slots__ = ['name', 'url', 'email'] + required_fields = ['name', 'url', 'email'] - __slots__ = ["name", "url", "email"] - required_fields = ["name", "url", "email"] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.email = self._get("email", str) - self.name = self._get("name", str) - self.url = self._get("url", str) + email: str + name: str + url: str +@dataclasses.dataclass(init=False) class License(ObjectBase): """ License object belonging to an Info object, as described `here`_ .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#license-object """ + __slots__ = ['name', 'url'] + required_fields = ['name'] - __slots__ = ["name", "url"] - required_fields = ["name"] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.name = self._get("name", str) - self.url = self._get("url", str) + name: str + url: str diff --git a/openapi3/object_base.py b/openapi3/object_base.py index af8f1c6..7230ffc 100644 --- a/openapi3/object_base.py +++ b/openapi3/object_base.py @@ -1,4 +1,9 @@ import sys +import typing + + + +import dataclasses from .errors import SpecError, ReferenceResolutionError @@ -12,7 +17,7 @@ def _asdict(x): - if hasattr(x, "__getstate__"): + if hasattr(x, '__getstate__'): return x.__getstate__() elif isinstance(x, dict): return {k: _asdict(v) for k, v in x.items()} @@ -42,8 +47,7 @@ def raise_on_unknown_type(parent, field, object_types, found): if len(object_types) == 1: if isinstance(object_types[0], str): expected_type = ObjectBase.get_object_type(object_types[0]) - raise SpecError( - "Expected {}.{} to be of type {}, with required fields {}".format( + raise SpecError('Expected {}.{} to be of type {}, with required fields {}'.format( parent.get_path(), field, object_types[0], @@ -52,16 +56,11 @@ def raise_on_unknown_type(parent, field, object_types, found): path=parent.path, element=parent, ) - elif ( - len(object_types) == 2 - and len([c for c in object_types if isinstance(c, str)]) == 2 - and "Reference" in object_types - ): + elif len(object_types) == 2 and len([c for c in object_types if isinstance(c, str)]) == 2 and "Reference" in object_types: # we can give a similar error here as above expected_type_str = [c for c in object_types if c != "Reference"][0] expected_type = ObjectBase.get_object_type(expected_type_str) - raise SpecError( - "Expected {}.{} to be of type {} or Reference, but did not find required fields {} or '$ref'".format( + raise SpecError("Expected {}.{} to be of type {} or Reference, but did not find required fields {} or '$ref'".format( parent.get_path(), field, expected_type_str, @@ -70,27 +69,25 @@ def raise_on_unknown_type(parent, field, object_types, found): path=parent.path, element=parent, ) - raise SpecError( - "Expected {}.{} to be one of [{}], got {}".format( - parent.get_path(), - field, - ",".join( - [str(c) for c in object_types], + print(object_types) + raise SpecError('Expected {}.{} to be one of [{}], got {}'.format( + parent.get_path(), + field, + ','.join([str(c) for c in object_types], ), - type(found), + type(found) ), path=parent.path, element=parent, ) - class ObjectBase(object): """ The base class for all schema objects. Includes helpers for common schema- related functions. """ - - __slots__ = ["path", "raw_element", "_accessed_members", "strict", "_root", "extensions", "_original_ref"] + __slots__ = ['path', 'raw_element', '_accessed_members', 'strict', '_root', + 'extensions', '_original_ref'] required_fields = [] def __init__(self, path, raw_element, root): @@ -108,7 +105,7 @@ def __init__(self, path, raw_element, root): """ # init empty slots for k in type(self).__slots__: - if k in ("_spec_errors", "validation_mode"): + if k in ('_spec_errors', 'validation_mode'): # allow these two fields to keep their values continue setattr(self, k, None) @@ -152,7 +149,9 @@ def __getstate__(self): Allows pickling objects by returning a dict of all slotted values. """ - return _asdict({k: getattr(self, k) for k in type(self).__slots__ if hasattr(self, k)}) + return _asdict({ + k: getattr(self, k) for k in type(self).__slots__ if hasattr(self, k) + }) def __setstate__(self, state): """ @@ -171,15 +170,13 @@ def _required_fields(self, *fields): :raises SpecError: if any of the required fields are not present. """ - missing_fields = [] - for field in fields: - if field not in self.raw_element: - missing_fields.append(field) + missing_fields = set(fields) - set(self.raw_element) if missing_fields: - raise SpecError( - "Missing required fields: {}".format(", ".join(missing_fields)), path=self.path, element=self - ) + raise SpecError('Missing required fields: {}'.format( + ', '.join(missing_fields)), + path=self.path, + element=self) def _parse_data(self): """ @@ -193,9 +190,11 @@ def _parse_data(self): are parsed and then an assertion is made that all keys in the raw_element were accessed - if not, the schema is considered invalid. """ - raise NotImplementedError("You must implement this method in subclasses!") + for field in dataclasses.fields(self): + v = self._get(field.name, field.type) + setattr(self, field.name, v) - def _get(self, field, object_types, is_list=False, is_map=False): + def _get(self, field, object_type): """ Retrieves a value from this object's raw element, and returns None if it is not present. Use :any:`_required_fields` to ensure all required @@ -203,19 +202,8 @@ def _get(self, field, object_types, is_list=False, is_map=False): :param field: The field name to retrieve :type field: str - :param object_types: The types of Objects that are accepted. One of - these types will be returned, or the spec will be - considered invalid. If the magic string '*' is - passed in, it must be the only accepted type, and - all types will be accepted. - :type object_types: list[str or Type] - :param is_list: If true, this should return a List of object of the give - types. - :param is_list: bool - :param is_map: If true, this must return a :any:`Map` of object of the given - types - :type is_map: bool - + :param object_type: The type + :type object_type: typing :returns: object_type if given, otherwise the type parsed from the spec file """ @@ -226,53 +214,44 @@ def _get(self, field, object_types, is_list=False, is_map=False): return None try: - if not isinstance(object_types, list): - # maybe don't accept not-lists - object_types = [object_types] - - if "*" in object_types and len(object_types) != 1: - raise ValueError("Fields that accept any type must not specify any other types!") - - # if yaml loads a value that includes a unicode character in python2, - # that value will come in as a ``unicode`` type instead of a ``str``. - # For the purposes of this library, those are the same thing, so in - # python2 only, we'll include ``unicode`` for any element that - # accepts ``str`` types. - if IS_PYTHON_2: - if str in object_types: - object_types += [unicode] - - if is_list: + origin = typing.get_origin(object_type) or object_type + if origin == list: if not isinstance(ret, list): - raise SpecError( - "Expected {}.{} to be a list of [{}], got {}".format( - self.get_path, field, ",".join([str(c) for c in object_types]), type(ret) - ), + raise SpecError('Expected {}.{} to be a list of {}, got {}'.format( + self.get_path, field, object_type, + type(ret)), path=self.path, - element=self, - ) - ret = self.parse_list(ret, object_types, field) - elif is_map: + element=self) + ret = self.parse_list(ret, object_type, field) + elif origin == Map: if not isinstance(ret, dict): - raise SpecError( - "Expected {}.{} to be a Map of string: [{}], got {}".format( - self.get_path, field, ",".join([str(c) for c in object_types]), type(ret) - ), + raise SpecError('Expected {}.{} to be a Map of string: [{}], got {}'.format( + self.get_path, field, object_type, + type(ret)), path=self.path, - element=self, - ) - ret = Map(self.path + [field], ret, object_types, self._root) + element=self) + ret = Map(self.path + [field], ret, object_type, self._root) else: - accepts_string = str in object_types - found_type = False - - for t in object_types: - if t == "*": - found_type = True + if origin == typing.Union: + types = [] + for t in typing.get_args(object_type): + if isinstance(t, typing.ForwardRef): + types.append(t.__forward_arg__) + else: + types.append(t) + elif isinstance(origin, typing.ForwardRef): + types = [origin.__forward_arg__] + else: + types = [object_type] + + + accepts_string = False + for t in types: + if t == typing.Any: break if t == str: - # try to parse everything else first + accepts_string = True continue if isinstance(t, str): @@ -282,19 +261,16 @@ def _get(self, field, object_types, is_list=False, is_map=False): if python_type.can_parse(ret): ret = python_type(self.path + [field], ret, self._root) - found_type = True break + elif isinstance(ret, t): # it's already the type we need - found_type = True break - - if not found_type: + else: if accepts_string and isinstance(ret, str): - found_type = True - - if not found_type: - raise_on_unknown_type(self, field, object_types, ret) + pass + else: + raise_on_unknown_type(self, field, types, ret) except SpecError as e: if self._root.validation_mode: self._root.log_spec_error(e) @@ -340,7 +316,7 @@ def can_parse(cls, dct): return False # ensure that the dict's keys are valid in our slots for key in dct.keys(): - if key.startswith("x-"): + if key.startswith('x-'): # ignore spec extensions continue @@ -364,38 +340,10 @@ def _parse_spec_extensions(self): .. _Specification Extensions: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#specificationExtensions """ for k, v in self.raw_element.items(): - if k.startswith("x-"): + if k.startswith('x-'): self.extensions[k[2:]] = v self._accessed_members.append(k) - def _clone(self): - """ - Returns a copy of this object - """ - cls = self.__class__ - inst = cls.__new__(cls) - - for c in self.__slots__: - val = getattr(self, c) - if issubclass(type(val), ObjectBase) or isinstance(val, Map): - val = val._clone() - elif isinstance(val, list): - new_val = [] - for cur in val: - if issubclass(type(cur), ObjectBase) or isinstance(cur, Map): - new_val.append(cur._clone()) - else: - new_val.append(cur) - val = new_val - - setattr(inst, c, val) - - for c in ObjectBase.__slots__: - if hasattr(self, c): - setattr(inst, c, getattr(self, c)) - - return inst - @classmethod def get_object_type(cls, typename): """ @@ -409,13 +357,19 @@ def get_object_type(cls, typename): :returns: The Type associated with this name :raises ValueError: if no Type with that name was found """ - if not hasattr(cls, "_subclass_map"): + if not hasattr(cls, '_subclass_map'): + def resolve(c, d): + r = {t.__name__: t for t in c.__subclasses__()} + for k,v in r.items(): + resolve(v, d) + d.update(r) + return d # generate subclass map on first call - setattr(cls, "_subclass_map", {t.__name__: t for t in cls.__subclasses__()}) + setattr(cls, '_subclass_map', resolve(cls, {})) # TODO - why? if typename not in cls._subclass_map: # pylint: disable=no-member - raise ValueError("ObjectBase has no subclass {}".format(typename)) + raise ValueError('ObjectBase has no subclass {}'.format(typename)) return cls._subclass_map[typename] # pylint: disable=no-member @@ -426,19 +380,17 @@ def get_path(self): :returns: The path in the spec for this element :rtype: str """ - return ".".join(self.path) + return '.'.join(self.path) - def parse_list(self, raw_list, object_types, field=None): + def parse_list(self, raw_list, object_type, field=None): """ Given a list of Objects, iterates over the list and creates the relevant Objects, returning the resulting list. :param raw_list: The list to parse :type raw_list: list[dict] - :param object_types: A list of subclass names to attempt to parse the - objects to. The list does not need to consist of - only one of these types. - :type object_type: list[str] + :param object_type: typing + :type object_type: typeing.Type :param field: The field to append to self.get_path() when determining path for created objects. :type field: str @@ -449,14 +401,12 @@ def parse_list(self, raw_list, object_types, field=None): if raw_list is None: return None - if not isinstance(object_types, list): - object_types = [object_types] - real_path = self.path[:] if field: real_path += [field] - python_types = [ObjectBase.get_object_type(t) if isinstance(t, str) else t for t in object_types] + python_types = self.types_of(object_type) + result = [] for i, cur in enumerate(raw_list): @@ -473,49 +423,54 @@ def parse_list(self, raw_list, object_types, field=None): continue if not found_type: - raise SpecError( - "Could not parse {}.{}, expected to be one of [{}]".format(".".join(real_path), i, object_types), + raise SpecError('Could not parse {}.{}, expected to be one of [{}]'.format( + '.'.join(real_path), i, python_types), path=self.path, - element=self, - ) + element=self) return result + @staticmethod + def _resolve_type(obj, value): + # we found a reference - attempt to resolve it + reference_path = value.ref + if not reference_path.startswith('#/'): + raise ReferenceResolutionError('Invalid reference path {}'.format( + reference_path), + path=obj.path, + element=obj) + + reference_path = reference_path.split('/')[1:] + + try: + resolved_value = obj._root.resolve_path(reference_path) + except ReferenceResolutionError as e: + # add metadata to the error + e.path = obj.path + e.element = obj + raise + + # FIXME - will break if multiple things reference the same + # node + resolved_value._original_ref = value + return resolved_value + def _resolve_references(self): """ Resolves all reference objects below this object and notes their original value was a reference. """ # don't circular import - reference_type = ObjectBase.get_object_type("Reference") + reference_type = ObjectBase.get_object_type('Reference') for slot in self.__slots__: - if slot.startswith("_"): + if slot.startswith('_'): # don't parse private members continue value = getattr(self, slot) if isinstance(value, reference_type): - # we found a reference - attempt to resolve it - reference_path = value.ref - if not reference_path.startswith("#/"): - raise ReferenceResolutionError( - "Invalid reference path {}".format(reference_path), path=self.path, element=self - ) - - reference_path = reference_path.split("/")[1:] - - try: - resolved_value = self._root.resolve_path(reference_path) - except ReferenceResolutionError as e: - # add metadata to the error - e.path = self.path - e.element = self - raise - - resolved_value._original_ref = value - - # resolved + resolved_value = self._resolve_type(self, value) setattr(self, slot, resolved_value) elif issubclass(type(value), ObjectBase) or isinstance(value, Map): # otherwise, continue resolving down the tree @@ -525,21 +480,10 @@ def _resolve_references(self): resolved_list = [] for item in value: if isinstance(item, reference_type): - # TODO - this is duplicated code - reference_path = item.ref.split("/")[1:] - - try: - resolved_value = self._root.resolve_path(reference_path) - except ReferenceResolutionError as e: - # add metadata to the error - e.path = self.path - e.element = self - raise - - resolved_value._original_ref = value + resolved_value = self._resolve_type(self, item) resolved_list.append(resolved_value) else: - if issubclass(type(item), ObjectBase) or isinstance(item, Map): + if issubclass(type(value), ObjectBase) or isinstance(value, Map): item._resolve_references() resolved_list.append(item) @@ -558,8 +502,9 @@ def _resolve_allOfs(self): continue value = getattr(self, slot) - - if issubclass(type(value), ObjectBase): + if value is None: + continue + elif issubclass(type(value), ObjectBase): value._resolve_allOfs() elif issubclass(type(value), Map): for _, c in value.items(): @@ -568,6 +513,34 @@ def _resolve_allOfs(self): for c in value: if issubclass(type(c), ObjectBase) or issubclass(type(c), Map): c._resolve_allOfs() + elif isinstance(value, (int, str, dict)): + continue + else: + raise TypeError(value) + + @staticmethod + def types_of(object_type, expected=None): + def resolve(t): + if typing.get_origin(t) == typing.Union: + t = typing.get_args(t) + else: + t = [t] + + t = [ObjectBase.get_object_type(i.__forward_arg__) if isinstance(i, typing.ForwardRef) else i for i in t] + return t + + if expected: + assert typing.get_origin(object_type) == expected + + if typing.get_origin(object_type) == list: + python_types = typing.get_args(object_type)[0] + return resolve(python_types) + + if typing.get_origin(object_type) == Map: + args = typing.get_args(object_type) + return resolve(args[0]),resolve(args[1]) + + raise TypeError(object_type) class Map(dict): @@ -575,9 +548,9 @@ class Map(dict): The Map object wraps a python dict and parses its values into the chosen type or types. """ - __slots__ = ['path', 'raw_element', '_root'] + __slots__ = ['dct', 'path', 'raw_element', '_root'] - def __init__(self, path, raw_element, object_types, root): + def __init__(self, path, raw_element, object_type, root): """ Creates a dict containing the parsed objects from the raw element @@ -586,24 +559,16 @@ def __init__(self, path, raw_element, object_types, root): :param raw_element: The raw spec data for this map. The keys must all be strings. :type raw_element: dict - :param object_types: A list of strings accepted by - :any:`ObjectBase.get_object_type`, or the python - types to parse. - :type object_types: list[str or Type] + :param object_type: typing + :type object_type: typing """ self.path = path self.raw_element = raw_element self._root = root - python_types = [] + python_types = ObjectBase.types_of(object_type, Map)[1] dct = {} - for t in object_types: - if isinstance(t, str): - python_types.append(ObjectBase.get_object_type(t)) - else: - python_types.append(t) - for k, v in self.raw_element.items(): found_type = False @@ -616,7 +581,7 @@ def __init__(self, path, raw_element, object_types, root): found_type = True if not found_type: - raise_on_unknown_type(self, k, object_types, v) + raise_on_unknown_type(self, k, python_types, v) self.update(dct) @@ -626,54 +591,14 @@ def _resolve_references(self): in :any:`ObjectBase._resolve_references`. This implementation simply calls the same on all values in this Map. """ - reference_type = ObjectBase.get_object_type("Reference") + reference_type = ObjectBase.get_object_type('Reference') for key, value in self.items(): if isinstance(value, reference_type): - # TODO - this is repeated code - # we found a reference - attempt to resolve it - reference_path = value.ref - if not reference_path.startswith("#/"): - raise ReferenceResolutionError( - "Invalid reference path {}".format(reference_path), path=self.path, element=self - ) - - reference_path = reference_path.split("/")[1:] - - try: - resolved_value = self._root.resolve_path(reference_path) - except ReferenceResolutionError as e: - # add metadata to the error - e.path = self.path - e.element = self - raise - - resolved_value._original_ref = value - - # resolved - self[key] = resolved_value + self[key] = ObjectBase._resolve_type(self, value) else: value._resolve_references() - def _clone(self): - """ - Returns a copy of this object and all its values - """ - ret = Map.__new__(self.__class__) - - for c in self.__slots__: - setattr(ret, c, getattr(self, c)) - - dct = {} - for k, v in self.items(): - if issubclass(type(v), ObjectBase) or isinstance(v, Map): - dct[k] = v._clone() - else: - dct[k] = v - - ret.update(dct) - return ret - def get_path(self): """ Get the full path for this element in the spec @@ -681,4 +606,6 @@ def get_path(self): :returns: The path in the spec for this element :rtype: str """ - return ".".join(self.path) + return '.'.join(self.path) + + diff --git a/openapi3/openapi.py b/openapi3/openapi.py index 5b79b14..7ab5b98 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -1,9 +1,12 @@ +import dataclasses +from typing import ForwardRef, Any, List + import requests from .object_base import ObjectBase, Map from .errors import ReferenceResolutionError, SpecError - +@dataclasses.dataclass(init=False) class OpenAPI(ObjectBase): """ This class represents the root of the OpenAPI schema document, as defined @@ -11,28 +14,27 @@ class OpenAPI(ObjectBase): .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#openapi-object """ - - __slots__ = [ - "openapi", - "info", - "servers", - "paths", - "components", - "security", - "tags", - "externalDocs", - "_operation_map", - "_security", - "validation_mode", - "_spec_errors", - "_ssl_verify", - "_session", - ] - required_fields = ["openapi", "info", "paths"] + __slots__ = ['openapi','info','servers','paths','components','security','tags', + 'externalDocs','_operation_map','_security', 'validation_mode', + '_spec_errors', '_ssl_verify', '_session'] + required_fields=['openapi','info','paths'] + + components: ForwardRef('Components') + externalDocs: Map[Any, Any] + info: ForwardRef('Info') + openapi: str + paths: Map[str, ForwardRef('Path')] + security: List['SecurityRequirement'] + servers: List['Server'] + tags: List['Tag'] def __init__( - self, raw_document, validate=False, ssl_verify=None, use_session=False, session_factory=requests.Session - ): + self, + raw_document, + validate=False, + ssl_verify=None, + use_session=False, + session_factory=requests.Session): """ Creates a new OpenAPI document from a loaded spec file. This is overridden here because we need to specify the path in the parent @@ -68,7 +70,7 @@ def __init__( self._session = session_factory() # public methods - def authenticte(self, security_scheme, value): + def authenticate(self, security_scheme, value): """ Authenticates all subsequent requests with the given arguments. @@ -81,18 +83,17 @@ def authenticte(self, security_scheme, value): return if security_scheme not in self.components.securitySchemes: - raise ValueError("{} does not accept security scheme {}".format(self.info.title, security_scheme)) + raise ValueError('{} does not accept security scheme {}'.format( + self.info.title, security_scheme)) self._security = {security_scheme: value} - authenticate = authenticte - def resolve_path(self, path): """ Given a $ref path, follows the document tree and returns the given attribute. :param path: The path down the spec tree to follow - :type path: list[str] + :type path: List[str] :returns: The node requested :rtype: ObjectBase @@ -103,12 +104,12 @@ def resolve_path(self, path): for part in path: if isinstance(node, Map): if part not in node: # pylint: disable=unsupported-membership-test - err_msg = "Invalid path {} in Reference".format(path) + err_msg = 'Invalid path {} in Reference'.format(path) raise ReferenceResolutionError(err_msg) node = node.get(part) else: if not hasattr(node, part): - err_msg = "Invalid path {} in Reference".format(path) + err_msg = 'Invalid path {} in Reference'.format(path) raise ReferenceResolutionError(err_msg) node = getattr(node, part) @@ -124,7 +125,8 @@ def log_spec_error(self, error): :type error: SpecError """ if not self.validation_mode: - raise RuntimeError("This client is not in Validation Mode, cannot " "record errors!") + raise RuntimeError('This client is not in Validation Mode, cannot ' + 'record errors!') self._spec_errors.append(error) def errors(self): @@ -133,10 +135,11 @@ def errors(self): This should not be called if not in Validation Mode. :returns: The errors encountered during the parsing of this spec. - :rtype: list[SpecError] + :rtype: List[SpecError] """ if not self.validation_mode: - raise RuntimeError("This client is not in Validation Mode, cannot " "return errors!") + raise RuntimeError('This client is not in Validation Mode, cannot ' + 'return errors!') return self._spec_errors # private methods @@ -160,14 +163,7 @@ def _parse_data(self): """ self._operation_map = {} - self.components = self._get("components", ["Components"]) - self.externalDocs = self._get("externalDocs", dict) - self.info = self._get("info", "Info") - self.openapi = self._get("openapi", str) - self.paths = self._get("paths", ["Path"], is_map=True) - self.security = self._get("security", ["SecurityRequirement"], is_list=True) - self.servers = self._get("servers", ["Server"], is_list=True) - self.tags = self._get("tags", ["Tag"], is_list=True) + super()._parse_data() # now that we've parsed _all_ the data, resolve all references self._resolve_references() @@ -187,7 +183,8 @@ def _get_callable(self, operation): """ base_url = self.servers[0].url - return OperationCallable(operation, base_url, self._security, self._ssl_verify, self._session) + return OperationCallable(operation, base_url, self._security, self._ssl_verify, + self._session) def __getattribute__(self, attr): """ @@ -208,12 +205,13 @@ def __getattribute__(self, attr): :rtype: any :raises AttributeError: if the requested attribute does not exist """ - if attr.startswith("call_"): - _, operationId = attr.split("_", 1) + if attr.startswith('call_'): + _, operationId = attr.split('_', 1) if operationId in self._operation_map: return self._get_callable(self._operation_map[operationId].request) else: - raise AttributeError("{} has no operation {}".format(self.info.title, operationId)) + raise AttributeError('{} has no operation {}'.format( + self.info.title, operationId)) return object.__getattribute__(self, attr) @@ -226,7 +224,6 @@ class OperationCallable: with the configured values included. This class is not intended to be used directly. """ - def __init__(self, operation, base_url, security, ssl_verify, session): self.operation = operation self.base_url = base_url @@ -236,7 +233,8 @@ def __init__(self, operation, base_url, security, ssl_verify, session): def __call__(self, *args, **kwargs): if self.ssl_verify is not None: - kwargs["verify"] = self.ssl_verify + kwargs['verify'] = self.ssl_verify if self.session: - kwargs["session"] = self.session - return self.operation(self.base_url, *args, security=self.security, **kwargs) + kwargs['session'] = self.session + return self.operation(self.base_url, *args, security=self.security, + **kwargs) diff --git a/openapi3/paths.py b/openapi3/paths.py index 0b31cc1..6369d03 100644 --- a/openapi3/paths.py +++ b/openapi3/paths.py @@ -1,3 +1,5 @@ +import dataclasses +from typing import ForwardRef, Union, List import json import re import requests @@ -8,7 +10,7 @@ from urllib import urlencode from .errors import SpecError -from .object_base import ObjectBase +from .object_base import ObjectBase, Map from .schemas import Model @@ -16,14 +18,14 @@ def _validate_parameters(instance): """ Ensures that all parameters for this path are valid """ - allowed_path_parameters = re.findall(r"{([a-zA-Z0-9\-\._~]+)}", instance.path[1]) + allowed_path_parameters = re.findall(r'{([a-zA-Z0-9\-\._~]+)}', instance.path[1]) for c in instance.parameters: - if c.in_ == "path": + if c.in_ == 'path': if c.name not in allowed_path_parameters: - raise SpecError("Parameter name not found in path: {}".format(c.name), path=instance.path) - + raise SpecError('Parameter name not found in path: {}'.format(c.name), path=instance.path) +@dataclasses.dataclass(init=False) class Path(ObjectBase): """ A Path object, as defined `here`_. Path objects represent URL paths that @@ -31,40 +33,28 @@ class Path(ObjectBase): .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#paths-object """ - - __slots__ = [ - "summary", - "description", - "get", - "put", - "post", - "delete", - "options", - "head", - "patch", - "trace", - "servers", - "parameters", - ] + __slots__ = ['summary', 'description', 'get', 'put', 'post', 'delete', + 'options', 'head', 'patch', 'trace', 'servers', 'parameters'] + + delete: ForwardRef('Operation') + description: str + get: ForwardRef('Operation') + head: ForwardRef('Operation') + options: ForwardRef('Operation') + parameters: List[Union['Parameter', 'Reference']] # = [] + patch: ForwardRef('Operation') + post: ForwardRef('Operation') + put: ForwardRef('Operation') + servers: List['Server'] + summary: str + trace: ForwardRef('Operation') def _parse_data(self): """ Implementation of :any:`ObjectBase._parse_data` """ # TODO - handle possible $ref - self.delete = self._get("delete", "Operation") - self.description = self._get("description", str) - self.get = self._get("get", "Operation") - self.head = self._get("head", "Operation") - self.options = self._get("options", "Operation") - self.parameters = self._get("parameters", ["Parameter", "Reference"], is_list=True) - self.patch = self._get("patch", "Operation") - self.post = self._get("post", "Operation") - self.put = self._get("put", "Operation") - self.servers = self._get("servers", ["Server"], is_list=True) - self.summary = self._get("summary", str) - self.trace = self._get("trace", "Operation") - + super()._parse_data() if self.parameters is None: # this will be iterated over later self.parameters = [] @@ -80,95 +70,73 @@ def _resolve_references(self): _validate_parameters(self) +@dataclasses.dataclass(init=False) class Parameter(ObjectBase): """ A `Parameter Object`_ defines a single operation parameter. .. _Parameter Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#parameterObject """ - - __slots__ = [ - "name", - "in", - "in_", - "description", - "required", - "deprecated", - "allowEmptyValue", - "style", - "explode", - "allowReserved", - "schema", - "example", - "examples", - ] - required_fields = ["name", "in"] + __slots__ = ['name', 'in', 'in_', 'description', 'required', 'deprecated', + 'allowEmptyValue', 'style', 'explode', 'allowReserved', + 'schema', 'example', 'examples'] + required_fields = ['name', 'in'] + + deprecated: bool + description: str + example: str + examples: Map[str, Union['Example','Reference']] + explode: bool + in_: str # TODO must be one of ["query","header","path","cookie"] + name: str + required: bool + schema: Union['Schema', 'Reference'] + style: str + + # allow empty or reserved values in Parameter data + allowEmptyValue: bool + allowReserved: bool def _parse_data(self): - self.deprecated = self._get("deprecated", bool) - self.description = self._get("description", str) - self.example = self._get("example", [str, int, bool, float]) # Spec notes 'Any' but just limited to primitives - self.examples = self._get("examples", dict) # Map[str: ['Example','Reference']] - self.explode = self._get("explode", bool) - self.in_ = self._get("in", str) # TODO must be one of ["query","header","path","cookie"] - self.name = self._get("name", str) - self.required = self._get("required", bool) - self.schema = self._get("schema", ["Schema", "Reference"]) - self.style = self._get("style", str) - - # allow empty or reserved values in Parameter data - self.allowEmptyValue = self._get("allowEmptyValue", bool) - self.allowReserved = self._get("allowReserved", bool) + super()._parse_data() + self.in_ = self._get("in", str) # required is required and must be True if this parameter is in the path if self.in_ == "path" and self.required is not True: - err_msg = "Parameter {} must be required since it is in the path" + err_msg = 'Parameter {} must be required since it is in the path' raise SpecError(err_msg.format(self.get_path()), path=self.path) +@dataclasses.dataclass(init=False) class Operation(ObjectBase): """ An Operation object as defined `here`_ .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#operationObject """ - - __slots__ = [ - "tags", - "summary", - "description", - "externalDocs", - "security", - "operationId", - "parameters", - "requestBody", - "responses", - "callbacks", - "deprecated", - "servers", - "_session", - "_request", - ] - required_fields = ["responses"] + __slots__ = ['tags', 'summary', 'description', 'externalDocs', 'security', + 'operationId', 'parameters', 'requestBody', 'responses', + 'callbacks', 'deprecated', 'servers', '_session', '_request'] + required_fields = ['responses'] + + deprecated: bool + description: str + externalDocs: ForwardRef('ExternalDocumentation') + operationId: str + parameters: List[Union['Parameter', 'Reference']] + requestBody: Union['RequestBody', 'Reference'] + responses: Map[str, Union['Response', 'Reference']] + security: List['SecurityRequirement'] + servers: List['Server'] + summary: str + tags: List[str] def _parse_data(self): """ Implementation of :any:`ObjectBase._parse_data` """ - raw_servers = self._get("servers", list) - self.deprecated = self._get("deprecated", bool) - self.description = self._get("description", str) - self.externalDocs = self._get("externalDocs", "ExternalDocumentation") - self.operationId = self._get("operationId", str) - self.parameters = self._get("parameters", ["Parameter", "Reference"], is_list=True) - self.requestBody = self._get("requestBody", ["RequestBody", "Reference"]) - self.responses = self._get("responses", ["Response", "Reference"], is_map=True) - self.security = self._get("security", ["SecurityRequirement"], is_list=True) - self.servers = self._get("servers", ["Server"], is_list=True) - self.summary = self._get("summary", str) - self.tags = self._get("tags", list) - raw_servers = self._get("servers", list) - # self.callbacks = self._get('callbacks', dict) TODO + super()._parse_data() + # callbacks: dict TODO # default parameters to an empty list for processing later if self.parameters is None: @@ -182,7 +150,7 @@ def _parse_data(self): # TODO - maybe make this generic if self.security is None: - self.security = self._root._get("security", ["SecurityRequirement"], is_list=True) or [] + self.security = [] # Store session object self._session = requests.Session() @@ -203,31 +171,31 @@ def _resolve_references(self): def _request_handle_secschemes(self, security_requirement, value): ss = self._root.components.securitySchemes[security_requirement.name] - if ss.type == "http" and ss.scheme == "basic": + if ss.type == 'http' and ss.scheme == 'basic': self._request.auth = requests.auth.HTTPBasicAuth(*value) - if ss.type == "http" and ss.scheme == "digest": + if ss.type == 'http' and ss.scheme == 'digest': self._request.auth = requests.auth.HTTPDigestAuth(*value) - if ss.type == "http" and ss.scheme == "bearer": - header = ss.bearerFormat or "Bearer {}" - self._request.headers["Authorization"] = header.format(value) + if ss.type == 'http' and ss.scheme == 'bearer': + header = ss.bearerFormat or 'Bearer {}' + self._request.headers['Authorization'] = header.format(value) - if ss.type == "mutualTLS": + if ss.type == 'mutualTLS': # TLS Client certificates (mutualTLS) self._request.cert = value - if ss.type == "apiKey": - if ss.in_ == "query": + if ss.type == 'apiKey': + if ss.in_ == 'query': # apiKey in query parameter - self._request.params[ss.name] = value + self._request.params[ss.name] = value - if ss.in_ == "header": + if ss.in_ == 'header': # apiKey in query header data self._request.headers[ss.name] = value - if ss.in_ == "cookie": - self._request.cookies = {ss.name: value} + if ss.in_ == 'cookie': + self._request.cookies ={ss.name:value} def _request_handle_parameters(self, parameters={}): # Parameters @@ -244,30 +212,30 @@ def _request_handle_parameters(self, parameters={}): value = parameters[name] except KeyError: if spec.required and name not in parameters: - err_msg = "Required parameter {} not provided".format(name) + err_msg = 'Required parameter {} not provided'.format(name) raise ValueError(err_msg) continue - if spec.in_ == "path": + if spec.in_ == 'path': # The string method `format` is incapable of partial updates, # as such we need to collect all the path parameters before # applying them to the format string. path_parameters[name] = value - if spec.in_ == "query": - self._request.params[name] = value + if spec.in_ == 'query': + self._request.params[name] = value - if spec.in_ == "header": + if spec.in_ == 'header': self._request.headers[name] = value - if spec.in_ == "cookie": + if spec.in_ == 'cookie': self._request.cookies[name] = value self._request.url = self._request.url.format(**path_parameters) def _request_handle_body(self, data): - if "application/json" in self.requestBody.content: + if 'application/json' in self.requestBody.content: if isinstance(data, dict) or isinstance(data, list): body = json.dumps(data) @@ -279,11 +247,12 @@ def _request_handle_body(self, data): body = json.dumps(data_dict, default=converter) self._request.data = body - self._request.headers["Content-Type"] = "application/json" + self._request.headers['Content-Type'] = 'application/json' else: raise NotImplementedError() - def request(self, base_url, security={}, data=None, parameters={}, verify=True, session=None, raw_response=False): + def request(self, base_url, security={}, data=None, parameters={}, verify=True, + session=None, raw_response=False): """ Sends an HTTP request as described by this Path @@ -322,15 +291,13 @@ def request(self, base_url, security={}, data=None, parameters={}, verify=True, self._request_handle_secschemes(r, value) if security_requirement is None: - err_msg = """No security requirement satisfied (accepts {}) \ - """.format( - ", ".join(self.security.keys()) - ) + err_msg = '''No security requirement satisfied (accepts {}) \ + '''.format(', '.join(self.security.keys())) raise ValueError(err_msg) if self.requestBody: if self.requestBody.required and data is None: - err_msg = "Request Body is required but none was provided." + err_msg = 'Request Body is required but none was provided.' raise ValueError(err_msg) self._request_handle_body(data) @@ -350,75 +317,69 @@ def request(self, base_url, security={}, data=None, parameters={}, verify=True, expected_response = None if status_code in self.responses: expected_response = self.responses[status_code] - elif "default" in self.responses: - expected_response = self.responses["default"] + elif 'default' in self.responses: + expected_response = self.responses['default'] if expected_response is None: # TODO - custom exception class that has the response object in it - err_msg = """Unexpected response {} from {} (expected one of {}, \ - no default is defined""" - err_var = result.status_code, self.operationId, ",".join(self.responses.keys()) + err_msg = '''Unexpected response {} from {} (expected one of {}, \ + no default is defined''' + err_var = result.status_code, self.operationId, ','.join(self.responses.keys()) raise RuntimeError(err_msg.format(*err_var)) - # if we got back a valid response code (or there was a default) and no - # response content was expected, return None - if expected_response.content is None: - return - - content_type = result.headers["Content-Type"] - if ';' in content_type: - # if the content type that came in included an encoding, we'll ignore - # it for now (requests has already parsed it for us) and only look at - # the MIME type when determining if an expected content type was returned. - content_type = content_type.split(';')[0].strip() - + content_type = result.headers['Content-Type'] expected_media = expected_response.content.get(content_type, None) - if expected_media is None and "/" in content_type: + if expected_media is None and '/' in content_type: # accept media type ranges in the spec. the most specific matching # type should always be chosen, but if we do not have a match here # a generic range should be accepted if one if provided # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#response-object - generic_type = content_type.split("/")[0] + "/*" + generic_type = content_type.split('/')[0] + '/*' expected_media = expected_response.content.get(generic_type, None) if expected_media is None: - err_msg = """Unexpected Content-Type {} returned for operation {} \ - (expected one of {})""" - err_var = result.headers["Content-Type"], self.operationId, ",".join(expected_response.content.keys()) + err_msg = '''Unexpected Content-Type {} returned for operation {} \ + (expected one of {})''' + err_var = result.headers['Content-Type'], self.operationId, ','.join(expected_response.content.keys()) raise RuntimeError(err_msg.format(*err_var)) response_data = None - if content_type.lower() == "application/json": + if content_type.lower() == 'application/json': return expected_media.schema.model(result.json()) else: raise NotImplementedError() +@dataclasses.dataclass(init=False) class SecurityRequirement(ObjectBase): """ A `SecurityRequirement`_ object describes security schemes for API access. .. _SecurityRequirement: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#securityRequirementObject """ - - ___slots__ = ["name", "types"] + ___slots__ = ['name', 'types'] required_fields = [] + name: str + types: List[str] + def _parse_data(self): - """ """ + """ + """ # usually these only ever have one key if len(self.raw_element.keys()) == 1: - self.name = [c for c in self.raw_element.keys()][0] - self.types = self._get(self.name, str, is_list=True) + self.name = [c for c in self.raw_element.keys()][0] + self.types = self._get(self.name, List[str]) elif len(self.raw_element.keys()) == 0: # optional self.name = self.types = None + @classmethod def can_parse(cls, dct): """ @@ -431,26 +392,22 @@ def __getstate__(self): return {self.name: self.types} +@dataclasses.dataclass(init=False) class RequestBody(ObjectBase): """ A `RequestBody`_ object describes a single request body. .. _RequestBody: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#requestBodyObject """ + __slots__ = ['description', 'content', 'required'] + required_fields = ['content'] - __slots__ = ["description", "content", "required"] - required_fields = ["content"] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.description = self._get("description", str) - self.content = self._get("content", ["MediaType"], is_map=True) - raw_content = self._get("content", dict) - self.required = self._get("required", bool) + description: str + content: Map[str, ForwardRef('MediaType')] + required: bool +@dataclasses.dataclass(init=False) class MediaType(ObjectBase): """ A `MediaType`_ object provides schema and examples for the media type identified @@ -458,20 +415,16 @@ class MediaType(ObjectBase): .. _MediaType: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#mediaTypeObject """ - - __slots__ = ["schema", "example", "examples", "encoding"] + __slots__ = ['schema', 'example', 'examples', 'encoding'] required_fields = [] - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.schema = self._get("schema", ["Schema", "Reference"]) - self.example = self._get("example", str) # 'any' type - self.examples = self._get("examples", ["Example", "Reference"], is_map=True) - self.encoding = self._get("encoding", dict) # Map['Encoding'] + schema: Union['Schema', 'Reference'] + example: str # 'any' type + examples: Map[str, Union['Example', 'Reference']] + encoding: Map[str, ForwardRef('Encoding')] +@dataclasses.dataclass(init=False) class Response(ObjectBase): """ A `Response Object`_ describes a single response from an API Operation, @@ -479,42 +432,38 @@ class Response(ObjectBase): .. _Response Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#response-object """ + __slots__ = ['description', 'headers', 'content', 'links'] + required_fields = ['description'] - __slots__ = ["description", "headers", "content", "links"] - required_fields = ["description"] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.content = self._get("content", ["MediaType"], is_map=True) - self.description = self._get("description", str) - raw_content = self._get("content", dict) - raw_headers = self._get("headers", dict) - self.links = self._get("links", ["Link", "Reference"], is_map=True) + content: Map[str, ForwardRef('MediaType')] + description: str + links: Map[str, Union['Link', 'Reference']] +@dataclasses.dataclass(init=False) class Link(ObjectBase): """ A `Link Object`_ describes a single Link from an API Operation Response to an API Operation Request .. _Link Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#linkObject """ + __slots__ = ['operationId', 'operationRef', 'description', 'parameters', 'requestBody', 'server'] - __slots__ = ["operationId", "operationRef", "description", "parameters", "requestBody", "server"] + operationId: str + operationRef: str + description: str + parameters: dict + requestBody: dict + server: ForwardRef('Server') def _parse_data(self): """ Implementation of :any:`ObjectBase._parse_data` """ - self.operationId = self._get("operationId", str) - self.operationRef = self._get("operationRef", str) - self.description = self._get("description", str) - self.parameters = self._get("parameters", dict) - self.requestBody = self._get("requestBody", dict) - self.server = self._get("server", ["Server"]) + super()._parse_data() if self.operationId and self.operationRef: raise SpecError("operationId and operationRef are mutually exclusive, only one of them is allowed") + if not (self.operationId or self.operationRef): raise SpecError("operationId and operationRef are mutually exclusive, one of them must be specified") diff --git a/openapi3/schemas.py b/openapi3/schemas.py index 5d341cf..df21f6c 100644 --- a/openapi3/schemas.py +++ b/openapi3/schemas.py @@ -1,104 +1,75 @@ -from .errors import SpecError, ModelError +from typing import Union, List +import dataclasses + +from .errors import SpecError from .general import Reference # need this for Model below from .object_base import ObjectBase, Map TYPE_LOOKUP = { - "array": list, - "integer": int, - "object": dict, - "string": str, - "boolean": bool, + 'array': list, + 'integer': int, + 'object': dict, + 'string': str, + 'boolean': bool, } +@dataclasses.dataclass(init=False) class Schema(ObjectBase): """ The `Schema Object`_ allows the definition of input and output data types. .. _Schema Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#schemaObject """ - - __slots__ = [ - "title", - "multipleOf", - "maximum", - "exclusiveMaximum", - "minimum", - "exclusiveMinimum", - "maxLength", - "minLength", - "pattern", - "maxItems", - "minItems", - "uniqueItems", - "maxProperties", - "minProperties", - "required", - "enum", - "type", - "allOf", - "oneOf", - "anyOf", - "not", - "items", - "properties", - "additionalProperties", - "description", - "format", - "default", - "nullable", - "discriminator", - "readOnly", - "writeOnly", - "xml", - "externalDocs", - "example", - "deprecated", - "contentEncoding", - "contentMediaType", - "contentSchema", - "_model_type", - "_request_model_type", - "_resolved_allOfs", - ] + __slots__ = ['title', 'multipleOf', 'maximum', 'exclusiveMaximum', + 'minimum', 'exclusiveMinimum', 'maxLength', 'minLength', + 'pattern', 'maxItems', 'minItems', 'uniqueItems', + 'maxProperties', 'minProperties', 'required', 'enum', 'type', + 'allOf', 'oneOf', 'anyOf', 'not', 'items', 'properties', + 'additionalProperties', 'description', 'format', 'default', + 'nullable', 'discriminator', 'readOnly', 'writeOnly', 'xml', + 'externalDocs', 'example', 'deprecated', 'contentEncoding', + 'contentMediaType', 'contentSchema', '_model_type', + '_request_model_type', '_resolved_allOfs'] required_fields = [] + title: str + maximum: Union[int, float] + minimum: Union[int, float] + maxLength: int + minLength: int + pattern: str + maxItems: int + minItems: int + required: List[str] + enum: list + type: str + allOf: List[Union["Schema", "Reference"]] + oneOf: list + anyOf: list + items: Union['Schema', 'Reference'] + properties: Map[str, Union['Schema', 'Reference']] + additionalProperties: [bool, dict] + description: str + format: str + default: str # TODO - str as a default? + nullable: bool + discriminator: dict # 'Discriminator' + readOnly: bool + writeOnly: bool + xml: dict # 'XML' + externalDocs: dict # 'ExternalDocs' + deprecated: bool + example: object + contentEncoding: str + contentMediaType: str + contentSchema: str + def _parse_data(self): """ Implementation of :any:`ObjectBase._parse_data` """ - self.title = self._get("title", str) - self.maximum = self._get("maximum", [int, float]) - self.minimum = self._get("minimum", [int, float]) - self.maxLength = self._get("maxLength", int) - self.minLength = self._get("minLength", int) - self.pattern = self._get("pattern", str) - self.maxItems = self._get("maxItems", int) - self.minItems = self._get("minItmes", int) - self.required = self._get("required", list) - self.enum = self._get("enum", list) - self.type = self._get("type", str) - self.allOf = self._get("allOf", ["Schema", "Reference"], is_list=True) - self.oneOf = self._get("oneOf", list) - self.anyOf = self._get("anyOf", list) - self.items = self._get("items", ["Schema", "Reference"]) - self.properties = self._get("properties", ["Schema", "Reference"], is_map=True) - self.additionalProperties = self._get("additionalProperties", [bool, dict]) - self.description = self._get("description", str) - self.format = self._get("format", str) - self.default = self._get("default", TYPE_LOOKUP.get(self.type, str)) # TODO - str as a default? - self.nullable = self._get("nullable", bool) - self.discriminator = self._get("discriminator", dict) # 'Discriminator' - self.readOnly = self._get("readOnly", bool) - self.writeOnly = self._get("writeOnly", bool) - self.xml = self._get("xml", dict) # 'XML' - self.externalDocs = self._get("externalDocs", dict) # 'ExternalDocs' - self.deprecated = self._get("deprecated", bool) - self.example = self._get("example", "*") - self.contentEncoding = self._get("contentEncoding", str) - self.contentMediaType = self._get("contentMediaType", str) - self.contentSchema = self._get("contentSchema", str) - + super()._parse_data() # TODO - Implement the following properties: # self.multipleOf # self.not @@ -110,8 +81,9 @@ def _parse_data(self): self._resolved_allOfs = False - if self.type == "array" and self.items is None: - raise SpecError('{}: items is required when type is "array"'.format(self.get_path())) + if self.type == 'array' and self.items is None: + raise SpecError('{}: items is required when type is "array"'.format( + self.get_path())) def get_type(self): """ @@ -128,11 +100,9 @@ def get_type(self): # this is defined in ObjectBase.__init__ as all slots are if self._model_type is None: # pylint: disable=access-member-before-definition type_name = self.title or self.path[-1] - self._model_type = type( - type_name, - (Model,), - {"__slots__": self.properties.keys()}, # pylint: disable=attribute-defined-outside-init - ) + self._model_type = type(type_name, (Model,), { # pylint: disable=attribute-defined-outside-init + '__slots__': self.properties.keys() + }) return self._model_type @@ -146,7 +116,7 @@ def model(self, data): :returns: A new :any:`Model` created in this Schema's type from the data. :rtype: self.get_type() """ - if self.properties is None and self.type in ("string", "number"): # more simple types + if self.properties is None and self.type in ('string', 'number'): # more simple types # if this schema represents a simple type, simply return the data # TODO - perhaps assert that the type of data matches the type we # expected @@ -164,13 +134,9 @@ def get_request_type(self): # this is defined in ObjectBase.__init__ as all slots are if self._request_model_type is None: # pylint: disable=access-member-before-definition type_name = self.title or self.path[-1] - self._request_model_type = type( - type_name + "Request", - (Model,), - { # pylint: disable=attribute-defined-outside-init - "__slots__": [k for k, v in self.properties.items() if not v.readOnly] - }, - ) + self._request_model_type = type(type_name + 'Request', (Model,), { # pylint: disable=attribute-defined-outside-init + '__slots__': [k for k, v in self.properties.items() if not v.readOnly] + }) return self._request_model_type @@ -193,6 +159,9 @@ def _resolve_allOfs(self): if self.allOf: for c in self.allOf: +# for c in typing.get_args(self.allOf): +# assert isinstance(c, typing.ForwardRef) +# c = ObjectBase.get_object_type(c.__forward_arg__) if isinstance(c, Schema): self._merge(c) @@ -200,19 +169,6 @@ def _merge(self, other): """ Merges ``other`` into this schema, preferring to use the values in ``other`` """ - # Clone the other object so that we're never merging a referenced object. - # This will ensure that an allOf like this: - # - # allOf: - # - $ref: '#/components/schema/Example' - # - type: object - # properties: - # foo: - # type string - # - # Does not add or modify "foo" on components.schemas['Example'] - other = other._clone() - for slot in self.__slots__: if slot.startswith("_"): # skip private members @@ -258,8 +214,7 @@ class Model: are generated from Schema objects by called :any:`Schema.model` with the contents of a response. """ - - __slots__ = ["_raw_data", "_schema"] + __slots__ = ['_raw_data', '_schema'] def __init__(self, data, schema): """ @@ -271,25 +226,21 @@ def __init__(self, data, schema): :type data: dict """ self._raw_data = data - self._schema = schema + self._schema = schema for s in self.__slots__: # initialize all slots to None setattr(self, s, None) - keys = set(data.keys()) - frozenset(self.__slots__) - if keys: - raise ModelError("Schema {} got unexpected attribute keys {}".format(self.__class__.__name__, keys)) - # collect the data into this model for k, v in data.items(): prop = schema.properties[k] - if prop.type == "array": + if prop.type == 'array': # handle arrays item_schema = prop.items setattr(self, k, [item_schema.model(c) for c in v]) - elif prop.type == "object": + elif prop.type == 'object': # handle nested objects object_schema = prop setattr(self, k, object_schema.model(v)) @@ -304,7 +255,9 @@ def __repr__(self): def __iter__(self): for s in self.__slots__: - if s.startswith("_"): + if s.startswith('_'): continue yield s, getattr(self, s) return + + diff --git a/openapi3/security.py b/openapi3/security.py index a10977d..6ff53d5 100644 --- a/openapi3/security.py +++ b/openapi3/security.py @@ -1,26 +1,26 @@ -from .errors import SpecError +import dataclasses from .object_base import ObjectBase, Map - +@dataclasses.dataclass(init=False) class SecurityScheme(ObjectBase): """ A `Security Scheme`_ defines a security scheme that can be used by the operations. .. _Security Scheme: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#securitySchemeObject """ + __slots__ = ['type', 'description', 'name', 'in', 'in_', 'scheme', + 'bearerFormat', 'flows', 'openIdConnectUrl'] + required_fields = ['type'] - __slots__ = ["type", "description", "name", "in", "in_", "scheme", "bearerFormat", "flows", "openIdConnectUrl"] - required_fields = ["type"] + bearerFormat: str + description: str + flows: Map[str, str] # TODO + in_: str + name: str + openIdConnectUrl: str + scheme: str + type: str def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.bearerFormat = self._get("bearerFormat", [str]) - self.description = self._get("description", [str]) - self.flows = self._get("flows", dict) # ['OAuthFlows']) TODO + super()._parse_data() self.in_ = self._get("in", str) - self.name = self._get("name", [str]) - self.openIdConnectUrl = self._get("openIdConnectUrl", [str]) - self.scheme = self._get("scheme", [str]) - self.type = self._get("type", [str]) diff --git a/openapi3/servers.py b/openapi3/servers.py index 2b687a0..0fee06a 100644 --- a/openapi3/servers.py +++ b/openapi3/servers.py @@ -1,39 +1,34 @@ -from .object_base import ObjectBase +import dataclasses +from typing import List +from .object_base import ObjectBase, Map +@dataclasses.dataclass(init=False) class Server(ObjectBase): """ The Server object, as described `here`_ .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#serverObject """ + __slots__ = ['url', 'description', 'variables'] + required_fields = ['url'] - __slots__ = ["url", "description", "variables"] - required_fields = ["url"] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.description = self._get("description", str) - self.url = self._get("url", str) - self.variables = self._get("variables", ["ServerVariable"], is_map=True) + description: str + url: str + variables: Map[str, 'ServerVariable'] +@dataclasses.dataclass(init=False) class ServerVariable(ObjectBase): """ A ServerVariable object as defined `here`_. .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#server-variable-object """ + __slots__ = ['enum', 'default', 'description'] + required_fields = ['default'] - __slots__ = ["enum", "default", "description"] - required_fields = ["default"] + default: str + description: str + enum: List[str] - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.default = self._get("default", str) - self.description = self._get("description", str) - self.enum = self._get("enum", [str], is_list=True) diff --git a/openapi3/tag.py b/openapi3/tag.py index c333aaa..e93baab 100644 --- a/openapi3/tag.py +++ b/openapi3/tag.py @@ -1,6 +1,8 @@ -from .object_base import ObjectBase +import dataclasses +from .object_base import ObjectBase +@dataclasses.dataclass(init=False) class Tag(ObjectBase): """ A `Tag Object`_ holds a reusable set of different aspects of the OAS @@ -8,13 +10,8 @@ class Tag(ObjectBase): .. _Tag Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#tagObject """ + __slots__ = ['name', 'description', 'externalDocs'] - __slots__ = ["name", "description", "externalDocs"] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.name = self._get("name", str) - self.description = self._get("description", str) - self.externalDocs = self._get("externalDocs", str) + name: str + description: str + externalDocs: str From bcc98a41449f79d5d04aa98e1119e4fe75b5ece7 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 22 Dec 2021 21:35:06 +0100 Subject: [PATCH 002/125] =?UTF-8?q?dataclasses=20-=20=E2=80=A6=20=20-=20us?= =?UTF-8?q?ing=20Optional=20=20-=20default=20value=20=20-=20have=20a=20dat?= =?UTF-8?q?aclass=20constructor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openapi3/components.py | 22 +++--- openapi3/example.py | 14 ++-- openapi3/general.py | 17 ++-- openapi3/info.py | 39 +++++----- openapi3/object_base.py | 147 +++++++++++++++++++---------------- openapi3/openapi.py | 27 +++---- openapi3/paths.py | 168 ++++++++++++++++++++-------------------- openapi3/schemas.py | 92 +++++++++++----------- openapi3/security.py | 25 +++--- openapi3/servers.py | 22 +++--- openapi3/tag.py | 12 +-- 11 files changed, 303 insertions(+), 282 deletions(-) diff --git a/openapi3/components.py b/openapi3/components.py index 39c8a28..83caab5 100644 --- a/openapi3/components.py +++ b/openapi3/components.py @@ -1,9 +1,9 @@ import dataclasses -from typing import Union +from typing import Union, Optional from .object_base import ObjectBase, Map -@dataclasses.dataclass(init=False) +@dataclasses.dataclass class Components(ObjectBase): """ A `Components Object`_ holds a reusable set of different aspects of the OAS @@ -11,15 +11,15 @@ class Components(ObjectBase): .. _Components Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#componentsObject """ - __slots__ = ['schemas', 'responses', 'parameters', 'examples', 'headers', - 'requestBodies', 'securitySchemes', 'links', 'callback'] +# __slots__ = ['schemas', 'responses', 'parameters', 'examples', 'headers', +# 'requestBodies', 'securitySchemes', 'links', 'callback'] - examples: Map[str, Union['Example', 'Reference']] - parameters: Map[str, Union['Parameter', 'Reference']] - requestBodies: Map[str, Union['RequestBody', 'Reference']] - responses: Map[str, Union['Response', 'Reference']] - schemas: Map[str, Union['Schema', 'Reference']] - securitySchemes: Map[str, Union['SecurityScheme', 'Reference']] + examples: Optional[Map[str, Union['Example', 'Reference']]] = dataclasses.field(default=None) + parameters: Optional[Map[str, Union['Parameter', 'Reference']]] = dataclasses.field(default=None) + requestBodies: Optional[Map[str, Union['RequestBody', 'Reference']]] = dataclasses.field(default=None) + responses: Optional[Map[str, Union['Response', 'Reference']]] = dataclasses.field(default=None) + schemas: Optional[Map[str, Union['Schema', 'Reference']]] = dataclasses.field(default=None) + securitySchemes: Optional[Map[str, Union['SecurityScheme', 'Reference']]] = dataclasses.field(default=None) # headers: ['Header', 'Reference'], is_map=True - links: Map[str, Union['Link', 'Reference']] + links: Optional[Map[str, Union['Link', 'Reference']]] = dataclasses.field(default=None) # callbacks: ['Callback', 'Reference'], is_map=True diff --git a/openapi3/example.py b/openapi3/example.py index d9f0158..d30d145 100644 --- a/openapi3/example.py +++ b/openapi3/example.py @@ -1,9 +1,9 @@ import dataclasses -from typing import Union +from typing import Union, Optional from .object_base import ObjectBase -@dataclasses.dataclass(init=False) +@dataclasses.dataclass class Example(ObjectBase): """ A `Example Object`_ holds a reusable set of different aspects of the OAS @@ -11,9 +11,9 @@ class Example(ObjectBase): .. _Example Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#exampleObject """ - __slots__ = ['summary', 'description', 'value', 'externalValue'] +# __slots__ = ['summary', 'description', 'value', 'externalValue'] - summary: str - description: str - value: Union['Reference', dict, str] # 'any' type - externalValue: str + summary: Optional[str] = dataclasses.field(default=None) + description: Optional[str] = dataclasses.field(default=None) + value: Optional[Union['Reference', dict, str]] = dataclasses.field(default=None) # 'any' type + externalValue: Optional[str] = dataclasses.field(default=None) diff --git a/openapi3/general.py b/openapi3/general.py index 56beef7..214c722 100644 --- a/openapi3/general.py +++ b/openapi3/general.py @@ -1,7 +1,8 @@ import dataclasses +from typing import Optional from .object_base import ObjectBase -@dataclasses.dataclass(init=False) +@dataclasses.dataclass class ExternalDocumentation(ObjectBase): """ An `External Documentation Object`_ references external resources for extended @@ -9,13 +10,15 @@ class ExternalDocumentation(ObjectBase): .. _External Documentation Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#externalDocumentationObject """ - __slos__ = ['description', 'url'] +# __slos__ = ['description', 'url'] required_fields = 'url' - description: str - url: str + url: str = dataclasses.field(default=None) -@dataclasses.dataclass(init=False) + description: Optional[str] = dataclasses.field(default=None) + + +@dataclasses.dataclass class Reference(ObjectBase): """ A `Reference Object`_ designates a reference to another node in the specification. @@ -23,10 +26,10 @@ class Reference(ObjectBase): .. _Reference Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#referenceObject """ # can't start a variable name with a $ - __slots__ = ['ref'] +# __slots__ = ['ref'] required_fields = ['$ref'] - ref: str + ref: str = dataclasses.field(default=None) def _parse_data(self): self.ref = self._get("$ref", str) diff --git a/openapi3/info.py b/openapi3/info.py index 379b4dc..0defb77 100644 --- a/openapi3/info.py +++ b/openapi3/info.py @@ -1,49 +1,50 @@ import dataclasses -from typing import ForwardRef +from typing import ForwardRef, Optional from .object_base import ObjectBase -@dataclasses.dataclass(init=False) +@dataclasses.dataclass class Info(ObjectBase): """ An OpenAPI Info object, as defined in `the spec`_. .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#infoObject """ - __slots__ = ['title', 'description', 'termsOfService', 'contact', - 'license', 'version'] +# __slots__ = ['title', 'description', 'termsOfService', 'contact', +# 'license', 'version'] required_fields = ['title', 'version'] - contact: ForwardRef('Contact') - description: str - license: ForwardRef('License') - termsOfService: str - title: str - version: str + title: str = dataclasses.field(default=None) + version: str = dataclasses.field(default=None) -@dataclasses.dataclass(init=False) + contact: Optional[ForwardRef('Contact')] = dataclasses.field(default=None) + description: Optional[str] = dataclasses.field(default=None) + license: Optional[ForwardRef('License')] = dataclasses.field(default=None) + termsOfService: Optional[str] = dataclasses.field(default=None) + +@dataclasses.dataclass class Contact(ObjectBase): """ Contact object belonging to an Info object, as described `here`_ .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#contactObject """ - __slots__ = ['name', 'url', 'email'] +# __slots__ = ['name', 'url', 'email'] required_fields = ['name', 'url', 'email'] - email: str - name: str - url: str + email: str = dataclasses.field(default=None) + name: str = dataclasses.field(default=None) + url: str = dataclasses.field(default=None) -@dataclasses.dataclass(init=False) +@dataclasses.dataclass class License(ObjectBase): """ License object belonging to an Info object, as described `here`_ .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#license-object """ - __slots__ = ['name', 'url'] +# __slots__ = ['name', 'url'] required_fields = ['name'] - name: str - url: str + name: str = dataclasses.field(default=None) + url: Optional[str] = dataclasses.field(default=None) diff --git a/openapi3/object_base.py b/openapi3/object_base.py index 7230ffc..3901e6b 100644 --- a/openapi3/object_base.py +++ b/openapi3/object_base.py @@ -45,17 +45,16 @@ def raise_on_unknown_type(parent, field, object_types, found): :raises: A SpecError describing the failure """ if len(object_types) == 1: - if isinstance(object_types[0], str): - expected_type = ObjectBase.get_object_type(object_types[0]) - raise SpecError('Expected {}.{} to be of type {}, with required fields {}'.format( - parent.get_path(), - field, - object_types[0], - expected_type.required_fields, - ), - path=parent.path, - element=parent, - ) + expected_type = object_types[0] + raise SpecError('Expected {}.{} to be of type {}, with required fields {}'.format( + parent.get_path(), + field, + expected_type.__name__, + expected_type.required_fields, + ), + path=parent.path, + element=parent, + ) elif len(object_types) == 2 and len([c for c in object_types if isinstance(c, str)]) == 2 and "Reference" in object_types: # we can give a similar error here as above expected_type_str = [c for c in object_types if c != "Reference"][0] @@ -90,7 +89,8 @@ class ObjectBase(object): 'extensions', '_original_ref'] required_fields = [] - def __init__(self, path, raw_element, root): + @classmethod + def create(cls, path, raw_element, root, obj=None): """ Creates a new Object for a OpenAPI schema with a reference to its own path in the schema. @@ -104,37 +104,40 @@ def __init__(self, path, raw_element, root): :type root: OpenAPI """ # init empty slots - for k in type(self).__slots__: - if k in ('_spec_errors', 'validation_mode'): - # allow these two fields to keep their values - continue - setattr(self, k, None) + obj = obj or cls() +# for k in type(obj).__slots__: +# if k in ('_spec_errors', 'validation_mode'): +# # allow these two fields to keep their values +# continue +# setattr(obj, k, None) - self.path = path - self.raw_element = raw_element - self._root = root + obj.path = path + obj.raw_element = raw_element + obj._root = root - self._accessed_members = [] - self.extensions = {} + obj._accessed_members = [] + obj.extensions = {} # TODO - add strict mode that errors if all members were not accessed - self.strict = False + obj.strict = False # parse our own element try: - self._required_fields(*type(self).required_fields) - self._parse_data() + obj._required_fields(*type(obj).required_fields) + obj._parse_data() except SpecError as e: - if self._root.validation_mode: - self._root.log_spec_error(e) + if obj._root.validation_mode: + obj._root.log_spec_error(e) else: raise # TODO - this may not be appropriate in all cases - self._parse_spec_extensions() + obj._parse_spec_extensions() # TODO - assert that all keys of raw_element were accessed + return obj + def __repr__(self): """ Returns a string representation of the parsed object @@ -192,7 +195,8 @@ def _parse_data(self): """ for field in dataclasses.fields(self): v = self._get(field.name, field.type) - setattr(self, field.name, v) + if v is not None: + setattr(self, field.name, v) def _get(self, field, object_type): """ @@ -208,13 +212,23 @@ def _get(self, field, object_type): file """ self._accessed_members.append(field) - + c = object_type ret = self.raw_element.get(field, None) if ret is None: return None try: + types = self.types_of(object_type) origin = typing.get_origin(object_type) or object_type + + # decapsule Optional + if origin == typing.Union: + args = typing.get_args(object_type) + if len(args) == 2 and args[1] == None.__class__: + object_type = args[0] + origin = typing.get_origin(args[0]) or origin + + if origin == list: if not isinstance(ret, list): raise SpecError('Expected {}.{} to be a list of {}, got {}'.format( @@ -232,19 +246,6 @@ def _get(self, field, object_type): element=self) ret = Map(self.path + [field], ret, object_type, self._root) else: - if origin == typing.Union: - types = [] - for t in typing.get_args(object_type): - if isinstance(t, typing.ForwardRef): - types.append(t.__forward_arg__) - else: - types.append(t) - elif isinstance(origin, typing.ForwardRef): - types = [origin.__forward_arg__] - else: - types = [object_type] - - accepts_string = False for t in types: if t == typing.Any: @@ -254,13 +255,13 @@ def _get(self, field, object_type): accepts_string = True continue - if isinstance(t, str): + if issubclass(t, ObjectBase): # we were given the name of a subclass of ObjectBase, # attempt to parse ret as that type - python_type = ObjectBase.get_object_type(t) + python_type = t #ObjectBase.get_object_type(t) if python_type.can_parse(ret): - ret = python_type(self.path + [field], ret, self._root) + ret = python_type.create(self.path + [field], ret, self._root) break elif isinstance(ret, t): @@ -314,21 +315,17 @@ def can_parse(cls, dct): # will be able to parse this value, an appropriate error is returned) if not isinstance(dct, dict): return False + fields = set(map(lambda x: x.name.rstrip("_"), dataclasses.fields(cls))) # ensure that the dict's keys are valid in our slots - for key in dct.keys(): - if key.startswith('x-'): - # ignore spec extensions - continue - if not cls.key_contained(key, cls.__slots__): - # it has something we don't - probably not a match - return False + keys = [key for key in filter(lambda x: not x.startswith("x-"), dct.keys())] + keys = set(keys) + + if keys - (set(cls.__slots__) | fields): + return False - # then, ensure that all required fields are present - for key in cls.required_fields: - if not cls.key_contained(key, dct): - # it doesn't have everything we need - probably not a match - return False + if set(cls.required_fields) - keys: + return False return True @@ -414,7 +411,7 @@ def parse_list(self, raw_list, object_type, field=None): for cur_type in python_types: if issubclass(cur_type, ObjectBase) and cur_type.can_parse(cur): - result.append(cur_type(real_path + [str(i)], cur, self._root)) + result.append(cur_type.create(real_path + [str(i)], cur, self._root)) found_type = True continue elif isinstance(cur, cur_type): @@ -463,10 +460,8 @@ def _resolve_references(self): # don't circular import reference_type = ObjectBase.get_object_type('Reference') - for slot in self.__slots__: - if slot.startswith('_'): - # don't parse private members - continue + for slot in filter(lambda x: not x.startswith("_"), + map(lambda x: x.name, dataclasses.fields(self))): value = getattr(self, slot) if isinstance(value, reference_type): @@ -496,7 +491,7 @@ def _resolve_allOfs(self): Types can override this to handle allOf handling themselves. Types that do so should call the parent class' _resolve_allOf when they do """ - for slot in self.__slots__: + for slot in map(lambda x: x.name, dataclasses.fields(self)): if slot.startswith("_"): # no need to handle private members continue @@ -526,12 +521,23 @@ def resolve(t): else: t = [t] - t = [ObjectBase.get_object_type(i.__forward_arg__) if isinstance(i, typing.ForwardRef) else i for i in t] - return t + r = [] + for tt in t: + if isinstance(tt, typing.ForwardRef): + r.append(ObjectBase.get_object_type(tt.__forward_arg__)) + else: + if typing.get_origin(t) == typing.Union: + r.extend(resolve(t)) + else: + r.append(tt) + return r if expected: assert typing.get_origin(object_type) == expected + if object_type in frozenset([str, int, float, dict, bool, typing.Any]): + return [object_type] + if typing.get_origin(object_type) == list: python_types = typing.get_args(object_type)[0] return resolve(python_types) @@ -540,9 +546,16 @@ def resolve(t): args = typing.get_args(object_type) return resolve(args[0]),resolve(args[1]) + if isinstance(object_type, typing.ForwardRef): + return resolve(object_type) + + if typing.get_origin(object_type) == typing.Union: + return resolve(object_type) + raise TypeError(object_type) + class Map(dict): """ The Map object wraps a python dict and parses its values into the chosen @@ -574,7 +587,7 @@ def __init__(self, path, raw_element, object_type, root): for t in python_types: if issubclass(t, ObjectBase) and t.can_parse(v): - dct[k] = t(path + [k], v, self._root) + dct[k] = t.create(path + [k], v, self._root) found_type = True elif isinstance(v, t): dct[k] = v diff --git a/openapi3/openapi.py b/openapi3/openapi.py index 7ab5b98..330ce15 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -1,5 +1,5 @@ import dataclasses -from typing import ForwardRef, Any, List +from typing import ForwardRef, Any, List, Optional import requests @@ -14,19 +14,20 @@ class OpenAPI(ObjectBase): .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#openapi-object """ - __slots__ = ['openapi','info','servers','paths','components','security','tags', - 'externalDocs','_operation_map','_security', 'validation_mode', - '_spec_errors', '_ssl_verify', '_session'] +# __slots__ = ['openapi','info','servers','paths','components','security','tags', +# 'externalDocs','_operation_map','_security', 'validation_mode', +# '_spec_errors', '_ssl_verify', '_session'] required_fields=['openapi','info','paths'] - components: ForwardRef('Components') - externalDocs: Map[Any, Any] - info: ForwardRef('Info') - openapi: str - paths: Map[str, ForwardRef('Path')] - security: List['SecurityRequirement'] - servers: List['Server'] - tags: List['Tag'] + openapi: str = dataclasses.field(default=None) + info: ForwardRef('Info') = dataclasses.field(default=None) + paths: Map[str, ForwardRef('Path')] = dataclasses.field(default=None) + + components: Optional[ForwardRef('Components')] = dataclasses.field(default=None) + externalDocs: Optional[Map[Any, Any]] = dataclasses.field(default=None) + security: Optional[List['SecurityRequirement']] = dataclasses.field(default=None) + servers: Optional[List['Server']] = dataclasses.field(default=None) + tags: Optional[List['Tag']] = dataclasses.field(default=None) def __init__( self, @@ -59,7 +60,7 @@ def __init__( self._spec_errors = [] # as the document root, we have no path - super(OpenAPI, self).__init__([], raw_document, self) + super(OpenAPI, self).create([], raw_document, self, obj=self) self._security = {} diff --git a/openapi3/paths.py b/openapi3/paths.py index 6369d03..dbcd634 100644 --- a/openapi3/paths.py +++ b/openapi3/paths.py @@ -1,5 +1,5 @@ import dataclasses -from typing import ForwardRef, Union, List +from typing import ForwardRef, Union, List, Optional import json import re import requests @@ -25,7 +25,7 @@ def _validate_parameters(instance): if c.name not in allowed_path_parameters: raise SpecError('Parameter name not found in path: {}'.format(c.name), path=instance.path) -@dataclasses.dataclass(init=False) +@dataclasses.dataclass class Path(ObjectBase): """ A Path object, as defined `here`_. Path objects represent URL paths that @@ -33,21 +33,20 @@ class Path(ObjectBase): .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#paths-object """ - __slots__ = ['summary', 'description', 'get', 'put', 'post', 'delete', - 'options', 'head', 'patch', 'trace', 'servers', 'parameters'] - - delete: ForwardRef('Operation') - description: str - get: ForwardRef('Operation') - head: ForwardRef('Operation') - options: ForwardRef('Operation') - parameters: List[Union['Parameter', 'Reference']] # = [] - patch: ForwardRef('Operation') - post: ForwardRef('Operation') - put: ForwardRef('Operation') - servers: List['Server'] - summary: str - trace: ForwardRef('Operation') + delete: Optional[ForwardRef('Operation')] = dataclasses.field(default=None) + description: Optional[str] = dataclasses.field(default=None) + get: Optional[ForwardRef('Operation')] = dataclasses.field(default=None) + head: Optional[ForwardRef('Operation')] = dataclasses.field(default=None) + options: Optional[ForwardRef('Operation')] = dataclasses.field(default=None) + + patch: Optional[ForwardRef('Operation')] = dataclasses.field(default=None) + post: Optional[ForwardRef('Operation')] = dataclasses.field(default=None) + put: Optional[ForwardRef('Operation')] = dataclasses.field(default=None) + servers: Optional[List['Server']] = dataclasses.field(default=None) + summary: Optional[str] = dataclasses.field(default=None) + trace: Optional[ForwardRef('Operation')] = dataclasses.field(default=None) + + parameters: Optional[List[Union['Parameter', 'Reference']]] = dataclasses.field(default_factory=list) def _parse_data(self): """ @@ -70,32 +69,37 @@ def _resolve_references(self): _validate_parameters(self) -@dataclasses.dataclass(init=False) +@dataclasses.dataclass class Parameter(ObjectBase): """ A `Parameter Object`_ defines a single operation parameter. .. _Parameter Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#parameterObject """ - __slots__ = ['name', 'in', 'in_', 'description', 'required', 'deprecated', - 'allowEmptyValue', 'style', 'explode', 'allowReserved', - 'schema', 'example', 'examples'] +# __slots__ = ['name', 'in', 'in_', 'description', 'required', 'deprecated', +# 'allowEmptyValue', 'style', 'explode', 'allowReserved', +# 'schema', 'example', 'examples'] required_fields = ['name', 'in'] - deprecated: bool - description: str - example: str - examples: Map[str, Union['Example','Reference']] - explode: bool - in_: str # TODO must be one of ["query","header","path","cookie"] - name: str - required: bool - schema: Union['Schema', 'Reference'] - style: str + in_: str = dataclasses.field(default=None) # TODO must be one of ["query","header","path","cookie"] + name: str = dataclasses.field(default=None) + + deprecated: Optional[bool] = dataclasses.field(default=None) + description: Optional[str] = dataclasses.field(default=None) + example: Optional[str] = dataclasses.field(default=None) + examples: Optional[Map[str, Union['Example','Reference']]] = dataclasses.field(default=None) + explode: Optional[bool] = dataclasses.field(default=None) + required: Optional[bool] = dataclasses.field(default=None) + schema: Optional[Union['Schema', 'Reference']] = dataclasses.field(default=None) + style: Optional[str] = dataclasses.field(default=None) # allow empty or reserved values in Parameter data - allowEmptyValue: bool - allowReserved: bool + allowEmptyValue: Optional[bool] = dataclasses.field(default=None) + allowReserved: Optional[bool] = dataclasses.field(default=None) + + @classmethod + def can_parse(cls, dct): + return super().can_parse(dct) def _parse_data(self): super()._parse_data() @@ -107,29 +111,29 @@ def _parse_data(self): raise SpecError(err_msg.format(self.get_path()), path=self.path) -@dataclasses.dataclass(init=False) +@dataclasses.dataclass class Operation(ObjectBase): """ An Operation object as defined `here`_ .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#operationObject """ - __slots__ = ['tags', 'summary', 'description', 'externalDocs', 'security', - 'operationId', 'parameters', 'requestBody', 'responses', - 'callbacks', 'deprecated', 'servers', '_session', '_request'] +# __slots__ = ['tags', 'summary', 'description', 'externalDocs', 'security', +# 'operationId', 'parameters', 'requestBody', 'responses', +# 'callbacks', 'deprecated', 'servers', '_session', '_request'] required_fields = ['responses'] - deprecated: bool - description: str - externalDocs: ForwardRef('ExternalDocumentation') - operationId: str - parameters: List[Union['Parameter', 'Reference']] - requestBody: Union['RequestBody', 'Reference'] - responses: Map[str, Union['Response', 'Reference']] - security: List['SecurityRequirement'] - servers: List['Server'] - summary: str - tags: List[str] + responses: Map[str, Union['Response', 'Reference']] = dataclasses.field(default=None) + deprecated: Optional[bool] = dataclasses.field(default=None) + description: Optional[str] = dataclasses.field(default=None) + externalDocs: Optional[ForwardRef('ExternalDocumentation')] = dataclasses.field(default=None) + operationId: Optional[str] = dataclasses.field(default=None) + parameters: Optional[List[Union['Parameter', 'Reference']]] = dataclasses.field(default_factory=list) + requestBody: Optional[Union['RequestBody', 'Reference']] = dataclasses.field(default=None) + security: Optional[List['SecurityRequirement']] = dataclasses.field(default_factory=list) + servers: Optional[List['Server']] = dataclasses.field(default=None) + summary: Optional[str] = dataclasses.field(default=None) + tags: Optional[List[str]] = dataclasses.field(default=None) def _parse_data(self): """ @@ -138,20 +142,12 @@ def _parse_data(self): super()._parse_data() # callbacks: dict TODO - # default parameters to an empty list for processing later - if self.parameters is None: - self.parameters = [] - # gather all operations into the spec object if self.operationId is not None: # TODO - how to store without an operationId? formatted_operation_id = self.operationId.replace(" ", "_") self._root._register_operation(formatted_operation_id, self) - # TODO - maybe make this generic - if self.security is None: - self.security = [] - # Store session object self._session = requests.Session() @@ -355,18 +351,18 @@ def request(self, base_url, security={}, data=None, parameters={}, verify=True, raise NotImplementedError() -@dataclasses.dataclass(init=False) +@dataclasses.dataclass class SecurityRequirement(ObjectBase): """ A `SecurityRequirement`_ object describes security schemes for API access. .. _SecurityRequirement: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#securityRequirementObject """ - ___slots__ = ['name', 'types'] - required_fields = [] +# ___slots__ = ['name', 'types'] +# required_fields = [] - name: str - types: List[str] + name: Optional[str] = dataclasses.field(default=None) + types: Optional[List[str]] = dataclasses.field(default=None) def _parse_data(self): """ @@ -392,22 +388,22 @@ def __getstate__(self): return {self.name: self.types} -@dataclasses.dataclass(init=False) +@dataclasses.dataclass class RequestBody(ObjectBase): """ A `RequestBody`_ object describes a single request body. .. _RequestBody: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#requestBodyObject """ - __slots__ = ['description', 'content', 'required'] +# __slots__ = ['description', 'content', 'required'] required_fields = ['content'] - description: str - content: Map[str, ForwardRef('MediaType')] - required: bool + content: Map[str, ForwardRef('MediaType')] = dataclasses.field(default=None) + description: Optional[str] = dataclasses.field(default=None) + required: Optional[bool] = dataclasses.field(default=None) -@dataclasses.dataclass(init=False) +@dataclasses.dataclass class MediaType(ObjectBase): """ A `MediaType`_ object provides schema and examples for the media type identified @@ -415,16 +411,16 @@ class MediaType(ObjectBase): .. _MediaType: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#mediaTypeObject """ - __slots__ = ['schema', 'example', 'examples', 'encoding'] +# __slots__ = ['schema', 'example', 'examples', 'encoding'] required_fields = [] - schema: Union['Schema', 'Reference'] - example: str # 'any' type - examples: Map[str, Union['Example', 'Reference']] - encoding: Map[str, ForwardRef('Encoding')] + schema: Optional[Union['Schema', 'Reference']] = dataclasses.field(default=None) + example: Optional[str] = dataclasses.field(default=None) # 'any' type + examples: Optional[Map[str, Union['Example', 'Reference']]] = dataclasses.field(default=None) + encoding: Optional[Map[str, ForwardRef('Encoding')]] = dataclasses.field(default=None) -@dataclasses.dataclass(init=False) +@dataclasses.dataclass class Response(ObjectBase): """ A `Response Object`_ describes a single response from an API Operation, @@ -432,29 +428,29 @@ class Response(ObjectBase): .. _Response Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#response-object """ - __slots__ = ['description', 'headers', 'content', 'links'] +# __slots__ = ['description', 'headers', 'content', 'links'] required_fields = ['description'] - content: Map[str, ForwardRef('MediaType')] - description: str - links: Map[str, Union['Link', 'Reference']] + description: str = dataclasses.field(default=None) + content: Optional[Map[str, ForwardRef('MediaType')]] = dataclasses.field(default=None) + links: Optional[Map[str, Union['Link', 'Reference']]] = dataclasses.field(default=None) -@dataclasses.dataclass(init=False) +@dataclasses.dataclass class Link(ObjectBase): """ A `Link Object`_ describes a single Link from an API Operation Response to an API Operation Request .. _Link Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#linkObject """ - __slots__ = ['operationId', 'operationRef', 'description', 'parameters', 'requestBody', 'server'] - - operationId: str - operationRef: str - description: str - parameters: dict - requestBody: dict - server: ForwardRef('Server') +# __slots__ = ['operationId', 'operationRef', 'description', 'parameters', 'requestBody', 'server'] + + operationId: Optional[str] = dataclasses.field(default=None) + operationRef: Optional[str] = dataclasses.field(default=None) + description: Optional[str] = dataclasses.field(default=None) + parameters: Optional[dict] = dataclasses.field(default=None) + requestBody: Optional[dict] = dataclasses.field(default=None) + server: Optional[ForwardRef('Server')] = dataclasses.field(default=None) def _parse_data(self): """ diff --git a/openapi3/schemas.py b/openapi3/schemas.py index df21f6c..1ce29bd 100644 --- a/openapi3/schemas.py +++ b/openapi3/schemas.py @@ -1,4 +1,4 @@ -from typing import Union, List +from typing import Union, List, Any, Optional import dataclasses from .errors import SpecError @@ -14,56 +14,60 @@ } -@dataclasses.dataclass(init=False) +@dataclasses.dataclass class Schema(ObjectBase): """ The `Schema Object`_ allows the definition of input and output data types. .. _Schema Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#schemaObject """ - __slots__ = ['title', 'multipleOf', 'maximum', 'exclusiveMaximum', - 'minimum', 'exclusiveMinimum', 'maxLength', 'minLength', - 'pattern', 'maxItems', 'minItems', 'uniqueItems', - 'maxProperties', 'minProperties', 'required', 'enum', 'type', - 'allOf', 'oneOf', 'anyOf', 'not', 'items', 'properties', - 'additionalProperties', 'description', 'format', 'default', - 'nullable', 'discriminator', 'readOnly', 'writeOnly', 'xml', - 'externalDocs', 'example', 'deprecated', 'contentEncoding', - 'contentMediaType', 'contentSchema', '_model_type', - '_request_model_type', '_resolved_allOfs'] +# __slots__ = ['title', 'multipleOf', 'maximum', 'exclusiveMaximum', +# 'minimum', 'exclusiveMinimum', 'maxLength', 'minLength', +# 'pattern', 'maxItems', 'minItems', 'uniqueItems', +# 'maxProperties', 'minProperties', 'required', 'enum', 'type', +# 'allOf', 'oneOf', 'anyOf', 'not', 'items', 'properties', +# 'additionalProperties', 'description', 'format', 'default', +# 'nullable', 'discriminator', 'readOnly', 'writeOnly', 'xml', +# 'externalDocs', 'example', 'deprecated', 'contentEncoding', +# 'contentMediaType', 'contentSchema', '_model_type', +# '_request_model_type', '_resolved_allOfs'] required_fields = [] - title: str - maximum: Union[int, float] - minimum: Union[int, float] - maxLength: int - minLength: int - pattern: str - maxItems: int - minItems: int - required: List[str] - enum: list - type: str - allOf: List[Union["Schema", "Reference"]] - oneOf: list - anyOf: list - items: Union['Schema', 'Reference'] - properties: Map[str, Union['Schema', 'Reference']] - additionalProperties: [bool, dict] - description: str - format: str - default: str # TODO - str as a default? - nullable: bool - discriminator: dict # 'Discriminator' - readOnly: bool - writeOnly: bool - xml: dict # 'XML' - externalDocs: dict # 'ExternalDocs' - deprecated: bool - example: object - contentEncoding: str - contentMediaType: str - contentSchema: str + title: Optional[str] = dataclasses.field(default=None) + maximum: Optional[Union[int, float]] = dataclasses.field(default=None) + minimum: Optional[Union[int, float]] = dataclasses.field(default=None) + maxLength: Optional[int] = dataclasses.field(default=None) + minLength: Optional[int] = dataclasses.field(default=None) + pattern: Optional[str] = dataclasses.field(default=None) + maxItems: Optional[int] = dataclasses.field(default=None) + minItems: Optional[int] = dataclasses.field(default=None) + required: Optional[List[str]] = dataclasses.field(default_factory=list) + enum: Optional[list] = dataclasses.field(default=None) + type: Optional[str] = dataclasses.field(default=None) + allOf: Optional[List[Union["Schema", "Reference"]]] = dataclasses.field(default=None) + oneOf: Optional[list] = dataclasses.field(default=None) + anyOf: Optional[list] = dataclasses.field(default=None) + items: Optional[Union['Schema', 'Reference']] = dataclasses.field(default=None) + properties: Optional[Map[str, Union['Schema', 'Reference']]] = dataclasses.field(default=None) + additionalProperties: Optional[Union[bool, dict]] = dataclasses.field(default=None) + description: Optional[str] = dataclasses.field(default=None) + format: Optional[str] = dataclasses.field(default=None) + default: Optional[str] = dataclasses.field(default=None) # TODO - str as a default? + nullable: Optional[bool] = dataclasses.field(default=None) + discriminator: Optional[dict] = dataclasses.field(default=None) # 'Discriminator' + readOnly: Optional[bool] = dataclasses.field(default=None) + writeOnly: Optional[bool] = dataclasses.field(default=None) + xml: Optional[dict] = dataclasses.field(default=None) # 'XML' + externalDocs: Optional[dict] = dataclasses.field(default=None) # 'ExternalDocs' + deprecated: Optional[bool] = dataclasses.field(default=None) + example: Optional[Any] = dataclasses.field(default=None) + contentEncoding: Optional[str] = dataclasses.field(default=None) + contentMediaType: Optional[str] = dataclasses.field(default=None) + contentSchema: Optional[str] = dataclasses.field(default=None) + + _model_type: object = dataclasses.field(default=None) + _request_model_type: object = dataclasses.field(default=None) + _resolved_allOfs: object = dataclasses.field(default=None) def _parse_data(self): """ @@ -169,7 +173,7 @@ def _merge(self, other): """ Merges ``other`` into this schema, preferring to use the values in ``other`` """ - for slot in self.__slots__: + for slot in map(lambda x: x.name, dataclasses.fields(self)): if slot.startswith("_"): # skip private members continue diff --git a/openapi3/security.py b/openapi3/security.py index 6ff53d5..edeb96f 100644 --- a/openapi3/security.py +++ b/openapi3/security.py @@ -1,25 +1,28 @@ import dataclasses +from typing import Optional from .object_base import ObjectBase, Map -@dataclasses.dataclass(init=False) +@dataclasses.dataclass class SecurityScheme(ObjectBase): """ A `Security Scheme`_ defines a security scheme that can be used by the operations. .. _Security Scheme: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#securitySchemeObject """ - __slots__ = ['type', 'description', 'name', 'in', 'in_', 'scheme', - 'bearerFormat', 'flows', 'openIdConnectUrl'] +# __slots__ = ['type', 'description', 'name', 'in', 'in_', 'scheme', +# 'bearerFormat', 'flows', 'openIdConnectUrl'] required_fields = ['type'] - bearerFormat: str - description: str - flows: Map[str, str] # TODO - in_: str - name: str - openIdConnectUrl: str - scheme: str - type: str + type: str = dataclasses.field(default=None) + + bearerFormat: Optional[str] = dataclasses.field(default=None) + description: Optional[str] = dataclasses.field(default=None) + flows: Optional[Map[str, str]] = dataclasses.field(default=None) # TODO + in_: Optional[str] = dataclasses.field(default=None) + name: Optional[str] = dataclasses.field(default=None) + openIdConnectUrl: Optional[str] = dataclasses.field(default=None) + scheme: Optional[str] = dataclasses.field(default=None) + def _parse_data(self): super()._parse_data() diff --git a/openapi3/servers.py b/openapi3/servers.py index 0fee06a..1915108 100644 --- a/openapi3/servers.py +++ b/openapi3/servers.py @@ -1,34 +1,34 @@ import dataclasses -from typing import List +from typing import List, Optional from .object_base import ObjectBase, Map -@dataclasses.dataclass(init=False) +@dataclasses.dataclass class Server(ObjectBase): """ The Server object, as described `here`_ .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#serverObject """ - __slots__ = ['url', 'description', 'variables'] +# __slots__ = ['url', 'description', 'variables'] required_fields = ['url'] - description: str - url: str - variables: Map[str, 'ServerVariable'] + url: str = dataclasses.field(default=None) + description: Optional[str] = dataclasses.field(default=None) + variables: Optional[Map[str, 'ServerVariable']] = dataclasses.field(default=None) -@dataclasses.dataclass(init=False) +@dataclasses.dataclass class ServerVariable(ObjectBase): """ A ServerVariable object as defined `here`_. .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#server-variable-object """ - __slots__ = ['enum', 'default', 'description'] +# __slots__ = ['enum', 'default', 'description'] required_fields = ['default'] - default: str - description: str - enum: List[str] + default: str = dataclasses.field(default=None) + description: Optional[str] = dataclasses.field(default=None) + enum: Optional[List[str]] = dataclasses.field(default=None) diff --git a/openapi3/tag.py b/openapi3/tag.py index e93baab..3964584 100644 --- a/openapi3/tag.py +++ b/openapi3/tag.py @@ -1,8 +1,8 @@ import dataclasses - +from typing import Optional from .object_base import ObjectBase -@dataclasses.dataclass(init=False) +@dataclasses.dataclass class Tag(ObjectBase): """ A `Tag Object`_ holds a reusable set of different aspects of the OAS @@ -10,8 +10,8 @@ class Tag(ObjectBase): .. _Tag Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#tagObject """ - __slots__ = ['name', 'description', 'externalDocs'] +# __slots__ = ['name', 'description', 'externalDocs'] - name: str - description: str - externalDocs: str + name: Optional[str] = dataclasses.field(default=None) + description: Optional[str] = dataclasses.field(default=None) + externalDocs: Optional[str] = dataclasses.field(default=None) From 880de1a2b28309f8ba1e3f9ff4d3f9daaad1c5d4 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Thu, 23 Dec 2021 09:18:06 +0100 Subject: [PATCH 003/125] required_fields - using typing Optional information instead - the classmethod property ObjectBase.required_fields requires python 3.9 --- openapi3/components.py | 2 -- openapi3/example.py | 1 - openapi3/general.py | 7 +------ openapi3/info.py | 7 ------- openapi3/object_base.py | 27 +++++++++++++++++++++++---- openapi3/openapi.py | 4 ---- openapi3/paths.py | 17 ----------------- openapi3/schemas.py | 11 ----------- openapi3/security.py | 4 ---- openapi3/servers.py | 4 ---- openapi3/tag.py | 1 - 11 files changed, 24 insertions(+), 61 deletions(-) diff --git a/openapi3/components.py b/openapi3/components.py index 83caab5..a8a13b2 100644 --- a/openapi3/components.py +++ b/openapi3/components.py @@ -11,8 +11,6 @@ class Components(ObjectBase): .. _Components Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#componentsObject """ -# __slots__ = ['schemas', 'responses', 'parameters', 'examples', 'headers', -# 'requestBodies', 'securitySchemes', 'links', 'callback'] examples: Optional[Map[str, Union['Example', 'Reference']]] = dataclasses.field(default=None) parameters: Optional[Map[str, Union['Parameter', 'Reference']]] = dataclasses.field(default=None) diff --git a/openapi3/example.py b/openapi3/example.py index d30d145..72a492f 100644 --- a/openapi3/example.py +++ b/openapi3/example.py @@ -11,7 +11,6 @@ class Example(ObjectBase): .. _Example Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#exampleObject """ -# __slots__ = ['summary', 'description', 'value', 'externalValue'] summary: Optional[str] = dataclasses.field(default=None) description: Optional[str] = dataclasses.field(default=None) diff --git a/openapi3/general.py b/openapi3/general.py index 214c722..aea89db 100644 --- a/openapi3/general.py +++ b/openapi3/general.py @@ -10,8 +10,6 @@ class ExternalDocumentation(ObjectBase): .. _External Documentation Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#externalDocumentationObject """ -# __slos__ = ['description', 'url'] - required_fields = 'url' url: str = dataclasses.field(default=None) @@ -25,10 +23,7 @@ class Reference(ObjectBase): .. _Reference Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#referenceObject """ - # can't start a variable name with a $ -# __slots__ = ['ref'] - required_fields = ['$ref'] - + _required_fields_cache = frozenset(['$ref']) ref: str = dataclasses.field(default=None) def _parse_data(self): diff --git a/openapi3/info.py b/openapi3/info.py index 0defb77..00b7849 100644 --- a/openapi3/info.py +++ b/openapi3/info.py @@ -9,9 +9,6 @@ class Info(ObjectBase): .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#infoObject """ -# __slots__ = ['title', 'description', 'termsOfService', 'contact', -# 'license', 'version'] - required_fields = ['title', 'version'] title: str = dataclasses.field(default=None) version: str = dataclasses.field(default=None) @@ -28,8 +25,6 @@ class Contact(ObjectBase): .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#contactObject """ -# __slots__ = ['name', 'url', 'email'] - required_fields = ['name', 'url', 'email'] email: str = dataclasses.field(default=None) name: str = dataclasses.field(default=None) @@ -43,8 +38,6 @@ class License(ObjectBase): .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#license-object """ -# __slots__ = ['name', 'url'] - required_fields = ['name'] name: str = dataclasses.field(default=None) url: Optional[str] = dataclasses.field(default=None) diff --git a/openapi3/object_base.py b/openapi3/object_base.py index 3901e6b..fd97c3a 100644 --- a/openapi3/object_base.py +++ b/openapi3/object_base.py @@ -50,7 +50,7 @@ def raise_on_unknown_type(parent, field, object_types, found): parent.get_path(), field, expected_type.__name__, - expected_type.required_fields, + sorted(expected_type.required_fields), ), path=parent.path, element=parent, @@ -80,6 +80,16 @@ def raise_on_unknown_type(parent, field, object_types, found): element=parent, ) +def isoptional(x): + if x.name[0] == '_': + return True + if typing.get_origin(x.type) != typing.Union: + return False + args = typing.get_args(x.type) + if None.__class__ not in args: + return False + return True + class ObjectBase(object): """ The base class for all schema objects. Includes helpers for common schema- @@ -87,7 +97,16 @@ class ObjectBase(object): """ __slots__ = ['path', 'raw_element', '_accessed_members', 'strict', '_root', 'extensions', '_original_ref'] - required_fields = [] + + @classmethod + @property + def required_fields(cls): + try: + return cls._required_fields_cache + except AttributeError: + fields = [x for x in map(lambda x: x.name.rstrip("_"), filter(lambda x: not isoptional(x), dataclasses.fields(cls)))] + cls._required_fields_cache = frozenset(fields) + return cls._required_fields_cache @classmethod def create(cls, path, raw_element, root, obj=None): @@ -321,10 +340,10 @@ def can_parse(cls, dct): keys = [key for key in filter(lambda x: not x.startswith("x-"), dct.keys())] keys = set(keys) - if keys - (set(cls.__slots__) | fields): + if keys - fields: return False - if set(cls.required_fields) - keys: + if cls.required_fields - keys: return False return True diff --git a/openapi3/openapi.py b/openapi3/openapi.py index 330ce15..68bbcad 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -14,10 +14,6 @@ class OpenAPI(ObjectBase): .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#openapi-object """ -# __slots__ = ['openapi','info','servers','paths','components','security','tags', -# 'externalDocs','_operation_map','_security', 'validation_mode', -# '_spec_errors', '_ssl_verify', '_session'] - required_fields=['openapi','info','paths'] openapi: str = dataclasses.field(default=None) info: ForwardRef('Info') = dataclasses.field(default=None) diff --git a/openapi3/paths.py b/openapi3/paths.py index dbcd634..bddb7de 100644 --- a/openapi3/paths.py +++ b/openapi3/paths.py @@ -76,10 +76,6 @@ class Parameter(ObjectBase): .. _Parameter Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#parameterObject """ -# __slots__ = ['name', 'in', 'in_', 'description', 'required', 'deprecated', -# 'allowEmptyValue', 'style', 'explode', 'allowReserved', -# 'schema', 'example', 'examples'] - required_fields = ['name', 'in'] in_: str = dataclasses.field(default=None) # TODO must be one of ["query","header","path","cookie"] name: str = dataclasses.field(default=None) @@ -118,10 +114,6 @@ class Operation(ObjectBase): .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#operationObject """ -# __slots__ = ['tags', 'summary', 'description', 'externalDocs', 'security', -# 'operationId', 'parameters', 'requestBody', 'responses', -# 'callbacks', 'deprecated', 'servers', '_session', '_request'] - required_fields = ['responses'] responses: Map[str, Union['Response', 'Reference']] = dataclasses.field(default=None) deprecated: Optional[bool] = dataclasses.field(default=None) @@ -358,8 +350,6 @@ class SecurityRequirement(ObjectBase): .. _SecurityRequirement: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#securityRequirementObject """ -# ___slots__ = ['name', 'types'] -# required_fields = [] name: Optional[str] = dataclasses.field(default=None) types: Optional[List[str]] = dataclasses.field(default=None) @@ -395,8 +385,6 @@ class RequestBody(ObjectBase): .. _RequestBody: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#requestBodyObject """ -# __slots__ = ['description', 'content', 'required'] - required_fields = ['content'] content: Map[str, ForwardRef('MediaType')] = dataclasses.field(default=None) description: Optional[str] = dataclasses.field(default=None) @@ -411,8 +399,6 @@ class MediaType(ObjectBase): .. _MediaType: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#mediaTypeObject """ -# __slots__ = ['schema', 'example', 'examples', 'encoding'] - required_fields = [] schema: Optional[Union['Schema', 'Reference']] = dataclasses.field(default=None) example: Optional[str] = dataclasses.field(default=None) # 'any' type @@ -428,8 +414,6 @@ class Response(ObjectBase): .. _Response Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#response-object """ -# __slots__ = ['description', 'headers', 'content', 'links'] - required_fields = ['description'] description: str = dataclasses.field(default=None) content: Optional[Map[str, ForwardRef('MediaType')]] = dataclasses.field(default=None) @@ -443,7 +427,6 @@ class Link(ObjectBase): .. _Link Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#linkObject """ -# __slots__ = ['operationId', 'operationRef', 'description', 'parameters', 'requestBody', 'server'] operationId: Optional[str] = dataclasses.field(default=None) operationRef: Optional[str] = dataclasses.field(default=None) diff --git a/openapi3/schemas.py b/openapi3/schemas.py index 1ce29bd..1b36c9c 100644 --- a/openapi3/schemas.py +++ b/openapi3/schemas.py @@ -21,17 +21,6 @@ class Schema(ObjectBase): .. _Schema Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#schemaObject """ -# __slots__ = ['title', 'multipleOf', 'maximum', 'exclusiveMaximum', -# 'minimum', 'exclusiveMinimum', 'maxLength', 'minLength', -# 'pattern', 'maxItems', 'minItems', 'uniqueItems', -# 'maxProperties', 'minProperties', 'required', 'enum', 'type', -# 'allOf', 'oneOf', 'anyOf', 'not', 'items', 'properties', -# 'additionalProperties', 'description', 'format', 'default', -# 'nullable', 'discriminator', 'readOnly', 'writeOnly', 'xml', -# 'externalDocs', 'example', 'deprecated', 'contentEncoding', -# 'contentMediaType', 'contentSchema', '_model_type', -# '_request_model_type', '_resolved_allOfs'] - required_fields = [] title: Optional[str] = dataclasses.field(default=None) maximum: Optional[Union[int, float]] = dataclasses.field(default=None) diff --git a/openapi3/security.py b/openapi3/security.py index edeb96f..0778429 100644 --- a/openapi3/security.py +++ b/openapi3/security.py @@ -9,9 +9,6 @@ class SecurityScheme(ObjectBase): .. _Security Scheme: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#securitySchemeObject """ -# __slots__ = ['type', 'description', 'name', 'in', 'in_', 'scheme', -# 'bearerFormat', 'flows', 'openIdConnectUrl'] - required_fields = ['type'] type: str = dataclasses.field(default=None) @@ -23,7 +20,6 @@ class SecurityScheme(ObjectBase): openIdConnectUrl: Optional[str] = dataclasses.field(default=None) scheme: Optional[str] = dataclasses.field(default=None) - def _parse_data(self): super()._parse_data() self.in_ = self._get("in", str) diff --git a/openapi3/servers.py b/openapi3/servers.py index 1915108..da6eda2 100644 --- a/openapi3/servers.py +++ b/openapi3/servers.py @@ -10,8 +10,6 @@ class Server(ObjectBase): .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#serverObject """ -# __slots__ = ['url', 'description', 'variables'] - required_fields = ['url'] url: str = dataclasses.field(default=None) description: Optional[str] = dataclasses.field(default=None) @@ -25,8 +23,6 @@ class ServerVariable(ObjectBase): .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#server-variable-object """ -# __slots__ = ['enum', 'default', 'description'] - required_fields = ['default'] default: str = dataclasses.field(default=None) description: Optional[str] = dataclasses.field(default=None) diff --git a/openapi3/tag.py b/openapi3/tag.py index 3964584..783a6bf 100644 --- a/openapi3/tag.py +++ b/openapi3/tag.py @@ -10,7 +10,6 @@ class Tag(ObjectBase): .. _Tag Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#tagObject """ -# __slots__ = ['name', 'description', 'externalDocs'] name: Optional[str] = dataclasses.field(default=None) description: Optional[str] = dataclasses.field(default=None) From de5fc0fda944ec40572d3792fc008ffcbaa05345 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Thu, 23 Dec 2021 20:49:11 +0100 Subject: [PATCH 004/125] pydantic migration - does not work yet --- openapi3/__init__.py | 3 +- openapi3/components.py | 24 +++-- openapi3/example.py | 12 ++- openapi3/general.py | 46 +++++---- openapi3/info.py | 34 ++++--- openapi3/object_base.py | 200 ++++++++++++++++++++++++---------------- openapi3/openapi.py | 180 +++++++++++++++++++++++++++++------- openapi3/paths.py | 196 +++++++++++++++++++++------------------ openapi3/schemas.py | 79 ++++++++-------- openapi3/security.py | 21 +++-- openapi3/servers.py | 21 +++-- openapi3/tag.py | 11 ++- tests/parsing_test.py | 44 +++------ 13 files changed, 532 insertions(+), 339 deletions(-) diff --git a/openapi3/__init__.py b/openapi3/__init__.py index 79e2a2a..5a8113e 100644 --- a/openapi3/__init__.py +++ b/openapi3/__init__.py @@ -5,4 +5,5 @@ from . import info, servers, paths, general, schemas, components, security, tag, example from .errors import SpecError, ReferenceResolutionError -__all__ = ["OpenAPI", "SpecError", "ReferenceResolutionError"] +__all__ = ['OpenAPI', 'SpecError', 'ReferenceResolutionError'] + diff --git a/openapi3/components.py b/openapi3/components.py index a8a13b2..eda7b8a 100644 --- a/openapi3/components.py +++ b/openapi3/components.py @@ -1,9 +1,15 @@ import dataclasses from typing import Union, Optional +from pydantic import Field + from .object_base import ObjectBase, Map -@dataclasses.dataclass +from .example import Example +from .paths import Reference, RequestBody, Link, Parameter, Response +from .schemas import Schema +from .security import SecurityScheme + class Components(ObjectBase): """ A `Components Object`_ holds a reusable set of different aspects of the OAS @@ -12,12 +18,14 @@ class Components(ObjectBase): .. _Components Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#componentsObject """ - examples: Optional[Map[str, Union['Example', 'Reference']]] = dataclasses.field(default=None) - parameters: Optional[Map[str, Union['Parameter', 'Reference']]] = dataclasses.field(default=None) - requestBodies: Optional[Map[str, Union['RequestBody', 'Reference']]] = dataclasses.field(default=None) - responses: Optional[Map[str, Union['Response', 'Reference']]] = dataclasses.field(default=None) - schemas: Optional[Map[str, Union['Schema', 'Reference']]] = dataclasses.field(default=None) - securitySchemes: Optional[Map[str, Union['SecurityScheme', 'Reference']]] = dataclasses.field(default=None) + examples: Optional[Map[str, Union['Example', 'Reference']]] = Field(default=None) + parameters: Optional[Map[str, Union['Parameter', 'Reference']]] = Field(default=None) + requestBodies: Optional[Map[str, Union['RequestBody', 'Reference']]] = Field(default=None) + responses: Optional[Map[str, Union['Response', 'Reference']]] = Field(default=None) + schemas: Optional[Map[str, Union['Schema', 'Reference']]] = Field(default=None) + securitySchemes: Optional[Map[str, Union['SecurityScheme', 'Reference']]] = Field(default=None) # headers: ['Header', 'Reference'], is_map=True - links: Optional[Map[str, Union['Link', 'Reference']]] = dataclasses.field(default=None) + links: Optional[Map[str, Union['Link', 'Reference']]] = Field(default=None) # callbacks: ['Callback', 'Reference'], is_map=True + +Components.update_forward_refs() \ No newline at end of file diff --git a/openapi3/example.py b/openapi3/example.py index 72a492f..28ac3af 100644 --- a/openapi3/example.py +++ b/openapi3/example.py @@ -1,9 +1,11 @@ import dataclasses from typing import Union, Optional +from pydantic import Field + from .object_base import ObjectBase -@dataclasses.dataclass + class Example(ObjectBase): """ A `Example Object`_ holds a reusable set of different aspects of the OAS @@ -12,7 +14,7 @@ class Example(ObjectBase): .. _Example Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#exampleObject """ - summary: Optional[str] = dataclasses.field(default=None) - description: Optional[str] = dataclasses.field(default=None) - value: Optional[Union['Reference', dict, str]] = dataclasses.field(default=None) # 'any' type - externalValue: Optional[str] = dataclasses.field(default=None) + summary: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + value: Optional[Union['Reference', dict, str]] = Field(default=None) # 'any' type + externalValue: Optional[str] = Field(default=None) diff --git a/openapi3/general.py b/openapi3/general.py index aea89db..38e983c 100644 --- a/openapi3/general.py +++ b/openapi3/general.py @@ -1,8 +1,11 @@ import dataclasses from typing import Optional + +from pydantic import Field, validator + from .object_base import ObjectBase -@dataclasses.dataclass + class ExternalDocumentation(ObjectBase): """ An `External Documentation Object`_ references external resources for extended @@ -11,31 +14,34 @@ class ExternalDocumentation(ObjectBase): .. _External Documentation Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#externalDocumentationObject """ - url: str = dataclasses.field(default=None) + url: str = Field(default=None) + + description: Optional[str] = Field(default=None) - description: Optional[str] = dataclasses.field(default=None) -@dataclasses.dataclass class Reference(ObjectBase): """ A `Reference Object`_ designates a reference to another node in the specification. .. _Reference Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#referenceObject """ - _required_fields_cache = frozenset(['$ref']) - ref: str = dataclasses.field(default=None) - - def _parse_data(self): - self.ref = self._get("$ref", str) - - @classmethod - def can_parse(cls, dct): - """ - Override ObjectBase.can_parse because we had to remove the $ from $ref - in __slots__ (since that's not a valid python variable name) - """ - # TODO - can a reference object have spec extensions? - cleaned_keys = [k for k in dct.keys() if not k.startswith('x-')] - - return len(cleaned_keys) == 1 and '$ref' in dct + ref: str = Field(required=True, default=None, alias="$ref") + + # def _parse_data(self): + # self.ref = self._get("$ref", str) + # + # @classmethod + # def can_parse(cls, dct): + # """ + # Override ObjectBase.can_parse because we had to remove the $ from $ref + # in __slots__ (since that's not a valid python variable name) + # """ + # # TODO - can a reference object have spec extensions? + # cleaned_keys = [k for k in dct.keys() if not k.startswith('x-')] + # + # return len(cleaned_keys) == 1 and '$ref' in dct + # + # @validator("ref") + # def resolve_references(cls, v, values, config, field, **kwargs): + # print(values) diff --git a/openapi3/info.py b/openapi3/info.py index 00b7849..293781c 100644 --- a/openapi3/info.py +++ b/openapi3/info.py @@ -1,8 +1,11 @@ import dataclasses from typing import ForwardRef, Optional + +from pydantic import Field + from .object_base import ObjectBase -@dataclasses.dataclass + class Info(ObjectBase): """ An OpenAPI Info object, as defined in `the spec`_. @@ -10,15 +13,16 @@ class Info(ObjectBase): .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#infoObject """ - title: str = dataclasses.field(default=None) - version: str = dataclasses.field(default=None) + title: str = Field(default=None) + version: str = Field(default=None) + + contact: Optional[ForwardRef('Contact')] = Field(default=None) + description: Optional[str] = Field(default=None) + license: Optional[ForwardRef('License')] = Field(default=None) + termsOfService: Optional[str] = Field(default=None) + - contact: Optional[ForwardRef('Contact')] = dataclasses.field(default=None) - description: Optional[str] = dataclasses.field(default=None) - license: Optional[ForwardRef('License')] = dataclasses.field(default=None) - termsOfService: Optional[str] = dataclasses.field(default=None) -@dataclasses.dataclass class Contact(ObjectBase): """ Contact object belonging to an Info object, as described `here`_ @@ -26,12 +30,10 @@ class Contact(ObjectBase): .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#contactObject """ - email: str = dataclasses.field(default=None) - name: str = dataclasses.field(default=None) - url: str = dataclasses.field(default=None) - + email: str = Field(default=None) + name: str = Field(default=None) + url: str = Field(default=None) -@dataclasses.dataclass class License(ObjectBase): """ License object belonging to an Info object, as described `here`_ @@ -39,5 +41,7 @@ class License(ObjectBase): .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#license-object """ - name: str = dataclasses.field(default=None) - url: Optional[str] = dataclasses.field(default=None) + name: str = Field(default=None) + url: Optional[str] = Field(default=None) + +Info.update_forward_refs() diff --git a/openapi3/object_base.py b/openapi3/object_base.py index fd97c3a..a193ac5 100644 --- a/openapi3/object_base.py +++ b/openapi3/object_base.py @@ -1,10 +1,10 @@ import sys import typing - - - +from typing import List, Optional, Set import dataclasses +from pydantic import BaseModel, Field + from .errors import SpecError, ReferenceResolutionError IS_PYTHON_2 = False @@ -52,7 +52,7 @@ def raise_on_unknown_type(parent, field, object_types, found): expected_type.__name__, sorted(expected_type.required_fields), ), - path=parent.path, + path=parent._path, element=parent, ) elif len(object_types) == 2 and len([c for c in object_types if isinstance(c, str)]) == 2 and "Reference" in object_types: @@ -63,9 +63,9 @@ def raise_on_unknown_type(parent, field, object_types, found): parent.get_path(), field, expected_type_str, - expected_type.required_fields, + expected_type._get_required_fields, ), - path=parent.path, + path=parent._path, element=parent, ) print(object_types) @@ -76,37 +76,41 @@ def raise_on_unknown_type(parent, field, object_types, found): ), type(found) ), - path=parent.path, + path=parent._path, element=parent, ) def isoptional(x): - if x.name[0] == '_': - return True - if typing.get_origin(x.type) != typing.Union: - return False - args = typing.get_args(x.type) - if None.__class__ not in args: - return False - return True - -class ObjectBase(object): + pass + +from pydantic.fields import ModelField + +class ObjectBase(BaseModel): """ The base class for all schema objects. Includes helpers for common schema- related functions. """ - __slots__ = ['path', 'raw_element', '_accessed_members', 'strict', '_root', - 'extensions', '_original_ref'] +# __slots__ = ['path', 'raw_element', '_accessed_members', 'strict', '_root', +# 'extensions', '_original_ref'] + + extensions: Optional[object] = Field(default=None) + + + _strict: bool + _path: List[str] + _raw_element: dict + _root: object + _accessed_members: object + + class Config: + underscore_attrs_are_private = True + arbitrary_types_allowed = True + + - @classmethod @property - def required_fields(cls): - try: - return cls._required_fields_cache - except AttributeError: - fields = [x for x in map(lambda x: x.name.rstrip("_"), filter(lambda x: not isoptional(x), dataclasses.fields(cls)))] - cls._required_fields_cache = frozenset(fields) - return cls._required_fields_cache + def _get_required_fields(self): + return set(map(lambda y: y.alias, filter(lambda z: z.required is True, self.__fields__.values()))) @classmethod def create(cls, path, raw_element, root, obj=None): @@ -123,29 +127,29 @@ def create(cls, path, raw_element, root, obj=None): :type root: OpenAPI """ # init empty slots - obj = obj or cls() + obj = obj or cls(raw_element) # for k in type(obj).__slots__: # if k in ('_spec_errors', 'validation_mode'): # # allow these two fields to keep their values # continue # setattr(obj, k, None) - obj.path = path - obj.raw_element = raw_element + obj._path = path + obj._raw_element = raw_element obj._root = root obj._accessed_members = [] obj.extensions = {} # TODO - add strict mode that errors if all members were not accessed - obj.strict = False + obj._strict = False # parse our own element try: - obj._required_fields(*type(obj).required_fields) + obj._required_fields(obj._get_required_fields) obj._parse_data() except SpecError as e: - if obj._root.validation_mode: + if obj._root._validation_mode: obj._root.log_spec_error(e) else: raise @@ -162,7 +166,7 @@ def __repr__(self): Returns a string representation of the parsed object """ # TODO - why? - return "<{} {}>".format(type(self), self.path) + return "<{} {}>".format(type(self), self._path) def __getstate__(self): """ @@ -182,7 +186,7 @@ def __setstate__(self, state): for k, v in state.items(): setattr(self, k, v) - def _required_fields(self, *fields): + def _required_fields(self, fields: Set): """ Given a list of require fields for this object, raises a SpecError if any of the fields do not exist. @@ -192,12 +196,12 @@ def _required_fields(self, *fields): :raises SpecError: if any of the required fields are not present. """ - missing_fields = set(fields) - set(self.raw_element) + missing_fields = fields - set(self._raw_element) if missing_fields: raise SpecError('Missing required fields: {}'.format( ', '.join(missing_fields)), - path=self.path, + path=self._path, element=self) def _parse_data(self): @@ -212,10 +216,7 @@ def _parse_data(self): are parsed and then an assertion is made that all keys in the raw_element were accessed - if not, the schema is considered invalid. """ - for field in dataclasses.fields(self): - v = self._get(field.name, field.type) - if v is not None: - setattr(self, field.name, v) + self.__class__.parse_obj(self._raw_element) def _get(self, field, object_type): """ @@ -232,7 +233,7 @@ def _get(self, field, object_type): """ self._accessed_members.append(field) c = object_type - ret = self.raw_element.get(field, None) + ret = self._raw_element.get(field, None) if ret is None: return None @@ -253,7 +254,7 @@ def _get(self, field, object_type): raise SpecError('Expected {}.{} to be a list of {}, got {}'.format( self.get_path, field, object_type, type(ret)), - path=self.path, + path=self._path, element=self) ret = self.parse_list(ret, object_type, field) elif origin == Map: @@ -261,9 +262,9 @@ def _get(self, field, object_type): raise SpecError('Expected {}.{} to be a Map of string: [{}], got {}'.format( self.get_path, field, object_type, type(ret)), - path=self.path, + path=self._path, element=self) - ret = Map(self.path + [field], ret, object_type, self._root) + ret = Map(self._path + [field], ret, object_type, self._root) else: accepts_string = False for t in types: @@ -280,7 +281,7 @@ def _get(self, field, object_type): python_type = t #ObjectBase.get_object_type(t) if python_type.can_parse(ret): - ret = python_type.create(self.path + [field], ret, self._root) + ret = python_type.create(self._path + [field], ret, self._root) break elif isinstance(ret, t): @@ -292,7 +293,7 @@ def _get(self, field, object_type): else: raise_on_unknown_type(self, field, types, ret) except SpecError as e: - if self._root.validation_mode: + if self._root._validation_mode: self._root.log_spec_error(e) ret = None else: @@ -343,7 +344,7 @@ def can_parse(cls, dct): if keys - fields: return False - if cls.required_fields - keys: + if cls._get_required_fields - keys: return False return True @@ -355,7 +356,7 @@ def _parse_spec_extensions(self): .. _Specification Extensions: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#specificationExtensions """ - for k, v in self.raw_element.items(): + for k, v in self._raw_element.items(): if k.startswith('x-'): self.extensions[k[2:]] = v self._accessed_members.append(k) @@ -396,7 +397,7 @@ def get_path(self): :returns: The path in the spec for this element :rtype: str """ - return '.'.join(self.path) + return '.'.join(self._path) def parse_list(self, raw_list, object_type, field=None): """ @@ -417,7 +418,7 @@ def parse_list(self, raw_list, object_type, field=None): if raw_list is None: return None - real_path = self.path[:] + real_path = self._path[:] if field: real_path += [field] @@ -441,28 +442,28 @@ def parse_list(self, raw_list, object_type, field=None): if not found_type: raise SpecError('Could not parse {}.{}, expected to be one of [{}]'.format( '.'.join(real_path), i, python_types), - path=self.path, + path=self._path, element=self) return result @staticmethod - def _resolve_type(obj, value): + def _resolve_type(root, obj, value): # we found a reference - attempt to resolve it reference_path = value.ref if not reference_path.startswith('#/'): raise ReferenceResolutionError('Invalid reference path {}'.format( reference_path), - path=obj.path, + path=obj._path, element=obj) reference_path = reference_path.split('/')[1:] try: - resolved_value = obj._root.resolve_path(reference_path) + resolved_value = root.resolve_path(reference_path) except ReferenceResolutionError as e: # add metadata to the error - e.path = obj.path + e.path = obj._path e.element = obj raise @@ -471,37 +472,51 @@ def _resolve_type(obj, value): resolved_value._original_ref = value return resolved_value - def _resolve_references(self): + def _resolve_references(self, root): """ Resolves all reference objects below this object and notes their original value was a reference. """ # don't circular import - reference_type = ObjectBase.get_object_type('Reference') - for slot in filter(lambda x: not x.startswith("_"), - map(lambda x: x.name, dataclasses.fields(self))): - value = getattr(self, slot) + reference_type = ObjectBase.get_object_type('Reference') + obj = root = self - if isinstance(value, reference_type): - resolved_value = self._resolve_type(self, value) - setattr(self, slot, resolved_value) - elif issubclass(type(value), ObjectBase) or isinstance(value, Map): - # otherwise, continue resolving down the tree - value._resolve_references() - elif isinstance(value, list): - # if it's a list, resolve its item's references - resolved_list = [] - for item in value: - if isinstance(item, reference_type): - resolved_value = self._resolve_type(self, item) - resolved_list.append(resolved_value) - else: - if issubclass(type(value), ObjectBase) or isinstance(value, Map): - item._resolve_references() + def resolve(obj): + for slot in filter(lambda x: not x.startswith("_"), obj.__fields_set__): + value = getattr(obj, slot) + if value is None: + continue + elif isinstance(value, reference_type): + resolved_value = ObjectBase._resolve_type(root, obj, value) + setattr(obj, slot, resolved_value) + elif issubclass(type(value), ObjectBase): + # otherwise, continue resolving down the tree + resolve(value) + elif isinstance(value, dict): # pydantic does not use Map + for k, v in value.items(): + if isinstance(v, reference_type): + if v.ref: + value[k] = ObjectBase._resolve_type(root, obj, v) + else: + resolve(value[k]) + elif isinstance(value, list): + # if it's a list, resolve its item's references + resolved_list = [] + for item in value: + if isinstance(item, reference_type): + resolved_value = ObjectBase._resolve_type(root, item) + resolved_list.append(resolved_value) + else: + resolve(value) resolved_list.append(item) + setattr(obj, slot, resolved_list) + elif isinstance(value, (str, int, float)): + pass + else: + raise TypeError(type(value)) - setattr(self, slot, resolved_list) + resolve(self) def _resolve_allOfs(self): """ @@ -574,15 +589,38 @@ def resolve(t): raise TypeError(object_type) +from collections.abc import Mapping +from typing import TypeVar, Hashable + +K = TypeVar("K", bound=Hashable) +V = TypeVar("V", bound=Hashable) + +#class Map(Mapping[K, V]): +import collections +class Map(collections.OrderedDict): +# @classmethod +# def __get_validators__(cls): +# yield cls.validate + + @classmethod + def __get_validators__(cls): + yield cls.validate + + + @classmethod + def __modify_schema__(cls, field_schema, field: Optional[ModelField]): + print(field_schema) -class Map(dict): """ The Map object wraps a python dict and parses its values into the chosen type or types. """ - __slots__ = ['dct', 'path', 'raw_element', '_root'] +# __slots__ = ['dct', 'path', 'raw_element', '_root'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) - def __init__(self, path, raw_element, object_type, root): + def old__init__(self, path, raw_element, object_type, root): """ Creates a dict containing the parsed objects from the raw element diff --git a/openapi3/openapi.py b/openapi3/openapi.py index 68bbcad..bdad866 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -1,30 +1,20 @@ import dataclasses from typing import ForwardRef, Any, List, Optional +from pydantic import Field, ValidationError import requests from .object_base import ObjectBase, Map from .errors import ReferenceResolutionError, SpecError -@dataclasses.dataclass(init=False) -class OpenAPI(ObjectBase): - """ - This class represents the root of the OpenAPI schema document, as defined - in `the spec`_ - - .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#openapi-object - """ +from .info import Info +from .paths import Path, SecurityRequirement +from .components import Components +from .servers import Server +from .tag import Tag - openapi: str = dataclasses.field(default=None) - info: ForwardRef('Info') = dataclasses.field(default=None) - paths: Map[str, ForwardRef('Path')] = dataclasses.field(default=None) - - components: Optional[ForwardRef('Components')] = dataclasses.field(default=None) - externalDocs: Optional[Map[Any, Any]] = dataclasses.field(default=None) - security: Optional[List['SecurityRequirement']] = dataclasses.field(default=None) - servers: Optional[List['Server']] = dataclasses.field(default=None) - tags: Optional[List['Tag']] = dataclasses.field(default=None) +class OpenAPI: def __init__( self, raw_document, @@ -49,16 +39,10 @@ def __init__( :param use_session: Should we use a consistant session between API calls :type use_session: bool """ - # do this first so super().__init__ can see it - self.validation_mode = validate - - if validate: - self._spec_errors = [] - # as the document root, we have no path - super(OpenAPI, self).create([], raw_document, self, obj=self) - - self._security = {} + self._spec = OpenAPISpec.parse_obj(raw_document) +# self._spec.resolve_path("#/components/responses/Missing".split('/')[1:]) + self._spec._resolve_references(self._spec) self._ssl_verify = ssl_verify @@ -66,6 +50,24 @@ def __init__( if use_session: self._session = session_factory() + @property + def paths(self): + return self._spec.path + + @property + def components(self): + return self._spec.components + + @property + def info(self): + return self._spec.info + + @property + def openapi(self): + return self._spec.openapi + + + # public methods def authenticate(self, security_scheme, value): """ @@ -79,12 +81,127 @@ def authenticate(self, security_scheme, value): self._security = None return - if security_scheme not in self.components.securitySchemes: + if security_scheme not in self._spec.components.securitySchemes: raise ValueError('{} does not accept security scheme {}'.format( self.info.title, security_scheme)) self._security = {security_scheme: value} + + def log_spec_error(self, error): + """ + In Validation Mode, this method is used when parsing a spec to record an + error that was encountered, for later reporting. This should not be used + outside of Validation Mode. + + :param error: The error encountered. + :type error: SpecError + """ + if not self._validation_mode: + raise RuntimeError('This client is not in Validation Mode, cannot ' + 'record errors!') + self._spec_errors.append(error) + + def errors(self): + """ + In Validation Mode, returns all errors encountered from parsing a spec. + This should not be called if not in Validation Mode. + + :returns: The errors encountered during the parsing of this spec. + :rtype: List[SpecError] + """ + if not self._validation_mode: + raise RuntimeError('This client is not in Validation Mode, cannot ' + 'return errors!') + return self._spec_errors + + # private methods + def _register_operation(self, operation_id, operation): + """ + Adds an Operation to this spec's _operation_map, raising an error if the + OperationId has already been registered. + + :param operation_id: The operation ID to register + :type operation_id: str + :param operation: The operation to register + :type operation: Operation + """ + if operation_id in self._operation_map: + raise SpecError("Duplicate operationId {}".format(operation_id), path=operation._path) + self._operation_map[operation_id] = operation + + def _get_callable(self, operation): + """ + A helper function to create OperationCallable objects for __getattribute__, + pre-initialized with the required values from this object. + + :param operation: The Operation the callable should call + :type operation: callable (Operation.request) + + :returns: The callable that executes this operation with this object's + configuration. + :rtype: OperationCallable + """ + base_url = self.servers[0].url + + return OperationCallable(operation, base_url, self._security, self._ssl_verify, + self._session) + + def __getattribute__(self, attr): + """ + Extended __getattribute__ function to allow resolving dynamic function + names. The purpose of this is to call syntax like this:: + + spec = OpenAPI(raw_spec) + spec.call_operationId() + + This method will intercept the dot notation above (spec.call_operationId) + and look up the requested operation, returning a callable object that + will then immediately be called by the parenthesis. + + :param attr: The attribute we're retrieving + :type attr: str + + :returns: The attribute requested + :rtype: any + :raises AttributeError: if the requested attribute does not exist + """ + if attr.startswith('call_'): + _, operationId = attr.split('_', 1) + if operationId in self._operation_map: + return self._get_callable(self._operation_map[operationId].request) + else: + raise AttributeError('{} has no operation {}'.format( + self.info.title, operationId)) + + return object.__getattribute__(self, attr) + + +class OpenAPISpec(ObjectBase): + """ + This class represents the root of the OpenAPI schema document, as defined + in `the spec`_ + + .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#openapi-object + """ + + openapi: str = Field(required=True) + info: Info = Field(required=True) + paths: Map[str, Path] = Field(required=True, default_factory=Map) + + components: Optional[Components] = Field(default=None) + externalDocs: Optional[Map[Any, Any]] = Field(default=None) + security: Optional[List[SecurityRequirement]] = Field(default=None) + servers: Optional[List[Server]] = Field(default=None) + tags: Optional[List[Tag]] = Field(default=None) + + _validation_mode: bool + _operation_map: set + + class Config: + underscore_attrs_are_private = True + arbitrary_types_allowed = True + def resolve_path(self, path): """ Given a $ref path, follows the document tree and returns the given attribute. @@ -99,7 +216,7 @@ def resolve_path(self, path): node = self for part in path: - if isinstance(node, Map): + if isinstance(node, dict): if part not in node: # pylint: disable=unsupported-membership-test err_msg = 'Invalid path {} in Reference'.format(path) raise ReferenceResolutionError(err_msg) @@ -121,7 +238,7 @@ def log_spec_error(self, error): :param error: The error encountered. :type error: SpecError """ - if not self.validation_mode: + if not self._validation_mode: raise RuntimeError('This client is not in Validation Mode, cannot ' 'record errors!') self._spec_errors.append(error) @@ -134,7 +251,7 @@ def errors(self): :returns: The errors encountered during the parsing of this spec. :rtype: List[SpecError] """ - if not self.validation_mode: + if not self._validation_mode: raise RuntimeError('This client is not in Validation Mode, cannot ' 'return errors!') return self._spec_errors @@ -151,7 +268,7 @@ def _register_operation(self, operation_id, operation): :type operation: Operation """ if operation_id in self._operation_map: - raise SpecError("Duplicate operationId {}".format(operation_id), path=operation.path) + raise SpecError("Duplicate operationId {}".format(operation_id), path=operation._path) self._operation_map[operation_id] = operation def _parse_data(self): @@ -212,6 +329,7 @@ def __getattribute__(self, attr): return object.__getattribute__(self, attr) +OpenAPISpec.update_forward_refs() class OperationCallable: """ diff --git a/openapi3/paths.py b/openapi3/paths.py index bddb7de..c66b420 100644 --- a/openapi3/paths.py +++ b/openapi3/paths.py @@ -2,6 +2,8 @@ from typing import ForwardRef, Union, List, Optional import json import re + +from pydantic import Field import requests try: @@ -13,19 +15,28 @@ from .object_base import ObjectBase, Map from .schemas import Model +from .info import Info +#from .components import Components +from .servers import Server +from .tag import Tag +from .general import Reference +from .general import ExternalDocumentation +from .schemas import Schema +from .example import Example + def _validate_parameters(instance): """ Ensures that all parameters for this path are valid """ - allowed_path_parameters = re.findall(r'{([a-zA-Z0-9\-\._~]+)}', instance.path[1]) + allowed_path_parameters = re.findall(r'{([a-zA-Z0-9\-\._~]+)}', instance._path[1]) for c in instance.parameters: if c.in_ == 'path': if c.name not in allowed_path_parameters: - raise SpecError('Parameter name not found in path: {}'.format(c.name), path=instance.path) + raise SpecError('Parameter name not found in path: {}'.format(c.name), path=instance._path) + -@dataclasses.dataclass class Path(ObjectBase): """ A Path object, as defined `here`_. Path objects represent URL paths that @@ -33,20 +44,20 @@ class Path(ObjectBase): .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#paths-object """ - delete: Optional[ForwardRef('Operation')] = dataclasses.field(default=None) - description: Optional[str] = dataclasses.field(default=None) - get: Optional[ForwardRef('Operation')] = dataclasses.field(default=None) - head: Optional[ForwardRef('Operation')] = dataclasses.field(default=None) - options: Optional[ForwardRef('Operation')] = dataclasses.field(default=None) + delete: Optional[ForwardRef('Operation')] = Field(default=None) + description: Optional[str] = Field(default=None) + get: Optional[ForwardRef('Operation')] = Field(default=None) + head: Optional[ForwardRef('Operation')] = Field(default=None) + options: Optional[ForwardRef('Operation')] = Field(default=None) - patch: Optional[ForwardRef('Operation')] = dataclasses.field(default=None) - post: Optional[ForwardRef('Operation')] = dataclasses.field(default=None) - put: Optional[ForwardRef('Operation')] = dataclasses.field(default=None) - servers: Optional[List['Server']] = dataclasses.field(default=None) - summary: Optional[str] = dataclasses.field(default=None) - trace: Optional[ForwardRef('Operation')] = dataclasses.field(default=None) + patch: Optional[ForwardRef('Operation')] = Field(default=None) + post: Optional[ForwardRef('Operation')] = Field(default=None) + put: Optional[ForwardRef('Operation')] = Field(default=None) + servers: Optional[List[Server]] = Field(default=None) + summary: Optional[str] = Field(default=None) + trace: Optional[ForwardRef('Operation')] = Field(default=None) - parameters: Optional[List[Union['Parameter', 'Reference']]] = dataclasses.field(default_factory=list) + parameters: Optional[List[Union['Parameter', Reference]]] = Field(default_factory=list) def _parse_data(self): """ @@ -58,18 +69,18 @@ def _parse_data(self): # this will be iterated over later self.parameters = [] - def _resolve_references(self): - """ - Overloaded _resolve_references to allow us to verify parameters after - we've got all references settled. - """ - super(self.__class__, self)._resolve_references() + # def _resolve_references(self, root): + # """ + # Overloaded _resolve_references to allow us to verify parameters after + # we've got all references settled. + # """ + # super(self.__class__, self)._resolve_references(root) + # + # # this will raise if parameters are invalid + # _validate_parameters(self) - # this will raise if parameters are invalid - _validate_parameters(self) -@dataclasses.dataclass class Parameter(ObjectBase): """ A `Parameter Object`_ defines a single operation parameter. @@ -77,37 +88,37 @@ class Parameter(ObjectBase): .. _Parameter Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#parameterObject """ - in_: str = dataclasses.field(default=None) # TODO must be one of ["query","header","path","cookie"] - name: str = dataclasses.field(default=None) + in_: str = Field(required=True, alias="in") # TODO must be one of ["query","header","path","cookie"] + name: str = Field(required=True) - deprecated: Optional[bool] = dataclasses.field(default=None) - description: Optional[str] = dataclasses.field(default=None) - example: Optional[str] = dataclasses.field(default=None) - examples: Optional[Map[str, Union['Example','Reference']]] = dataclasses.field(default=None) - explode: Optional[bool] = dataclasses.field(default=None) - required: Optional[bool] = dataclasses.field(default=None) - schema: Optional[Union['Schema', 'Reference']] = dataclasses.field(default=None) - style: Optional[str] = dataclasses.field(default=None) + deprecated: Optional[bool] = Field(default=None) + description: Optional[str] = Field(default=None) + example: Optional[str] = Field(default=None) + examples: Optional[Map[str, Union['Example','Reference']]] = Field(default=None) + explode: Optional[bool] = Field(default=None) + required: Optional[bool] = Field(default=None) + schema_: Optional[Union['Schema', 'Reference']] = Field(default=None, alias="schema") + style: Optional[str] = Field(default=None) # allow empty or reserved values in Parameter data - allowEmptyValue: Optional[bool] = dataclasses.field(default=None) - allowReserved: Optional[bool] = dataclasses.field(default=None) + allowEmptyValue: Optional[bool] = Field(default=None) + allowReserved: Optional[bool] = Field(default=None) @classmethod def can_parse(cls, dct): return super().can_parse(dct) def _parse_data(self): - super()._parse_data() - self.in_ = self._get("in", str) +# super()._parse_data() +# self.in_ = self._get("in", str) # required is required and must be True if this parameter is in the path if self.in_ == "path" and self.required is not True: err_msg = 'Parameter {} must be required since it is in the path' - raise SpecError(err_msg.format(self.get_path()), path=self.path) + raise SpecError(err_msg.format(self.get_path()), path=self._path) +from pydantic import validator -@dataclasses.dataclass class Operation(ObjectBase): """ An Operation object as defined `here`_ @@ -115,17 +126,19 @@ class Operation(ObjectBase): .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#operationObject """ - responses: Map[str, Union['Response', 'Reference']] = dataclasses.field(default=None) - deprecated: Optional[bool] = dataclasses.field(default=None) - description: Optional[str] = dataclasses.field(default=None) - externalDocs: Optional[ForwardRef('ExternalDocumentation')] = dataclasses.field(default=None) - operationId: Optional[str] = dataclasses.field(default=None) - parameters: Optional[List[Union['Parameter', 'Reference']]] = dataclasses.field(default_factory=list) - requestBody: Optional[Union['RequestBody', 'Reference']] = dataclasses.field(default=None) - security: Optional[List['SecurityRequirement']] = dataclasses.field(default_factory=list) - servers: Optional[List['Server']] = dataclasses.field(default=None) - summary: Optional[str] = dataclasses.field(default=None) - tags: Optional[List[str]] = dataclasses.field(default=None) + responses: Map[str, Union['Response', 'Reference']] = Field(required=True) + + deprecated: Optional[bool] = Field(default=None) + description: Optional[str] = Field(default=None) + externalDocs: Optional[ForwardRef('ExternalDocumentation')] = Field(default=None) + operationId: Optional[str] = Field(default=None) + parameters: Optional[List[Union['Parameter', 'Reference']]] = Field(default_factory=list) + requestBody: Optional[Union['RequestBody', 'Reference']] = Field(default=None) + security: Optional[List['SecurityRequirement']] = Field(default_factory=list) + servers: Optional[List['Server']] = Field(default=None) + summary: Optional[str] = Field(default=None) + tags: Optional[List[str]] = Field(default=None) + def _parse_data(self): """ @@ -146,15 +159,15 @@ def _parse_data(self): # Store request object self._request = requests.Request() - def _resolve_references(self): - """ - Overloaded _resolve_references to allow us to verify parameters after - we've got all references settled. - """ - super(self.__class__, self)._resolve_references() - - # this will raise if parameters are invalid - _validate_parameters(self) +# def _resolve_references(self, root): +# """ +# Overloaded _resolve_references to allow us to verify parameters after +# we've got all references settled. +# """ +# super(self.__class__, self)._resolve_references() +# +# # this will raise if parameters are invalid +# _validate_parameters(self) def _request_handle_secschemes(self, security_requirement, value): ss = self._root.components.securitySchemes[security_requirement.name] @@ -189,7 +202,7 @@ def _request_handle_parameters(self, parameters={}): # Parameters path_parameters = {} accepted_parameters = {} - p = self.parameters + self._root.paths[self.path[-2]].parameters + p = self.parameters + self._root.paths[self._path[-2]].parameters for _ in list(p): # TODO - make this work with $refs - can operations be $refs? @@ -264,10 +277,10 @@ def request(self, base_url, security={}, data=None, parameters={}, verify=True, :type raw_response: bool """ # Set request method (e.g. 'GET') - self._request = requests.Request(self.path[-1]) + self._request = requests.Request(self._path[-1]) # Set self._request.url to base_url w/ path - self._request.url = base_url + self.path[-2] + self._request.url = base_url + self._path[-2] if security and self.security: security_requirement = None @@ -343,7 +356,7 @@ def request(self, base_url, security={}, data=None, parameters={}, verify=True, raise NotImplementedError() -@dataclasses.dataclass + class SecurityRequirement(ObjectBase): """ A `SecurityRequirement`_ object describes security schemes for API access. @@ -351,17 +364,17 @@ class SecurityRequirement(ObjectBase): .. _SecurityRequirement: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#securityRequirementObject """ - name: Optional[str] = dataclasses.field(default=None) - types: Optional[List[str]] = dataclasses.field(default=None) + name: Optional[str] = Field(default=None) + types: Optional[List[str]] = Field(default=None) def _parse_data(self): """ """ # usually these only ever have one key - if len(self.raw_element.keys()) == 1: - self.name = [c for c in self.raw_element.keys()][0] + if len(self._raw_element.keys()) == 1: + self.name = [c for c in self._raw_element.keys()][0] self.types = self._get(self.name, List[str]) - elif len(self.raw_element.keys()) == 0: + elif len(self._raw_element.keys()) == 0: # optional self.name = self.types = None @@ -378,7 +391,7 @@ def __getstate__(self): return {self.name: self.types} -@dataclasses.dataclass + class RequestBody(ObjectBase): """ A `RequestBody`_ object describes a single request body. @@ -386,12 +399,12 @@ class RequestBody(ObjectBase): .. _RequestBody: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#requestBodyObject """ - content: Map[str, ForwardRef('MediaType')] = dataclasses.field(default=None) - description: Optional[str] = dataclasses.field(default=None) - required: Optional[bool] = dataclasses.field(default=None) + content: Map[str, ForwardRef('MediaType')] = Field(default=None) + description: Optional[str] = Field(default=None) + required: Optional[bool] = Field(default=None) + -@dataclasses.dataclass class MediaType(ObjectBase): """ A `MediaType`_ object provides schema and examples for the media type identified @@ -400,13 +413,13 @@ class MediaType(ObjectBase): .. _MediaType: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#mediaTypeObject """ - schema: Optional[Union['Schema', 'Reference']] = dataclasses.field(default=None) - example: Optional[str] = dataclasses.field(default=None) # 'any' type - examples: Optional[Map[str, Union['Example', 'Reference']]] = dataclasses.field(default=None) - encoding: Optional[Map[str, ForwardRef('Encoding')]] = dataclasses.field(default=None) + schema_: Optional[Union['Schema', 'Reference']] = Field(default=None, alias="schema") + example: Optional[str] = Field(default=None) # 'any' type + examples: Optional[Map[str, Union['Example', 'Reference']]] = Field(default=None) + encoding: Optional[Map[str, str]] = Field(default=None) + -@dataclasses.dataclass class Response(ObjectBase): """ A `Response Object`_ describes a single response from an API Operation, @@ -415,12 +428,12 @@ class Response(ObjectBase): .. _Response Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#response-object """ - description: str = dataclasses.field(default=None) - content: Optional[Map[str, ForwardRef('MediaType')]] = dataclasses.field(default=None) - links: Optional[Map[str, Union['Link', 'Reference']]] = dataclasses.field(default=None) + description: str = Field(required=True) + content: Optional[Map[str, ForwardRef('MediaType')]] = Field(default=None) + links: Optional[Map[str, Union['Link', 'Reference']]] = Field(default=None) + -@dataclasses.dataclass class Link(ObjectBase): """ A `Link Object`_ describes a single Link from an API Operation Response to an API Operation Request @@ -428,12 +441,12 @@ class Link(ObjectBase): .. _Link Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#linkObject """ - operationId: Optional[str] = dataclasses.field(default=None) - operationRef: Optional[str] = dataclasses.field(default=None) - description: Optional[str] = dataclasses.field(default=None) - parameters: Optional[dict] = dataclasses.field(default=None) - requestBody: Optional[dict] = dataclasses.field(default=None) - server: Optional[ForwardRef('Server')] = dataclasses.field(default=None) + operationId: Optional[str] = Field(default=None) + operationRef: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + parameters: Optional[dict] = Field(default=None) + requestBody: Optional[dict] = Field(default=None) + server: Optional[ForwardRef('Server')] = Field(default=None) def _parse_data(self): """ @@ -446,3 +459,8 @@ def _parse_data(self): if not (self.operationId or self.operationRef): raise SpecError("operationId and operationRef are mutually exclusive, one of them must be specified") + +Path.update_forward_refs() +Operation.update_forward_refs() +MediaType.update_forward_refs() +RequestBody.update_forward_refs() \ No newline at end of file diff --git a/openapi3/schemas.py b/openapi3/schemas.py index 1b36c9c..7789294 100644 --- a/openapi3/schemas.py +++ b/openapi3/schemas.py @@ -1,6 +1,8 @@ from typing import Union, List, Any, Optional import dataclasses +from pydantic import Field + from .errors import SpecError from .general import Reference # need this for Model below from .object_base import ObjectBase, Map @@ -14,7 +16,7 @@ } -@dataclasses.dataclass + class Schema(ObjectBase): """ The `Schema Object`_ allows the definition of input and output data types. @@ -22,41 +24,41 @@ class Schema(ObjectBase): .. _Schema Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#schemaObject """ - title: Optional[str] = dataclasses.field(default=None) - maximum: Optional[Union[int, float]] = dataclasses.field(default=None) - minimum: Optional[Union[int, float]] = dataclasses.field(default=None) - maxLength: Optional[int] = dataclasses.field(default=None) - minLength: Optional[int] = dataclasses.field(default=None) - pattern: Optional[str] = dataclasses.field(default=None) - maxItems: Optional[int] = dataclasses.field(default=None) - minItems: Optional[int] = dataclasses.field(default=None) - required: Optional[List[str]] = dataclasses.field(default_factory=list) - enum: Optional[list] = dataclasses.field(default=None) - type: Optional[str] = dataclasses.field(default=None) - allOf: Optional[List[Union["Schema", "Reference"]]] = dataclasses.field(default=None) - oneOf: Optional[list] = dataclasses.field(default=None) - anyOf: Optional[list] = dataclasses.field(default=None) - items: Optional[Union['Schema', 'Reference']] = dataclasses.field(default=None) - properties: Optional[Map[str, Union['Schema', 'Reference']]] = dataclasses.field(default=None) - additionalProperties: Optional[Union[bool, dict]] = dataclasses.field(default=None) - description: Optional[str] = dataclasses.field(default=None) - format: Optional[str] = dataclasses.field(default=None) - default: Optional[str] = dataclasses.field(default=None) # TODO - str as a default? - nullable: Optional[bool] = dataclasses.field(default=None) - discriminator: Optional[dict] = dataclasses.field(default=None) # 'Discriminator' - readOnly: Optional[bool] = dataclasses.field(default=None) - writeOnly: Optional[bool] = dataclasses.field(default=None) - xml: Optional[dict] = dataclasses.field(default=None) # 'XML' - externalDocs: Optional[dict] = dataclasses.field(default=None) # 'ExternalDocs' - deprecated: Optional[bool] = dataclasses.field(default=None) - example: Optional[Any] = dataclasses.field(default=None) - contentEncoding: Optional[str] = dataclasses.field(default=None) - contentMediaType: Optional[str] = dataclasses.field(default=None) - contentSchema: Optional[str] = dataclasses.field(default=None) - - _model_type: object = dataclasses.field(default=None) - _request_model_type: object = dataclasses.field(default=None) - _resolved_allOfs: object = dataclasses.field(default=None) + title: Optional[str] = Field(default=None) + maximum: Optional[Union[int, float]] = Field(default=None) + minimum: Optional[Union[int, float]] = Field(default=None) + maxLength: Optional[int] = Field(default=None) + minLength: Optional[int] = Field(default=None) + pattern: Optional[str] = Field(default=None) + maxItems: Optional[int] = Field(default=None) + minItems: Optional[int] = Field(default=None) + required: Optional[List[str]] = Field(default_factory=list) + enum: Optional[list] = Field(default=None) + type: Optional[str] = Field(default=None) + allOf: Optional[List[Union["Schema", "Reference"]]] = Field(default=None) + oneOf: Optional[list] = Field(default=None) + anyOf: Optional[list] = Field(default=None) + items: Optional[Union['Schema', 'Reference']] = Field(default=None) + properties: Optional[Map[str, Union['Schema', 'Reference']]] = Field(default=None) + additionalProperties: Optional[Union[bool, dict]] = Field(default=None) + description: Optional[str] = Field(default=None) + format: Optional[str] = Field(default=None) + default: Optional[str] = Field(default=None) # TODO - str as a default? + nullable: Optional[bool] = Field(default=None) + discriminator: Optional[dict] = Field(default=None) # 'Discriminator' + readOnly: Optional[bool] = Field(default=None) + writeOnly: Optional[bool] = Field(default=None) + xml: Optional[dict] = Field(default=None) # 'XML' + externalDocs: Optional[dict] = Field(default=None) # 'ExternalDocs' + deprecated: Optional[bool] = Field(default=None) + example: Optional[Any] = Field(default=None) + contentEncoding: Optional[str] = Field(default=None) + contentMediaType: Optional[str] = Field(default=None) + contentSchema: Optional[str] = Field(default=None) + + _model_type: object = Field(default=None) + _request_model_type: object = Field(default=None) + _resolved_allOfs: object = Field(default=None) def _parse_data(self): """ @@ -92,7 +94,7 @@ def get_type(self): """ # this is defined in ObjectBase.__init__ as all slots are if self._model_type is None: # pylint: disable=access-member-before-definition - type_name = self.title or self.path[-1] + type_name = self.title or self._path[-1] self._model_type = type(type_name, (Model,), { # pylint: disable=attribute-defined-outside-init '__slots__': self.properties.keys() }) @@ -126,7 +128,7 @@ def get_request_type(self): """ # this is defined in ObjectBase.__init__ as all slots are if self._request_model_type is None: # pylint: disable=access-member-before-definition - type_name = self.title or self.path[-1] + type_name = self.title or self._path[-1] self._request_model_type = type(type_name + 'Request', (Model,), { # pylint: disable=attribute-defined-outside-init '__slots__': [k for k, v in self.properties.items() if not v.readOnly] }) @@ -254,3 +256,4 @@ def __iter__(self): return +Schema.update_forward_refs() \ No newline at end of file diff --git a/openapi3/security.py b/openapi3/security.py index 0778429..e0957d4 100644 --- a/openapi3/security.py +++ b/openapi3/security.py @@ -1,8 +1,11 @@ import dataclasses from typing import Optional + +from pydantic import Field + from .object_base import ObjectBase, Map -@dataclasses.dataclass + class SecurityScheme(ObjectBase): """ A `Security Scheme`_ defines a security scheme that can be used by the operations. @@ -10,15 +13,15 @@ class SecurityScheme(ObjectBase): .. _Security Scheme: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#securitySchemeObject """ - type: str = dataclasses.field(default=None) + type: str = Field(default=None) - bearerFormat: Optional[str] = dataclasses.field(default=None) - description: Optional[str] = dataclasses.field(default=None) - flows: Optional[Map[str, str]] = dataclasses.field(default=None) # TODO - in_: Optional[str] = dataclasses.field(default=None) - name: Optional[str] = dataclasses.field(default=None) - openIdConnectUrl: Optional[str] = dataclasses.field(default=None) - scheme: Optional[str] = dataclasses.field(default=None) + bearerFormat: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + flows: Optional[Map[str, str]] = Field(default=None) # TODO + in_: Optional[str] = Field(default=None) + name: Optional[str] = Field(default=None) + openIdConnectUrl: Optional[str] = Field(default=None) + scheme: Optional[str] = Field(default=None) def _parse_data(self): super()._parse_data() diff --git a/openapi3/servers.py b/openapi3/servers.py index da6eda2..5f29c3f 100644 --- a/openapi3/servers.py +++ b/openapi3/servers.py @@ -1,9 +1,12 @@ import dataclasses -from typing import List, Optional +from typing import List, Optional, ForwardRef + +from pydantic import Field + from .object_base import ObjectBase, Map -@dataclasses.dataclass + class Server(ObjectBase): """ The Server object, as described `here`_ @@ -11,12 +14,12 @@ class Server(ObjectBase): .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#serverObject """ - url: str = dataclasses.field(default=None) - description: Optional[str] = dataclasses.field(default=None) - variables: Optional[Map[str, 'ServerVariable']] = dataclasses.field(default=None) + url: str = Field(default=None) + description: Optional[str] = Field(default=None) + variables: Optional[Map[str, ForwardRef('ServerVariable')]] = Field(default=None) + -@dataclasses.dataclass class ServerVariable(ObjectBase): """ A ServerVariable object as defined `here`_. @@ -24,7 +27,7 @@ class ServerVariable(ObjectBase): .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#server-variable-object """ - default: str = dataclasses.field(default=None) - description: Optional[str] = dataclasses.field(default=None) - enum: Optional[List[str]] = dataclasses.field(default=None) + default: str = Field(default=None) + description: Optional[str] = Field(default=None) + enum: Optional[List[str]] = Field(default=None) diff --git a/openapi3/tag.py b/openapi3/tag.py index 783a6bf..dfa1f38 100644 --- a/openapi3/tag.py +++ b/openapi3/tag.py @@ -1,8 +1,11 @@ import dataclasses from typing import Optional + +from pydantic import Field + from .object_base import ObjectBase -@dataclasses.dataclass + class Tag(ObjectBase): """ A `Tag Object`_ holds a reusable set of different aspects of the OAS @@ -11,6 +14,6 @@ class Tag(ObjectBase): .. _Tag Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#tagObject """ - name: Optional[str] = dataclasses.field(default=None) - description: Optional[str] = dataclasses.field(default=None) - externalDocs: Optional[str] = dataclasses.field(default=None) + name: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + externalDocs: Optional[str] = Field(default=None) diff --git a/tests/parsing_test.py b/tests/parsing_test.py index 74bcda3..47fa9de 100644 --- a/tests/parsing_test.py +++ b/tests/parsing_test.py @@ -3,6 +3,7 @@ """ import pytest +from pydantic import ValidationError from openapi3 import OpenAPI, SpecError, ReferenceResolutionError @@ -17,9 +18,7 @@ def test_parsing_fails(broken): """ Tests that broken specs fail to parse """ - with pytest.raises( - SpecError, match=r"Expected .info to be of type Info, with required fields \['title', 'version'\]" - ): + with pytest.raises(ValidationError) as e: spec = OpenAPI(broken) @@ -60,25 +59,25 @@ def test_object_example(obj_example_expanded): Tests that `example` exists. """ spec = OpenAPI(obj_example_expanded) - schema = spec.paths["/check-dict"].get.responses["200"].content["application/json"].schema + schema = spec.paths['/check-dict'].get.responses['200'].content['application/json'].schema assert isinstance(schema.example, dict) - assert isinstance(schema.example["real"], float) + assert isinstance(schema.example['real'], float) - schema = spec.paths["/check-str"].get.responses["200"].content["text/plain"] + schema = spec.paths['/check-str'].get.responses['200'].content['text/plain'] assert isinstance(schema.example, str) - + def test_parsing_float_validation(float_validation_expanded): """ Tests that `minimum` and similar validators work with floats. """ spec = OpenAPI(float_validation_expanded) - properties = spec.paths["/foo"].get.responses["200"].content["application/json"].schema.properties + properties = spec.paths['/foo'].get.responses['200'].content['application/json'].schema.properties - assert isinstance(properties["integer"].minimum, int) - assert isinstance(properties["integer"].maximum, int) - assert isinstance(properties["real"].minimum, float) - assert isinstance(properties["real"].maximum, float) + assert isinstance(properties['integer'].minimum, int) + assert isinstance(properties['integer'].maximum, int) + assert isinstance(properties['real'].minimum, float) + assert isinstance(properties['real'].maximum, float) def test_parsing_with_links(with_links): @@ -99,14 +98,6 @@ def test_parsing_with_links(with_links): assert response_b.links["exampleWithRef"] == spec.components.links["exampleWithOperationRef"] -def test_param_types(with_param_types): - spec = OpenAPI(with_param_types, validate=True) - - errors = spec.errors() - - assert len(errors) == 0 - - def test_parsing_broken_links(with_broken_links): """ Tests that broken "links" values error properly @@ -117,15 +108,10 @@ def test_parsing_broken_links(with_broken_links): assert len(errors) == 2 error_strs = [str(e) for e in errors] - assert ( - sorted( - [ - "operationId and operationRef are mutually exclusive, only one of them is allowed", - "operationId and operationRef are mutually exclusive, one of them must be specified", - ] - ) - == sorted(error_strs) - ) + assert sorted([ + "operationId and operationRef are mutually exclusive, only one of them is allowed", + "operationId and operationRef are mutually exclusive, one of them must be specified", + ]) == sorted(error_strs) def test_securityparameters(with_securityparameters): From c7d1748bbff9226d3fa97b75349f447b95a5d0ec Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 27 Dec 2021 06:58:47 +0100 Subject: [PATCH 005/125] pydantic migration - basics working --- openapi3/errors.py | 2 + openapi3/general.py | 29 ++++-------- openapi3/object_base.py | 46 ++++++++++++------- openapi3/openapi.py | 99 ++++++++++++++++++++--------------------- openapi3/paths.py | 39 +++++++++------- openapi3/schemas.py | 23 +++++++--- tests/parsing_test.py | 13 +++--- tests/path_test.py | 84 +++++++++++++++++----------------- tests/ref_test.py | 78 ++++++++++---------------------- 9 files changed, 200 insertions(+), 213 deletions(-) diff --git a/openapi3/errors.py b/openapi3/errors.py index 3edbbab..bf977ef 100644 --- a/openapi3/errors.py +++ b/openapi3/errors.py @@ -1,3 +1,5 @@ +from pydantic import ValidationError + class SpecError(ValueError): """ This error class is used when an invalid format is found while parsing an diff --git a/openapi3/general.py b/openapi3/general.py index 38e983c..7c8a917 100644 --- a/openapi3/general.py +++ b/openapi3/general.py @@ -1,7 +1,7 @@ import dataclasses from typing import Optional -from pydantic import Field, validator +from pydantic import Field, root_validator from .object_base import ObjectBase @@ -14,7 +14,7 @@ class ExternalDocumentation(ObjectBase): .. _External Documentation Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#externalDocumentationObject """ - url: str = Field(default=None) + url: str description: Optional[str] = Field(default=None) @@ -26,22 +26,9 @@ class Reference(ObjectBase): .. _Reference Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#referenceObject """ - ref: str = Field(required=True, default=None, alias="$ref") - - # def _parse_data(self): - # self.ref = self._get("$ref", str) - # - # @classmethod - # def can_parse(cls, dct): - # """ - # Override ObjectBase.can_parse because we had to remove the $ from $ref - # in __slots__ (since that's not a valid python variable name) - # """ - # # TODO - can a reference object have spec extensions? - # cleaned_keys = [k for k in dct.keys() if not k.startswith('x-')] - # - # return len(cleaned_keys) == 1 and '$ref' in dct - # - # @validator("ref") - # def resolve_references(cls, v, values, config, field, **kwargs): - # print(values) + ref: str = Field(alias="$ref") + +# @root_validator +# def root_check(cls, values): +# print(values) +# return values \ No newline at end of file diff --git a/openapi3/object_base.py b/openapi3/object_base.py index a193ac5..e8c27c8 100644 --- a/openapi3/object_base.py +++ b/openapi3/object_base.py @@ -3,7 +3,7 @@ from typing import List, Optional, Set import dataclasses -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, root_validator from .errors import SpecError, ReferenceResolutionError @@ -107,6 +107,20 @@ class Config: arbitrary_types_allowed = True + @root_validator(pre=True) + def check_extensions(cls, values): + e = dict() + for k,v in values.items(): + if k.startswith("x-"): + e[k[2]] = v + if len(e): + for i in e.keys(): + del values[f"x-{i}"] + if "extensions" in values.keys(): + raise ValueError("extensions") + values["extensions"] = e + + return values @property def _get_required_fields(self): @@ -161,12 +175,12 @@ def create(cls, path, raw_element, root, obj=None): return obj - def __repr__(self): - """ - Returns a string representation of the parsed object - """ - # TODO - why? - return "<{} {}>".format(type(self), self._path) +# def __repr__(self): +# """ +# Returns a string representation of the parsed object +# """ +# # TODO - why? +# return "<{} {}>".format(type(self), self._path) def __getstate__(self): """ @@ -463,13 +477,13 @@ def _resolve_type(root, obj, value): resolved_value = root.resolve_path(reference_path) except ReferenceResolutionError as e: # add metadata to the error - e.path = obj._path +# e.path = obj._path e.element = obj raise # FIXME - will break if multiple things reference the same # node - resolved_value._original_ref = value +# resolved_value._original_ref = value return resolved_value def _resolve_references(self, root): @@ -498,21 +512,23 @@ def resolve(obj): if isinstance(v, reference_type): if v.ref: value[k] = ObjectBase._resolve_type(root, obj, v) - else: - resolve(value[k]) + elif isinstance(v, (ObjectBase, dict, list)): + resolve(v) elif isinstance(value, list): # if it's a list, resolve its item's references resolved_list = [] for item in value: if isinstance(item, reference_type): - resolved_value = ObjectBase._resolve_type(root, item) + resolved_value = ObjectBase._resolve_type(root, obj, item) resolved_list.append(resolved_value) + elif isinstance(item, (ObjectBase, dict, list)): + resolve(item) + resolved_list.append(item) else: - resolve(value) - resolved_list.append(item) + resolved_list.append(item) setattr(obj, slot, resolved_list) elif isinstance(value, (str, int, float)): - pass + continue else: raise TypeError(type(value)) diff --git a/openapi3/openapi.py b/openapi3/openapi.py index bdad866..5bef4de 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -8,13 +8,34 @@ from .errors import ReferenceResolutionError, SpecError from .info import Info -from .paths import Path, SecurityRequirement +from .paths import Path, SecurityRequirement, _validate_parameters from .components import Components from .servers import Server from .tag import Tag class OpenAPI: + + @property + def paths(self): + return self._spec.paths + + @property + def components(self): + return self._spec.components + + @property + def info(self): + return self._spec.info + + @property + def openapi(self): + return self._spec.openapi + + @property + def servers(self): + return self._spec.servers + def __init__( self, raw_document, @@ -40,32 +61,36 @@ def __init__( :type use_session: bool """ - self._spec = OpenAPISpec.parse_obj(raw_document) -# self._spec.resolve_path("#/components/responses/Missing".split('/')[1:]) - self._spec._resolve_references(self._spec) + self._validation_mode = validate + self._spec_errors = None + self._operation_map = dict() - self._ssl_verify = ssl_verify + try: + self._spec = OpenAPISpec.parse_obj(raw_document) + except Exception as e: + if not self._validation_mode: + raise e + self._spec_errors = e - self._session = None - if use_session: - self._session = session_factory() + else: + for n,p in self.paths.items(): + for m in p.__fields_set__ & frozenset(["get","set","head","post","put","patch","trace"]): + op = getattr(p, m) + _validate_parameters(op, ['x', n]) + if op.operationId is None: + continue + formatted_operation_id = op.operationId.replace(" ", "_") + self._register_operation(formatted_operation_id, op) - @property - def paths(self): - return self._spec.path - @property - def components(self): - return self._spec.components + # self._spec.resolve_path("#/components/responses/Missing".split('/')[1:]) + self._spec._resolve_references(self._spec) - @property - def info(self): - return self._spec.info - - @property - def openapi(self): - return self._spec.openapi + self._ssl_verify = ssl_verify + self._session = None + if use_session: + self._session = session_factory() # public methods @@ -115,6 +140,7 @@ def errors(self): 'return errors!') return self._spec_errors + # private methods def _register_operation(self, operation_id, operation): """ @@ -127,7 +153,7 @@ def _register_operation(self, operation_id, operation): :type operation: Operation """ if operation_id in self._operation_map: - raise SpecError("Duplicate operationId {}".format(operation_id), path=operation._path) + raise SpecError("Duplicate operationId {}".format(operation_id), path=None) self._operation_map[operation_id] = operation def _get_callable(self, operation): @@ -195,9 +221,6 @@ class OpenAPISpec(ObjectBase): servers: Optional[List[Server]] = Field(default=None) tags: Optional[List[Tag]] = Field(default=None) - _validation_mode: bool - _operation_map: set - class Config: underscore_attrs_are_private = True arbitrary_types_allowed = True @@ -229,32 +252,6 @@ def resolve_path(self, path): return node - def log_spec_error(self, error): - """ - In Validation Mode, this method is used when parsing a spec to record an - error that was encountered, for later reporting. This should not be used - outside of Validation Mode. - - :param error: The error encountered. - :type error: SpecError - """ - if not self._validation_mode: - raise RuntimeError('This client is not in Validation Mode, cannot ' - 'record errors!') - self._spec_errors.append(error) - - def errors(self): - """ - In Validation Mode, returns all errors encountered from parsing a spec. - This should not be called if not in Validation Mode. - - :returns: The errors encountered during the parsing of this spec. - :rtype: List[SpecError] - """ - if not self._validation_mode: - raise RuntimeError('This client is not in Validation Mode, cannot ' - 'return errors!') - return self._spec_errors # private methods def _register_operation(self, operation_id, operation): diff --git a/openapi3/paths.py b/openapi3/paths.py index c66b420..809c36e 100644 --- a/openapi3/paths.py +++ b/openapi3/paths.py @@ -25,16 +25,16 @@ from .example import Example -def _validate_parameters(instance): +def _validate_parameters(op: "Operation", _path): """ Ensures that all parameters for this path are valid """ - allowed_path_parameters = re.findall(r'{([a-zA-Z0-9\-\._~]+)}', instance._path[1]) + allowed_path_parameters = re.findall(r'{([a-zA-Z0-9\-\._~]+)}', _path[1]) - for c in instance.parameters: + for c in op.parameters: if c.in_ == 'path': if c.name not in allowed_path_parameters: - raise SpecError('Parameter name not found in path: {}'.format(c.name), path=instance._path) + raise SpecError('Parameter name not found in path: {}'.format(c.name), path=_path) class Path(ObjectBase): @@ -57,7 +57,7 @@ class Path(ObjectBase): summary: Optional[str] = Field(default=None) trace: Optional[ForwardRef('Operation')] = Field(default=None) - parameters: Optional[List[Union['Parameter', Reference]]] = Field(default_factory=list) + parameters: Optional[List[Union['Parameter', Reference]]] = Field(default=None) def _parse_data(self): """ @@ -132,7 +132,7 @@ class Operation(ObjectBase): description: Optional[str] = Field(default=None) externalDocs: Optional[ForwardRef('ExternalDocumentation')] = Field(default=None) operationId: Optional[str] = Field(default=None) - parameters: Optional[List[Union['Parameter', 'Reference']]] = Field(default_factory=list) + parameters: List[Union['Parameter', 'Reference']] = Field(default_factory=list) requestBody: Optional[Union['RequestBody', 'Reference']] = Field(default=None) security: Optional[List['SecurityRequirement']] = Field(default_factory=list) servers: Optional[List['Server']] = Field(default=None) @@ -351,7 +351,7 @@ def request(self, base_url, security={}, data=None, parameters={}, verify=True, response_data = None if content_type.lower() == 'application/json': - return expected_media.schema.model(result.json()) + return expected_media.schema_.model(result.json()) else: raise NotImplementedError() @@ -413,7 +413,7 @@ class MediaType(ObjectBase): .. _MediaType: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#mediaTypeObject """ - schema_: Optional[Union['Schema', 'Reference']] = Field(default=None, alias="schema") + schema_: Optional[Union['Schema', 'Reference']] = Field(required=True, alias="schema") example: Optional[str] = Field(default=None) # 'any' type examples: Optional[Map[str, Union['Example', 'Reference']]] = Field(default=None) encoding: Optional[Map[str, str]] = Field(default=None) @@ -429,10 +429,11 @@ class Response(ObjectBase): """ description: str = Field(required=True) - content: Optional[Map[str, ForwardRef('MediaType')]] = Field(default=None) + content: Map[str, ForwardRef('MediaType')] = Field(required=False, default=None) links: Optional[Map[str, Union['Link', 'Reference']]] = Field(default=None) +from pydantic import root_validator, validator class Link(ObjectBase): """ @@ -448,19 +449,23 @@ class Link(ObjectBase): requestBody: Optional[dict] = Field(default=None) server: Optional[ForwardRef('Server')] = Field(default=None) - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - super()._parse_data() +# @validator("operationId", always=True) +# def operationId_check(cls, v): +# assert False - if self.operationId and self.operationRef: + @root_validator(pre=False) + def operation_check(cls, values): + if values["operationId"] != None and values["operationRef"] != None: raise SpecError("operationId and operationRef are mutually exclusive, only one of them is allowed") - if not (self.operationId or self.operationRef): + if values["operationId"] == values["operationRef"] == None: raise SpecError("operationId and operationRef are mutually exclusive, one of them must be specified") + return values + + Path.update_forward_refs() Operation.update_forward_refs() MediaType.update_forward_refs() -RequestBody.update_forward_refs() \ No newline at end of file +RequestBody.update_forward_refs() +Response.update_forward_refs() \ No newline at end of file diff --git a/openapi3/schemas.py b/openapi3/schemas.py index 7789294..2a5f449 100644 --- a/openapi3/schemas.py +++ b/openapi3/schemas.py @@ -1,7 +1,7 @@ from typing import Union, List, Any, Optional import dataclasses -from pydantic import Field +from pydantic import Field, root_validator, Extra from .errors import SpecError from .general import Reference # need this for Model below @@ -25,8 +25,8 @@ class Schema(ObjectBase): """ title: Optional[str] = Field(default=None) - maximum: Optional[Union[int, float]] = Field(default=None) - minimum: Optional[Union[int, float]] = Field(default=None) + maximum: Optional[float] = Field(default=None) # FIXME Field(discriminator='type') would be better + minimum: Optional[float] = Field(default=None) maxLength: Optional[int] = Field(default=None) minLength: Optional[int] = Field(default=None) pattern: Optional[str] = Field(default=None) @@ -37,7 +37,7 @@ class Schema(ObjectBase): type: Optional[str] = Field(default=None) allOf: Optional[List[Union["Schema", "Reference"]]] = Field(default=None) oneOf: Optional[list] = Field(default=None) - anyOf: Optional[list] = Field(default=None) + anyOf: Optional[List[Union["Schema", "Reference"]]] = Field(default=None) items: Optional[Union['Schema', 'Reference']] = Field(default=None) properties: Optional[Map[str, Union['Schema', 'Reference']]] = Field(default=None) additionalProperties: Optional[Union[bool, dict]] = Field(default=None) @@ -45,7 +45,7 @@ class Schema(ObjectBase): format: Optional[str] = Field(default=None) default: Optional[str] = Field(default=None) # TODO - str as a default? nullable: Optional[bool] = Field(default=None) - discriminator: Optional[dict] = Field(default=None) # 'Discriminator' + discriminator: Optional[dict[str, Union["Schema", "Reference"]]] = Field(default=None) # 'Discriminator' readOnly: Optional[bool] = Field(default=None) writeOnly: Optional[bool] = Field(default=None) xml: Optional[dict] = Field(default=None) # 'XML' @@ -60,6 +60,19 @@ class Schema(ObjectBase): _request_model_type: object = Field(default=None) _resolved_allOfs: object = Field(default=None) + class Config: + extra = Extra.forbid + + @root_validator + def check_number_type(cls, values): + conv = ["minimum","maximum"] + if values.get("type", None) == "integer": + for i in conv: + v = values.get(i, None) + if v is not None: + values[i] = int(v) + return values + def _parse_data(self): """ Implementation of :any:`ObjectBase._parse_data` diff --git a/tests/parsing_test.py b/tests/parsing_test.py index 47fa9de..5d8dd86 100644 --- a/tests/parsing_test.py +++ b/tests/parsing_test.py @@ -59,7 +59,7 @@ def test_object_example(obj_example_expanded): Tests that `example` exists. """ spec = OpenAPI(obj_example_expanded) - schema = spec.paths['/check-dict'].get.responses['200'].content['application/json'].schema + schema = spec.paths['/check-dict'].get.responses['200'].content['application/json'].schema_ assert isinstance(schema.example, dict) assert isinstance(schema.example['real'], float) @@ -72,7 +72,7 @@ def test_parsing_float_validation(float_validation_expanded): Tests that `minimum` and similar validators work with floats. """ spec = OpenAPI(float_validation_expanded) - properties = spec.paths['/foo'].get.responses['200'].content['application/json'].schema.properties + properties = spec.paths['/foo'].get.responses['200'].content['application/json'].schema_.properties assert isinstance(properties['integer'].minimum, int) assert isinstance(properties['integer'].maximum, int) @@ -106,12 +106,13 @@ def test_parsing_broken_links(with_broken_links): errors = spec.errors() - assert len(errors) == 2 - error_strs = [str(e) for e in errors] - assert sorted([ + assert len(errors.args) == 2 + error_strs = str(errors) + + assert all([i in error_strs for i in [ "operationId and operationRef are mutually exclusive, only one of them is allowed", "operationId and operationRef are mutually exclusive, one of them must be specified", - ]) == sorted(error_strs) + ]]) def test_securityparameters(with_securityparameters): diff --git a/tests/path_test.py b/tests/path_test.py index 94b8dd3..1188975 100644 --- a/tests/path_test.py +++ b/tests/path_test.py @@ -18,22 +18,21 @@ def test_paths_exist(petstore_expanded_spec): """ Tests that paths are parsed correctly """ - assert "/pets" in petstore_expanded_spec.paths - assert "/pets/{id}" in petstore_expanded_spec.paths + assert '/pets' in petstore_expanded_spec.paths + assert '/pets/{id}' in petstore_expanded_spec.paths assert len(petstore_expanded_spec.paths) == 2 - def test_operations_exist(petstore_expanded_spec): """ Tests that methods are populated as expected in paths """ - pets_path = petstore_expanded_spec.paths["/pets"] + pets_path = petstore_expanded_spec.paths['/pets'] assert pets_path.get is not None assert pets_path.post is not None assert pets_path.put is None assert pets_path.delete is None - pets_id_path = petstore_expanded_spec.paths["/pets/{id}"] + pets_id_path = petstore_expanded_spec.paths['/pets/{id}'] assert pets_id_path.get is not None assert pets_id_path.post is None assert pets_id_path.put is None @@ -44,7 +43,7 @@ def test_operation_populated(petstore_expanded_spec): """ Tests that operations are populated as expected """ - op = petstore_expanded_spec.paths["/pets"].get + op = petstore_expanded_spec.paths['/pets'].get # check description and metadata populated correctly assert op.operationId == "findPets" @@ -60,64 +59,65 @@ def test_operation_populated(petstore_expanded_spec): assert param1.description == "tags to filter by" assert param1.required == False assert param1.style == "form" - assert param1.schema is not None - assert param1.schema.type == "array" - assert param1.schema.items.type == "string" + assert param1.schema_ is not None + assert param1.schema_.type == "array" + assert param1.schema_.items.type == "string" param2 = op.parameters[1] assert param2.name == "limit" assert param2.in_ == "query" assert param2.description == "maximum number of results to return" assert param2.required == False - assert param2.schema is not None - assert param2.schema.type == "integer" - assert param2.schema.format == "int32" + assert param2.schema_ is not None + assert param2.schema_.type == "integer" + assert param2.schema_.format == "int32" # check that responses populated correctly - assert "200" in op.responses - assert "default" in op.responses + assert '200' in op.responses + assert 'default' in op.responses assert len(op.responses) == 2 - resp1 = op.responses["200"] + resp1 = op.responses['200'] assert resp1.description == "pet response" assert len(resp1.content) == 1 - assert "application/json" in resp1.content - con1 = resp1.content["application/json"] - assert con1.schema is not None - assert con1.schema.type == "array" + assert 'application/json' in resp1.content + con1 = resp1.content['application/json'] + assert con1.schema_ is not None + assert con1.schema_.type == "array" # we're not going to test that the ref resolved correctly here - that's a separate test - assert type(con1.schema.items) == Schema + assert type(con1.schema_.items) == Schema - resp2 = op.responses["default"] + resp2 = op.responses['default'] assert resp2.description == "unexpected error" assert len(resp2.content) == 1 - assert "application/json" in resp2.content - con2 = resp2.content["application/json"] - assert con2.schema is not None + assert 'application/json' in resp2.content + con2 = resp2.content['application/json'] + assert con2.schema_ is not None # again, test ref resolution elsewhere - assert type(con2.schema) == Schema + assert type(con2.schema_) == Schema def test_securityparameters(with_securityparameters): api = OpenAPI(with_securityparameters) r = patch("requests.sessions.Session.send") - auth = str(uuid.uuid4()) + auth=str(uuid.uuid4()) # global security - api.authenticate("cookieAuth", auth) - resp = MagicMock(status_code=200, headers={"Content-Type": "application/json"}) + api.authenticate('cookieAuth', auth) + resp = MagicMock(status_code=200, headers={"Content-Type":"application/json"}) with patch("requests.sessions.Session.send", return_value=resp) as r: api.call_api_v1_auth_login_create(data={}, parameters={}) + # path - api.authenticate("tokenAuth", auth) + api.authenticate('tokenAuth', auth) resp = MagicMock(status_code=200, headers={"Content-Type": "application/json"}) with patch("requests.sessions.Session.send", return_value=resp) as r: api.call_api_v1_auth_login_create(data={}, parameters={}) - assert r.call_args.args[0].headers["Authorization"] == auth + assert r.call_args.args[0].headers['Authorization'] == auth - api.authenticate("paramAuth", auth) + api.authenticate('paramAuth', auth) resp = MagicMock(status_code=200, headers={"Content-Type": "application/json"}) with patch("requests.sessions.Session.send", return_value=resp) as r: api.call_api_v1_auth_login_create(data={}, parameters={}) @@ -125,34 +125,32 @@ def test_securityparameters(with_securityparameters): parsed_url = urlparse(r.call_args.args[0].url) parsed_url.query == auth - api.authenticate("cookieAuth", auth) - resp = MagicMock(status_code=200, headers={"Content-Type": "application/json"}, json=lambda: []) + api.authenticate('cookieAuth', auth) + resp = MagicMock(status_code=200, headers={"Content-Type":"application/json"}, json=lambda: []) with patch("requests.sessions.Session.send", return_value=resp) as r: api.call_api_v1_auth_login_create(data={}, parameters={}) assert r.call_args.args[0].headers["Cookie"] == "Session=%s" % (auth,) - api.authenticate("basicAuth", (auth, auth)) - resp = MagicMock(status_code=200, headers={"Content-Type": "application/json"}, json=lambda: []) + api.authenticate('basicAuth', (auth, auth)) + resp = MagicMock(status_code=200, headers={"Content-Type":"application/json"}, json=lambda: []) with patch("requests.sessions.Session.send", return_value=resp) as r: api.call_api_v1_auth_login_create(data={}, parameters={}) - r.call_args.args[0].headers["Authorization"].split(" ")[1] == base64.b64encode( - (auth + ":" + auth).encode() - ).decode() + r.call_args.args[0].headers["Authorization"].split(" ")[1] == base64.b64encode((auth + ':' + auth).encode()).decode() - api.authenticate("digestAuth", (auth, auth)) - resp = MagicMock(status_code=200, headers={"Content-Type": "application/json"}, json=lambda: []) + api.authenticate('digestAuth', (auth,auth)) + resp = MagicMock(status_code=200, headers={"Content-Type":"application/json"}, json=lambda: []) with patch("requests.sessions.Session.send", return_value=resp) as r: api.call_api_v1_auth_login_create(data={}, parameters={}) assert requests.auth.HTTPDigestAuth.handle_401 == r.call_args.args[0].hooks["response"][0].__func__ - api.authenticate("bearerAuth", auth) - resp = MagicMock(status_code=200, headers={"Content-Type": "application/json"}, json=lambda: []) + api.authenticate('bearerAuth', auth) + resp = MagicMock(status_code=200, headers={"Content-Type":"application/json"}, json=lambda: []) with patch("requests.sessions.Session.send", return_value=resp) as r: api.call_api_v1_auth_login_create(data={}, parameters={}) assert r.call_args.args[0].headers["Authorization"] == "Bearer %s" % (auth,) api.authenticate(None, None) - resp = MagicMock(status_code=200, headers={"Content-Type": "application/json"}) + resp = MagicMock(status_code=200, headers={"Content-Type":"application/json"}) with patch("requests.sessions.Session.send", return_value=resp) as r: api.call_api_v1_auth_login_create(data={}, parameters={}) api.call_api_v1_auth_login_create(data={}, parameters={}) diff --git a/tests/ref_test.py b/tests/ref_test.py index f7999be..3d582bd 100644 --- a/tests/ref_test.py +++ b/tests/ref_test.py @@ -12,29 +12,29 @@ def test_ref_resolution(petstore_expanded_spec): """ Tests that $refs are resolved as we expect them to be """ - ref = petstore_expanded_spec.paths["/pets"].get.responses["default"].content["application/json"].schema + ref = petstore_expanded_spec.paths['/pets'].get.responses['default'].content['application/json'].schema_ assert type(ref) == Schema assert ref.type == "object" assert len(ref.properties) == 2 - assert "code" in ref.properties - assert "message" in ref.properties - assert ref.required == ["code", "message"] + assert 'code' in ref.properties + assert 'message' in ref.properties + assert ref.required == ['code','message'] - code = ref.properties["code"] - assert code.type == "integer" - assert code.format == "int32" + code = ref.properties['code'] + assert code.type == 'integer' + assert code.format == 'int32' - message = ref.properties["message"] - assert message.type == "string" + message = ref.properties['message'] + assert message.type == 'string' def test_allOf_resolution(petstore_expanded_spec): """ Tests that allOfs are resolved correctly """ - ref = petstore_expanded_spec.paths["/pets"].get.responses["200"].content["application/json"].schema - ref = petstore_expanded_spec.paths["/pets"].get.responses["200"].content["application/json"].schema + ref = petstore_expanded_spec.paths['/pets'].get.responses['200'].content['application/json'].schema + ref = petstore_expanded_spec.paths['/pets'].get.responses['200'].content['application/json'].schema assert type(ref) == Schema assert ref.type == "array" @@ -42,53 +42,21 @@ def test_allOf_resolution(petstore_expanded_spec): items = ref.items assert type(items) == Schema - assert sorted(items.required) == sorted(["id", "name"]) + assert sorted(items.required) == sorted(["id","name"]) assert len(items.properties) == 3 - assert "id" in items.properties - assert "name" in items.properties - assert "tag" in items.properties + assert 'id' in items.properties + assert 'name' in items.properties + assert 'tag' in items.properties - id_prop = items.properties["id"] - id_prop = items.properties["id"] + id_prop = items.properties['id'] + id_prop = items.properties['id'] assert id_prop.type == "integer" assert id_prop.format == "int64" - name = items.properties["name"] - name = items.properties["name"] - assert name.type == "string" + name = items.properties['name'] + name = items.properties['name'] + assert name.type == 'string' - tag = items.properties["tag"] - assert tag.type == "string" - - -def test_resolving_nested_allof_ref(with_nested_allof_ref): - """ - Tests that a schema with a $ref nested within a schema defined in an allOf - parses correctly - """ - spec = OpenAPI(with_nested_allof_ref) - - schema = spec.paths['/example'].get.responses['200'].content['application/json'].schema - assert type(schema.properties['other']) == Schema - assert schema.properties['other'].type == 'string' - - assert type(schema.properties['data'].items) == Schema - assert 'bar' in schema.properties['data'].items.properties - - -def test_ref_allof_handling(with_ref_allof): - """ - Tests that allOfs do not modify the originally loaded value of a $ref they - includes (which would cause all references to that schema to be modified) - """ - spec = OpenAPI(with_ref_allof) - referenced_schema = spec.components.schemas['Example'] - - # this should have only one property; the allOf from - # paths['/allof-example']get.responses['200'].content['application/json'].schema - # should not modify the component - assert len(referenced_schema.properties) == 1, \ - "Unexpectedly found {} properties on componenets.schemas['Example']: {}".format( - len(referenced_schema.properties), - ", ".join(referenced_schema.properties.keys()), - ) + tag = items.properties['tag'] + tag = items.properties['tag'] + assert tag.type == 'string' From 44954730a56892d3ff936a180e6963ae52426ec5 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 27 Dec 2021 08:36:55 +0100 Subject: [PATCH 006/125] pydantic - more tests work --- openapi3/object_base.py | 279 ++++++---------------------------------- openapi3/openapi.py | 83 +----------- openapi3/paths.py | 26 ++-- openapi3/schemas.py | 57 ++++++-- 4 files changed, 106 insertions(+), 339 deletions(-) diff --git a/openapi3/object_base.py b/openapi3/object_base.py index e8c27c8..268fe79 100644 --- a/openapi3/object_base.py +++ b/openapi3/object_base.py @@ -413,53 +413,6 @@ def get_path(self): """ return '.'.join(self._path) - def parse_list(self, raw_list, object_type, field=None): - """ - Given a list of Objects, iterates over the list and creates the relevant - Objects, returning the resulting list. - - :param raw_list: The list to parse - :type raw_list: list[dict] - :param object_type: typing - :type object_type: typeing.Type - :param field: The field to append to self.get_path() when determining path - for created objects. - :type field: str - - :returns: A list of parsed objects - :rtype: list[object_type] - """ - if raw_list is None: - return None - - real_path = self._path[:] - if field: - real_path += [field] - - python_types = self.types_of(object_type) - - - result = [] - for i, cur in enumerate(raw_list): - found_type = False - - for cur_type in python_types: - if issubclass(cur_type, ObjectBase) and cur_type.can_parse(cur): - result.append(cur_type.create(real_path + [str(i)], cur, self._root)) - found_type = True - continue - elif isinstance(cur, cur_type): - result.append(cur) - found_type = True - continue - - if not found_type: - raise SpecError('Could not parse {}.{}, expected to be one of [{}]'.format( - '.'.join(real_path), i, python_types), - path=self._path, - element=self) - - return result @staticmethod def _resolve_type(root, obj, value): @@ -496,202 +449,46 @@ def _resolve_references(self, root): reference_type = ObjectBase.get_object_type('Reference') obj = root = self - def resolve(obj): - for slot in filter(lambda x: not x.startswith("_"), obj.__fields_set__): - value = getattr(obj, slot) - if value is None: - continue - elif isinstance(value, reference_type): - resolved_value = ObjectBase._resolve_type(root, obj, value) - setattr(obj, slot, resolved_value) - elif issubclass(type(value), ObjectBase): - # otherwise, continue resolving down the tree - resolve(value) - elif isinstance(value, dict): # pydantic does not use Map - for k, v in value.items(): - if isinstance(v, reference_type): - if v.ref: - value[k] = ObjectBase._resolve_type(root, obj, v) - elif isinstance(v, (ObjectBase, dict, list)): - resolve(v) - elif isinstance(value, list): - # if it's a list, resolve its item's references - resolved_list = [] - for item in value: - if isinstance(item, reference_type): - resolved_value = ObjectBase._resolve_type(root, obj, item) - resolved_list.append(resolved_value) - elif isinstance(item, (ObjectBase, dict, list)): - resolve(item) - resolved_list.append(item) - else: - resolved_list.append(item) - setattr(obj, slot, resolved_list) - elif isinstance(value, (str, int, float)): - continue - else: - raise TypeError(type(value)) - resolve(self) - def _resolve_allOfs(self): - """ - Walks object tree calling _resolve_allOf on each type. - - Types can override this to handle allOf handling themselves. Types that - do so should call the parent class' _resolve_allOf when they do - """ - for slot in map(lambda x: x.name, dataclasses.fields(self)): - if slot.startswith("_"): - # no need to handle private members - continue - - value = getattr(self, slot) - if value is None: - continue - elif issubclass(type(value), ObjectBase): - value._resolve_allOfs() - elif issubclass(type(value), Map): - for _, c in value.items(): - c._resolve_allOfs() - elif isinstance(value, list): - for c in value: - if issubclass(type(c), ObjectBase) or issubclass(type(c), Map): - c._resolve_allOfs() - elif isinstance(value, (int, str, dict)): - continue - else: - raise TypeError(value) - - @staticmethod - def types_of(object_type, expected=None): - def resolve(t): - if typing.get_origin(t) == typing.Union: - t = typing.get_args(t) - else: - t = [t] - - r = [] - for tt in t: - if isinstance(tt, typing.ForwardRef): - r.append(ObjectBase.get_object_type(tt.__forward_arg__)) - else: - if typing.get_origin(t) == typing.Union: - r.extend(resolve(t)) + def resolve(obj): + if isinstance(obj, ObjectBase): + for slot in filter(lambda x: not x.startswith("_"), obj.__fields_set__): + value = getattr(obj, slot) + if value is None: + continue + elif isinstance(value, reference_type): + resolved_value = ObjectBase._resolve_type(root, obj, value) + setattr(obj, slot, resolved_value) + elif issubclass(type(value), ObjectBase): + # otherwise, continue resolving down the tree + resolve(value) + elif isinstance(value, dict): # pydantic does not use Map + resolve(value) + elif isinstance(value, list): + # if it's a list, resolve its item's references + resolved_list = [] + for item in value: + if isinstance(item, reference_type): + resolved_value = ObjectBase._resolve_type(root, obj, item) + resolved_list.append(resolved_value) + elif isinstance(item, (ObjectBase, dict, list)): + resolve(item) + resolved_list.append(item) + else: + resolved_list.append(item) + setattr(obj, slot, resolved_list) + elif isinstance(value, (str, int, float)): + continue else: - r.append(tt) - return r - - if expected: - assert typing.get_origin(object_type) == expected - - if object_type in frozenset([str, int, float, dict, bool, typing.Any]): - return [object_type] - - if typing.get_origin(object_type) == list: - python_types = typing.get_args(object_type)[0] - return resolve(python_types) - - if typing.get_origin(object_type) == Map: - args = typing.get_args(object_type) - return resolve(args[0]),resolve(args[1]) - - if isinstance(object_type, typing.ForwardRef): - return resolve(object_type) - - if typing.get_origin(object_type) == typing.Union: - return resolve(object_type) - - raise TypeError(object_type) - - -from collections.abc import Mapping -from typing import TypeVar, Hashable - -K = TypeVar("K", bound=Hashable) -V = TypeVar("V", bound=Hashable) - -#class Map(Mapping[K, V]): -import collections -class Map(collections.OrderedDict): -# @classmethod -# def __get_validators__(cls): -# yield cls.validate - - @classmethod - def __get_validators__(cls): - yield cls.validate - - - @classmethod - def __modify_schema__(cls, field_schema, field: Optional[ModelField]): - print(field_schema) - - """ - The Map object wraps a python dict and parses its values into the chosen - type or types. - """ -# __slots__ = ['dct', 'path', 'raw_element', '_root'] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def old__init__(self, path, raw_element, object_type, root): - """ - Creates a dict containing the parsed objects from the raw element - - :param path: The path to this Map in the spec. - :type path: list - :param raw_element: The raw spec data for this map. The keys must all - be strings. - :type raw_element: dict - :param object_type: typing - :type object_type: typing - """ - self.path = path - self.raw_element = raw_element - self._root = root - - python_types = ObjectBase.types_of(object_type, Map)[1] - dct = {} - - for k, v in self.raw_element.items(): - found_type = False - - for t in python_types: - if issubclass(t, ObjectBase) and t.can_parse(v): - dct[k] = t.create(path + [k], v, self._root) - found_type = True - elif isinstance(v, t): - dct[k] = v - found_type = True - - if not found_type: - raise_on_unknown_type(self, k, python_types, v) - - self.update(dct) - - def _resolve_references(self): - """ - This has been added to allow propagation of reference resolution as defined - in :any:`ObjectBase._resolve_references`. This implementation simply - calls the same on all values in this Map. - """ - reference_type = ObjectBase.get_object_type('Reference') - - for key, value in self.items(): - if isinstance(value, reference_type): - self[key] = ObjectBase._resolve_type(self, value) - else: - value._resolve_references() - - def get_path(self): - """ - Get the full path for this element in the spec - - :returns: The path in the spec for this element - :rtype: str - """ - return '.'.join(self.path) + raise TypeError(type(value)) + elif isinstance(obj, dict): + for k, v in obj.items(): + if isinstance(v, reference_type): + if v.ref: + obj[k] = ObjectBase._resolve_type(root, obj, v) + elif isinstance(v, (ObjectBase, dict, list)): + resolve(v) + resolve(self) diff --git a/openapi3/openapi.py b/openapi3/openapi.py index 5bef4de..4180ea0 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -64,6 +64,7 @@ def __init__( self._validation_mode = validate self._spec_errors = None self._operation_map = dict() + self._security = None try: self._spec = OpenAPISpec.parse_obj(raw_document) @@ -73,10 +74,11 @@ def __init__( self._spec_errors = e else: - for n,p in self.paths.items(): - for m in p.__fields_set__ & frozenset(["get","set","head","post","put","patch","trace"]): - op = getattr(p, m) - _validate_parameters(op, ['x', n]) + for path,obj in self.paths.items(): + for m in obj.__fields_set__ & frozenset(["get","delete","head","post","put","patch","trace"]): + op = getattr(obj, m) + op._path,op._method, op._root = path, m, self + _validate_parameters(op, ['x', path]) if op.operationId is None: continue formatted_operation_id = op.operationId.replace(" ", "_") @@ -253,79 +255,6 @@ def resolve_path(self, path): return node - # private methods - def _register_operation(self, operation_id, operation): - """ - Adds an Operation to this spec's _operation_map, raising an error if the - OperationId has already been registered. - - :param operation_id: The operation ID to register - :type operation_id: str - :param operation: The operation to register - :type operation: Operation - """ - if operation_id in self._operation_map: - raise SpecError("Duplicate operationId {}".format(operation_id), path=operation._path) - self._operation_map[operation_id] = operation - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self._operation_map = {} - - super()._parse_data() - - # now that we've parsed _all_ the data, resolve all references - self._resolve_references() - self._resolve_allOfs() - - def _get_callable(self, operation): - """ - A helper function to create OperationCallable objects for __getattribute__, - pre-initialized with the required values from this object. - - :param operation: The Operation the callable should call - :type operation: callable (Operation.request) - - :returns: The callable that executes this operation with this object's - configuration. - :rtype: OperationCallable - """ - base_url = self.servers[0].url - - return OperationCallable(operation, base_url, self._security, self._ssl_verify, - self._session) - - def __getattribute__(self, attr): - """ - Extended __getattribute__ function to allow resolving dynamic function - names. The purpose of this is to call syntax like this:: - - spec = OpenAPI(raw_spec) - spec.call_operationId() - - This method will intercept the dot notation above (spec.call_operationId) - and look up the requested operation, returning a callable object that - will then immediately be called by the parenthesis. - - :param attr: The attribute we're retrieving - :type attr: str - - :returns: The attribute requested - :rtype: any - :raises AttributeError: if the requested attribute does not exist - """ - if attr.startswith('call_'): - _, operationId = attr.split('_', 1) - if operationId in self._operation_map: - return self._get_callable(self._operation_map[operationId].request) - else: - raise AttributeError('{} has no operation {}'.format( - self.info.title, operationId)) - - return object.__getattribute__(self, attr) - OpenAPISpec.update_forward_refs() class OperationCallable: diff --git a/openapi3/paths.py b/openapi3/paths.py index 809c36e..6794325 100644 --- a/openapi3/paths.py +++ b/openapi3/paths.py @@ -57,7 +57,7 @@ class Path(ObjectBase): summary: Optional[str] = Field(default=None) trace: Optional[ForwardRef('Operation')] = Field(default=None) - parameters: Optional[List[Union['Parameter', Reference]]] = Field(default=None) + parameters: Optional[List[Union['Parameter', Reference]]] = Field(default_factory=list) def _parse_data(self): """ @@ -139,6 +139,14 @@ class Operation(ObjectBase): summary: Optional[str] = Field(default=None) tags: Optional[List[str]] = Field(default=None) + _root = object + _path: str + _method: str + _request: object + _session: object + + class Config: + underscore_attrs_are_private = True def _parse_data(self): """ @@ -154,10 +162,6 @@ def _parse_data(self): self._root._register_operation(formatted_operation_id, self) # Store session object - self._session = requests.Session() - - # Store request object - self._request = requests.Request() # def _resolve_references(self, root): # """ @@ -202,7 +206,7 @@ def _request_handle_parameters(self, parameters={}): # Parameters path_parameters = {} accepted_parameters = {} - p = self.parameters + self._root.paths[self._path[-2]].parameters + p = self.parameters + self._root.paths[self._path].parameters for _ in list(p): # TODO - make this work with $refs - can operations be $refs? @@ -277,10 +281,13 @@ def request(self, base_url, security={}, data=None, parameters={}, verify=True, :type raw_response: bool """ # Set request method (e.g. 'GET') - self._request = requests.Request(self._path[-1]) + self._request = requests.Request(self._method) # Set self._request.url to base_url w/ path - self._request.url = base_url + self._path[-2] + self._request.url = base_url + self._path + + self._session = requests.Session() + if security and self.security: security_requirement = None @@ -329,6 +336,9 @@ def request(self, base_url, security={}, data=None, parameters={}, verify=True, raise RuntimeError(err_msg.format(*err_var)) + if expected_response.content is None: + return None + content_type = result.headers['Content-Type'] expected_media = expected_response.content.get(content_type, None) diff --git a/openapi3/schemas.py b/openapi3/schemas.py index 2a5f449..0c1f0c6 100644 --- a/openapi3/schemas.py +++ b/openapi3/schemas.py @@ -1,7 +1,7 @@ from typing import Union, List, Any, Optional import dataclasses -from pydantic import Field, root_validator, Extra +from pydantic import Field, root_validator, Extra, BaseModel from .errors import SpecError from .general import Reference # need this for Model below @@ -39,13 +39,13 @@ class Schema(ObjectBase): oneOf: Optional[list] = Field(default=None) anyOf: Optional[List[Union["Schema", "Reference"]]] = Field(default=None) items: Optional[Union['Schema', 'Reference']] = Field(default=None) - properties: Optional[Map[str, Union['Schema', 'Reference']]] = Field(default=None) + properties: Optional[Map[str, Union['Schema', 'Reference']]] = Field(default_factory=dict) additionalProperties: Optional[Union[bool, dict]] = Field(default=None) description: Optional[str] = Field(default=None) format: Optional[str] = Field(default=None) default: Optional[str] = Field(default=None) # TODO - str as a default? nullable: Optional[bool] = Field(default=None) - discriminator: Optional[dict[str, Union["Schema", "Reference"]]] = Field(default=None) # 'Discriminator' + discriminator: Optional[dict[str, Union[str, dict]]] = Field(default=None) # 'Discriminator' readOnly: Optional[bool] = Field(default=None) writeOnly: Optional[bool] = Field(default=None) xml: Optional[dict] = Field(default=None) # 'XML' @@ -56,9 +56,9 @@ class Schema(ObjectBase): contentMediaType: Optional[str] = Field(default=None) contentSchema: Optional[str] = Field(default=None) - _model_type: object = Field(default=None) - _request_model_type: object = Field(default=None) - _resolved_allOfs: object = Field(default=None) + _model_type: object + _request_model_type: object + _resolved_allOfs: object class Config: extra = Extra.forbid @@ -106,11 +106,41 @@ def get_type(self): type(object1) == type(object2) # true """ # this is defined in ObjectBase.__init__ as all slots are - if self._model_type is None: # pylint: disable=access-member-before-definition + + try: + return self._model_type + except AttributeError: + def typeof(schema): + r = None + if schema.type == "string": + r = str + elif schema.type == "integer": + r = int + else: + raise TypeError(schema.type) + + return r + type_name = self.title or self._path[-1] - self._model_type = type(type_name, (Model,), { # pylint: disable=attribute-defined-outside-init - '__slots__': self.properties.keys() - }) + namespace = dict() + annos = dict() + if self.allOf: + pass + elif self.anyOf: + types = [i.get_type() for i in self.anyOf] + namespace["__root__"] = Union[types] + elif self.oneOf: + pass + else: + for name, f in self.properties.items(): + r = typeof(f) + if name not in self.required: + annos[name] = Optional[r] + else: + annos[name] = r + namespace['__annotations__'] = annos + import types + self._model_type = types.new_class(type_name, (BaseModel, ), {}, lambda ns: ns.update(namespace)) return self._model_type @@ -130,9 +160,9 @@ def model(self, data): # expected return data elif self.type == "array": - return [self.items.get_type()(i, self.items) for i in data] + return [self.items.get_type().parse_obj(i) for i in data] else: - return self.get_type()(data, self) + return self.get_type().parse_obj(data) def get_request_type(self): """ @@ -142,7 +172,8 @@ def get_request_type(self): # this is defined in ObjectBase.__init__ as all slots are if self._request_model_type is None: # pylint: disable=access-member-before-definition type_name = self.title or self._path[-1] - self._request_model_type = type(type_name + 'Request', (Model,), { # pylint: disable=attribute-defined-outside-init + self._request_model_type = type(type_name + 'Request', (BaseModel, ), + { # pylint: disable=attribute-defined-outside-init '__slots__': [k for k, v in self.properties.items() if not v.readOnly] }) From babde761cb2a2526929574be9d78e96a0203e3db Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 27 Dec 2021 10:16:35 +0100 Subject: [PATCH 007/125] pydantic - use Dict instead of Map --- openapi3/components.py | 18 +++++++++--------- openapi3/openapi.py | 10 +++++----- openapi3/paths.py | 18 +++++++++--------- openapi3/schemas.py | 8 ++++---- openapi3/security.py | 14 +++++--------- openapi3/servers.py | 6 +++--- 6 files changed, 35 insertions(+), 39 deletions(-) diff --git a/openapi3/components.py b/openapi3/components.py index eda7b8a..7302810 100644 --- a/openapi3/components.py +++ b/openapi3/components.py @@ -1,9 +1,9 @@ import dataclasses -from typing import Union, Optional +from typing import Union, Optional, Dict from pydantic import Field -from .object_base import ObjectBase, Map +from .object_base import ObjectBase from .example import Example from .paths import Reference, RequestBody, Link, Parameter, Response @@ -18,14 +18,14 @@ class Components(ObjectBase): .. _Components Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#componentsObject """ - examples: Optional[Map[str, Union['Example', 'Reference']]] = Field(default=None) - parameters: Optional[Map[str, Union['Parameter', 'Reference']]] = Field(default=None) - requestBodies: Optional[Map[str, Union['RequestBody', 'Reference']]] = Field(default=None) - responses: Optional[Map[str, Union['Response', 'Reference']]] = Field(default=None) - schemas: Optional[Map[str, Union['Schema', 'Reference']]] = Field(default=None) - securitySchemes: Optional[Map[str, Union['SecurityScheme', 'Reference']]] = Field(default=None) + examples: Optional[Dict[str, Union['Example', 'Reference']]] = Field(default_factory=dict) + parameters: Optional[Dict[str, Union['Parameter', 'Reference']]] = Field(default_factory=dict) + requestBodies: Optional[Dict[str, Union['RequestBody', 'Reference']]] = Field(default_factory=dict) + responses: Optional[Dict[str, Union['Response', 'Reference']]] = Field(default_factory=dict) + schemas: Optional[Dict[str, Union['Schema', 'Reference']]] = Field(default_factory=dict) + securitySchemes: Optional[Dict[str, Union['SecurityScheme', 'Reference']]] = Field(default_factory=dict) # headers: ['Header', 'Reference'], is_map=True - links: Optional[Map[str, Union['Link', 'Reference']]] = Field(default=None) + links: Optional[Dict[str, Union['Link', 'Reference']]] = Field(default_factory=dict) # callbacks: ['Callback', 'Reference'], is_map=True Components.update_forward_refs() \ No newline at end of file diff --git a/openapi3/openapi.py b/openapi3/openapi.py index 4180ea0..ffe63f6 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -1,10 +1,10 @@ import dataclasses -from typing import ForwardRef, Any, List, Optional +from typing import ForwardRef, Any, List, Optional, Dict from pydantic import Field, ValidationError import requests -from .object_base import ObjectBase, Map +from .object_base import ObjectBase from .errors import ReferenceResolutionError, SpecError from .info import Info @@ -215,10 +215,10 @@ class OpenAPISpec(ObjectBase): openapi: str = Field(required=True) info: Info = Field(required=True) - paths: Map[str, Path] = Field(required=True, default_factory=Map) + paths: Dict[str, Path] = Field(required=True, default_factory=dict) - components: Optional[Components] = Field(default=None) - externalDocs: Optional[Map[Any, Any]] = Field(default=None) + components: Optional[Components] = Field(default_factory=Components) + externalDocs: Optional[Dict[Any, Any]] = Field(default_factory=dict) security: Optional[List[SecurityRequirement]] = Field(default=None) servers: Optional[List[Server]] = Field(default=None) tags: Optional[List[Tag]] = Field(default=None) diff --git a/openapi3/paths.py b/openapi3/paths.py index 6794325..30aec1b 100644 --- a/openapi3/paths.py +++ b/openapi3/paths.py @@ -1,5 +1,5 @@ import dataclasses -from typing import ForwardRef, Union, List, Optional +from typing import ForwardRef, Union, List, Optional, Dict import json import re @@ -12,7 +12,7 @@ from urllib import urlencode from .errors import SpecError -from .object_base import ObjectBase, Map +from .object_base import ObjectBase from .schemas import Model from .info import Info @@ -94,7 +94,7 @@ class Parameter(ObjectBase): deprecated: Optional[bool] = Field(default=None) description: Optional[str] = Field(default=None) example: Optional[str] = Field(default=None) - examples: Optional[Map[str, Union['Example','Reference']]] = Field(default=None) + examples: Optional[Dict[str, Union['Example','Reference']]] = Field(default_factory=dict) explode: Optional[bool] = Field(default=None) required: Optional[bool] = Field(default=None) schema_: Optional[Union['Schema', 'Reference']] = Field(default=None, alias="schema") @@ -126,7 +126,7 @@ class Operation(ObjectBase): .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#operationObject """ - responses: Map[str, Union['Response', 'Reference']] = Field(required=True) + responses: Dict[str, Union['Response', 'Reference']] = Field(required=True) deprecated: Optional[bool] = Field(default=None) description: Optional[str] = Field(default=None) @@ -409,7 +409,7 @@ class RequestBody(ObjectBase): .. _RequestBody: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#requestBodyObject """ - content: Map[str, ForwardRef('MediaType')] = Field(default=None) + content: Dict[str, ForwardRef('MediaType')] = Field(default_factory=dict) description: Optional[str] = Field(default=None) required: Optional[bool] = Field(default=None) @@ -425,8 +425,8 @@ class MediaType(ObjectBase): schema_: Optional[Union['Schema', 'Reference']] = Field(required=True, alias="schema") example: Optional[str] = Field(default=None) # 'any' type - examples: Optional[Map[str, Union['Example', 'Reference']]] = Field(default=None) - encoding: Optional[Map[str, str]] = Field(default=None) + examples: Optional[Dict[str, Union['Example', 'Reference']]] = Field(default_factory=dict) + encoding: Optional[Dict[str, str]] = Field(default_factory=dict) @@ -439,8 +439,8 @@ class Response(ObjectBase): """ description: str = Field(required=True) - content: Map[str, ForwardRef('MediaType')] = Field(required=False, default=None) - links: Optional[Map[str, Union['Link', 'Reference']]] = Field(default=None) + content: Optional[Dict[str, ForwardRef('MediaType')]] = Field(default_factory=dict) + links: Optional[Dict[str, Union['Link', 'Reference']]] = Field(default_factory=dict) from pydantic import root_validator, validator diff --git a/openapi3/schemas.py b/openapi3/schemas.py index 0c1f0c6..e6e91de 100644 --- a/openapi3/schemas.py +++ b/openapi3/schemas.py @@ -1,11 +1,11 @@ -from typing import Union, List, Any, Optional +from typing import Union, List, Any, Optional, Dict import dataclasses from pydantic import Field, root_validator, Extra, BaseModel from .errors import SpecError from .general import Reference # need this for Model below -from .object_base import ObjectBase, Map +from .object_base import ObjectBase TYPE_LOOKUP = { 'array': list, @@ -35,11 +35,11 @@ class Schema(ObjectBase): required: Optional[List[str]] = Field(default_factory=list) enum: Optional[list] = Field(default=None) type: Optional[str] = Field(default=None) - allOf: Optional[List[Union["Schema", "Reference"]]] = Field(default=None) + allOf: Optional[List[Union["Schema", "Reference"]]] = Field(default_factory=list) oneOf: Optional[list] = Field(default=None) anyOf: Optional[List[Union["Schema", "Reference"]]] = Field(default=None) items: Optional[Union['Schema', 'Reference']] = Field(default=None) - properties: Optional[Map[str, Union['Schema', 'Reference']]] = Field(default_factory=dict) + properties: Optional[Dict[str, Union['Schema', 'Reference']]] = Field(default_factory=dict) additionalProperties: Optional[Union[bool, dict]] = Field(default=None) description: Optional[str] = Field(default=None) format: Optional[str] = Field(default=None) diff --git a/openapi3/security.py b/openapi3/security.py index e0957d4..50d57f3 100644 --- a/openapi3/security.py +++ b/openapi3/security.py @@ -1,9 +1,9 @@ import dataclasses -from typing import Optional +from typing import Optional, Dict from pydantic import Field -from .object_base import ObjectBase, Map +from .object_base import ObjectBase class SecurityScheme(ObjectBase): @@ -17,12 +17,8 @@ class SecurityScheme(ObjectBase): bearerFormat: Optional[str] = Field(default=None) description: Optional[str] = Field(default=None) - flows: Optional[Map[str, str]] = Field(default=None) # TODO - in_: Optional[str] = Field(default=None) + flows: Optional[Dict[str, str]] = Field(default_factory=dict) # TODO + in_: Optional[str] = Field(default=None, alias="in") name: Optional[str] = Field(default=None) openIdConnectUrl: Optional[str] = Field(default=None) - scheme: Optional[str] = Field(default=None) - - def _parse_data(self): - super()._parse_data() - self.in_ = self._get("in", str) + scheme_: Optional[str] = Field(default=None, alias="scheme") diff --git a/openapi3/servers.py b/openapi3/servers.py index 5f29c3f..31e9c1b 100644 --- a/openapi3/servers.py +++ b/openapi3/servers.py @@ -1,9 +1,9 @@ import dataclasses -from typing import List, Optional, ForwardRef +from typing import List, Optional, ForwardRef, Dict from pydantic import Field -from .object_base import ObjectBase, Map +from .object_base import ObjectBase @@ -16,7 +16,7 @@ class Server(ObjectBase): url: str = Field(default=None) description: Optional[str] = Field(default=None) - variables: Optional[Map[str, ForwardRef('ServerVariable')]] = Field(default=None) + variables: Optional[Dict[str, ForwardRef('ServerVariable')]] = Field(default_factory=dict) From 8fc06c98d574b6b0ee89f4ba9bb1502b92942867 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 27 Dec 2021 10:18:39 +0100 Subject: [PATCH 008/125] Model - remove --- openapi3/paths.py | 10 +-------- openapi3/schemas.py | 53 --------------------------------------------- 2 files changed, 1 insertion(+), 62 deletions(-) diff --git a/openapi3/paths.py b/openapi3/paths.py index 30aec1b..897b0c8 100644 --- a/openapi3/paths.py +++ b/openapi3/paths.py @@ -13,7 +13,6 @@ from .errors import SpecError from .object_base import ObjectBase -from .schemas import Model from .info import Info #from .components import Components @@ -241,16 +240,9 @@ def _request_handle_parameters(self, parameters={}): def _request_handle_body(self, data): if 'application/json' in self.requestBody.content: - if isinstance(data, dict) or isinstance(data, list): + if isinstance(data, (dict, list)): body = json.dumps(data) - if issubclass(type(data), Model): - # serialize models as dicts - converter = lambda c: dict(c) - data_dict = {k: v for k, v in data if v is not None} - - body = json.dumps(data_dict, default=converter) - self._request.data = body self._request.headers['Content-Type'] = 'application/json' else: diff --git a/openapi3/schemas.py b/openapi3/schemas.py index e6e91de..0ff1f49 100644 --- a/openapi3/schemas.py +++ b/openapi3/schemas.py @@ -247,57 +247,4 @@ def _merge(self, other): setattr(self, slot, other_value) -class Model: - """ - A Model is a representation of a Schema as a request or response. Models - are generated from Schema objects by called :any:`Schema.model` with the - contents of a response. - """ - __slots__ = ['_raw_data', '_schema'] - - def __init__(self, data, schema): - """ - Creates a new Model from data. This should never be called directly, - but instead should be called through :any:`Schema.model` to generate a - Model from a defined Schema. - - :param data: The data to create this Model with - :type data: dict - """ - self._raw_data = data - self._schema = schema - - for s in self.__slots__: - # initialize all slots to None - setattr(self, s, None) - - # collect the data into this model - for k, v in data.items(): - prop = schema.properties[k] - - if prop.type == 'array': - # handle arrays - item_schema = prop.items - setattr(self, k, [item_schema.model(c) for c in v]) - elif prop.type == 'object': - # handle nested objects - object_schema = prop - setattr(self, k, object_schema.model(v)) - else: - setattr(self, k, v) - - def __repr__(self): - """ - A generic representation of this model - """ - return str(dict(self)) - - def __iter__(self): - for s in self.__slots__: - if s.startswith('_'): - continue - yield s, getattr(self, s) - return - - Schema.update_forward_refs() \ No newline at end of file From 3c4cd76a0fcdcfb768ba5b6b61a04fac241f372a Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 27 Dec 2021 10:21:57 +0100 Subject: [PATCH 009/125] pydantic - remove relicts --- openapi3/object_base.py | 259 ---------------------------------------- openapi3/paths.py | 17 --- openapi3/schemas.py | 80 ------------- 3 files changed, 356 deletions(-) diff --git a/openapi3/object_base.py b/openapi3/object_base.py index 268fe79..6ffb981 100644 --- a/openapi3/object_base.py +++ b/openapi3/object_base.py @@ -80,18 +80,12 @@ def raise_on_unknown_type(parent, field, object_types, found): element=parent, ) -def isoptional(x): - pass - -from pydantic.fields import ModelField class ObjectBase(BaseModel): """ The base class for all schema objects. Includes helpers for common schema- related functions. """ -# __slots__ = ['path', 'raw_element', '_accessed_members', 'strict', '_root', -# 'extensions', '_original_ref'] extensions: Optional[object] = Field(default=None) @@ -122,259 +116,6 @@ def check_extensions(cls, values): return values - @property - def _get_required_fields(self): - return set(map(lambda y: y.alias, filter(lambda z: z.required is True, self.__fields__.values()))) - - @classmethod - def create(cls, path, raw_element, root, obj=None): - """ - Creates a new Object for a OpenAPI schema with a reference to its own - path in the schema. - - :param path: The path to this element in the spec. - :type path: list[str] - :param raw_element: The raw element parsed from the spec that this object - is parsing. - :type raw_element: dict - :param root: The root of the spec, for reference - :type root: OpenAPI - """ - # init empty slots - obj = obj or cls(raw_element) -# for k in type(obj).__slots__: -# if k in ('_spec_errors', 'validation_mode'): -# # allow these two fields to keep their values -# continue -# setattr(obj, k, None) - - obj._path = path - obj._raw_element = raw_element - obj._root = root - - obj._accessed_members = [] - obj.extensions = {} - - # TODO - add strict mode that errors if all members were not accessed - obj._strict = False - - # parse our own element - try: - obj._required_fields(obj._get_required_fields) - obj._parse_data() - except SpecError as e: - if obj._root._validation_mode: - obj._root.log_spec_error(e) - else: - raise - - # TODO - this may not be appropriate in all cases - obj._parse_spec_extensions() - - # TODO - assert that all keys of raw_element were accessed - - return obj - -# def __repr__(self): -# """ -# Returns a string representation of the parsed object -# """ -# # TODO - why? -# return "<{} {}>".format(type(self), self._path) - - def __getstate__(self): - """ - Returns this object as a dict, removing all empty keys. This can be used - to serialize a spec. - - Allows pickling objects by returning a dict of all slotted values. - """ - return _asdict({ - k: getattr(self, k) for k in type(self).__slots__ if hasattr(self, k) - }) - - def __setstate__(self, state): - """ - Allows unpickling objects - """ - for k, v in state.items(): - setattr(self, k, v) - - def _required_fields(self, fields: Set): - """ - Given a list of require fields for this object, raises a SpecError if any - of the fields do not exist. - - :param *fields: A list of fields to ensure exist in this object - :type *fields: str - - :raises SpecError: if any of the required fields are not present. - """ - missing_fields = fields - set(self._raw_element) - - if missing_fields: - raise SpecError('Missing required fields: {}'.format( - ', '.join(missing_fields)), - path=self._path, - element=self) - - def _parse_data(self): - """ - Parses the raw_element into this object. This is not implemented here, - but is called in the constructor and _must_ be implemented in all - subclasses. - - An implementation of this method should use :any:`_get` to retrieve - values from the raw_element, which has the side-effect of noting that - those members were accessed. After this is executed, spec extensions - are parsed and then an assertion is made that all keys in the - raw_element were accessed - if not, the schema is considered invalid. - """ - self.__class__.parse_obj(self._raw_element) - - def _get(self, field, object_type): - """ - Retrieves a value from this object's raw element, and returns None if - it is not present. Use :any:`_required_fields` to ensure all required - fields are present before depending on the output of this method. - - :param field: The field name to retrieve - :type field: str - :param object_type: The type - :type object_type: typing - :returns: object_type if given, otherwise the type parsed from the spec - file - """ - self._accessed_members.append(field) - c = object_type - ret = self._raw_element.get(field, None) - if ret is None: - return None - - try: - types = self.types_of(object_type) - origin = typing.get_origin(object_type) or object_type - - # decapsule Optional - if origin == typing.Union: - args = typing.get_args(object_type) - if len(args) == 2 and args[1] == None.__class__: - object_type = args[0] - origin = typing.get_origin(args[0]) or origin - - - if origin == list: - if not isinstance(ret, list): - raise SpecError('Expected {}.{} to be a list of {}, got {}'.format( - self.get_path, field, object_type, - type(ret)), - path=self._path, - element=self) - ret = self.parse_list(ret, object_type, field) - elif origin == Map: - if not isinstance(ret, dict): - raise SpecError('Expected {}.{} to be a Map of string: [{}], got {}'.format( - self.get_path, field, object_type, - type(ret)), - path=self._path, - element=self) - ret = Map(self._path + [field], ret, object_type, self._root) - else: - accepts_string = False - for t in types: - if t == typing.Any: - break - - if t == str: - accepts_string = True - continue - - if issubclass(t, ObjectBase): - # we were given the name of a subclass of ObjectBase, - # attempt to parse ret as that type - python_type = t #ObjectBase.get_object_type(t) - - if python_type.can_parse(ret): - ret = python_type.create(self._path + [field], ret, self._root) - break - - elif isinstance(ret, t): - # it's already the type we need - break - else: - if accepts_string and isinstance(ret, str): - pass - else: - raise_on_unknown_type(self, field, types, ret) - except SpecError as e: - if self._root._validation_mode: - self._root.log_spec_error(e) - ret = None - else: - raise - - return ret - - @classmethod - def key_contained(cls, key, target_list): - """ - Returns whether the key is contained in the given list or not. - We use this specific function as to prevent usage of keywords we add "_" - to several parameters, and we still want to validate those parameters - """ - if key.endswith("_"): - extra_key = key[:-1] - else: - extra_key = key + "_" - return key in target_list or extra_key in target_list - - @classmethod - def can_parse(cls, dct): - """ - Returns True if this class can parse the given dict. This is based on - the __slots__ and required_fields of the class, and the keys of the dict. - This is intended to be used when an element may be one of a number of - allowed Object types - each type should independently consider if it - can parse the given element, and the first to report that it can should - be used. - - :param dct: The dict to consider. - :type dct: dict - - :returns: True if this class can parse dct into an instance of itself, - otherwise False - :rtype: bool - """ - # if this isn't a dict, the spec is dreadfully wrong (and since no type - # will be able to parse this value, an appropriate error is returned) - if not isinstance(dct, dict): - return False - fields = set(map(lambda x: x.name.rstrip("_"), dataclasses.fields(cls))) - # ensure that the dict's keys are valid in our slots - - keys = [key for key in filter(lambda x: not x.startswith("x-"), dct.keys())] - keys = set(keys) - - if keys - fields: - return False - - if cls._get_required_fields - keys: - return False - - return True - - def _parse_spec_extensions(self): - """ - Examines the keys of this Object's raw_element and collects any `Specification - Extensions`_ into the extensions attribute of this Object. - - .. _Specification Extensions: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#specificationExtensions - """ - for k, v in self._raw_element.items(): - if k.startswith('x-'): - self.extensions[k[2:]] = v - self._accessed_members.append(k) - @classmethod def get_object_type(cls, typename): """ diff --git a/openapi3/paths.py b/openapi3/paths.py index 897b0c8..38f8914 100644 --- a/openapi3/paths.py +++ b/openapi3/paths.py @@ -369,25 +369,8 @@ class SecurityRequirement(ObjectBase): name: Optional[str] = Field(default=None) types: Optional[List[str]] = Field(default=None) - def _parse_data(self): - """ - """ - # usually these only ever have one key - if len(self._raw_element.keys()) == 1: - self.name = [c for c in self._raw_element.keys()][0] - self.types = self._get(self.name, List[str]) - elif len(self._raw_element.keys()) == 0: - # optional - self.name = self.types = None - @classmethod - def can_parse(cls, dct): - """ - This needs to ignore can_parse since the objects it's parsing are not - regular - they must always have only one key though or be empty for Optional Security Requirements - """ - return len(dct.keys()) == 1 and isinstance([c for c in dct.values()][0], list) or len(dct.keys()) == 0 def __getstate__(self): return {self.name: self.types} diff --git a/openapi3/schemas.py b/openapi3/schemas.py index 0ff1f49..d778b31 100644 --- a/openapi3/schemas.py +++ b/openapi3/schemas.py @@ -73,26 +73,6 @@ def check_number_type(cls, values): values[i] = int(v) return values - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - super()._parse_data() - # TODO - Implement the following properties: - # self.multipleOf - # self.not - # self.uniqueItems - # self.maxProperties - # self.minProperties - # self.exclusiveMinimum - # self.exclusiveMaximum - - self._resolved_allOfs = False - - if self.type == 'array' and self.items is None: - raise SpecError('{}: items is required when type is "array"'.format( - self.get_path())) - def get_type(self): """ Returns the Type that this schema represents. This Type is created once @@ -187,64 +167,4 @@ def request_model(self, **kwargs): # TODO - this doesn't get nested schemas return self.get_request_type()(kwargs, self) - def _resolve_allOfs(self): - """ - Handles merging properties for allOfs - """ - if self._resolved_allOfs: - return - - self._resolved_allOfs = True - - if self.allOf: - for c in self.allOf: -# for c in typing.get_args(self.allOf): -# assert isinstance(c, typing.ForwardRef) -# c = ObjectBase.get_object_type(c.__forward_arg__) - if isinstance(c, Schema): - self._merge(c) - - def _merge(self, other): - """ - Merges ``other`` into this schema, preferring to use the values in ``other`` - """ - for slot in map(lambda x: x.name, dataclasses.fields(self)): - if slot.startswith("_"): - # skip private members - continue - - my_value = getattr(self, slot) - other_value = getattr(other, slot) - - if other_value: - # we got a value to merge - if isinstance(other_value, Schema): - # if it's another schema, merge them - if my_value is not None: - my_value._merge(other_value) - else: - setattr(self, slot, other_value) - elif isinstance(other_value, list): - # we got a list, combine them - if my_value is None: - my_value = [] - setattr(self, slot, my_value + other_value) - elif isinstance(other_value, dict) or isinstance(other_value, Map): - if my_value: - for k, v in my_value.items(): - if k in other_value: - if isinstance(v, Schema): - v._merge(other_value[k]) - continue - else: - my_value[k] = other_value[k] - for ok, ov in other_value.items(): - if ok not in my_value: - my_value[ok] = ov - else: - setattr(self, slot, other_value) - else: - setattr(self, slot, other_value) - - Schema.update_forward_refs() \ No newline at end of file From ff81552c35bfaaf9394fd372325a79a2d2e9323a Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 27 Dec 2021 10:22:30 +0100 Subject: [PATCH 010/125] pydantic - all tests work --- openapi3/object_base.py | 5 +++++ openapi3/openapi.py | 13 +++++++++++-- openapi3/paths.py | 31 +++++++++++++++++++++-------- openapi3/schemas.py | 39 +++++++++++++++++++++++++------------ tests/ref_test.py | 43 ++++++++++++++++------------------------- 5 files changed, 83 insertions(+), 48 deletions(-) diff --git a/openapi3/object_base.py b/openapi3/object_base.py index 6ffb981..2f0c9c9 100644 --- a/openapi3/object_base.py +++ b/openapi3/object_base.py @@ -103,6 +103,11 @@ class Config: @root_validator(pre=True) def check_extensions(cls, values): + """ FIXME + https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.1.md#specificationExtensions + :param values: + :return: values + """ e = dict() for k,v in values.items(): if k.startswith("x-"): diff --git a/openapi3/openapi.py b/openapi3/openapi.py index ffe63f6..ec02731 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -10,6 +10,7 @@ from .info import Info from .paths import Path, SecurityRequirement, _validate_parameters from .components import Components +from .general import Reference from .servers import Server from .tag import Tag @@ -62,7 +63,7 @@ def __init__( """ self._validation_mode = validate - self._spec_errors = None + self._spec_errors = list() self._operation_map = dict() self._security = None @@ -72,8 +73,10 @@ def __init__( if not self._validation_mode: raise e self._spec_errors = e - else: + for name, schema in self.components.schemas.items(): + schema._path = name + for path,obj in self.paths.items(): for m in obj.__fields_set__ & frozenset(["get","delete","head","post","put","patch","trace"]): op = getattr(obj, m) @@ -83,6 +86,12 @@ def __init__( continue formatted_operation_id = op.operationId.replace(" ", "_") self._register_operation(formatted_operation_id, op) + for r, response in op.responses.items(): + if isinstance(response, Reference): + continue + for c, content in response.content.items(): + content.schema_._path = f"{path}.{m}.{r}.{c}" + # self._spec.resolve_path("#/components/responses/Missing".split('/')[1:]) diff --git a/openapi3/paths.py b/openapi3/paths.py index 38f8914..c21977a 100644 --- a/openapi3/paths.py +++ b/openapi3/paths.py @@ -3,7 +3,7 @@ import json import re -from pydantic import Field +from pydantic import Field, BaseModel, root_validator import requests try: @@ -175,13 +175,13 @@ def _parse_data(self): def _request_handle_secschemes(self, security_requirement, value): ss = self._root.components.securitySchemes[security_requirement.name] - if ss.type == 'http' and ss.scheme == 'basic': + if ss.type == 'http' and ss.scheme_ == 'basic': self._request.auth = requests.auth.HTTPBasicAuth(*value) - if ss.type == 'http' and ss.scheme == 'digest': + if ss.type == 'http' and ss.scheme_ == 'digest': self._request.auth = requests.auth.HTTPDigestAuth(*value) - if ss.type == 'http' and ss.scheme == 'bearer': + if ss.type == 'http' and ss.scheme_ == 'bearer': header = ss.bearerFormat or 'Bearer {}' self._request.headers['Authorization'] = header.format(value) @@ -328,7 +328,7 @@ def request(self, base_url, security={}, data=None, parameters={}, verify=True, raise RuntimeError(err_msg.format(*err_var)) - if expected_response.content is None: + if len(expected_response.content) == 0: return None content_type = result.headers['Content-Type'] @@ -359,18 +359,33 @@ def request(self, base_url, security={}, data=None, parameters={}, verify=True, -class SecurityRequirement(ObjectBase): +class SecurityRequirement(BaseModel): """ A `SecurityRequirement`_ object describes security schemes for API access. .. _SecurityRequirement: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#securityRequirementObject """ + __root__: Dict[str, List[str]] - name: Optional[str] = Field(default=None) - types: Optional[List[str]] = Field(default=None) + @root_validator + def validate_SecurityRequirement(cls, values): + root = values.get("__root__", {}) + if not (len(root.keys()) == 1 and isinstance([c for c in root.values()][0], list) or len(root.keys()) == 0): + raise ValueError(root) + return values + @property + def name(self): + if len(self.__root__.keys()): + return list(self.__root__.keys())[0] + return None + @property + def types(self): + if self.name: + return self.__root__[self.name] + return None def __getstate__(self): return {self.name: self.types} diff --git a/openapi3/schemas.py b/openapi3/schemas.py index d778b31..41fe165 100644 --- a/openapi3/schemas.py +++ b/openapi3/schemas.py @@ -59,6 +59,7 @@ class Schema(ObjectBase): _model_type: object _request_model_type: object _resolved_allOfs: object + _path: str class Config: extra = Extra.forbid @@ -90,34 +91,48 @@ def get_type(self): try: return self._model_type except AttributeError: + def typeof(schema): r = None if schema.type == "string": r = str elif schema.type == "integer": r = int + elif schema.type == "array": + r = schema.items.get_type() else: raise TypeError(schema.type) return r - - type_name = self.title or self._path[-1] + def annotationsof(schema): + annos = dict() + if schema.type == "array": + annos["__root__"] = List[typeof(schema)] + else: + for name, f in schema.properties.items(): + r = typeof(f) + if name not in schema.required: + annos[name] = Optional[r] + else: + annos[name] = r + return annos + + type_name = self.title or self._path namespace = dict() annos = dict() if self.allOf: - pass + for i in self.allOf: + annos.update(annotationsof(i)) elif self.anyOf: - types = [i.get_type() for i in self.anyOf] - namespace["__root__"] = Union[types] +# types = [i.get_type() for i in self.anyOf] +# namespace["__root__"] = Union[types] + raise NotImplementedError("anyOf") elif self.oneOf: - pass + raise NotImplementedError("oneOf") else: - for name, f in self.properties.items(): - r = typeof(f) - if name not in self.required: - annos[name] = Optional[r] - else: - annos[name] = r + + annos = annotationsof(self) + namespace['__annotations__'] = annos import types self._model_type = types.new_class(type_name, (BaseModel, ), {}, lambda ns: ns.update(namespace)) diff --git a/tests/ref_test.py b/tests/ref_test.py index 3d582bd..6f95a6b 100644 --- a/tests/ref_test.py +++ b/tests/ref_test.py @@ -2,11 +2,15 @@ This file tests that $ref resolution works as expected, and that allOfs are populated as expected as well. """ +import typing + import pytest + from openapi3 import OpenAPI from openapi3.schemas import Schema +from pydantic.main import ModelMetaclass def test_ref_resolution(petstore_expanded_spec): """ @@ -33,30 +37,17 @@ def test_allOf_resolution(petstore_expanded_spec): """ Tests that allOfs are resolved correctly """ - ref = petstore_expanded_spec.paths['/pets'].get.responses['200'].content['application/json'].schema - ref = petstore_expanded_spec.paths['/pets'].get.responses['200'].content['application/json'].schema + ref = petstore_expanded_spec.paths['/pets'].get.responses['200'].content['application/json'].schema_.get_type() - assert type(ref) == Schema - assert ref.type == "array" - assert ref.items is not None - - items = ref.items - assert type(items) == Schema - assert sorted(items.required) == sorted(["id","name"]) - assert len(items.properties) == 3 - assert 'id' in items.properties - assert 'name' in items.properties - assert 'tag' in items.properties - - id_prop = items.properties['id'] - id_prop = items.properties['id'] - assert id_prop.type == "integer" - assert id_prop.format == "int64" - - name = items.properties['name'] - name = items.properties['name'] - assert name.type == 'string' - - tag = items.properties['tag'] - tag = items.properties['tag'] - assert tag.type == 'string' + assert type(ref) == ModelMetaclass + assert typing.get_origin(ref.__fields__["__root__"].outer_type_) == list + + items = typing.get_args(ref.__fields__["__root__"].outer_type_)[0].__fields__ + + assert sorted(map(lambda x: x.name, filter(lambda y: y.required==True, items.values()))) == sorted(["id","name"]) + + assert sorted(map(lambda x: x.name, items.values())) == ["id","name","tag"] + + assert items['id'].outer_type_ == int + assert items['name'].outer_type_ == str + assert items["tag"].outer_type_ == str From eb2f8d6e62738388b4a154ca24f7b65150b1ddde Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 27 Dec 2021 15:09:43 +0100 Subject: [PATCH 011/125] tests - include specs from bugs #9 & #10 - local data loading for split specifications (open5gs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit edit - not including all the api specs … --- openapi3/__init__.py | 5 ++- openapi3/object_base.py | 49 ++++--------------------- openapi3/openapi.py | 79 ++++++++++++++++++++++++++++++++++++++--- openapi3/paths.py | 44 +++++++++++++++-------- openapi3/schemas.py | 10 +++++- openapi3/security.py | 15 ++++++-- openapi3/servers.py | 2 ++ tests/parsing_test.py | 25 ++++++++++++- 8 files changed, 160 insertions(+), 69 deletions(-) diff --git a/openapi3/__init__.py b/openapi3/__init__.py index 5a8113e..39ae14d 100644 --- a/openapi3/__init__.py +++ b/openapi3/__init__.py @@ -1,9 +1,8 @@ -from .openapi import OpenAPI - +from .openapi import OpenAPI, FileSystemLoader # these imports appear unused, but in fact load up the subclasses ObjectBase so # that they may be referenced throughout the schema without issue from . import info, servers, paths, general, schemas, components, security, tag, example from .errors import SpecError, ReferenceResolutionError -__all__ = ['OpenAPI', 'SpecError', 'ReferenceResolutionError'] +__all__ = ['OpenAPI', 'SpecError', 'ReferenceResolutionError','FileSystemLoader'] diff --git a/openapi3/object_base.py b/openapi3/object_base.py index 2f0c9c9..35c03c6 100644 --- a/openapi3/object_base.py +++ b/openapi3/object_base.py @@ -1,4 +1,5 @@ import sys +import datetime import typing from typing import List, Optional, Set import dataclasses @@ -16,17 +17,6 @@ unicode = str -def _asdict(x): - if hasattr(x, '__getstate__'): - return x.__getstate__() - elif isinstance(x, dict): - return {k: _asdict(v) for k, v in x.items()} - elif isinstance(x, (list, tuple, set)): - return x.__class__(_asdict(y) for y in x) - else: - return x - - def raise_on_unknown_type(parent, field, object_types, found): """ Raises a SpecError describing a situation where an unknown type was given. @@ -111,7 +101,7 @@ def check_extensions(cls, values): e = dict() for k,v in values.items(): if k.startswith("x-"): - e[k[2]] = v + e[k[2:]] = v if len(e): for i in e.keys(): del values[f"x-{i}"] @@ -160,32 +150,7 @@ def get_path(self): return '.'.join(self._path) - @staticmethod - def _resolve_type(root, obj, value): - # we found a reference - attempt to resolve it - reference_path = value.ref - if not reference_path.startswith('#/'): - raise ReferenceResolutionError('Invalid reference path {}'.format( - reference_path), - path=obj._path, - element=obj) - - reference_path = reference_path.split('/')[1:] - - try: - resolved_value = root.resolve_path(reference_path) - except ReferenceResolutionError as e: - # add metadata to the error -# e.path = obj._path - e.element = obj - raise - - # FIXME - will break if multiple things reference the same - # node -# resolved_value._original_ref = value - return resolved_value - - def _resolve_references(self, root): + def _resolve_references(self, api): """ Resolves all reference objects below this object and notes their original value was a reference. @@ -204,7 +169,7 @@ def resolve(obj): if value is None: continue elif isinstance(value, reference_type): - resolved_value = ObjectBase._resolve_type(root, obj, value) + resolved_value = api._resolve_type(root, obj, value) setattr(obj, slot, resolved_value) elif issubclass(type(value), ObjectBase): # otherwise, continue resolving down the tree @@ -216,7 +181,7 @@ def resolve(obj): resolved_list = [] for item in value: if isinstance(item, reference_type): - resolved_value = ObjectBase._resolve_type(root, obj, item) + resolved_value = api._resolve_type(root, obj, item) resolved_list.append(resolved_value) elif isinstance(item, (ObjectBase, dict, list)): resolve(item) @@ -224,7 +189,7 @@ def resolve(obj): else: resolved_list.append(item) setattr(obj, slot, resolved_list) - elif isinstance(value, (str, int, float)): + elif isinstance(value, (str, int, float, datetime.datetime)): continue else: raise TypeError(type(value)) @@ -232,7 +197,7 @@ def resolve(obj): for k, v in obj.items(): if isinstance(v, reference_type): if v.ref: - obj[k] = ObjectBase._resolve_type(root, obj, v) + obj[k] = api._resolve_type(root, obj, v) elif isinstance(v, (ObjectBase, dict, list)): resolve(v) diff --git a/openapi3/openapi.py b/openapi3/openapi.py index ec02731..b4074a8 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -1,8 +1,10 @@ -import dataclasses +import json +import pathlib from typing import ForwardRef, Any, List, Optional, Dict from pydantic import Field, ValidationError import requests +import yaml from .object_base import ObjectBase from .errors import ReferenceResolutionError, SpecError @@ -15,6 +17,29 @@ from .tag import Tag +class Loader: + def load(self, name): + raise NotImplementedError("load") + + +class FileSystemLoader(Loader): + def __init__(self, base): + self.base = pathlib.Path(base) + + def load(self, file): + file = pathlib.Path(file) + path = self.base / file + assert path.is_relative_to(self.base) + data = path.open("r").read() + if file.suffix == ".yaml": + data = yaml.safe_load(data) + elif file.suffix == ".json": + data = json.loads(data) + else: + raise ValueError(file.name) + return data + + class OpenAPI: @property @@ -43,7 +68,8 @@ def __init__( validate=False, ssl_verify=None, use_session=False, - session_factory=requests.Session): + session_factory=requests.Session, + loader=None): """ Creates a new OpenAPI document from a loaded spec file. This is overridden here because we need to specify the path in the parent @@ -66,17 +92,21 @@ def __init__( self._spec_errors = list() self._operation_map = dict() self._security = None + self.loader = loader + self._cached = dict() try: self._spec = OpenAPISpec.parse_obj(raw_document) except Exception as e: if not self._validation_mode: - raise e + raise self._spec_errors = e else: for name, schema in self.components.schemas.items(): schema._path = name + self._spec._resolve_references(self) + for path,obj in self.paths.items(): for m in obj.__fields_set__ & frozenset(["get","delete","head","post","put","patch","trace"]): op = getattr(obj, m) @@ -90,12 +120,12 @@ def __init__( if isinstance(response, Reference): continue for c, content in response.content.items(): + if content.schema_ is None: + continue content.schema_._path = f"{path}.{m}.{r}.{c}" - # self._spec.resolve_path("#/components/responses/Missing".split('/')[1:]) - self._spec._resolve_references(self._spec) self._ssl_verify = ssl_verify @@ -213,6 +243,45 @@ def __getattribute__(self, attr): return object.__getattribute__(self, attr) + def _load(self, i): + data = self.loader.load(i) + return OpenAPISpec.parse_obj(data) + + + + + def _resolve_type(self, root, obj, value): + # we found a reference - attempt to resolve it + reference_path = value.ref + if not reference_path.startswith('#/'): + from pathlib import Path + import yaml + filename = Path(reference_path.split("#")[0]) + if filename not in self._cached: + self._cached[filename] = self._load(filename) + root = self._cached[filename] +# return self._resolve_type(child, obj, value) + + # raise ReferenceResolutionError('Invalid reference path {}'.format( + # reference_path), + # path=obj._path, + # element=obj) + + reference_path = reference_path.split('/')[1:] + + try: + resolved_value = root.resolve_path(reference_path) + except ReferenceResolutionError as e: + # add metadata to the error +# e.path = obj._path + e.element = obj + raise + + # FIXME - will break if multiple things reference the same + # node +# resolved_value._original_ref = value + return resolved_value + class OpenAPISpec(ObjectBase): """ diff --git a/openapi3/paths.py b/openapi3/paths.py index c21977a..85f25fe 100644 --- a/openapi3/paths.py +++ b/openapi3/paths.py @@ -1,5 +1,5 @@ import dataclasses -from typing import ForwardRef, Union, List, Optional, Dict +from typing import ForwardRef, Union, List, Optional, Dict, Any import json import re @@ -103,18 +103,13 @@ class Parameter(ObjectBase): allowEmptyValue: Optional[bool] = Field(default=None) allowReserved: Optional[bool] = Field(default=None) - @classmethod - def can_parse(cls, dct): - return super().can_parse(dct) - - def _parse_data(self): -# super()._parse_data() -# self.in_ = self._get("in", str) - - # required is required and must be True if this parameter is in the path - if self.in_ == "path" and self.required is not True: - err_msg = 'Parameter {} must be required since it is in the path' - raise SpecError(err_msg.format(self.get_path()), path=self._path) + @root_validator + def validate_Parameter(cls, values): +# if values["in_"] == +# if self.in_ == "path" and self.required is not True: +# err_msg = 'Parameter {} must be required since it is in the path' +# raise SpecError(err_msg.format(self.get_path()), path=self._path) + return values from pydantic import validator @@ -403,6 +398,25 @@ class RequestBody(ObjectBase): description: Optional[str] = Field(default=None) required: Optional[bool] = Field(default=None) +class Header(ObjectBase): + """ + + .. _HeaderObject: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#headerObject + """ + deprecated: Optional[bool] = Field(default=None) + + +class Encoding(ObjectBase): + """ + A single encoding definition applied to a single schema property. + + .. _Encoding: https://github.com/OAI/OpeI-Specification/blob/main/versions/3.1.0.md#encodingObject + """ + contentType: Optional[str] = Field(default=None) + headers: Optional[Dict[str, Union[Header, Reference]]] = Field(default_factory=dict) + style: Optional[str] = Field(default=None) + explode: Optional[bool] = Field(default=None) + allowReserved: Optional[bool] = Field(default=None) class MediaType(ObjectBase): @@ -414,9 +428,9 @@ class MediaType(ObjectBase): """ schema_: Optional[Union['Schema', 'Reference']] = Field(required=True, alias="schema") - example: Optional[str] = Field(default=None) # 'any' type + example: Optional[Any] = Field(default=None) # 'any' type examples: Optional[Dict[str, Union['Example', 'Reference']]] = Field(default_factory=dict) - encoding: Optional[Dict[str, str]] = Field(default_factory=dict) + encoding: Optional[Dict[str, Encoding]] = Field(default_factory=dict) diff --git a/openapi3/schemas.py b/openapi3/schemas.py index 41fe165..323cc38 100644 --- a/openapi3/schemas.py +++ b/openapi3/schemas.py @@ -25,22 +25,30 @@ class Schema(ObjectBase): """ title: Optional[str] = Field(default=None) + multipleOf: Optional[int] = Field(default=None) maximum: Optional[float] = Field(default=None) # FIXME Field(discriminator='type') would be better + exclusiveMaximum: Optional[bool] = Field(default=None) minimum: Optional[float] = Field(default=None) + exclusiveMinimum: Optional[bool] = Field(default=None) maxLength: Optional[int] = Field(default=None) minLength: Optional[int] = Field(default=None) pattern: Optional[str] = Field(default=None) maxItems: Optional[int] = Field(default=None) minItems: Optional[int] = Field(default=None) + uniqueItems: Optional[bool] = Field(default=None) + maxProperties: Optional[int] = Field(default=None) + minProperties: Optional[int] = Field(default=None) required: Optional[List[str]] = Field(default_factory=list) enum: Optional[list] = Field(default=None) + type: Optional[str] = Field(default=None) allOf: Optional[List[Union["Schema", "Reference"]]] = Field(default_factory=list) oneOf: Optional[list] = Field(default=None) anyOf: Optional[List[Union["Schema", "Reference"]]] = Field(default=None) + not_: Optional[Union["Schema", "Reference"]] = Field(default=None, alias="not") items: Optional[Union['Schema', 'Reference']] = Field(default=None) properties: Optional[Dict[str, Union['Schema', 'Reference']]] = Field(default_factory=dict) - additionalProperties: Optional[Union[bool, dict]] = Field(default=None) + additionalProperties: Optional[Union[bool, 'Schema', 'Reference']] = Field(default=None) description: Optional[str] = Field(default=None) format: Optional[str] = Field(default=None) default: Optional[str] = Field(default=None) # TODO - str as a default? diff --git a/openapi3/security.py b/openapi3/security.py index 50d57f3..d0998c0 100644 --- a/openapi3/security.py +++ b/openapi3/security.py @@ -1,10 +1,21 @@ import dataclasses from typing import Optional, Dict -from pydantic import Field +from pydantic import Field, BaseModel from .object_base import ObjectBase +class OAuthFlowObject(ObjectBase): + authorizationUrl: Optional[str] = Field(default=None) + tokenUrl: Optional[str] = Field(default=None) + refreshUrl: Optional[str] = Field(default=None) + scopes: Dict[str, str] = Field(default_factory=dict) + +class OAuthFlows(ObjectBase): + implicit: Optional[OAuthFlowObject] = Field(default=None) + password: Optional[OAuthFlowObject] = Field(default=None) + clientCredentials: Optional[OAuthFlowObject] = Field(default=None) + authorizationCode: Optional[OAuthFlowObject] = Field(default=None) class SecurityScheme(ObjectBase): """ @@ -17,7 +28,7 @@ class SecurityScheme(ObjectBase): bearerFormat: Optional[str] = Field(default=None) description: Optional[str] = Field(default=None) - flows: Optional[Dict[str, str]] = Field(default_factory=dict) # TODO + flows: Optional[OAuthFlows] = Field(default=None) in_: Optional[str] = Field(default=None, alias="in") name: Optional[str] = Field(default=None) openIdConnectUrl: Optional[str] = Field(default=None) diff --git a/openapi3/servers.py b/openapi3/servers.py index 31e9c1b..28cd000 100644 --- a/openapi3/servers.py +++ b/openapi3/servers.py @@ -31,3 +31,5 @@ class ServerVariable(ObjectBase): description: Optional[str] = Field(default=None) enum: Optional[List[str]] = Field(default=None) + +Server.update_forward_refs() \ No newline at end of file diff --git a/tests/parsing_test.py b/tests/parsing_test.py index 5d8dd86..77bc252 100644 --- a/tests/parsing_test.py +++ b/tests/parsing_test.py @@ -4,7 +4,7 @@ import pytest from pydantic import ValidationError -from openapi3 import OpenAPI, SpecError, ReferenceResolutionError +from openapi3 import OpenAPI, SpecError, ReferenceResolutionError, FileSystemLoader def test_parse_from_yaml(petstore_expanded): @@ -114,8 +114,31 @@ def test_parsing_broken_links(with_broken_links): "operationId and operationRef are mutually exclusive, one of them must be specified", ]]) +def test_parsing_data(): + import yaml, json + from pathlib import Path + datadir = Path("tests/data") + for i in filter(lambda x: x.is_file(), datadir.iterdir()): + data = None + if i.suffix == ".yaml": + data = yaml.safe_load(i.open('r').read()) + elif i.suffix == ".json": + data = json.loads(i.open('r').read()) + elif i.name == "README.md": + continue + else: + raise ValueError(i.name) + spec = OpenAPI(data, loader=FileSystemLoader(i.parent)) + +def test_parsing_data_open5gs(): + from pathlib import Path + import yaml + i = Path("tests/data/open5gs/TS29510_Nnrf_NFManagement.yaml") + data = yaml.safe_load(i.open('r').read()) + spec = OpenAPI(data, loader=FileSystemLoader(i.parent)) def test_securityparameters(with_securityparameters): spec = OpenAPI(with_securityparameters, validate=True) errors = spec.errors() + print(errors) assert len(errors) == 0 From 4c14f99e3a216f448e983e53a62fc299f2ac73ba Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 27 Dec 2021 21:28:46 +0100 Subject: [PATCH 012/125] pydantic - clean up OpjectBase --- openapi3/object_base.py | 170 +--------------------------------------- openapi3/openapi.py | 52 ++++++++++++ 2 files changed, 56 insertions(+), 166 deletions(-) diff --git a/openapi3/object_base.py b/openapi3/object_base.py index 35c03c6..44162d8 100644 --- a/openapi3/object_base.py +++ b/openapi3/object_base.py @@ -1,75 +1,7 @@ -import sys -import datetime -import typing -from typing import List, Optional, Set -import dataclasses +from typing import List, Optional -from pydantic import BaseModel, Field, root_validator - -from .errors import SpecError, ReferenceResolutionError - -IS_PYTHON_2 = False -if sys.version_info[0] == 2: - IS_PYTHON_2 = True -else: - # unicode was removed in python3, but we need to support both here, so define - # it in python 3 only - unicode = str - - -def raise_on_unknown_type(parent, field, object_types, found): - """ - Raises a SpecError describing a situation where an unknown type was given. - This function attempts to produce as useful an error as possible based on the - type of types that were expected. - - :param parent: The parent element who was attempting to parse the field - :type parent: Subclass of ObjectBase - :param field: The field we were trying to parse - :type field: str - :param object_types: The types allowed for this field - :type object_types: List of str or Class - :param found: The value that was found (and did not match any expected type) - :type found: any - - :raises: A SpecError describing the failure - """ - if len(object_types) == 1: - expected_type = object_types[0] - raise SpecError('Expected {}.{} to be of type {}, with required fields {}'.format( - parent.get_path(), - field, - expected_type.__name__, - sorted(expected_type.required_fields), - ), - path=parent._path, - element=parent, - ) - elif len(object_types) == 2 and len([c for c in object_types if isinstance(c, str)]) == 2 and "Reference" in object_types: - # we can give a similar error here as above - expected_type_str = [c for c in object_types if c != "Reference"][0] - expected_type = ObjectBase.get_object_type(expected_type_str) - raise SpecError("Expected {}.{} to be of type {} or Reference, but did not find required fields {} or '$ref'".format( - parent.get_path(), - field, - expected_type_str, - expected_type._get_required_fields, - ), - path=parent._path, - element=parent, - ) - print(object_types) - raise SpecError('Expected {}.{} to be one of [{}], got {}'.format( - parent.get_path(), - field, - ','.join([str(c) for c in object_types], - ), - type(found) - ), - path=parent._path, - element=parent, - ) +from pydantic import BaseModel, Field, root_validator class ObjectBase(BaseModel): """ @@ -79,7 +11,6 @@ class ObjectBase(BaseModel): extensions: Optional[object] = Field(default=None) - _strict: bool _path: List[str] _raw_element: dict @@ -92,7 +23,7 @@ class Config: @root_validator(pre=True) - def check_extensions(cls, values): + def validate_ObjectBase_extensions(cls, values): """ FIXME https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.1.md#specificationExtensions :param values: @@ -109,97 +40,4 @@ def check_extensions(cls, values): raise ValueError("extensions") values["extensions"] = e - return values - - @classmethod - def get_object_type(cls, typename): - """ - Introspects the subclasses of this class to decide which to return for - object_type - - :param object_type: The name of a class that inherits from this class. - Must exactly equal type(Class).__name__ - :type object_type: str - - :returns: The Type associated with this name - :raises ValueError: if no Type with that name was found - """ - if not hasattr(cls, '_subclass_map'): - def resolve(c, d): - r = {t.__name__: t for t in c.__subclasses__()} - for k,v in r.items(): - resolve(v, d) - d.update(r) - return d - # generate subclass map on first call - setattr(cls, '_subclass_map', resolve(cls, {})) - - # TODO - why? - if typename not in cls._subclass_map: # pylint: disable=no-member - raise ValueError('ObjectBase has no subclass {}'.format(typename)) - - return cls._subclass_map[typename] # pylint: disable=no-member - - def get_path(self): - """ - Get the full path for this element in the spec - - :returns: The path in the spec for this element - :rtype: str - """ - return '.'.join(self._path) - - - def _resolve_references(self, api): - """ - Resolves all reference objects below this object and notes their original - value was a reference. - """ - # don't circular import - - reference_type = ObjectBase.get_object_type('Reference') - obj = root = self - - - - def resolve(obj): - if isinstance(obj, ObjectBase): - for slot in filter(lambda x: not x.startswith("_"), obj.__fields_set__): - value = getattr(obj, slot) - if value is None: - continue - elif isinstance(value, reference_type): - resolved_value = api._resolve_type(root, obj, value) - setattr(obj, slot, resolved_value) - elif issubclass(type(value), ObjectBase): - # otherwise, continue resolving down the tree - resolve(value) - elif isinstance(value, dict): # pydantic does not use Map - resolve(value) - elif isinstance(value, list): - # if it's a list, resolve its item's references - resolved_list = [] - for item in value: - if isinstance(item, reference_type): - resolved_value = api._resolve_type(root, obj, item) - resolved_list.append(resolved_value) - elif isinstance(item, (ObjectBase, dict, list)): - resolve(item) - resolved_list.append(item) - else: - resolved_list.append(item) - setattr(obj, slot, resolved_list) - elif isinstance(value, (str, int, float, datetime.datetime)): - continue - else: - raise TypeError(type(value)) - elif isinstance(obj, dict): - for k, v in obj.items(): - if isinstance(v, reference_type): - if v.ref: - obj[k] = api._resolve_type(root, obj, v) - elif isinstance(v, (ObjectBase, dict, list)): - resolve(v) - - resolve(self) - + return values \ No newline at end of file diff --git a/openapi3/openapi.py b/openapi3/openapi.py index b4074a8..604b8ec 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -305,6 +305,58 @@ class Config: underscore_attrs_are_private = True arbitrary_types_allowed = True + def _resolve_references(self, api): + """ + Resolves all reference objects below this object and notes their original + value was a reference. + """ + # don't circular import + + reference_type = Reference + root = self + + def resolve(obj): + if isinstance(obj, ObjectBase): + for slot in filter(lambda x: not x.startswith("_"), obj.__fields_set__): + value = getattr(obj, slot) + if value is None: + continue + elif isinstance(value, reference_type): + resolved_value = api._resolve_type(root, obj, value) + setattr(obj, slot, resolved_value) + elif issubclass(type(value), ObjectBase): + # otherwise, continue resolving down the tree + resolve(value) + elif isinstance(value, dict): # pydantic does not use Map + resolve(value) + elif isinstance(value, list): + # if it's a list, resolve its item's references + resolved_list = [] + for item in value: + if isinstance(item, reference_type): + resolved_value = api._resolve_type(root, obj, item) + resolved_list.append(resolved_value) + elif isinstance(item, (ObjectBase, dict, list)): + resolve(item) + resolved_list.append(item) + else: + resolved_list.append(item) + setattr(obj, slot, resolved_list) + elif isinstance(value, (str, int, float, datetime.datetime)): + continue + else: + raise TypeError(type(value)) + elif isinstance(obj, dict): + for k, v in obj.items(): + if isinstance(v, reference_type): + if v.ref: + obj[k] = api._resolve_type(root, obj, v) + elif isinstance(v, (ObjectBase, dict, list)): + resolve(v) + + resolve(self) + + def resolve_path(self, path): """ Given a $ref path, follows the document tree and returns the given attribute. From 0e30074b18c73ed4597349091e4343108c02758d Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 27 Dec 2021 21:30:49 +0100 Subject: [PATCH 013/125] tests - parameterize tests/data tests --- openapi3/openapi.py | 17 +++++++++++++++-- tests/parse_data_test.py | 32 ++++++++++++++++++++++++++++++++ tests/parsing_test.py | 22 ---------------------- 3 files changed, 47 insertions(+), 24 deletions(-) create mode 100644 tests/parse_data_test.py diff --git a/openapi3/openapi.py b/openapi3/openapi.py index 604b8ec..f473c82 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -1,5 +1,6 @@ import json import pathlib +import datetime from typing import ForwardRef, Any, List, Optional, Dict from pydantic import Field, ValidationError @@ -26,11 +27,23 @@ class FileSystemLoader(Loader): def __init__(self, base): self.base = pathlib.Path(base) - def load(self, file): + def load(self, file, codec=None): file = pathlib.Path(file) path = self.base / file assert path.is_relative_to(self.base) - data = path.open("r").read() + data = path.open("rb").read() + if codec is not None: + codecs = [codec] + else: + codecs = ["ascii","utf-8"] + for c in codecs: + try: + r = data.decode(c) + break + except UnicodeError: + continue + else: + raise ValueError("encoding") if file.suffix == ".yaml": data = yaml.safe_load(data) elif file.suffix == ".json": diff --git a/tests/parse_data_test.py b/tests/parse_data_test.py new file mode 100644 index 0000000..69a8fa1 --- /dev/null +++ b/tests/parse_data_test.py @@ -0,0 +1,32 @@ +import pytest +from openapi3 import FileSystemLoader,OpenAPI +import pathlib + + +def pytest_generate_tests(metafunc): + argnames, dir, filterfn = metafunc.cls.params[metafunc.function.__name__] + dir = pathlib.Path(dir) + metafunc.parametrize( + argnames, [[dir, i.name] for i in filter(filterfn, dir.iterdir())] + ) + + +class TestParseData: + # a map specifying multiple argument sets for a test method + params = { + "test_data": [("dir","file"),"tests/data", lambda x: x.is_file() and x.suffix in (".json",".yaml")], + "test_data_open5gs": [("dir","file"), "tests/data/open5gs/", + lambda x: x.is_file() and x.suffix in (".json",".yaml") and x.name.split("_")[0] not in ("TS29520","TS29509","TS29544","TS29517")], + } + + def test_data(self, dir, file): + loader = FileSystemLoader(pathlib.Path(dir)) + data = loader.load(pathlib.Path(file).name) + spec = OpenAPI(data, loader=loader) + + def test_data_open5gs(self, dir, file): + loader = FileSystemLoader(pathlib.Path(dir)) + data = loader.load(pathlib.Path(file).name) +# if "servers" in "data": + spec = OpenAPI(data, loader=loader) + diff --git a/tests/parsing_test.py b/tests/parsing_test.py index 77bc252..daa8549 100644 --- a/tests/parsing_test.py +++ b/tests/parsing_test.py @@ -114,28 +114,6 @@ def test_parsing_broken_links(with_broken_links): "operationId and operationRef are mutually exclusive, one of them must be specified", ]]) -def test_parsing_data(): - import yaml, json - from pathlib import Path - datadir = Path("tests/data") - for i in filter(lambda x: x.is_file(), datadir.iterdir()): - data = None - if i.suffix == ".yaml": - data = yaml.safe_load(i.open('r').read()) - elif i.suffix == ".json": - data = json.loads(i.open('r').read()) - elif i.name == "README.md": - continue - else: - raise ValueError(i.name) - spec = OpenAPI(data, loader=FileSystemLoader(i.parent)) - -def test_parsing_data_open5gs(): - from pathlib import Path - import yaml - i = Path("tests/data/open5gs/TS29510_Nnrf_NFManagement.yaml") - data = yaml.safe_load(i.open('r').read()) - spec = OpenAPI(data, loader=FileSystemLoader(i.parent)) def test_securityparameters(with_securityparameters): spec = OpenAPI(with_securityparameters, validate=True) From 75b9236ee1d4dc8acd537cefcfe9250be016369d Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 27 Dec 2021 21:31:14 +0100 Subject: [PATCH 014/125] pydantic - cleanups --- openapi3/general.py | 4 ---- openapi3/openapi.py | 25 ++++++------------------- openapi3/paths.py | 6 +----- openapi3/schemas.py | 8 ++++---- 4 files changed, 11 insertions(+), 32 deletions(-) diff --git a/openapi3/general.py b/openapi3/general.py index 7c8a917..5f5650b 100644 --- a/openapi3/general.py +++ b/openapi3/general.py @@ -28,7 +28,3 @@ class Reference(ObjectBase): """ ref: str = Field(alias="$ref") -# @root_validator -# def root_check(cls, values): -# print(values) -# return values \ No newline at end of file diff --git a/openapi3/openapi.py b/openapi3/openapi.py index f473c82..9bc795b 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -123,7 +123,7 @@ def __init__( for path,obj in self.paths.items(): for m in obj.__fields_set__ & frozenset(["get","delete","head","post","put","patch","trace"]): op = getattr(obj, m) - op._path,op._method, op._root = path, m, self + op._path, op._method, op._root = path, m, self _validate_parameters(op, ['x', path]) if op.operationId is None: continue @@ -265,35 +265,22 @@ def _load(self, i): def _resolve_type(self, root, obj, value): # we found a reference - attempt to resolve it - reference_path = value.ref - if not reference_path.startswith('#/'): - from pathlib import Path - import yaml - filename = Path(reference_path.split("#")[0]) + + filename,reference_path = value.ref.split("#/", maxsplit=1) + if filename != '': + filename = pathlib.Path(filename) if filename not in self._cached: self._cached[filename] = self._load(filename) root = self._cached[filename] # return self._resolve_type(child, obj, value) - # raise ReferenceResolutionError('Invalid reference path {}'.format( - # reference_path), - # path=obj._path, - # element=obj) - - reference_path = reference_path.split('/')[1:] - try: - resolved_value = root.resolve_path(reference_path) + return root.resolve_path(reference_path.split('/')) except ReferenceResolutionError as e: # add metadata to the error -# e.path = obj._path e.element = obj raise - # FIXME - will break if multiple things reference the same - # node -# resolved_value._original_ref = value - return resolved_value class OpenAPISpec(ObjectBase): diff --git a/openapi3/paths.py b/openapi3/paths.py index 85f25fe..483b4e5 100644 --- a/openapi3/paths.py +++ b/openapi3/paths.py @@ -463,12 +463,8 @@ class Link(ObjectBase): requestBody: Optional[dict] = Field(default=None) server: Optional[ForwardRef('Server')] = Field(default=None) -# @validator("operationId", always=True) -# def operationId_check(cls, v): -# assert False - @root_validator(pre=False) - def operation_check(cls, values): + def validate_Link_operation(cls, values): if values["operationId"] != None and values["operationRef"] != None: raise SpecError("operationId and operationRef are mutually exclusive, only one of them is allowed") diff --git a/openapi3/schemas.py b/openapi3/schemas.py index 323cc38..30b5cb4 100644 --- a/openapi3/schemas.py +++ b/openapi3/schemas.py @@ -1,9 +1,9 @@ +import types from typing import Union, List, Any, Optional, Dict -import dataclasses + from pydantic import Field, root_validator, Extra, BaseModel -from .errors import SpecError from .general import Reference # need this for Model below from .object_base import ObjectBase @@ -73,7 +73,7 @@ class Config: extra = Extra.forbid @root_validator - def check_number_type(cls, values): + def validate_Schema_number_type(cls, values): conv = ["minimum","maximum"] if values.get("type", None) == "integer": for i in conv: @@ -142,7 +142,7 @@ def annotationsof(schema): annos = annotationsof(self) namespace['__annotations__'] = annos - import types + self._model_type = types.new_class(type_name, (BaseModel, ), {}, lambda ns: ns.update(namespace)) return self._model_type From aea94b42af7c7e6f57795ef16077d46b16c0fbcb Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 27 Dec 2021 21:51:39 +0100 Subject: [PATCH 015/125] pydantic - cleanups --- openapi3/paths.py | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/openapi3/paths.py b/openapi3/paths.py index 483b4e5..e35847f 100644 --- a/openapi3/paths.py +++ b/openapi3/paths.py @@ -142,30 +142,6 @@ class Operation(ObjectBase): class Config: underscore_attrs_are_private = True - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - super()._parse_data() - # callbacks: dict TODO - - # gather all operations into the spec object - if self.operationId is not None: - # TODO - how to store without an operationId? - formatted_operation_id = self.operationId.replace(" ", "_") - self._root._register_operation(formatted_operation_id, self) - - # Store session object - -# def _resolve_references(self, root): -# """ -# Overloaded _resolve_references to allow us to verify parameters after -# we've got all references settled. -# """ -# super(self.__class__, self)._resolve_references() -# -# # this will raise if parameters are invalid -# _validate_parameters(self) def _request_handle_secschemes(self, security_requirement, value): ss = self._root.components.securitySchemes[security_requirement.name] From 901da191e90926fa0051bbdcab49e2574480912c Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 28 Dec 2021 12:38:29 +0100 Subject: [PATCH 016/125] pydantic - ObjectBase is not extended, ObjectExtended is - required as __root__ can not have additional fields (extensions) in Reference --- openapi3/components.py | 4 +- openapi3/example.py | 5 +- openapi3/general.py | 4 +- openapi3/info.py | 41 +++-- openapi3/object_base.py | 14 +- openapi3/openapi.py | 4 +- openapi3/paths.py | 342 +++++++++++++++++++--------------------- openapi3/schemas.py | 4 +- openapi3/security.py | 32 ++-- openapi3/servers.py | 28 ++-- openapi3/tag.py | 4 +- 11 files changed, 239 insertions(+), 243 deletions(-) diff --git a/openapi3/components.py b/openapi3/components.py index 7302810..bab3120 100644 --- a/openapi3/components.py +++ b/openapi3/components.py @@ -3,14 +3,14 @@ from pydantic import Field -from .object_base import ObjectBase +from .object_base import ObjectExtended from .example import Example from .paths import Reference, RequestBody, Link, Parameter, Response from .schemas import Schema from .security import SecurityScheme -class Components(ObjectBase): +class Components(ObjectExtended): """ A `Components Object`_ holds a reusable set of different aspects of the OAS spec. diff --git a/openapi3/example.py b/openapi3/example.py index 28ac3af..538a96b 100644 --- a/openapi3/example.py +++ b/openapi3/example.py @@ -2,11 +2,10 @@ from typing import Union, Optional from pydantic import Field +from .object_base import ObjectExtended -from .object_base import ObjectBase - -class Example(ObjectBase): +class Example(ObjectExtended): """ A `Example Object`_ holds a reusable set of different aspects of the OAS spec. diff --git a/openapi3/general.py b/openapi3/general.py index 5f5650b..95919bc 100644 --- a/openapi3/general.py +++ b/openapi3/general.py @@ -3,10 +3,10 @@ from pydantic import Field, root_validator -from .object_base import ObjectBase +from .object_base import ObjectExtended, ObjectBase -class ExternalDocumentation(ObjectBase): +class ExternalDocumentation(ObjectExtended): """ An `External Documentation Object`_ references external resources for extended documentation. diff --git a/openapi3/info.py b/openapi3/info.py index 293781c..431e6fd 100644 --- a/openapi3/info.py +++ b/openapi3/info.py @@ -3,27 +3,10 @@ from pydantic import Field -from .object_base import ObjectBase +from .object_base import ObjectBase, ObjectExtended -class Info(ObjectBase): - """ - An OpenAPI Info object, as defined in `the spec`_. - - .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#infoObject - """ - - title: str = Field(default=None) - version: str = Field(default=None) - - contact: Optional[ForwardRef('Contact')] = Field(default=None) - description: Optional[str] = Field(default=None) - license: Optional[ForwardRef('License')] = Field(default=None) - termsOfService: Optional[str] = Field(default=None) - - - -class Contact(ObjectBase): +class Contact(ObjectExtended): """ Contact object belonging to an Info object, as described `here`_ @@ -34,7 +17,8 @@ class Contact(ObjectBase): name: str = Field(default=None) url: str = Field(default=None) -class License(ObjectBase): + +class License(ObjectExtended): """ License object belonging to an Info object, as described `here`_ @@ -44,4 +28,19 @@ class License(ObjectBase): name: str = Field(default=None) url: Optional[str] = Field(default=None) -Info.update_forward_refs() + +class Info(ObjectExtended): + """ + An OpenAPI Info object, as defined in `the spec`_. + + .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#infoObject + """ + + title: str = Field(default=None) + version: str = Field(default=None) + + contact: Optional[Contact] = Field(default=None) + description: Optional[str] = Field(default=None) + license: Optional[License] = Field(default=None) + termsOfService: Optional[str] = Field(default=None) + diff --git a/openapi3/object_base.py b/openapi3/object_base.py index 44162d8..da180a3 100644 --- a/openapi3/object_base.py +++ b/openapi3/object_base.py @@ -1,7 +1,7 @@ from typing import List, Optional -from pydantic import BaseModel, Field, root_validator +from pydantic import BaseModel, Field, root_validator, Extra class ObjectBase(BaseModel): """ @@ -9,7 +9,7 @@ class ObjectBase(BaseModel): related functions. """ - extensions: Optional[object] = Field(default=None) + _strict: bool _path: List[str] @@ -20,17 +20,21 @@ class ObjectBase(BaseModel): class Config: underscore_attrs_are_private = True arbitrary_types_allowed = True + extra = Extra.forbid +class ObjectExtended(ObjectBase): + extensions: Optional[object] = Field(default=None) + @root_validator(pre=True) - def validate_ObjectBase_extensions(cls, values): + def validate_ObjectExtended_extensions(cls, values): """ FIXME https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.1.md#specificationExtensions :param values: :return: values """ e = dict() - for k,v in values.items(): + for k, v in values.items(): if k.startswith("x-"): e[k[2:]] = v if len(e): @@ -40,4 +44,4 @@ def validate_ObjectBase_extensions(cls, values): raise ValueError("extensions") values["extensions"] = e - return values \ No newline at end of file + return values diff --git a/openapi3/openapi.py b/openapi3/openapi.py index 9bc795b..3a14b88 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -7,7 +7,7 @@ import requests import yaml -from .object_base import ObjectBase +from .object_base import ObjectExtended, ObjectBase from .errors import ReferenceResolutionError, SpecError from .info import Info @@ -283,7 +283,7 @@ def _resolve_type(self, root, obj, value): -class OpenAPISpec(ObjectBase): +class OpenAPISpec(ObjectExtended): """ This class represents the root of the OpenAPI schema document, as defined in `the spec`_ diff --git a/openapi3/paths.py b/openapi3/paths.py index e35847f..6d1fa50 100644 --- a/openapi3/paths.py +++ b/openapi3/paths.py @@ -1,5 +1,5 @@ import dataclasses -from typing import ForwardRef, Union, List, Optional, Dict, Any +from typing import Union, List, Optional, Dict, Any import json import re @@ -12,7 +12,7 @@ from urllib import urlencode from .errors import SpecError -from .object_base import ObjectBase +from .object_base import ObjectBase, ObjectExtended from .info import Info #from .components import Components @@ -36,103 +36,180 @@ def _validate_parameters(op: "Operation", _path): raise SpecError('Parameter name not found in path: {}'.format(c.name), path=_path) -class Path(ObjectBase): +class ParameterBase(ObjectExtended): """ - A Path object, as defined `here`_. Path objects represent URL paths that - may be accessed by appending them to a Server + A `Parameter Object`_ defines a single operation parameter. - .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#paths-object + .. _Parameter Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#parameterObject """ - delete: Optional[ForwardRef('Operation')] = Field(default=None) + description: Optional[str] = Field(default=None) - get: Optional[ForwardRef('Operation')] = Field(default=None) - head: Optional[ForwardRef('Operation')] = Field(default=None) - options: Optional[ForwardRef('Operation')] = Field(default=None) + required: Optional[bool] = Field(default=None) + deprecated: Optional[bool] = Field(default=None) + allowEmptyValue: Optional[bool] = Field(default=None) - patch: Optional[ForwardRef('Operation')] = Field(default=None) - post: Optional[ForwardRef('Operation')] = Field(default=None) - put: Optional[ForwardRef('Operation')] = Field(default=None) - servers: Optional[List[Server]] = Field(default=None) - summary: Optional[str] = Field(default=None) - trace: Optional[ForwardRef('Operation')] = Field(default=None) + style: Optional[str] = Field(default=None) + explode: Optional[bool] = Field(default=None) + allowReserved: Optional[bool] = Field(default=None) + schema_: Optional[Union[Schema, Reference]] = Field(default=None, alias="schema") + example: Optional[str] = Field(default=None) + examples: Optional[Dict[str, Union['Example',Reference]]] = Field(default_factory=dict) - parameters: Optional[List[Union['Parameter', Reference]]] = Field(default_factory=list) + content: Optional[Dict[str, "MediaType"]] - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - # TODO - handle possible $ref - super()._parse_data() - if self.parameters is None: - # this will be iterated over later - self.parameters = [] + @root_validator + def validate_Parameter(cls, values): +# if values["in_"] == +# if self.in_ == "path" and self.required is not True: +# err_msg = 'Parameter {} must be required since it is in the path' +# raise SpecError(err_msg.format(self.get_path()), path=self._path) + return values - # def _resolve_references(self, root): - # """ - # Overloaded _resolve_references to allow us to verify parameters after - # we've got all references settled. - # """ - # super(self.__class__, self)._resolve_references(root) - # - # # this will raise if parameters are invalid - # _validate_parameters(self) +class Parameter(ParameterBase): + in_: str = Field(required=True, alias="in") # TODO must be one of ["query","header","path","cookie"] + name: str = Field(required=True) -class Parameter(ObjectBase): +class SecurityRequirement(BaseModel): """ - A `Parameter Object`_ defines a single operation parameter. + A `SecurityRequirement`_ object describes security schemes for API access. - .. _Parameter Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#parameterObject + .. _SecurityRequirement: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#securityRequirementObject """ + __root__: Dict[str, List[str]] - in_: str = Field(required=True, alias="in") # TODO must be one of ["query","header","path","cookie"] - name: str = Field(required=True) + @root_validator + def validate_SecurityRequirement(cls, values): + root = values.get("__root__", {}) + if not (len(root.keys()) == 1 and isinstance([c for c in root.values()][0], list) or len(root.keys()) == 0): + raise ValueError(root) + return values - deprecated: Optional[bool] = Field(default=None) - description: Optional[str] = Field(default=None) - example: Optional[str] = Field(default=None) - examples: Optional[Dict[str, Union['Example','Reference']]] = Field(default_factory=dict) + + @property + def name(self): + if len(self.__root__.keys()): + return list(self.__root__.keys())[0] + return None + + @property + def types(self): + if self.name: + return self.__root__[self.name] + return None + + def __getstate__(self): + return {self.name: self.types} + + +class Header(ParameterBase): + """ + + .. _HeaderObject: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#headerObject + """ + + +class Encoding(ObjectExtended): + """ + A single encoding definition applied to a single schema property. + + .. _Encoding: https://github.com/OAI/OpeI-Specification/blob/main/versions/3.1.0.md#encodingObject + """ + contentType: Optional[str] = Field(default=None) + headers: Optional[Dict[str, Union[Header, Reference]]] = Field(default_factory=dict) + style: Optional[str] = Field(default=None) explode: Optional[bool] = Field(default=None) + allowReserved: Optional[bool] = Field(default=None) + + +class MediaType(ObjectExtended): + """ + A `MediaType`_ object provides schema and examples for the media type identified + by its key. These are used in a RequestBody object. + + .. _MediaType: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#mediaTypeObject + """ + + schema_: Optional[Union[Schema, Reference]] = Field(required=True, alias="schema") + example: Optional[Any] = Field(default=None) # 'any' type + examples: Optional[Dict[str, Union[Example, Reference]]] = Field(default_factory=dict) + encoding: Optional[Dict[str, Encoding]] = Field(default_factory=dict) + + +class RequestBody(ObjectExtended): + """ + A `RequestBody`_ object describes a single request body. + + .. _RequestBody: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#requestBodyObject + """ + + content: Dict[str, MediaType] = Field(default_factory=dict) + description: Optional[str] = Field(default=None) required: Optional[bool] = Field(default=None) - schema_: Optional[Union['Schema', 'Reference']] = Field(default=None, alias="schema") - style: Optional[str] = Field(default=None) - # allow empty or reserved values in Parameter data - allowEmptyValue: Optional[bool] = Field(default=None) - allowReserved: Optional[bool] = Field(default=None) - @root_validator - def validate_Parameter(cls, values): -# if values["in_"] == -# if self.in_ == "path" and self.required is not True: -# err_msg = 'Parameter {} must be required since it is in the path' -# raise SpecError(err_msg.format(self.get_path()), path=self._path) +class Link(ObjectExtended): + """ + A `Link Object`_ describes a single Link from an API Operation Response to an API Operation Request + + .. _Link Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#linkObject + """ + + operationId: Optional[str] = Field(default=None) + operationRef: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + parameters: Optional[dict] = Field(default=None) + requestBody: Optional[dict] = Field(default=None) + server: Optional[Server] = Field(default=None) + + @root_validator(pre=False) + def validate_Link_operation(cls, values): + if values["operationId"] != None and values["operationRef"] != None: + raise SpecError("operationId and operationRef are mutually exclusive, only one of them is allowed") + + if values["operationId"] == values["operationRef"] == None: + raise SpecError("operationId and operationRef are mutually exclusive, one of them must be specified") + return values -from pydantic import validator -class Operation(ObjectBase): +class Response(ObjectExtended): + """ + A `Response Object`_ describes a single response from an API Operation, + including design-time, static links to operations based on the response. + + .. _Response Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#response-object + """ + + description: str = Field(required=True) + headers: Optional[Dict[str, Union[Header, Reference]]] = Field(default_factory=dict) + content: Optional[Dict[str, MediaType]] = Field(default_factory=dict) + links: Optional[Dict[str, Union[Link, Reference]]] = Field(default_factory=dict) + + +class Operation(ObjectExtended): """ An Operation object as defined `here`_ .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#operationObject """ - responses: Dict[str, Union['Response', 'Reference']] = Field(required=True) + responses: Dict[str, Union[Response, Reference]] = Field(required=True) deprecated: Optional[bool] = Field(default=None) description: Optional[str] = Field(default=None) - externalDocs: Optional[ForwardRef('ExternalDocumentation')] = Field(default=None) + externalDocs: Optional[ExternalDocumentation] = Field(default=None) operationId: Optional[str] = Field(default=None) - parameters: List[Union['Parameter', 'Reference']] = Field(default_factory=list) - requestBody: Optional[Union['RequestBody', 'Reference']] = Field(default=None) - security: Optional[List['SecurityRequirement']] = Field(default_factory=list) - servers: Optional[List['Server']] = Field(default=None) + parameters: List[Union[Parameter, Reference]] = Field(default_factory=list) + requestBody: Optional[Union[RequestBody, Reference]] = Field(default=None) + security: Optional[List[SecurityRequirement]] = Field(default_factory=list) + servers: Optional[List[Server]] = Field(default=None) summary: Optional[str] = Field(default=None) tags: Optional[List[str]] = Field(default=None) + callbacks: Optional[Dict[str, "Callback"]] = Field(default_factory=dict) + _root = object _path: str _method: str @@ -244,7 +321,7 @@ def request(self, base_url, security={}, data=None, parameters={}, verify=True, :type raw_response: bool """ # Set request method (e.g. 'GET') - self._request = requests.Request(self._method) + self._request = requests.Request(self._method, cookies={}) # Set self._request.url to base_url w/ path self._request.url = base_url + self._path @@ -329,129 +406,38 @@ def request(self, base_url, security={}, data=None, parameters={}, verify=True, raise NotImplementedError() - -class SecurityRequirement(BaseModel): - """ - A `SecurityRequirement`_ object describes security schemes for API access. - - .. _SecurityRequirement: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#securityRequirementObject - """ - __root__: Dict[str, List[str]] - - @root_validator - def validate_SecurityRequirement(cls, values): - root = values.get("__root__", {}) - if not (len(root.keys()) == 1 and isinstance([c for c in root.values()][0], list) or len(root.keys()) == 0): - raise ValueError(root) - return values - - - @property - def name(self): - if len(self.__root__.keys()): - return list(self.__root__.keys())[0] - return None - - @property - def types(self): - if self.name: - return self.__root__[self.name] - return None - - def __getstate__(self): - return {self.name: self.types} - - - -class RequestBody(ObjectBase): +class Path(ObjectExtended): """ - A `RequestBody`_ object describes a single request body. + A Path object, as defined `here`_. Path objects represent URL paths that + may be accessed by appending them to a Server - .. _RequestBody: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#requestBodyObject + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#paths-object """ - - content: Dict[str, ForwardRef('MediaType')] = Field(default_factory=dict) + ref: Optional[str] = Field(default=None, alias="$ref") + summary: Optional[str] = Field(default=None) description: Optional[str] = Field(default=None) - required: Optional[bool] = Field(default=None) - -class Header(ObjectBase): - """ - - .. _HeaderObject: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#headerObject - """ - deprecated: Optional[bool] = Field(default=None) - - -class Encoding(ObjectBase): - """ - A single encoding definition applied to a single schema property. - - .. _Encoding: https://github.com/OAI/OpeI-Specification/blob/main/versions/3.1.0.md#encodingObject - """ - contentType: Optional[str] = Field(default=None) - headers: Optional[Dict[str, Union[Header, Reference]]] = Field(default_factory=dict) - style: Optional[str] = Field(default=None) - explode: Optional[bool] = Field(default=None) - allowReserved: Optional[bool] = Field(default=None) - - -class MediaType(ObjectBase): - """ - A `MediaType`_ object provides schema and examples for the media type identified - by its key. These are used in a RequestBody object. - - .. _MediaType: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#mediaTypeObject - """ - - schema_: Optional[Union['Schema', 'Reference']] = Field(required=True, alias="schema") - example: Optional[Any] = Field(default=None) # 'any' type - examples: Optional[Dict[str, Union['Example', 'Reference']]] = Field(default_factory=dict) - encoding: Optional[Dict[str, Encoding]] = Field(default_factory=dict) - - + get: Optional[Operation] = Field(default=None) + put: Optional[Operation] = Field(default=None) + post: Optional[Operation] = Field(default=None) + delete: Optional[Operation] = Field(default=None) + options: Optional[Operation] = Field(default=None) + head: Optional[Operation] = Field(default=None) + patch: Optional[Operation] = Field(default=None) + trace: Optional[Operation] = Field(default=None) + servers: Optional[List[Server]] = Field(default=None) + parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) -class Response(ObjectBase): - """ - A `Response Object`_ describes a single response from an API Operation, - including design-time, static links to operations based on the response. - .. _Response Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#response-object +class Callback(ObjectBase): """ + A map of possible out-of band callbacks related to the parent operation. - description: str = Field(required=True) - content: Optional[Dict[str, ForwardRef('MediaType')]] = Field(default_factory=dict) - links: Optional[Dict[str, Union['Link', 'Reference']]] = Field(default_factory=dict) - - -from pydantic import root_validator, validator + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.1.md#callbackObject -class Link(ObjectBase): + This object MAY be extended with Specification Extensions. """ - A `Link Object`_ describes a single Link from an API Operation Response to an API Operation Request - - .. _Link Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#linkObject - """ - - operationId: Optional[str] = Field(default=None) - operationRef: Optional[str] = Field(default=None) - description: Optional[str] = Field(default=None) - parameters: Optional[dict] = Field(default=None) - requestBody: Optional[dict] = Field(default=None) - server: Optional[ForwardRef('Server')] = Field(default=None) - - @root_validator(pre=False) - def validate_Link_operation(cls, values): - if values["operationId"] != None and values["operationRef"] != None: - raise SpecError("operationId and operationRef are mutually exclusive, only one of them is allowed") - - if values["operationId"] == values["operationRef"] == None: - raise SpecError("operationId and operationRef are mutually exclusive, one of them must be specified") - - return values - + __root__: Dict[str, Union[str, Path]] -Path.update_forward_refs() Operation.update_forward_refs() -MediaType.update_forward_refs() -RequestBody.update_forward_refs() -Response.update_forward_refs() \ No newline at end of file +Parameter.update_forward_refs() +Header.update_forward_refs() \ No newline at end of file diff --git a/openapi3/schemas.py b/openapi3/schemas.py index 30b5cb4..2d1e3b1 100644 --- a/openapi3/schemas.py +++ b/openapi3/schemas.py @@ -5,7 +5,7 @@ from pydantic import Field, root_validator, Extra, BaseModel from .general import Reference # need this for Model below -from .object_base import ObjectBase +from .object_base import ObjectExtended TYPE_LOOKUP = { 'array': list, @@ -17,7 +17,7 @@ -class Schema(ObjectBase): +class Schema(ObjectExtended): """ The `Schema Object`_ allows the definition of input and output data types. diff --git a/openapi3/security.py b/openapi3/security.py index d0998c0..729adc0 100644 --- a/openapi3/security.py +++ b/openapi3/security.py @@ -1,23 +1,35 @@ -import dataclasses from typing import Optional, Dict -from pydantic import Field, BaseModel +from pydantic import Field -from .object_base import ObjectBase +from .object_base import ObjectExtended -class OAuthFlowObject(ObjectBase): + +class OAuthFlow(ObjectExtended): + """ + Configuration details for a supported OAuth Flow + + .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.1.md#oauth-flow-object + """ authorizationUrl: Optional[str] = Field(default=None) tokenUrl: Optional[str] = Field(default=None) refreshUrl: Optional[str] = Field(default=None) scopes: Dict[str, str] = Field(default_factory=dict) -class OAuthFlows(ObjectBase): - implicit: Optional[OAuthFlowObject] = Field(default=None) - password: Optional[OAuthFlowObject] = Field(default=None) - clientCredentials: Optional[OAuthFlowObject] = Field(default=None) - authorizationCode: Optional[OAuthFlowObject] = Field(default=None) -class SecurityScheme(ObjectBase): +class OAuthFlows(ObjectExtended): + """ + Allows configuration of the supported OAuth Flows. + + .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.1.md#oauth-flows-object + """ + implicit: Optional[OAuthFlow] = Field(default=None) + password: Optional[OAuthFlow] = Field(default=None) + clientCredentials: Optional[OAuthFlow] = Field(default=None) + authorizationCode: Optional[OAuthFlow] = Field(default=None) + + +class SecurityScheme(ObjectExtended): """ A `Security Scheme`_ defines a security scheme that can be used by the operations. diff --git a/openapi3/servers.py b/openapi3/servers.py index 28cd000..22cc27e 100644 --- a/openapi3/servers.py +++ b/openapi3/servers.py @@ -1,35 +1,31 @@ import dataclasses -from typing import List, Optional, ForwardRef, Dict +from typing import List, Optional, Dict from pydantic import Field -from .object_base import ObjectBase +from .object_base import ObjectBase, ObjectExtended - -class Server(ObjectBase): +class ServerVariable(ObjectExtended): """ - The Server object, as described `here`_ + A ServerVariable object as defined `here`_. - .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#serverObject + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#server-variable-object """ - url: str = Field(default=None) + default: str = Field(default=None) description: Optional[str] = Field(default=None) - variables: Optional[Dict[str, ForwardRef('ServerVariable')]] = Field(default_factory=dict) - + enum: Optional[List[str]] = Field(default=None) -class ServerVariable(ObjectBase): +class Server(ObjectExtended): """ - A ServerVariable object as defined `here`_. + The Server object, as described `here`_ - .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#server-variable-object + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#serverObject """ - default: str = Field(default=None) + url: str = Field(default=None) description: Optional[str] = Field(default=None) - enum: Optional[List[str]] = Field(default=None) - + variables: Optional[Dict[str, ServerVariable]] = Field(default_factory=dict) -Server.update_forward_refs() \ No newline at end of file diff --git a/openapi3/tag.py b/openapi3/tag.py index dfa1f38..2c33692 100644 --- a/openapi3/tag.py +++ b/openapi3/tag.py @@ -3,10 +3,10 @@ from pydantic import Field -from .object_base import ObjectBase +from .object_base import ObjectExtended -class Tag(ObjectBase): +class Tag(ObjectExtended): """ A `Tag Object`_ holds a reusable set of different aspects of the OAS spec. From 2b8f23230bde4792e9f1fbf1b4ac53d0309b6fa8 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 28 Dec 2021 12:41:05 +0100 Subject: [PATCH 017/125] pydantic objects - avoid ForwardRef --- openapi3/components.py | 20 ++++++++++---------- openapi3/example.py | 3 ++- openapi3/schemas.py | 22 +++++++++++++++------- 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/openapi3/components.py b/openapi3/components.py index bab3120..7eb08af 100644 --- a/openapi3/components.py +++ b/openapi3/components.py @@ -6,7 +6,7 @@ from .object_base import ObjectExtended from .example import Example -from .paths import Reference, RequestBody, Link, Parameter, Response +from .paths import Reference, RequestBody, Link, Parameter, Response, Callback, Header from .schemas import Schema from .security import SecurityScheme @@ -18,14 +18,14 @@ class Components(ObjectExtended): .. _Components Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#componentsObject """ - examples: Optional[Dict[str, Union['Example', 'Reference']]] = Field(default_factory=dict) - parameters: Optional[Dict[str, Union['Parameter', 'Reference']]] = Field(default_factory=dict) - requestBodies: Optional[Dict[str, Union['RequestBody', 'Reference']]] = Field(default_factory=dict) - responses: Optional[Dict[str, Union['Response', 'Reference']]] = Field(default_factory=dict) - schemas: Optional[Dict[str, Union['Schema', 'Reference']]] = Field(default_factory=dict) - securitySchemes: Optional[Dict[str, Union['SecurityScheme', 'Reference']]] = Field(default_factory=dict) - # headers: ['Header', 'Reference'], is_map=True - links: Optional[Dict[str, Union['Link', 'Reference']]] = Field(default_factory=dict) - # callbacks: ['Callback', 'Reference'], is_map=True + examples: Optional[Dict[str, Union[Example, Reference]]] = Field(default_factory=dict) + parameters: Optional[Dict[str, Union[Parameter, Reference]]] = Field(default_factory=dict) + requestBodies: Optional[Dict[str, Union[RequestBody, Reference]]] = Field(default_factory=dict) + responses: Optional[Dict[str, Union[Response, Reference]]] = Field(default_factory=dict) + schemas: Optional[Dict[str, Union[Schema, Reference]]] = Field(default_factory=dict) + securitySchemes: Optional[Dict[str, Union[SecurityScheme, Reference]]] = Field(default_factory=dict) + headers: Optional[Dict[str, Union[Header, Reference]]] + links: Optional[Dict[str, Union[Link, Reference]]] = Field(default_factory=dict) + callbacks: Optional[Dict[str, Union[Callback, Reference]]] = Field(default_factory=dict) Components.update_forward_refs() \ No newline at end of file diff --git a/openapi3/example.py b/openapi3/example.py index 538a96b..5798ae0 100644 --- a/openapi3/example.py +++ b/openapi3/example.py @@ -2,6 +2,7 @@ from typing import Union, Optional from pydantic import Field +from .general import Reference from .object_base import ObjectExtended @@ -15,5 +16,5 @@ class Example(ObjectExtended): summary: Optional[str] = Field(default=None) description: Optional[str] = Field(default=None) - value: Optional[Union['Reference', dict, str]] = Field(default=None) # 'any' type + value: Optional[Union[Reference, dict, str]] = Field(default=None) # 'any' type externalValue: Optional[str] = Field(default=None) diff --git a/openapi3/schemas.py b/openapi3/schemas.py index 2d1e3b1..8c9799f 100644 --- a/openapi3/schemas.py +++ b/openapi3/schemas.py @@ -16,6 +16,14 @@ } +class Discriminator(ObjectExtended): + """ + + .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.1.md#discriminator-object + """ + propertyName: str = Field(required=True) + mapping: Optional[Dict[str, str]] = Field(default_factory=dict) + class Schema(ObjectExtended): """ @@ -42,18 +50,18 @@ class Schema(ObjectExtended): enum: Optional[list] = Field(default=None) type: Optional[str] = Field(default=None) - allOf: Optional[List[Union["Schema", "Reference"]]] = Field(default_factory=list) + allOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) oneOf: Optional[list] = Field(default=None) - anyOf: Optional[List[Union["Schema", "Reference"]]] = Field(default=None) - not_: Optional[Union["Schema", "Reference"]] = Field(default=None, alias="not") - items: Optional[Union['Schema', 'Reference']] = Field(default=None) - properties: Optional[Dict[str, Union['Schema', 'Reference']]] = Field(default_factory=dict) - additionalProperties: Optional[Union[bool, 'Schema', 'Reference']] = Field(default=None) + anyOf: Optional[List[Union["Schema", Reference]]] = Field(default=None) + not_: Optional[Union["Schema", Reference]] = Field(default=None, alias="not") + items: Optional[Union['Schema', Reference]] = Field(default=None) + properties: Optional[Dict[str, Union['Schema', Reference]]] = Field(default_factory=dict) + additionalProperties: Optional[Union[bool, 'Schema', Reference]] = Field(default=None) description: Optional[str] = Field(default=None) format: Optional[str] = Field(default=None) default: Optional[str] = Field(default=None) # TODO - str as a default? nullable: Optional[bool] = Field(default=None) - discriminator: Optional[dict[str, Union[str, dict]]] = Field(default=None) # 'Discriminator' + discriminator: Optional[Discriminator] = Field(default=None) # 'Discriminator' readOnly: Optional[bool] = Field(default=None) writeOnly: Optional[bool] = Field(default=None) xml: Optional[dict] = Field(default=None) # 'XML' From 82372f94990706e78b89b86e064e1b2bf0be70ff Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 28 Dec 2021 12:43:50 +0100 Subject: [PATCH 018/125] rename resolve_type -> resolve_jr / resolve_path -> resolve_jp --- openapi3/general.py | 24 ++++++++++++++++++++++-- openapi3/openapi.py | 42 +++++++++++++++++++++++------------------- setup.py | 11 ++++++----- 3 files changed, 51 insertions(+), 26 deletions(-) diff --git a/openapi3/general.py b/openapi3/general.py index 95919bc..cc2dd14 100644 --- a/openapi3/general.py +++ b/openapi3/general.py @@ -1,7 +1,8 @@ -import dataclasses +import urllib.parse from typing import Optional -from pydantic import Field, root_validator +from pydantic import Field, Extra +from yarl import URL from .object_base import ObjectExtended, ObjectBase @@ -19,6 +20,23 @@ class ExternalDocumentation(ObjectExtended): description: Optional[str] = Field(default=None) +class JSONPointer: + @staticmethod + def decode(part): + """ + + https://swagger.io/docs/specification/using-ref/ + :param part: + """ + part = urllib.parse.unquote(part) + part = part.replace('~1', '/') + return part.replace('~0', '~') + +class JSONReference: + @staticmethod + def split(url): + u = URL(url) + return str(u.with_fragment("")), u.raw_fragment class Reference(ObjectBase): """ @@ -28,3 +46,5 @@ class Reference(ObjectBase): """ ref: str = Field(alias="$ref") + class Config: + extra = Extra.ignore diff --git a/openapi3/openapi.py b/openapi3/openapi.py index 3a14b88..f798e54 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -1,6 +1,7 @@ import json import pathlib import datetime +import urllib.parse from typing import ForwardRef, Any, List, Optional, Dict from pydantic import Field, ValidationError @@ -13,7 +14,7 @@ from .info import Info from .paths import Path, SecurityRequirement, _validate_parameters from .components import Components -from .general import Reference +from .general import Reference, JSONPointer, JSONReference from .servers import Server from .tag import Tag @@ -263,19 +264,16 @@ def _load(self, i): - def _resolve_type(self, root, obj, value): - # we found a reference - attempt to resolve it - - filename,reference_path = value.ref.split("#/", maxsplit=1) - if filename != '': - filename = pathlib.Path(filename) - if filename not in self._cached: - self._cached[filename] = self._load(filename) - root = self._cached[filename] -# return self._resolve_type(child, obj, value) + def resolve_jr(self, root: "OpenAPISpec", obj, value: Reference): + url,jp = JSONReference.split(value.ref) + if url != '': + url = pathlib.Path(url) + if url not in self._cached: + self._cached[url] = self._load(url) + root = self._cached[url] try: - return root.resolve_path(reference_path.split('/')) + return root.resolve_jp(jp) except ReferenceResolutionError as e: # add metadata to the error e.element = obj @@ -321,8 +319,12 @@ def resolve(obj): value = getattr(obj, slot) if value is None: continue - elif isinstance(value, reference_type): - resolved_value = api._resolve_type(root, obj, value) + + if isinstance(obj, Path) and slot == "ref": + resolved_value = api.resolve_jr(root, obj, Reference.construct(ref=value)) + setattr(obj, slot, resolved_value) + if isinstance(value, reference_type): + resolved_value = api.resolve_jr(root, obj, value) setattr(obj, slot, resolved_value) elif issubclass(type(value), ObjectBase): # otherwise, continue resolving down the tree @@ -334,7 +336,7 @@ def resolve(obj): resolved_list = [] for item in value: if isinstance(item, reference_type): - resolved_value = api._resolve_type(root, obj, item) + resolved_value = api.resolve_jr(root, obj, item) resolved_list.append(resolved_value) elif isinstance(item, (ObjectBase, dict, list)): resolve(item) @@ -350,27 +352,29 @@ def resolve(obj): for k, v in obj.items(): if isinstance(v, reference_type): if v.ref: - obj[k] = api._resolve_type(root, obj, v) + obj[k] = api.resolve_jr(root, obj, v) elif isinstance(v, (ObjectBase, dict, list)): resolve(v) resolve(self) - def resolve_path(self, path): + def resolve_jp(self, jp): """ Given a $ref path, follows the document tree and returns the given attribute. - :param path: The path down the spec tree to follow - :type path: List[str] + :param jp: The path down the spec tree to follow + :type jp: str #/foo/bar :returns: The node requested :rtype: ObjectBase :raises ValueError: if the given path is not valid """ + path = jp.split("/")[1:] node = self for part in path: + part = JSONPointer.decode(part) if isinstance(node, dict): if part not in node: # pylint: disable=unsupported-membership-test err_msg = 'Invalid path {} in Reference'.format(path) diff --git a/setup.py b/setup.py index b92c558..d4019be 100755 --- a/setup.py +++ b/setup.py @@ -2,25 +2,26 @@ from io import open from setuptools import setup from os import path +import subprocess here = path.abspath(path.dirname(__file__)) # get the long description from the README.rst -with open(path.join(here, "README.rst"), encoding="utf-8") as f: +with open(path.join(here, 'README.rst'), encoding='utf-8') as f: long_description = f.read() setup( name="openapi3", - version="1.6.2", + version='1.5.0', description="Client and Validator of OpenAPI 3 Specifications", long_description=long_description, author="dorthu", url="https://github.com/dorthu/openapi3", - packages=["openapi3"], + packages=['openapi3'], license="BSD 3-Clause License", - install_requires=["PyYaml", "requests"], - tests_require=["pytest", "pytest-asyncio", "uvloop", "hypercorn", "pydantic", "fastapi"], + install_requires=["PyYaml", "requests", "pydantic", "yarl"], + tests_require=["pytest"], ) From 776cab2a5579b8a8ff64f1f6d616780dd2ca53df Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 28 Dec 2021 12:45:08 +0100 Subject: [PATCH 019/125] tests - with parameters & callback --- tests/conftest.py | 37 ++++++----------- tests/fixtures/callback-example.yaml | 61 +++++++++++++++++++++++++++ tests/fixtures/with-parameters.yaml | 62 ++++++++++++++++++++++++++++ tests/parsing_test.py | 6 +++ tests/path_test.py | 23 ++++++++--- 5 files changed, 159 insertions(+), 30 deletions(-) create mode 100644 tests/fixtures/callback-example.yaml create mode 100644 tests/fixtures/with-parameters.yaml diff --git a/tests/conftest.py b/tests/conftest.py index 1db9424..e66f716 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,8 +15,8 @@ def _get_parsed_yaml(filename): include extension. :type filename: str """ - if filename not in LOADED_FILES: - with open("tests/fixtures/" + filename) as f: + if filename not in LOADED_FILES: + with open("tests/fixtures/"+filename) as f: raw = f.read() parsed = safe_load(raw) @@ -33,14 +33,14 @@ def _get_parsed_spec(filename): include extension. :type filename: str """ - if "spec:" + filename not in LOADED_FILES: + if "spec:"+filename not in LOADED_FILES: parsed = _get_parsed_yaml(filename) spec = OpenAPI(parsed) - LOADED_FILES["spec:" + filename] = spec + LOADED_FILES["spec:"+filename] = spec - return LOADED_FILES["spec:" + filename] + return LOADED_FILES["spec:"+filename] @pytest.fixture @@ -105,7 +105,7 @@ def obj_example_expanded(): """ yield _get_parsed_yaml("obj-example.yaml") - + @pytest.fixture def float_validation_expanded(): """ @@ -137,16 +137,6 @@ def with_broken_links(): """ yield _get_parsed_yaml("with-broken-links.yaml") - -@pytest.fixture -def with_param_types(): - """ - Provides a spec with multiple parameter types and typed examples - """ - # JSON file to allow specific typing of bool example (bool is a subclass of int in Python) - yield _get_parsed_yaml("parameter-types.json") - - @pytest.fixture def with_securityparameters(): """ @@ -154,19 +144,16 @@ def with_securityparameters(): """ yield _get_parsed_yaml("with-securityparameters.yaml") - @pytest.fixture -def with_nested_allof_ref(): +def with_parameters(): """ - Provides a spec with a $ref under a schema defined in an allOf + Provides a spec with parameters """ - yield _get_parsed_yaml("nested-allOf.yaml") - + yield _get_parsed_yaml("with-parameters.yaml") @pytest.fixture -def with_ref_allof(): +def with_callback(): """ - Provides a spec that includes a reference to a component schema in and out of - an allOf + Provides a spec with callback """ - yield _get_parsed_yaml("ref-allof.yaml") + yield _get_parsed_yaml("callback-example.yaml") diff --git a/tests/fixtures/callback-example.yaml b/tests/fixtures/callback-example.yaml new file mode 100644 index 0000000..262b8df --- /dev/null +++ b/tests/fixtures/callback-example.yaml @@ -0,0 +1,61 @@ +openapi: 3.0.0 +info: + title: Callback Example + version: 1.0.0 +paths: + /streams: + post: + description: subscribes a client to receive out-of-band data + parameters: + - name: callbackUrl + in: query + required: true + description: | + the location where data will be sent. Must be network accessible + by the source server + schema: + type: string + format: uri + example: https://tonys-server.com + responses: + '201': + description: subscription successfully created + content: + application/json: + schema: + description: subscription information + required: + - subscriptionId + properties: + subscriptionId: + description: this unique identifier allows management of the subscription + type: string + example: 2531329f-fb09-4ef7-887e-84e648214436 + callbacks: + # the name `onData` is a convenience locator + onData: + # when data is sent, it will be sent to the `callbackUrl` provided + # when making the subscription PLUS the suffix `/data` + '{$request.query.callbackUrl}/data': + post: + requestBody: + description: subscription payload + content: + application/json: + schema: + type: object + properties: + timestamp: + type: string + format: date-time + userData: + type: string + responses: + '202': + description: | + Your server implementation should return this HTTP status code + if the data was received successfully + '204': + description: | + Your server should return this HTTP status code if no longer interested + in further updates diff --git a/tests/fixtures/with-parameters.yaml b/tests/fixtures/with-parameters.yaml new file mode 100644 index 0000000..61bef22 --- /dev/null +++ b/tests/fixtures/with-parameters.yaml @@ -0,0 +1,62 @@ +openapi: 3.0.3 +info: + title: '' + version: 0.0.0 +servers: + - url: http://127.0.0.1/api + +security: + - {} + +paths: + /test/{Path}: + get: + operationId: getTest + parameters: + - $ref: "#/components/parameters/Cookie" + - name: Query + in: query + description: "" + required: True + schema: + type: string + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Test' + description: '' + parameters: + - $ref: "#/components/parameters/Path" + - name: Header + in: header + description: "" + required: True + schema: + type: array + items: + type: integer + + +components: + schemas: + Test: + type: string + parameters: + Path: + name: Path + in: path + required: true + schema: + type: string + Cookie: + name: Cookie + in: cookie + required: True + schema: + type: string + + + + diff --git a/tests/parsing_test.py b/tests/parsing_test.py index daa8549..a34e16e 100644 --- a/tests/parsing_test.py +++ b/tests/parsing_test.py @@ -120,3 +120,9 @@ def test_securityparameters(with_securityparameters): errors = spec.errors() print(errors) assert len(errors) == 0 + +def test_callback(with_callback): + spec = OpenAPI(with_callback, validate=True) + errors = spec.errors() + print(errors) + assert len(errors) == 0 diff --git a/tests/path_test.py b/tests/path_test.py index 1188975..636ea3b 100644 --- a/tests/path_test.py +++ b/tests/path_test.py @@ -3,6 +3,7 @@ """ import base64 import uuid +import pathlib from unittest.mock import patch, MagicMock from urllib.parse import urlparse @@ -99,8 +100,6 @@ def test_operation_populated(petstore_expanded_spec): def test_securityparameters(with_securityparameters): api = OpenAPI(with_securityparameters) - r = patch("requests.sessions.Session.send") - auth=str(uuid.uuid4()) # global security @@ -123,7 +122,7 @@ def test_securityparameters(with_securityparameters): api.call_api_v1_auth_login_create(data={}, parameters={}) parsed_url = urlparse(r.call_args.args[0].url) - parsed_url.query == auth + assert parsed_url.query == f"auth={auth}" api.authenticate('cookieAuth', auth) resp = MagicMock(status_code=200, headers={"Content-Type":"application/json"}, json=lambda: []) @@ -152,5 +151,19 @@ def test_securityparameters(with_securityparameters): api.authenticate(None, None) resp = MagicMock(status_code=200, headers={"Content-Type":"application/json"}) with patch("requests.sessions.Session.send", return_value=resp) as r: - api.call_api_v1_auth_login_create(data={}, parameters={}) - api.call_api_v1_auth_login_create(data={}, parameters={}) + api.call_api_v1_auth_login_info(data={}, parameters={}) + +def test_parameters(with_parameters): + api = OpenAPI(with_parameters) + + with pytest.raises(ValueError, match="Required parameter \w+ not provided"): + api.call_getTest(data={}, parameters={}) + + resp = MagicMock(status_code=200, headers={"Content-Type":"application/json"}) + with patch("requests.sessions.Session.send", return_value=resp) as r: + Header = str([i ** i for i in range(3)]) + api.call_getTest(data={}, parameters={"Cookie":"Cookie", "Path":"Path", "Header":Header, "Query":"Query"}) + assert r.call_args.args[0].headers["Header"] == Header + assert r.call_args.args[0].headers["Cookie"] == "Cookie=Cookie" + assert pathlib.Path(urlparse(r.call_args.args[0].path_url).path).name == "Path" + assert urlparse(r.call_args.args[0].path_url).query == "Query=Query" From 507a830a4197fbe2c6d87dbb19187d26c003681e Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 28 Dec 2021 12:47:55 +0100 Subject: [PATCH 020/125] cleanup - unused imports --- openapi3/components.py | 5 ++--- openapi3/errors.py | 2 -- openapi3/example.py | 2 +- openapi3/info.py | 5 ++--- openapi3/openapi.py | 14 ++++++-------- openapi3/paths.py | 8 ++------ openapi3/servers.py | 3 +-- openapi3/tag.py | 1 - 8 files changed, 14 insertions(+), 26 deletions(-) diff --git a/openapi3/components.py b/openapi3/components.py index 7eb08af..6c052cc 100644 --- a/openapi3/components.py +++ b/openapi3/components.py @@ -1,15 +1,14 @@ -import dataclasses from typing import Union, Optional, Dict from pydantic import Field -from .object_base import ObjectExtended - from .example import Example +from .object_base import ObjectExtended from .paths import Reference, RequestBody, Link, Parameter, Response, Callback, Header from .schemas import Schema from .security import SecurityScheme + class Components(ObjectExtended): """ A `Components Object`_ holds a reusable set of different aspects of the OAS diff --git a/openapi3/errors.py b/openapi3/errors.py index bf977ef..3edbbab 100644 --- a/openapi3/errors.py +++ b/openapi3/errors.py @@ -1,5 +1,3 @@ -from pydantic import ValidationError - class SpecError(ValueError): """ This error class is used when an invalid format is found while parsing an diff --git a/openapi3/example.py b/openapi3/example.py index 5798ae0..bbec94b 100644 --- a/openapi3/example.py +++ b/openapi3/example.py @@ -1,7 +1,7 @@ -import dataclasses from typing import Union, Optional from pydantic import Field + from .general import Reference from .object_base import ObjectExtended diff --git a/openapi3/info.py b/openapi3/info.py index 431e6fd..3c27553 100644 --- a/openapi3/info.py +++ b/openapi3/info.py @@ -1,9 +1,8 @@ -import dataclasses -from typing import ForwardRef, Optional +from typing import Optional from pydantic import Field -from .object_base import ObjectBase, ObjectExtended +from .object_base import ObjectExtended class Contact(ObjectExtended): diff --git a/openapi3/openapi.py b/openapi3/openapi.py index f798e54..f0b4437 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -1,20 +1,18 @@ +import datetime import json import pathlib -import datetime -import urllib.parse -from typing import ForwardRef, Any, List, Optional, Dict +from typing import Any, List, Optional, Dict -from pydantic import Field, ValidationError import requests import yaml +from pydantic import Field -from .object_base import ObjectExtended, ObjectBase +from .components import Components from .errors import ReferenceResolutionError, SpecError - +from .general import Reference, JSONPointer, JSONReference from .info import Info +from .object_base import ObjectExtended, ObjectBase from .paths import Path, SecurityRequirement, _validate_parameters -from .components import Components -from .general import Reference, JSONPointer, JSONReference from .servers import Server from .tag import Tag diff --git a/openapi3/paths.py b/openapi3/paths.py index 6d1fa50..35a21be 100644 --- a/openapi3/paths.py +++ b/openapi3/paths.py @@ -1,10 +1,9 @@ -import dataclasses -from typing import Union, List, Optional, Dict, Any import json import re +from typing import Union, List, Optional, Dict, Any -from pydantic import Field, BaseModel, root_validator import requests +from pydantic import Field, BaseModel, root_validator try: from urllib.parse import urlencode @@ -14,10 +13,7 @@ from .errors import SpecError from .object_base import ObjectBase, ObjectExtended -from .info import Info -#from .components import Components from .servers import Server -from .tag import Tag from .general import Reference from .general import ExternalDocumentation from .schemas import Schema diff --git a/openapi3/servers.py b/openapi3/servers.py index 22cc27e..1c24dbc 100644 --- a/openapi3/servers.py +++ b/openapi3/servers.py @@ -1,9 +1,8 @@ -import dataclasses from typing import List, Optional, Dict from pydantic import Field -from .object_base import ObjectBase, ObjectExtended +from .object_base import ObjectExtended class ServerVariable(ObjectExtended): diff --git a/openapi3/tag.py b/openapi3/tag.py index 2c33692..4429e01 100644 --- a/openapi3/tag.py +++ b/openapi3/tag.py @@ -1,4 +1,3 @@ -import dataclasses from typing import Optional from pydantic import Field From c46bae8f2c710a97251e5231a78f944863cbd564 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 28 Dec 2021 14:19:36 +0100 Subject: [PATCH 021/125] ObjectBase - cleanup private fields --- openapi3/general.py | 1 + openapi3/object_base.py | 14 +++----------- openapi3/openapi.py | 8 ++++---- openapi3/paths.py | 17 +++++++++++------ openapi3/schemas.py | 10 +++++----- 5 files changed, 24 insertions(+), 26 deletions(-) diff --git a/openapi3/general.py b/openapi3/general.py index cc2dd14..f3dbe52 100644 --- a/openapi3/general.py +++ b/openapi3/general.py @@ -47,4 +47,5 @@ class Reference(ObjectBase): ref: str = Field(alias="$ref") class Config: + """This object cannot be extended with additional properties and any properties added SHALL be ignored.""" extra = Extra.ignore diff --git a/openapi3/object_base.py b/openapi3/object_base.py index da180a3..444a971 100644 --- a/openapi3/object_base.py +++ b/openapi3/object_base.py @@ -1,25 +1,17 @@ -from typing import List, Optional - +from typing import Optional from pydantic import BaseModel, Field, root_validator, Extra + class ObjectBase(BaseModel): """ The base class for all schema objects. Includes helpers for common schema- related functions. """ - - - _strict: bool - _path: List[str] - _raw_element: dict - _root: object - _accessed_members: object - class Config: underscore_attrs_are_private = True - arbitrary_types_allowed = True + arbitrary_types_allowed = False extra = Extra.forbid diff --git a/openapi3/openapi.py b/openapi3/openapi.py index f0b4437..5d40067 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -122,8 +122,8 @@ def __init__( for path,obj in self.paths.items(): for m in obj.__fields_set__ & frozenset(["get","delete","head","post","put","patch","trace"]): op = getattr(obj, m) - op._path, op._method, op._root = path, m, self - _validate_parameters(op, ['x', path]) + op._path, op._method, op._spec = path, m, self + _validate_parameters(op, path) if op.operationId is None: continue formatted_operation_id = op.operationId.replace(" ", "_") @@ -387,8 +387,6 @@ def resolve_jp(self, jp): return node -OpenAPISpec.update_forward_refs() - class OperationCallable: """ This class is returned by instances of the OpenAPI class when members @@ -411,3 +409,5 @@ def __call__(self, *args, **kwargs): kwargs['session'] = self.session return self.operation(self.base_url, *args, security=self.security, **kwargs) + +OpenAPISpec.update_forward_refs() \ No newline at end of file diff --git a/openapi3/paths.py b/openapi3/paths.py index 35a21be..9b0cfc2 100644 --- a/openapi3/paths.py +++ b/openapi3/paths.py @@ -20,16 +20,17 @@ from .example import Example -def _validate_parameters(op: "Operation", _path): +def _validate_parameters(op: "Operation", path): """ Ensures that all parameters for this path are valid """ - allowed_path_parameters = re.findall(r'{([a-zA-Z0-9\-\._~]+)}', _path[1]) + assert isinstance(path, str) + allowed_path_parameters = re.findall(r'{([a-zA-Z0-9\-\._~]+)}', path) for c in op.parameters: if c.in_ == 'path': if c.name not in allowed_path_parameters: - raise SpecError('Parameter name not found in path: {}'.format(c.name), path=_path) + raise SpecError('Parameter name not found in path: {}'.format(c.name), path=path) class ParameterBase(ObjectExtended): @@ -206,7 +207,11 @@ class Operation(ObjectExtended): callbacks: Optional[Dict[str, "Callback"]] = Field(default_factory=dict) - _root = object + """ + The OpenAPISpec this is part of + """ + _spec: "OpenAPISpec" + _path: str _method: str _request: object @@ -217,7 +222,7 @@ class Config: def _request_handle_secschemes(self, security_requirement, value): - ss = self._root.components.securitySchemes[security_requirement.name] + ss = self._spec.components.securitySchemes[security_requirement.name] if ss.type == 'http' and ss.scheme_ == 'basic': self._request.auth = requests.auth.HTTPBasicAuth(*value) @@ -249,7 +254,7 @@ def _request_handle_parameters(self, parameters={}): # Parameters path_parameters = {} accepted_parameters = {} - p = self.parameters + self._root.paths[self._path].parameters + p = self.parameters + self._spec.paths[self._path].parameters for _ in list(p): # TODO - make this work with $refs - can operations be $refs? diff --git a/openapi3/schemas.py b/openapi3/schemas.py index 8c9799f..9b60f76 100644 --- a/openapi3/schemas.py +++ b/openapi3/schemas.py @@ -74,11 +74,11 @@ class Schema(ObjectExtended): _model_type: object _request_model_type: object - _resolved_allOfs: object - _path: str - class Config: - extra = Extra.forbid + """ + The _identity attribute is set during OpenAPI.__init__ and used at get_type() + """ + _identity: str @root_validator def validate_Schema_number_type(cls, values): @@ -133,7 +133,7 @@ def annotationsof(schema): annos[name] = r return annos - type_name = self.title or self._path + type_name = self.title or self._identity namespace = dict() annos = dict() if self.allOf: From faf8b1601d9749dffe5b0ba3650fb0bbd85aef20 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 28 Dec 2021 14:20:42 +0100 Subject: [PATCH 022/125] validation mode - error() return Exception/ValidationError --- openapi3/openapi.py | 84 ++++++++++++++++++++----------------------- tests/parsing_test.py | 9 ++--- 2 files changed, 40 insertions(+), 53 deletions(-) diff --git a/openapi3/openapi.py b/openapi3/openapi.py index 5d40067..45b018b 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -14,6 +14,7 @@ from .object_base import ObjectExtended, ObjectBase from .paths import Path, SecurityRequirement, _validate_parameters from .servers import Server +from .schemas import Schema from .tag import Tag @@ -100,51 +101,56 @@ def __init__( :type use_session: bool """ + self.loader = loader + self._validation_mode = validate - self._spec_errors = list() + self._spec_error = None self._operation_map = dict() self._security = None - self.loader = loader self._cached = dict() + self._ssl_verify = ssl_verify + + self._session = None + if use_session: + self._session = session_factory() + try: self._spec = OpenAPISpec.parse_obj(raw_document) except Exception as e: if not self._validation_mode: raise - self._spec_errors = e - else: - for name, schema in self.components.schemas.items(): - schema._path = name + self._spec_error = e + return + try: self._spec._resolve_references(self) + except ValueError as e: + if not self._validation_mode: + raise + self._spec_error = e + return - for path,obj in self.paths.items(): - for m in obj.__fields_set__ & frozenset(["get","delete","head","post","put","patch","trace"]): - op = getattr(obj, m) - op._path, op._method, op._spec = path, m, self - _validate_parameters(op, path) - if op.operationId is None: + for name, schema in self.components.schemas.items(): + schema._identity = name + + for path,obj in self.paths.items(): + for m in obj.__fields_set__ & frozenset(["get","delete","head","post","put","patch","trace"]): + op = getattr(obj, m) + op._path, op._method, op._spec = path, m, self + _validate_parameters(op, path) + if op.operationId is None: + continue + formatted_operation_id = op.operationId.replace(" ", "_") + self._register_operation(formatted_operation_id, op) + for r, response in op.responses.items(): + if isinstance(response, Reference): continue - formatted_operation_id = op.operationId.replace(" ", "_") - self._register_operation(formatted_operation_id, op) - for r, response in op.responses.items(): - if isinstance(response, Reference): + for c, content in response.content.items(): + if content.schema_ is None: continue - for c, content in response.content.items(): - if content.schema_ is None: - continue - content.schema_._path = f"{path}.{m}.{r}.{c}" - - - - - self._ssl_verify = ssl_verify - - self._session = None - if use_session: - self._session = session_factory() - + if isinstance(content.schema_, Schema): + content.schema_._identity = f"{path}.{m}.{r}.{c}" # public methods def authenticate(self, security_scheme, value): @@ -166,32 +172,18 @@ def authenticate(self, security_scheme, value): self._security = {security_scheme: value} - def log_spec_error(self, error): - """ - In Validation Mode, this method is used when parsing a spec to record an - error that was encountered, for later reporting. This should not be used - outside of Validation Mode. - - :param error: The error encountered. - :type error: SpecError - """ - if not self._validation_mode: - raise RuntimeError('This client is not in Validation Mode, cannot ' - 'record errors!') - self._spec_errors.append(error) - def errors(self): """ In Validation Mode, returns all errors encountered from parsing a spec. This should not be called if not in Validation Mode. :returns: The errors encountered during the parsing of this spec. - :rtype: List[SpecError] + :rtype: ValidationError """ if not self._validation_mode: raise RuntimeError('This client is not in Validation Mode, cannot ' 'return errors!') - return self._spec_errors + return self._spec_error # private methods diff --git a/tests/parsing_test.py b/tests/parsing_test.py index a34e16e..a288799 100644 --- a/tests/parsing_test.py +++ b/tests/parsing_test.py @@ -105,10 +105,7 @@ def test_parsing_broken_links(with_broken_links): spec = OpenAPI(with_broken_links, validate=True) errors = spec.errors() - - assert len(errors.args) == 2 error_strs = str(errors) - assert all([i in error_strs for i in [ "operationId and operationRef are mutually exclusive, only one of them is allowed", "operationId and operationRef are mutually exclusive, one of them must be specified", @@ -118,11 +115,9 @@ def test_parsing_broken_links(with_broken_links): def test_securityparameters(with_securityparameters): spec = OpenAPI(with_securityparameters, validate=True) errors = spec.errors() - print(errors) - assert len(errors) == 0 + assert errors is None def test_callback(with_callback): spec = OpenAPI(with_callback, validate=True) errors = spec.errors() - print(errors) - assert len(errors) == 0 + assert errors is None From ef14b655a2f8b56ac685a8d27a68b23260a28513 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 28 Dec 2021 15:33:48 +0100 Subject: [PATCH 023/125] errors - drop path attribute --- openapi3/errors.py | 10 ++-------- openapi3/openapi.py | 8 +++----- openapi3/paths.py | 16 +++------------- 3 files changed, 8 insertions(+), 26 deletions(-) diff --git a/openapi3/errors.py b/openapi3/errors.py index 3edbbab..c5d185c 100644 --- a/openapi3/errors.py +++ b/openapi3/errors.py @@ -3,11 +3,9 @@ class SpecError(ValueError): This error class is used when an invalid format is found while parsing an object in the spec. """ - - def __init__(self, message, path=None, element=None): - self.element = element + def __init__(self, message, element=None): self.message = message - self.path = path + self.element = element class ReferenceResolutionError(SpecError): @@ -15,7 +13,3 @@ class ReferenceResolutionError(SpecError): This error class is used when resolving a reference fails, usually because of a malformed path in the reference. """ - - -class ModelError(ValueError): - """The data supplied to the Model mismatches the models attributes""" diff --git a/openapi3/openapi.py b/openapi3/openapi.py index 45b018b..f68e56f 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -198,7 +198,7 @@ def _register_operation(self, operation_id, operation): :type operation: Operation """ if operation_id in self._operation_map: - raise SpecError("Duplicate operationId {}".format(operation_id), path=None) + raise SpecError(f"Duplicate operationId {operation_id}", element=operation) self._operation_map[operation_id] = operation def _get_callable(self, operation): @@ -367,13 +367,11 @@ def resolve_jp(self, jp): part = JSONPointer.decode(part) if isinstance(node, dict): if part not in node: # pylint: disable=unsupported-membership-test - err_msg = 'Invalid path {} in Reference'.format(path) - raise ReferenceResolutionError(err_msg) + raise ReferenceResolutionError(f'Invalid path {path} in Reference') node = node.get(part) else: if not hasattr(node, part): - err_msg = 'Invalid path {} in Reference'.format(path) - raise ReferenceResolutionError(err_msg) + raise ReferenceResolutionError(f'Invalid path {path} in Reference') node = getattr(node, part) return node diff --git a/openapi3/paths.py b/openapi3/paths.py index 9b0cfc2..765221f 100644 --- a/openapi3/paths.py +++ b/openapi3/paths.py @@ -5,11 +5,6 @@ import requests from pydantic import Field, BaseModel, root_validator -try: - from urllib.parse import urlencode -except ImportError: - from urllib import urlencode - from .errors import SpecError from .object_base import ObjectBase, ObjectExtended @@ -30,7 +25,7 @@ def _validate_parameters(op: "Operation", path): for c in op.parameters: if c.in_ == 'path': if c.name not in allowed_path_parameters: - raise SpecError('Parameter name not found in path: {}'.format(c.name), path=path) + raise SpecError('Parameter name not found in path: {}'.format(c.name)) class ParameterBase(ObjectExtended): @@ -55,7 +50,7 @@ class ParameterBase(ObjectExtended): content: Optional[Dict[str, "MediaType"]] @root_validator - def validate_Parameter(cls, values): + def validate_ParameterBase(cls, values): # if values["in_"] == # if self.in_ == "path" and self.required is not True: # err_msg = 'Parameter {} must be required since it is in the path' @@ -96,9 +91,6 @@ def types(self): return self.__root__[self.name] return None - def __getstate__(self): - return {self.name: self.types} - class Header(ParameterBase): """ @@ -340,9 +332,7 @@ def request(self, base_url, security={}, data=None, parameters={}, verify=True, self._request_handle_secschemes(r, value) if security_requirement is None: - err_msg = '''No security requirement satisfied (accepts {}) \ - '''.format(', '.join(self.security.keys())) - raise ValueError(err_msg) + raise ValueError(f"No security requirement satisfied (accepts {', '.join(self.security.keys()) })") if self.requestBody: if self.requestBody.required and data is None: From 3bfa8e18ef8ae356e4f127258ee04f2fb7191456 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 29 Dec 2021 07:14:04 +0100 Subject: [PATCH 024/125] revise models to match v3.0.3 & order fields accordingly --- openapi3/components.py | 14 ++++---- openapi3/example.py | 2 +- openapi3/general.py | 7 ++-- openapi3/info.py | 19 ++++++----- openapi3/object_base.py | 2 +- openapi3/openapi.py | 17 +++++----- openapi3/paths.py | 73 ++++++++++++++++++++++++----------------- openapi3/schemas.py | 17 +++++----- openapi3/security.py | 34 +++++++++++++------ openapi3/servers.py | 11 ++++--- openapi3/tag.py | 7 ++-- openapi3/xml.py | 14 ++++++++ 12 files changed, 129 insertions(+), 88 deletions(-) create mode 100644 openapi3/xml.py diff --git a/openapi3/components.py b/openapi3/components.py index 6c052cc..f32f5be 100644 --- a/openapi3/components.py +++ b/openapi3/components.py @@ -4,7 +4,7 @@ from .example import Example from .object_base import ObjectExtended -from .paths import Reference, RequestBody, Link, Parameter, Response, Callback, Header +from .paths import Reference, RequestBody, Link, Parameter, Response, Callback, Header, PathItem from .schemas import Schema from .security import SecurityScheme @@ -14,17 +14,17 @@ class Components(ObjectExtended): A `Components Object`_ holds a reusable set of different aspects of the OAS spec. - .. _Components Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#componentsObject + .. _Components Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#components-object """ - - examples: Optional[Dict[str, Union[Example, Reference]]] = Field(default_factory=dict) + schemas: Optional[Dict[str, Union[Schema, Reference]]] = Field(default_factory=dict) + responses: Optional[Dict[str, Union[Response, Reference]]] = Field(default_factory=dict) parameters: Optional[Dict[str, Union[Parameter, Reference]]] = Field(default_factory=dict) + examples: Optional[Dict[str, Union[Example, Reference]]] = Field(default_factory=dict) requestBodies: Optional[Dict[str, Union[RequestBody, Reference]]] = Field(default_factory=dict) - responses: Optional[Dict[str, Union[Response, Reference]]] = Field(default_factory=dict) - schemas: Optional[Dict[str, Union[Schema, Reference]]] = Field(default_factory=dict) + headers: Optional[Dict[str, Union[Header, Reference]]] = Field(default_factory=dict) securitySchemes: Optional[Dict[str, Union[SecurityScheme, Reference]]] = Field(default_factory=dict) - headers: Optional[Dict[str, Union[Header, Reference]]] links: Optional[Dict[str, Union[Link, Reference]]] = Field(default_factory=dict) callbacks: Optional[Dict[str, Union[Callback, Reference]]] = Field(default_factory=dict) +# pathItems: Optional[Dict[str, Union[PathItem, Reference]]] = Field(default_factory=dict) #v3.1 Components.update_forward_refs() \ No newline at end of file diff --git a/openapi3/example.py b/openapi3/example.py index bbec94b..5c2cec6 100644 --- a/openapi3/example.py +++ b/openapi3/example.py @@ -11,7 +11,7 @@ class Example(ObjectExtended): A `Example Object`_ holds a reusable set of different aspects of the OAS spec. - .. _Example Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#exampleObject + .. _Example Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#example-object """ summary: Optional[str] = Field(default=None) diff --git a/openapi3/general.py b/openapi3/general.py index f3dbe52..1b1fde1 100644 --- a/openapi3/general.py +++ b/openapi3/general.py @@ -12,11 +12,10 @@ class ExternalDocumentation(ObjectExtended): An `External Documentation Object`_ references external resources for extended documentation. - .. _External Documentation Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#externalDocumentationObject + .. _External Documentation Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#external-documentation-object """ - url: str - + url: str = Field(...) description: Optional[str] = Field(default=None) @@ -42,7 +41,7 @@ class Reference(ObjectBase): """ A `Reference Object`_ designates a reference to another node in the specification. - .. _Reference Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#referenceObject + .. _Reference Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#reference-object """ ref: str = Field(alias="$ref") diff --git a/openapi3/info.py b/openapi3/info.py index 3c27553..6e0a019 100644 --- a/openapi3/info.py +++ b/openapi3/info.py @@ -9,7 +9,7 @@ class Contact(ObjectExtended): """ Contact object belonging to an Info object, as described `here`_ - .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#contactObject + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#contact-object """ email: str = Field(default=None) @@ -21,10 +21,10 @@ class License(ObjectExtended): """ License object belonging to an Info object, as described `here`_ - .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#license-object + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#license-object """ - name: str = Field(default=None) + name: str = Field(...) url: Optional[str] = Field(default=None) @@ -32,14 +32,15 @@ class Info(ObjectExtended): """ An OpenAPI Info object, as defined in `the spec`_. - .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#infoObject + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#info-object """ - title: str = Field(default=None) - version: str = Field(default=None) - - contact: Optional[Contact] = Field(default=None) + title: str = Field(...) description: Optional[str] = Field(default=None) - license: Optional[License] = Field(default=None) termsOfService: Optional[str] = Field(default=None) + license: Optional[License] = Field(default=None) + contact: Optional[Contact] = Field(default=None) + version: str = Field(...) + + diff --git a/openapi3/object_base.py b/openapi3/object_base.py index 444a971..1267c20 100644 --- a/openapi3/object_base.py +++ b/openapi3/object_base.py @@ -21,7 +21,7 @@ class ObjectExtended(ObjectBase): @root_validator(pre=True) def validate_ObjectExtended_extensions(cls, values): """ FIXME - https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.1.md#specificationExtensions + https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#specification-extensions :param values: :return: values """ diff --git a/openapi3/openapi.py b/openapi3/openapi.py index f68e56f..7cb2056 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -12,7 +12,7 @@ from .general import Reference, JSONPointer, JSONReference from .info import Info from .object_base import ObjectExtended, ObjectBase -from .paths import Path, SecurityRequirement, _validate_parameters +from .paths import PathItem, SecurityRequirement, _validate_parameters from .servers import Server from .schemas import Schema from .tag import Tag @@ -276,18 +276,17 @@ class OpenAPISpec(ObjectExtended): This class represents the root of the OpenAPI schema document, as defined in `the spec`_ - .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#openapi-object + .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#openapi-object """ - openapi: str = Field(required=True) - info: Info = Field(required=True) - paths: Dict[str, Path] = Field(required=True, default_factory=dict) - + openapi: str = Field(...) + info: Info = Field(...) + servers: Optional[List[Server]] = Field(default=None) + paths: Dict[str, PathItem] = Field(required=True, default_factory=dict) components: Optional[Components] = Field(default_factory=Components) - externalDocs: Optional[Dict[Any, Any]] = Field(default_factory=dict) security: Optional[List[SecurityRequirement]] = Field(default=None) - servers: Optional[List[Server]] = Field(default=None) tags: Optional[List[Tag]] = Field(default=None) + externalDocs: Optional[Dict[Any, Any]] = Field(default_factory=dict) class Config: underscore_attrs_are_private = True @@ -310,7 +309,7 @@ def resolve(obj): if value is None: continue - if isinstance(obj, Path) and slot == "ref": + if isinstance(obj, PathItem) and slot == "ref": resolved_value = api.resolve_jr(root, obj, Reference.construct(ref=value)) setattr(obj, slot, resolved_value) if isinstance(value, reference_type): diff --git a/openapi3/paths.py b/openapi3/paths.py index 765221f..280caad 100644 --- a/openapi3/paths.py +++ b/openapi3/paths.py @@ -32,7 +32,7 @@ class ParameterBase(ObjectExtended): """ A `Parameter Object`_ defines a single operation parameter. - .. _Parameter Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#parameterObject + .. _Parameter Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#external-documentation-object """ description: Optional[str] = Field(default=None) @@ -44,7 +44,7 @@ class ParameterBase(ObjectExtended): explode: Optional[bool] = Field(default=None) allowReserved: Optional[bool] = Field(default=None) schema_: Optional[Union[Schema, Reference]] = Field(default=None, alias="schema") - example: Optional[str] = Field(default=None) + example: Optional[Any] = Field(default=None) examples: Optional[Dict[str, Union['Example',Reference]]] = Field(default_factory=dict) content: Optional[Dict[str, "MediaType"]] @@ -59,15 +59,16 @@ def validate_ParameterBase(cls, values): class Parameter(ParameterBase): - in_: str = Field(required=True, alias="in") # TODO must be one of ["query","header","path","cookie"] name: str = Field(required=True) + in_: str = Field(required=True, alias="in") # TODO must be one of ["query","header","path","cookie"] + class SecurityRequirement(BaseModel): """ A `SecurityRequirement`_ object describes security schemes for API access. - .. _SecurityRequirement: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#securityRequirementObject + .. _SecurityRequirement: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#security-requirement-object """ __root__: Dict[str, List[str]] @@ -95,7 +96,7 @@ def types(self): class Header(ParameterBase): """ - .. _HeaderObject: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#headerObject + .. _HeaderObject: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#header-object """ @@ -103,7 +104,7 @@ class Encoding(ObjectExtended): """ A single encoding definition applied to a single schema property. - .. _Encoding: https://github.com/OAI/OpeI-Specification/blob/main/versions/3.1.0.md#encodingObject + .. _Encoding: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#encoding-object """ contentType: Optional[str] = Field(default=None) headers: Optional[Dict[str, Union[Header, Reference]]] = Field(default_factory=dict) @@ -117,7 +118,7 @@ class MediaType(ObjectExtended): A `MediaType`_ object provides schema and examples for the media type identified by its key. These are used in a RequestBody object. - .. _MediaType: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#mediaTypeObject + .. _MediaType: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#media-type-object """ schema_: Optional[Union[Schema, Reference]] = Field(required=True, alias="schema") @@ -130,26 +131,26 @@ class RequestBody(ObjectExtended): """ A `RequestBody`_ object describes a single request body. - .. _RequestBody: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#requestBodyObject + .. _RequestBody: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#request-body-object """ - content: Dict[str, MediaType] = Field(default_factory=dict) description: Optional[str] = Field(default=None) - required: Optional[bool] = Field(default=None) + content: Dict[str, MediaType] = Field(...) + required: Optional[bool] = Field(default=False) class Link(ObjectExtended): """ A `Link Object`_ describes a single Link from an API Operation Response to an API Operation Request - .. _Link Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#linkObject + .. _Link Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#link-object """ - operationId: Optional[str] = Field(default=None) operationRef: Optional[str] = Field(default=None) - description: Optional[str] = Field(default=None) - parameters: Optional[dict] = Field(default=None) + operationId: Optional[str] = Field(default=None) + parameters: Optional[Dict[str, Union["RuntimeExpression", str]]] = Field(default=None) requestBody: Optional[dict] = Field(default=None) + description: Optional[str] = Field(default=None) server: Optional[Server] = Field(default=None) @root_validator(pre=False) @@ -168,10 +169,10 @@ class Response(ObjectExtended): A `Response Object`_ describes a single response from an API Operation, including design-time, static links to operations based on the response. - .. _Response Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#response-object + .. _Response Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#responses-object """ - description: str = Field(required=True) + description: str = Field(...) headers: Optional[Dict[str, Union[Header, Reference]]] = Field(default_factory=dict) content: Optional[Dict[str, MediaType]] = Field(default_factory=dict) links: Optional[Dict[str, Union[Link, Reference]]] = Field(default_factory=dict) @@ -181,23 +182,22 @@ class Operation(ObjectExtended): """ An Operation object as defined `here`_ - .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#operationObject + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#operation-object """ - responses: Dict[str, Union[Response, Reference]] = Field(required=True) - - deprecated: Optional[bool] = Field(default=None) + tags: Optional[List[str]] = Field(default=None) + summary: Optional[str] = Field(default=None) description: Optional[str] = Field(default=None) externalDocs: Optional[ExternalDocumentation] = Field(default=None) operationId: Optional[str] = Field(default=None) parameters: List[Union[Parameter, Reference]] = Field(default_factory=list) requestBody: Optional[Union[RequestBody, Reference]] = Field(default=None) + responses: Dict[str, Union[Response, Reference]] = Field(required=True) + callbacks: Optional[Dict[str, Union["Callback", Reference]]] = Field(default_factory=dict) + deprecated: Optional[bool] = Field(default=None) security: Optional[List[SecurityRequirement]] = Field(default_factory=list) servers: Optional[List[Server]] = Field(default=None) - summary: Optional[str] = Field(default=None) - tags: Optional[List[str]] = Field(default=None) - callbacks: Optional[Dict[str, "Callback"]] = Field(default_factory=dict) """ The OpenAPISpec this is part of @@ -377,7 +377,7 @@ def request(self, base_url, security={}, data=None, parameters={}, verify=True, # accept media type ranges in the spec. the most specific matching # type should always be chosen, but if we do not have a match here # a generic range should be accepted if one if provided - # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#response-object + # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#response-object generic_type = content_type.split('/')[0] + '/*' expected_media = expected_response.content.get(generic_type, None) @@ -397,12 +397,12 @@ def request(self, base_url, security={}, data=None, parameters={}, verify=True, raise NotImplementedError() -class Path(ObjectExtended): +class PathItem(ObjectExtended): """ - A Path object, as defined `here`_. Path objects represent URL paths that - may be accessed by appending them to a Server + A Path Item, as defined `here`_. + Describes the operations available on a single path. - .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#paths-object + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#paths-object """ ref: Optional[str] = Field(default=None, alias="$ref") summary: Optional[str] = Field(default=None) @@ -423,12 +423,23 @@ class Callback(ObjectBase): """ A map of possible out-of band callbacks related to the parent operation. - .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.1.md#callbackObject + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#callback-object This object MAY be extended with Specification Extensions. """ - __root__: Dict[str, Union[str, Path]] + __root__: Dict[str, PathItem] + + +class RuntimeExpression(ObjectBase): + """ + + + .. Runtime Expression: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#runtime-expressions + """ + __root__: str = Field(...) + Operation.update_forward_refs() Parameter.update_forward_refs() -Header.update_forward_refs() \ No newline at end of file +Header.update_forward_refs() +Link.update_forward_refs() \ No newline at end of file diff --git a/openapi3/schemas.py b/openapi3/schemas.py index 9b60f76..892db56 100644 --- a/openapi3/schemas.py +++ b/openapi3/schemas.py @@ -6,6 +6,7 @@ from .general import Reference # need this for Model below from .object_base import ObjectExtended +from .xml import XML TYPE_LOOKUP = { 'array': list, @@ -19,9 +20,9 @@ class Discriminator(ObjectExtended): """ - .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.1.md#discriminator-object + .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#discriminator-object """ - propertyName: str = Field(required=True) + propertyName: str = Field(...) mapping: Optional[Dict[str, str]] = Field(default_factory=dict) @@ -29,7 +30,7 @@ class Schema(ObjectExtended): """ The `Schema Object`_ allows the definition of input and output data types. - .. _Schema Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#schemaObject + .. _Schema Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#schema-object """ title: Optional[str] = Field(default=None) @@ -64,13 +65,13 @@ class Schema(ObjectExtended): discriminator: Optional[Discriminator] = Field(default=None) # 'Discriminator' readOnly: Optional[bool] = Field(default=None) writeOnly: Optional[bool] = Field(default=None) - xml: Optional[dict] = Field(default=None) # 'XML' + xml: Optional[XML] = Field(default=None) # 'XML' externalDocs: Optional[dict] = Field(default=None) # 'ExternalDocs' - deprecated: Optional[bool] = Field(default=None) example: Optional[Any] = Field(default=None) - contentEncoding: Optional[str] = Field(default=None) - contentMediaType: Optional[str] = Field(default=None) - contentSchema: Optional[str] = Field(default=None) + deprecated: Optional[bool] = Field(default=None) +# contentEncoding: Optional[str] = Field(default=None) +# contentMediaType: Optional[str] = Field(default=None) +# contentSchema: Optional[str] = Field(default=None) _model_type: object _request_model_type: object diff --git a/openapi3/security.py b/openapi3/security.py index 729adc0..7ca4116 100644 --- a/openapi3/security.py +++ b/openapi3/security.py @@ -1,6 +1,6 @@ from typing import Optional, Dict -from pydantic import Field +from pydantic import Field, root_validator from .object_base import ObjectExtended @@ -9,7 +9,7 @@ class OAuthFlow(ObjectExtended): """ Configuration details for a supported OAuth Flow - .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.1.md#oauth-flow-object + .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#oauth-flow-object """ authorizationUrl: Optional[str] = Field(default=None) tokenUrl: Optional[str] = Field(default=None) @@ -21,7 +21,7 @@ class OAuthFlows(ObjectExtended): """ Allows configuration of the supported OAuth Flows. - .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.1.md#oauth-flows-object + .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#oauth-flows-object """ implicit: Optional[OAuthFlow] = Field(default=None) password: Optional[OAuthFlow] = Field(default=None) @@ -33,15 +33,29 @@ class SecurityScheme(ObjectExtended): """ A `Security Scheme`_ defines a security scheme that can be used by the operations. - .. _Security Scheme: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#securitySchemeObject + .. _Security Scheme: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#security-scheme-object """ - type: str = Field(default=None) - - bearerFormat: Optional[str] = Field(default=None) + type: str = Field(...) description: Optional[str] = Field(default=None) - flows: Optional[OAuthFlows] = Field(default=None) - in_: Optional[str] = Field(default=None, alias="in") name: Optional[str] = Field(default=None) - openIdConnectUrl: Optional[str] = Field(default=None) + in_: Optional[str] = Field(default=None, alias="in") scheme_: Optional[str] = Field(default=None, alias="scheme") + bearerFormat: Optional[str] = Field(default=None) + flows: Optional[OAuthFlows] = Field(default=None) + openIdConnectUrl: Optional[str] = Field(default=None) + + @root_validator + def validate_SecurityScheme(cls, values): + t = values.get("type", None) + keys = set(map(lambda x: x[0], filter(lambda x: x[1] is not None, values.items()))) + keys -= frozenset(["type","description"]) + if t == "apikey": + assert keys == set(["in_","name"]) + if t == "http": + assert keys - frozenset(["scheme_","bearerFormat"]) == set([]) + if t == "oauth2": + assert keys == frozenset(["flows"]) + if t == "openIdConnect": + assert keys - frozenset(["openIdConnectUrl"]) == set([]) + return values diff --git a/openapi3/servers.py b/openapi3/servers.py index 1c24dbc..35270ee 100644 --- a/openapi3/servers.py +++ b/openapi3/servers.py @@ -9,22 +9,23 @@ class ServerVariable(ObjectExtended): """ A ServerVariable object as defined `here`_. - .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#server-variable-object + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#server-variable-object """ - default: str = Field(default=None) - description: Optional[str] = Field(default=None) enum: Optional[List[str]] = Field(default=None) + default: str = Field(...) + description: Optional[str] = Field(default=None) + class Server(ObjectExtended): """ The Server object, as described `here`_ - .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#serverObject + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#server-object """ - url: str = Field(default=None) + url: str = Field(...) description: Optional[str] = Field(default=None) variables: Optional[Dict[str, ServerVariable]] = Field(default_factory=dict) diff --git a/openapi3/tag.py b/openapi3/tag.py index 4429e01..4724505 100644 --- a/openapi3/tag.py +++ b/openapi3/tag.py @@ -3,6 +3,7 @@ from pydantic import Field from .object_base import ObjectExtended +from .general import ExternalDocumentation class Tag(ObjectExtended): @@ -10,9 +11,9 @@ class Tag(ObjectExtended): A `Tag Object`_ holds a reusable set of different aspects of the OAS spec. - .. _Tag Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#tagObject + .. _Tag Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#tag-object """ - name: Optional[str] = Field(default=None) + name: str = Field(...) description: Optional[str] = Field(default=None) - externalDocs: Optional[str] = Field(default=None) + externalDocs: Optional[ExternalDocumentation] = Field(default=None) diff --git a/openapi3/xml.py b/openapi3/xml.py new file mode 100644 index 0000000..d52df37 --- /dev/null +++ b/openapi3/xml.py @@ -0,0 +1,14 @@ +from pydantic import Field + +from .general import ObjectExtended + +class XML(ObjectExtended): + """ + + .. XML Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#xml-object + """ + name: str = Field(default=None) + namespace: str = Field(default=None) + prefix: str = Field(default=None) + attribute: bool = Field(default=False) + wrapped: bool = Field(default=False) From 09e157a848d3f54160f4b1f53ce811a2f210c283 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 29 Dec 2021 07:49:33 +0100 Subject: [PATCH 025/125] Schema - re-introduce Model --- openapi3/schemas.py | 117 +++++++++++++++++++------------------------- tests/path_test.py | 16 ++++++ 2 files changed, 67 insertions(+), 66 deletions(-) diff --git a/openapi3/schemas.py b/openapi3/schemas.py index 892db56..b3c9ed5 100644 --- a/openapi3/schemas.py +++ b/openapi3/schemas.py @@ -108,52 +108,7 @@ def get_type(self): try: return self._model_type except AttributeError: - - def typeof(schema): - r = None - if schema.type == "string": - r = str - elif schema.type == "integer": - r = int - elif schema.type == "array": - r = schema.items.get_type() - else: - raise TypeError(schema.type) - - return r - def annotationsof(schema): - annos = dict() - if schema.type == "array": - annos["__root__"] = List[typeof(schema)] - else: - for name, f in schema.properties.items(): - r = typeof(f) - if name not in schema.required: - annos[name] = Optional[r] - else: - annos[name] = r - return annos - - type_name = self.title or self._identity - namespace = dict() - annos = dict() - if self.allOf: - for i in self.allOf: - annos.update(annotationsof(i)) - elif self.anyOf: -# types = [i.get_type() for i in self.anyOf] -# namespace["__root__"] = Union[types] - raise NotImplementedError("anyOf") - elif self.oneOf: - raise NotImplementedError("oneOf") - else: - - annos = annotationsof(self) - - namespace['__annotations__'] = annos - - self._model_type = types.new_class(type_name, (BaseModel, ), {}, lambda ns: ns.update(namespace)) - + self._model_type = Model.from_schema(self) return self._model_type def model(self, data): @@ -176,27 +131,57 @@ def model(self, data): else: return self.get_type().parse_obj(data) - def get_request_type(self): - """ - Similar to :any:`get_type`, but the resulting type does not accept readOnly - fields - """ - # this is defined in ObjectBase.__init__ as all slots are - if self._request_model_type is None: # pylint: disable=access-member-before-definition - type_name = self.title or self._path[-1] - self._request_model_type = type(type_name + 'Request', (BaseModel, ), - { # pylint: disable=attribute-defined-outside-init - '__slots__': [k for k, v in self.properties.items() if not v.readOnly] - }) - return self._request_model_type +class Model(BaseModel): + @classmethod + def from_schema(cls, shma): + + def typeof(schema): + r = None + if schema.type == "string": + r = str + elif schema.type == "integer": + r = int + elif schema.type == "array": + r = schema.items.get_type() + else: + raise TypeError(schema.type) + + return r + + def annotationsof(schema): + annos = dict() + if schema.type == "array": + annos["__root__"] = List[typeof(schema)] + else: + for name, f in schema.properties.items(): + r = typeof(f) + if name not in schema.required: + annos[name] = Optional[r] + else: + annos[name] = r + return annos + + type_name = shma.title or shma._identity + namespace = dict() + annos = dict() + if shma.allOf: + for i in shma.allOf: + annos.update(annotationsof(i)) + elif shma.anyOf: + # types = [i.get_type() for i in self.anyOf] + # namespace["__root__"] = Union[types] + raise NotImplementedError("anyOf") + elif shma.oneOf: + raise NotImplementedError("oneOf") + else: + + annos = annotationsof(shma) + + namespace['__annotations__'] = annos + + r = types.new_class(type_name, (BaseModel,), {}, lambda ns: ns.update(namespace)) + return r - def request_model(self, **kwargs): - """ - Converts the kwargs passed into a model of writeable fields of this - schema - """ - # TODO - this doesn't get nested schemas - return self.get_request_type()(kwargs, self) Schema.update_forward_refs() \ No newline at end of file diff --git a/tests/path_test.py b/tests/path_test.py index 636ea3b..2823c50 100644 --- a/tests/path_test.py +++ b/tests/path_test.py @@ -102,6 +102,22 @@ def test_securityparameters(with_securityparameters): api = OpenAPI(with_securityparameters) auth=str(uuid.uuid4()) + + for i in api.paths.values(): + if not i.post or not i.post.security: + continue + s = i.post.security[0] + assert type(s.name) == str + assert type(s.types) == list + break + else: + assert False + + with pytest.raises(ValueError, match="does not accept security scheme"): + api.authenticate('xAuth', auth) + api.call_api_v1_auth_login_create(data={}, parameters={}) + + # global security api.authenticate('cookieAuth', auth) resp = MagicMock(status_code=200, headers={"Content-Type":"application/json"}) From ae6b33e5e5c438ba8dc7d75b65c025247d366797 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 29 Dec 2021 07:54:37 +0100 Subject: [PATCH 026/125] paths - split -> media & parameters --- openapi3/media.py | 38 ++++++++++++++++++++ openapi3/parameter.py | 50 ++++++++++++++++++++++++++ openapi3/paths.py | 81 +++---------------------------------------- 3 files changed, 93 insertions(+), 76 deletions(-) create mode 100644 openapi3/media.py create mode 100644 openapi3/parameter.py diff --git a/openapi3/media.py b/openapi3/media.py new file mode 100644 index 0000000..2d82c6a --- /dev/null +++ b/openapi3/media.py @@ -0,0 +1,38 @@ +from typing import Union, Optional, Dict, Any + +from pydantic import Field + +from .example import Example +from .general import Reference +from .object_base import ObjectExtended +from .schemas import Schema + + +class Encoding(ObjectExtended): + """ + A single encoding definition applied to a single schema property. + + .. _Encoding: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#encoding-object + """ + contentType: Optional[str] = Field(default=None) + headers: Optional[Dict[str, Union["Header", Reference]]] = Field(default_factory=dict) + style: Optional[str] = Field(default=None) + explode: Optional[bool] = Field(default=None) + allowReserved: Optional[bool] = Field(default=None) + + +class MediaType(ObjectExtended): + """ + A `MediaType`_ object provides schema and examples for the media type identified + by its key. These are used in a RequestBody object. + + .. _MediaType: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#media-type-object + """ + + schema_: Optional[Union[Schema, Reference]] = Field(required=True, alias="schema") + example: Optional[Any] = Field(default=None) # 'any' type + examples: Optional[Dict[str, Union[Example, Reference]]] = Field(default_factory=dict) + encoding: Optional[Dict[str, Encoding]] = Field(default_factory=dict) + +from .parameter import Header +Encoding.update_forward_refs() \ No newline at end of file diff --git a/openapi3/parameter.py b/openapi3/parameter.py new file mode 100644 index 0000000..7675d15 --- /dev/null +++ b/openapi3/parameter.py @@ -0,0 +1,50 @@ +from typing import Union, Optional, Dict, Any + +from pydantic import Field, root_validator + +from .example import Example +from .general import Reference +from .object_base import ObjectExtended +from .schemas import Schema +from .media import MediaType + +class ParameterBase(ObjectExtended): + """ + A `Parameter Object`_ defines a single operation parameter. + + .. _Parameter Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#external-documentation-object + """ + + description: Optional[str] = Field(default=None) + required: Optional[bool] = Field(default=None) + deprecated: Optional[bool] = Field(default=None) + allowEmptyValue: Optional[bool] = Field(default=None) + + style: Optional[str] = Field(default=None) + explode: Optional[bool] = Field(default=None) + allowReserved: Optional[bool] = Field(default=None) + schema_: Optional[Union[Schema, Reference]] = Field(default=None, alias="schema") + example: Optional[Any] = Field(default=None) + examples: Optional[Dict[str, Union['Example',Reference]]] = Field(default_factory=dict) + + content: Optional[Dict[str, "MediaType"]] + + @root_validator + def validate_ParameterBase(cls, values): +# if values["in_"] == +# if self.in_ == "path" and self.required is not True: +# err_msg = 'Parameter {} must be required since it is in the path' +# raise SpecError(err_msg.format(self.get_path()), path=self._path) + return values + + +class Parameter(ParameterBase): + name: str = Field(required=True) + in_: str = Field(required=True, alias="in") # TODO must be one of ["query","header","path","cookie"] + + +class Header(ParameterBase): + """ + + .. _HeaderObject: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#header-object + """ diff --git a/openapi3/paths.py b/openapi3/paths.py index 280caad..8f8f69b 100644 --- a/openapi3/paths.py +++ b/openapi3/paths.py @@ -1,18 +1,17 @@ import json import re -from typing import Union, List, Optional, Dict, Any +from typing import Union, List, Optional, Dict import requests from pydantic import Field, BaseModel, root_validator from .errors import SpecError +from .general import ExternalDocumentation +from .general import Reference +from .media import MediaType from .object_base import ObjectBase, ObjectExtended - +from .parameter import Header, Parameter from .servers import Server -from .general import Reference -from .general import ExternalDocumentation -from .schemas import Schema -from .example import Example def _validate_parameters(op: "Operation", path): @@ -28,42 +27,6 @@ def _validate_parameters(op: "Operation", path): raise SpecError('Parameter name not found in path: {}'.format(c.name)) -class ParameterBase(ObjectExtended): - """ - A `Parameter Object`_ defines a single operation parameter. - - .. _Parameter Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#external-documentation-object - """ - - description: Optional[str] = Field(default=None) - required: Optional[bool] = Field(default=None) - deprecated: Optional[bool] = Field(default=None) - allowEmptyValue: Optional[bool] = Field(default=None) - - style: Optional[str] = Field(default=None) - explode: Optional[bool] = Field(default=None) - allowReserved: Optional[bool] = Field(default=None) - schema_: Optional[Union[Schema, Reference]] = Field(default=None, alias="schema") - example: Optional[Any] = Field(default=None) - examples: Optional[Dict[str, Union['Example',Reference]]] = Field(default_factory=dict) - - content: Optional[Dict[str, "MediaType"]] - - @root_validator - def validate_ParameterBase(cls, values): -# if values["in_"] == -# if self.in_ == "path" and self.required is not True: -# err_msg = 'Parameter {} must be required since it is in the path' -# raise SpecError(err_msg.format(self.get_path()), path=self._path) - return values - - -class Parameter(ParameterBase): - name: str = Field(required=True) - in_: str = Field(required=True, alias="in") # TODO must be one of ["query","header","path","cookie"] - - - class SecurityRequirement(BaseModel): """ A `SecurityRequirement`_ object describes security schemes for API access. @@ -93,40 +56,6 @@ def types(self): return None -class Header(ParameterBase): - """ - - .. _HeaderObject: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#header-object - """ - - -class Encoding(ObjectExtended): - """ - A single encoding definition applied to a single schema property. - - .. _Encoding: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#encoding-object - """ - contentType: Optional[str] = Field(default=None) - headers: Optional[Dict[str, Union[Header, Reference]]] = Field(default_factory=dict) - style: Optional[str] = Field(default=None) - explode: Optional[bool] = Field(default=None) - allowReserved: Optional[bool] = Field(default=None) - - -class MediaType(ObjectExtended): - """ - A `MediaType`_ object provides schema and examples for the media type identified - by its key. These are used in a RequestBody object. - - .. _MediaType: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#media-type-object - """ - - schema_: Optional[Union[Schema, Reference]] = Field(required=True, alias="schema") - example: Optional[Any] = Field(default=None) # 'any' type - examples: Optional[Dict[str, Union[Example, Reference]]] = Field(default_factory=dict) - encoding: Optional[Dict[str, Encoding]] = Field(default_factory=dict) - - class RequestBody(ObjectExtended): """ A `RequestBody`_ object describes a single request body. From 645363ab5923d56f781483967f0d17e980c70d95 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 29 Dec 2021 16:47:30 +0100 Subject: [PATCH 027/125] runtime expression - with tatsu --- openapi3/expression/Makefile | 8 ++ openapi3/expression/grammar.ebnf | 61 +++++++++++++++ openapi3/expression/grammar.py | 11 +++ openapi3/expression/model.py | 127 +++++++++++++++++++++++++++++++ tests/test_runtimexpression.py | 59 ++++++++++++++ 5 files changed, 266 insertions(+) create mode 100644 openapi3/expression/Makefile create mode 100644 openapi3/expression/grammar.ebnf create mode 100644 openapi3/expression/grammar.py create mode 100644 openapi3/expression/model.py create mode 100644 tests/test_runtimexpression.py diff --git a/openapi3/expression/Makefile b/openapi3/expression/Makefile new file mode 100644 index 0000000..00e2b62 --- /dev/null +++ b/openapi3/expression/Makefile @@ -0,0 +1,8 @@ +_model.py: generate + +_grammar.py: generate + +generate: + python3 -m tatsu grammar.ebnf --name RuntimeExpression --outfile _grammar.py --object-model-outfile _model.py + +all: generate \ No newline at end of file diff --git a/openapi3/expression/grammar.ebnf b/openapi3/expression/grammar.ebnf new file mode 100644 index 0000000..40a1354 --- /dev/null +++ b/openapi3/expression/grammar.ebnf @@ -0,0 +1,61 @@ +@@grammar::RuntimeExpression +@@whitespace :: // +@@comments :: // +@@eol_comments :: // +@@keyword :: if elsif "shared-network" group host subnet pool class subclass +@@parseinfo :: True + +start::RuntimeExpression = expression $ ; + +# expression = ( "$url" / "$method" / "$statusCode" / "$request." source / "$response." source ) +expression::Expression + = root:"$url" + | root:"$method" + | root:"$statusCode" + | root:"$request." next:source + | root:"$response." next:source + ; + +# source = ( header-reference / query-reference / path-reference / body-reference ) +source + = header_reference + | query_reference + | path_reference + | body_reference + ; + +# header-reference = "header." token +header_reference::Header = "header." key:token ; + +# query-reference = "query." name +query_reference::Query = "query." key:name ; + +# path-reference = "path." name +path_reference::Path = "path." key:name ; + +# body-reference = "body" ["#" json-pointer ] +body_reference::Body = "body" fragment:[ json_pointer ] ; + +# json-pointer = *( "/" reference-token ) +json_pointer::JSONPointer = "#/" tokens:"/".{ reference_token }*; + +# reference-token = *( unescaped / escaped ) +reference_token = { unescaped | escaped }* ; + +# unescaped = %x00-2E / %x30-7D / %x7F-10FFFF +# ; %x2F ('/') and %x7E ('~') are excluded from 'unescaped' +unescaped = /[^\/~]/ ; + +# escaped = "~" ( "0" / "1" ) +#; representing '~' and '/', respectively +escaped = "~" ( "0" | "1" ) ; + +# name = *( CHAR ) +name = /[\w]*/ ; + +# token = 1*tchar +token = {tchar}+; + +#tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / +#"^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA" +tchar = /[^\(\),\/:;<=>?@[\]{}]/ ; diff --git a/openapi3/expression/grammar.py b/openapi3/expression/grammar.py new file mode 100644 index 0000000..c3620c4 --- /dev/null +++ b/openapi3/expression/grammar.py @@ -0,0 +1,11 @@ +from ._grammar import RuntimeExpressionBuffer, RuntimeExpressionParser + +def loads(data, trace=False, colorize=False): + from .model import RuntimeExpressionModelBuilderSemantics, RuntimeExpression, Expression, JSONPointer, Body, Header, Query, Path + parser = RuntimeExpressionParser() + model = parser.parse(data, + trace=trace, colorize=colorize, + tokenizercls=RuntimeExpressionBuffer, + semantics=RuntimeExpressionModelBuilderSemantics(types=[RuntimeExpression, Expression, JSONPointer, Body, Header, Query, Path]), + filename='…') + return model \ No newline at end of file diff --git a/openapi3/expression/model.py b/openapi3/expression/model.py new file mode 100644 index 0000000..4f9243a --- /dev/null +++ b/openapi3/expression/model.py @@ -0,0 +1,127 @@ +import json +from yarl import URL +import requests + +from ._model import RuntimeExpressionModelBuilderSemantics as RuntimeExpressionModelBuilderSemanticsBase, \ + JSONPointer as JSONPointerBase, \ + Header as HeaderBase, \ + Query as QueryBase, \ + Path as PathBase, \ + Body as BodyBase, \ + RuntimeExpression as RuntimeExpressionBase, \ + Expression as ExpressionBase + + +class RuntimeExpressionModelBuilderSemantics(RuntimeExpressionModelBuilderSemanticsBase): + + def reference_token(self, ast, name=None): + return "".join(ast) + + def token(self, ast, name=None): + return "".join(ast) + + +class RuntimeExpression(RuntimeExpressionBase): + def __init__(self, ctx=None, ast=None, parseinfo=None, **kwargs): + super().__init__(ctx, None, parseinfo, **kwargs) + self.expression = ast + + def eval(self, req, resp): + return self.expression.eval(req, resp) + + +class Expression(ExpressionBase): + def __init__(self, ctx=None, ast=None, parseinfo=None, **kwargs): + super().__init__(ctx, None, parseinfo, **kwargs) + self.root = ast.root + self.next = ast.next + + def eval(self, req, resp): + data = None + item = self.root + if item[-1] == ".": + if item == "$request.": + data = req + if item == "$response.": + data = resp + + return self.next.eval(data) + else: + if item == "$url": + return req.url + elif item == "$method": + return req.method + elif item == "$statusCode": + return resp.status_code + + + +class JSONPointer(JSONPointerBase): + def __init__(self, ctx=None, ast=None, parseinfo=None, **kwargs): + super().__init__(ctx, None, parseinfo, **kwargs) + self.tokens = ast.tokens + def eval(self, data): + pass + + +class Header(HeaderBase): + def __init__(self, ctx=None, ast=None, parseinfo=None, **kwargs): + super().__init__(ctx, None, parseinfo, **kwargs) + self.key = ast.key + + def eval(self, data): + headers = None + if isinstance(data, requests.PreparedRequest): + headers = data.headers + elif isinstance(data, requests.Response): + headers = data.headers + if headers is None: + return None + return headers.get(self.key, None) + + +class Query(QueryBase): + def __init__(self, ctx=None, ast=None, parseinfo=None, **kwargs): + super().__init__(ctx, None, parseinfo, **kwargs) + self.key = ast.key + + def eval(self, data): + if isinstance(data, requests.PreparedRequest): + url = URL(data.url) + elif isinstance(data, requests.Response): + url = None + return url.query.get(self.key, None) + + +class Path(PathBase): + def __init__(self, ctx=None, ast=None, parseinfo=None, **kwargs): + super().__init__(ctx, None, parseinfo, **kwargs) + self.key = ast.key + + def eval(self, data): + return data.path.get(self.key) + + +class Body(BodyBase): + def __init__(self, ctx=None, ast=None, parseinfo=None, **kwargs): + super().__init__(ctx, None, parseinfo, **kwargs) + self.fragment = ast.fragment + + def eval(self, data): + try: + if isinstance(data, requests.PreparedRequest): + body = json.loads(data.body) + elif isinstance(data, requests.Response): + body = data.json() + except Exception: + return None + + data = body + try: + for i in self.fragment.tokens: + if isinstance(data, list): + i = int(i)-1 + data = data[i] + return data + except KeyError: + return None diff --git a/tests/test_runtimexpression.py b/tests/test_runtimexpression.py new file mode 100644 index 0000000..2d6f9ae --- /dev/null +++ b/tests/test_runtimexpression.py @@ -0,0 +1,59 @@ +import json + +import pytest + +import requests +import requests_mock + +import tatsu +from openapi3.expression.grammar import loads + +parse_testdata = [ + "$url", + "$method", + "$statusCode", +# "$request.", + "$request.body#/url", +] + +@pytest.mark.parametrize("data", parse_testdata) +def test_parse(data): + m = loads(data) + assert m is not None + +def test_parse_fail(): + with pytest.raises(tatsu.exceptions.FailedParse): + loads("x") + +get_testdata = { + "$url":"http://example.org/subscribe/myevent?queryUrl=http://clientdomain.com/stillrunning", + "$method":"POST", + "$request.path.eventType":"myevent", + "$request.query.queryUrl":"http://clientdomain.com/stillrunning", + "$request.header.content-Type":"application/json", + "$request.body#/failedUrl":"http://clientdomain.com/failed", + "$request.body#/successUrls/2":"http://clientdomain.com/medium", + "$response.header.Location":"http://example.org/subscription/1" , +} +@pytest.mark.parametrize("param, result", get_testdata.items()) +def test_get(param, result): + url = "http://example.org/subscribe/myevent?queryUrl=http://clientdomain.com/stillrunning" + data = { + "failedUrl": "http://clientdomain.com/failed", + "successUrls": [ + "http://clientdomain.com/fast", + "http://clientdomain.com/medium", + "http://clientdomain.com/slow" + ] + } + + with requests_mock.Mocker() as m: + m.post(url, headers={"Location":"http://example.org/subscription/1"}) + req = requests.Request(method="POST", url=url, data=json.dumps(data), headers={"Content-Type":"application/json"}) + req = req.prepare() + req.path = {"eventType":"myevent"} + resp = requests.Session().send(req) + + m = loads(param) + r = m.eval(req, resp) + assert r == result From 85c7d325c0d2e065723584c789f8ab88e008778a Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 29 Dec 2021 16:50:07 +0100 Subject: [PATCH 028/125] schema - get_type does Union[]s --- openapi3/paths.py | 9 ++------- openapi3/schemas.py | 43 ++++++++++++++++++++++++++++++++----------- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/openapi3/paths.py b/openapi3/paths.py index 8f8f69b..4165dc1 100644 --- a/openapi3/paths.py +++ b/openapi3/paths.py @@ -248,9 +248,6 @@ def request(self, base_url, security={}, data=None, parameters={}, verify=True, # Set self._request.url to base_url w/ path self._request.url = base_url + self._path - self._session = requests.Session() - - if security and self.security: security_requirement = None for scheme, value in security.items(): @@ -273,7 +270,7 @@ def request(self, base_url, security={}, data=None, parameters={}, verify=True, self._request_handle_parameters(parameters) if session is None: - session = self._session + session = self._session = requests.Session() # send the prepared request result = session.send(self._request.prepare()) @@ -369,6 +366,4 @@ class RuntimeExpression(ObjectBase): Operation.update_forward_refs() -Parameter.update_forward_refs() -Header.update_forward_refs() -Link.update_forward_refs() \ No newline at end of file +Link.update_forward_refs() diff --git a/openapi3/schemas.py b/openapi3/schemas.py index b3c9ed5..7311c0b 100644 --- a/openapi3/schemas.py +++ b/openapi3/schemas.py @@ -1,7 +1,7 @@ import types +import uuid from typing import Union, List, Any, Optional, Dict - from pydantic import Field, root_validator, Extra, BaseModel from .general import Reference # need this for Model below @@ -103,8 +103,6 @@ def get_type(self): isinstance(object1, example._schema.get_type()) # true type(object1) == type(object2) # true """ - # this is defined in ObjectBase.__init__ as all slots are - try: return self._model_type except AttributeError: @@ -121,7 +119,9 @@ def model(self, data): :returns: A new :any:`Model` created in this Schema's type from the data. :rtype: self.get_type() """ - if self.properties is None and self.type in ('string', 'number'): # more simple types + if self.type in ('string', 'number'): + assert len(self.properties) == 0 + # more simple types # if this schema represents a simple type, simply return the data # TODO - perhaps assert that the type of data matches the type we # expected @@ -133,6 +133,9 @@ def model(self, data): class Model(BaseModel): + class Config: + extra: Extra.forbid + @classmethod def from_schema(cls, shma): @@ -143,7 +146,9 @@ def typeof(schema): elif schema.type == "integer": r = int elif schema.type == "array": - r = schema.items.get_type() + r = List[schema.items.get_type()] + elif schema.type == 'object': + return schema.get_type() else: raise TypeError(schema.type) @@ -152,7 +157,7 @@ def typeof(schema): def annotationsof(schema): annos = dict() if schema.type == "array": - annos["__root__"] = List[typeof(schema)] + annos["__root__"] = typeof(schema) else: for name, f in schema.properties.items(): r = typeof(f) @@ -162,21 +167,37 @@ def annotationsof(schema): annos[name] = r return annos - type_name = shma.title or shma._identity + def fieldof(schema): + r = dict() + if schema.type == "array": + return r + else: + for name, f in schema.properties.items(): + args = dict() + for i in ["enum"]: + if (v:=getattr(f, i, None)): + args[i] = v + r[name] = Field(**args) + return r + + # do not create models for primitive types + if shma.type in ("string","integer"): + return typeof(shma) + + type_name = shma.title or shma._identity if hasattr(shma, '_identity') else str(uuid.uuid4()) namespace = dict() annos = dict() if shma.allOf: for i in shma.allOf: annos.update(annotationsof(i)) elif shma.anyOf: - # types = [i.get_type() for i in self.anyOf] - # namespace["__root__"] = Union[types] - raise NotImplementedError("anyOf") + t = tuple([i.get_type() for i in shma.anyOf]) + annos["__root__"] = Union[t] elif shma.oneOf: raise NotImplementedError("oneOf") else: - annos = annotationsof(shma) + namespace.update(fieldof(shma)) namespace['__annotations__'] = annos From 1f8c42b7d10940d42cc2369ba677ee0a0c046028 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Thu, 30 Dec 2021 07:19:29 +0100 Subject: [PATCH 029/125] model_test - - test_model supporting basic discrimination --- openapi3/schemas.py | 26 ++++++--- tests/api/main.py | 55 +++++++++++------- tests/api/v1/schema.py | 25 +++++++++ tests/api/v2/schema.py | 57 +++++++++++++++++++ tests/model_test.py | 125 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 259 insertions(+), 29 deletions(-) create mode 100644 tests/api/v1/schema.py create mode 100644 tests/api/v2/schema.py create mode 100644 tests/model_test.py diff --git a/openapi3/schemas.py b/openapi3/schemas.py index 7311c0b..d4bd4db 100644 --- a/openapi3/schemas.py +++ b/openapi3/schemas.py @@ -1,6 +1,6 @@ import types import uuid -from typing import Union, List, Any, Optional, Dict +from typing import Union, List, Any, Optional, Dict, Literal, Annotated from pydantic import Field, root_validator, Extra, BaseModel @@ -91,7 +91,7 @@ def validate_Schema_number_type(cls, values): values[i] = int(v) return values - def get_type(self): + def get_type(self, name=None, discriminator=None): """ Returns the Type that this schema represents. This Type is created once per Schema and cached so that all instances of the same schema are the @@ -103,10 +103,12 @@ def get_type(self): isinstance(object1, example._schema.get_type()) # true type(object1) == type(object2) # true """ + if discriminator and hasattr(self, "_model_type") and getattr(self._model_type, "discriminator", None) == None: + return Model.from_schema(self, name, discriminator) try: return self._model_type except AttributeError: - self._model_type = Model.from_schema(self) + self._model_type = Model.from_schema(self, name, discriminator) return self._model_type def model(self, data): @@ -137,8 +139,7 @@ class Config: extra: Extra.forbid @classmethod - def from_schema(cls, shma): - + def from_schema(cls, shma, shmanm=None, discriminator=None): def typeof(schema): r = None if schema.type == "string": @@ -160,7 +161,13 @@ def annotationsof(schema): annos["__root__"] = typeof(schema) else: for name, f in schema.properties.items(): - r = typeof(f) + if discriminator and name == discriminator.propertyName: + for disc,v in discriminator.mapping.items(): + if v == shmanm: + r = Literal[disc] + break + else: + r = typeof(f) if name not in schema.required: annos[name] = Optional[r] else: @@ -191,8 +198,11 @@ def fieldof(schema): for i in shma.allOf: annos.update(annotationsof(i)) elif shma.anyOf: - t = tuple([i.get_type() for i in shma.anyOf]) - annos["__root__"] = Union[t] + t = tuple([i.get_type(name=i.ref, discriminator=shma.discriminator) for i in shma.anyOf]) + if shma.discriminator: + annos["__root__"] = Annotated[Union[t], Field(discriminator=shma.discriminator.propertyName)] + else: + annos["__root__"] = Union[t] elif shma.oneOf: raise NotImplementedError("oneOf") else: diff --git a/tests/api/main.py b/tests/api/main.py index 9f37f9d..cb4a95f 100644 --- a/tests/api/main.py +++ b/tests/api/main.py @@ -7,7 +7,10 @@ from fastapi import FastAPI, Query, Body, Response from fastapi.responses import JSONResponse -from .schema import Pets, Pet, PetCreate, Error +from fastapi_versioning import VersionedFastAPI, version + +import api.v1.schema as v1 +import api.v2.schema as v2 app = FastAPI(version="1.0.0", title="Dorthu's Petstore", @@ -22,65 +25,75 @@ def _idx(l): idx = _idx(100) + @app.post('/pet', operation_id="createPet", - response_model=Pet, - responses={201: {"model": Pet}, - 409: {"model": Error}} + response_model=v2.Pet, + responses={201: {"model": v2.Pet}, + 409: {"model": v2.Error}} ) +@version(2) def createPet(response: Response, - pet: PetCreate = Body(..., embed=True), + pet: v2.Pet = Body(..., embed=True), ) -> None: + # if isinstance(pet, Cat): + # pet = pet.__root__ + # elif isinstance(pet, Dog): + # pass if pet.name in ZOO: return JSONResponse(status_code=starlette.status.HTTP_409_CONFLICT, - content=Error(code=errno.EEXIST, + content=v2.Error(code=errno.EEXIST, message=f"{pet.name} already exists" ).dict() ) - ZOO[pet.name] = r = Pet(id=next(idx), **pet.dict()) + ZOO[pet.name] = r = pet response.status_code = starlette.status.HTTP_201_CREATED return r @app.get('/pet', operation_id="listPet", - response_model=Pets) -def listPet(limit: Optional[int] = None) -> Pets: + response_model=v2.Pets) +@version(2) +def listPet(limit: Optional[int] = None) -> v2.Pets: return list(ZOO.values()) @app.get('/pets/{pet_id}', operation_id="getPet", - response_model=Pet, + response_model=v2.Pet, responses={ - 404: {"model": Error} + 404: {"model": v2.Error} } ) -def getPet(pet_id: int = Query(..., alias='petId')) -> Pets: +@version(2) +def getPet(pet_id: str = Query(..., alias='petId')) -> v2.Pets: for k, v in ZOO.items(): - if pet_id == v.id: + if pet_id == v.identifier: return v else: - # media_type included here is to ensure that content encodings do not break - # expected response type handling for requests return JSONResponse(status_code=starlette.status.HTTP_404_NOT_FOUND, - content=Error(code=errno.ENOENT, message=f"{pet_id} not found").dict(), - media_type="application/json; utf-8") + content=v2.Error(code=errno.ENOENT, message=f"{pet_id} not found").dict()) @app.delete('/pets/{pet_id}', operation_id="deletePet", responses={ 204: {"model": None}, - 404: {"model": Error} + 404: {"model": v2.Error} }) +@version(2) def deletePet(response: Response, - pet_id: int = Query(..., alias='petId')) -> Pets: + pet_id: int = Query(..., alias='petId')) -> v2.Pets: for k, v in ZOO.items(): - if pet_id == v.id: + if pet_id == v.identifier: del ZOO[k] response.status_code = starlette.status.HTTP_204_NO_CONTENT return response else: return JSONResponse(status_code=starlette.status.HTTP_404_NOT_FOUND, - content=Error(code=errno.ENOENT, message=f"{pet_id} not found").dict()) + content=v2.Error(code=errno.ENOENT, message=f"{pet_id} not found").dict()) + +app = VersionedFastAPI(app, + version_format='{major}', + prefix_format='/v{major}') diff --git a/tests/api/v1/schema.py b/tests/api/v1/schema.py new file mode 100644 index 0000000..34ffb89 --- /dev/null +++ b/tests/api/v1/schema.py @@ -0,0 +1,25 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class PetBase(BaseModel): + name: str + tag: Optional[str] = Field(default=None) + + +class PetCreate(PetBase): + pass + + +class Pet(PetBase): + id: int + + +class Pets(BaseModel): + __root__: List[Pet] = Field(..., description='list of pet') + + +class Error(BaseModel): + code: int + message: str \ No newline at end of file diff --git a/tests/api/v2/schema.py b/tests/api/v2/schema.py new file mode 100644 index 0000000..cbfd3f2 --- /dev/null +++ b/tests/api/v2/schema.py @@ -0,0 +1,57 @@ +import uuid +from typing import List, Optional, Literal, Union, Annotated + +import pydantic +from pydantic import BaseModel, Field +from pydantic.fields import Undefined + +class PetBase(BaseModel): + identifier: Optional[str] = Field(default_factory=lambda: str(uuid.uuid4())) + name: str + tags: Optional[List[str]] #= Field(default_factory=list) + + +class BlackCat(PetBase): + pet_type: Literal['cat'] + color: Literal['black'] + black_name: str + + +class WhiteCat(PetBase): + pet_type: Literal['cat'] + color: Literal['white'] + white_name: str + + +# Can also be written with a custom root type +# +class Cat(BaseModel): + __root__: Annotated[Union[BlackCat, WhiteCat], Field(discriminator='color')] + + def __getattr__(self, item): + return getattr(self.__root__, item) + +#Cat = Annotated[Union[BlackCat, WhiteCat], Field(default=Undefined, discriminator='color')] + + +class Dog(PetBase): + pet_type: Literal['dog'] + name: str + + +#Pet = Annotated[Union[Cat, Dog], Field(default=Undefined, discriminator='pet_type')] + +class Pet(BaseModel): + __root__: Annotated[Union[Cat, Dog], Field(discriminator='pet_type')] + def __getattr__(self, item): + return getattr(self.__root__, item) + + +class Pets(BaseModel): + __root__: List[Pet] = Field(..., description='list of pet') + + + +class Error(BaseModel): + code: int + message: str \ No newline at end of file diff --git a/tests/model_test.py b/tests/model_test.py new file mode 100644 index 0000000..dcfa837 --- /dev/null +++ b/tests/model_test.py @@ -0,0 +1,125 @@ +import pytest + +from pydantic import Extra + +from tests.api.v2.schema import Pet, Dog, Cat, WhiteCat, BlackCat +from openapi3.schemas import Schema + +def test_Pet(): + data = Dog.schema() + shma = Schema.parse_obj(data) + shma._identity = "Dog" + assert shma.get_type().schema() == data + +import asyncio +import uuid + +import pytest + +import requests + +import uvloop +from hypercorn.asyncio import serve +from hypercorn.config import Config + +import openapi3 + +from tests.api.main import app + +@pytest.fixture(scope="session") +def config(unused_tcp_port_factory): + c = Config() + c.bind = [f"localhost:{unused_tcp_port_factory()}"] + return c + +@pytest.fixture(scope="session") +def event_loop(request): + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +@pytest.fixture(scope="session") +async def server(event_loop, config): + uvloop.install() + try: + sd = asyncio.Event() + task = event_loop.create_task(serve(app, config, shutdown_trigger=sd.wait)) + yield config + finally: + sd.set() + await task + +@pytest.fixture(scope="session", params=[2]) +def version(request): + return f"v{request.param}" + +@pytest.fixture(scope="session") +async def client(event_loop, server, version): + data = await asyncio.to_thread(requests.get, f"http://{server.bind[0]}/{version}/openapi.json") + data = data.json() + data["servers"][0]["url"] = f"http://{server.bind[0]}/{version}" + api = openapi3.OpenAPI(data) + return api + +@pytest.mark.asyncio +async def test_model(event_loop, server, client): + orig = client.components.schemas["WhiteCat"].dict(exclude_unset=True) + crea = client.components.schemas["WhiteCat"].get_type().schema() + assert orig == crea + + orig = client.components.schemas["Cat"].dict(exclude_unset=True, by_alias=True) + crea = client.components.schemas["Cat"].get_type().schema(ref_template="#/components/schemas/{model}", by_alias=True) + if "definitions" in crea: + del crea["definitions"] + assert crea == orig + + orig = client.components.schemas["Pet"].dict(exclude_unset=True, by_alias=True) + crea = client.components.schemas["Pet"].get_type().schema(ref_template="#/components/schemas/{model}", by_alias=True) + if "definitions" in crea: + del crea["definitions"] + assert crea == orig + + +def randomPet(name=None): + if name: + return {"pet":Dog(name=name, pet_type="dog").dict()} + else: + return {"pet":WhiteCat(name=str(uuid.uuid4()), pet_type="cat", white_name=str(uuid.uuid4()), color="white").dict()} + +@pytest.mark.asyncio +async def test_createPet(event_loop, server, client): + r = await asyncio.to_thread(client.call_createPet, data=randomPet()) + assert type(r.__root__.__root__) == client.components.schemas["WhiteCat"].get_type() + + r = await asyncio.to_thread(client.call_createPet, data=randomPet(name=r.__root__.__root__.name)) + assert type(r) == client.components.schemas["Error"].get_type() + + +@pytest.mark.asyncio +async def test_listPet(event_loop, server, client): + r = await asyncio.to_thread(client.call_createPet, data=randomPet(str(uuid.uuid4()))) + l = await asyncio.to_thread(client.call_listPet) + assert len(l) > 0 + +@pytest.mark.asyncio +async def test_getPet(event_loop, server, client): + pet = await asyncio.to_thread(client.call_createPet, data=randomPet(str(uuid.uuid4()))) + r = await asyncio.to_thread(client.call_getPet, parameters={"pet_id":pet.__root__.identifier}) + assert type(r.__root__) == type(pet.__root__) + + r = await asyncio.to_thread(client.call_getPet, parameters={"pet_id":"-1"}) + assert type(r) == client.components.schemas["Error"].get_type() + +@pytest.mark.asyncio +async def test_deletePet(event_loop, server, client): + r = await asyncio.to_thread(client.call_deletePet, parameters={"pet_id":-1}) + assert type(r) == client.components.schemas["Error"].get_type() + + await asyncio.to_thread(client.call_createPet, data=randomPet(str(uuid.uuid4()))) + zoo = await asyncio.to_thread(client.call_listPet) + for pet in zoo: + while hasattr(pet, '__root__'): + pet = pet.__root__ + await asyncio.to_thread(client.call_deletePet, parameters={"pet_id":pet.identifier}) + From c7cfef17df2f08ccf1593349f968d82b4c4fbf2c Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Thu, 30 Dec 2021 07:21:30 +0100 Subject: [PATCH 030/125] openapi - resolving references is in-situ just links the target Reference uses __{g,s}etattr__ to forward to target --- openapi3/general.py | 13 +++++++++++++ openapi3/openapi.py | 40 +++++++++++++++++++++------------------- tests/ref_test.py | 2 +- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/openapi3/general.py b/openapi3/general.py index 1b1fde1..37539fe 100644 --- a/openapi3/general.py +++ b/openapi3/general.py @@ -45,6 +45,19 @@ class Reference(ObjectBase): """ ref: str = Field(alias="$ref") + _target: object = None class Config: """This object cannot be extended with additional properties and any properties added SHALL be ignored.""" extra = Extra.ignore + + def __getattr__(self, item): + if item != "_target": + return getattr(self._target, item) + else: + return getattr(self, item) + + def __setattr__(self, item, value): + if item != "_target": + setattr(self._target, item, value) + else: + super().__setattr__(item, value) diff --git a/openapi3/openapi.py b/openapi3/openapi.py index 7cb2056..eeeb4ac 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -12,9 +12,9 @@ from .general import Reference, JSONPointer, JSONReference from .info import Info from .object_base import ObjectExtended, ObjectBase -from .paths import PathItem, SecurityRequirement, _validate_parameters +from .paths import PathItem, SecurityRequirement, _validate_parameters, Operation from .servers import Server -from .schemas import Schema +from .schemas import Schema, Discriminator from .tag import Tag @@ -299,7 +299,6 @@ def _resolve_references(self, api): """ # don't circular import - reference_type = Reference root = self def resolve(obj): @@ -310,11 +309,20 @@ def resolve(obj): continue if isinstance(obj, PathItem) and slot == "ref": - resolved_value = api.resolve_jr(root, obj, Reference.construct(ref=value)) - setattr(obj, slot, resolved_value) - if isinstance(value, reference_type): - resolved_value = api.resolve_jr(root, obj, value) - setattr(obj, slot, resolved_value) + ref = Reference.construct(ref=value) + ref._target = api.resolve_jr(root, obj, ref) + setattr(obj, slot, ref) + +# if isinstance(obj, Discriminator) and slot == "mapping": +# mapping = dict() +# for k,v in value.items(): +# mapping[k] = Reference.construct(ref=v) +# setattr(obj, slot, mapping) + + value = getattr(obj, slot) + if isinstance(value, Reference): + value._target = api.resolve_jr(root, obj, value) +# setattr(obj, slot, resolved_value) elif issubclass(type(value), ObjectBase): # otherwise, continue resolving down the tree resolve(value) @@ -322,26 +330,20 @@ def resolve(obj): resolve(value) elif isinstance(value, list): # if it's a list, resolve its item's references - resolved_list = [] for item in value: - if isinstance(item, reference_type): - resolved_value = api.resolve_jr(root, obj, item) - resolved_list.append(resolved_value) + if isinstance(item, Reference): + item._target = api.resolve_jr(root, obj, item) elif isinstance(item, (ObjectBase, dict, list)): resolve(item) - resolved_list.append(item) - else: - resolved_list.append(item) - setattr(obj, slot, resolved_list) elif isinstance(value, (str, int, float, datetime.datetime)): continue else: raise TypeError(type(value)) elif isinstance(obj, dict): for k, v in obj.items(): - if isinstance(v, reference_type): + if isinstance(v, Reference): if v.ref: - obj[k] = api.resolve_jr(root, obj, v) + v._target = api.resolve_jr(root, obj, v) elif isinstance(v, (ObjectBase, dict, list)): resolve(v) @@ -384,7 +386,7 @@ class OperationCallable: with the configured values included. This class is not intended to be used directly. """ - def __init__(self, operation, base_url, security, ssl_verify, session): + def __init__(self, operation: Operation.request, base_url, security, ssl_verify, session): self.operation = operation self.base_url = base_url self.security = security diff --git a/tests/ref_test.py b/tests/ref_test.py index 6f95a6b..6865acc 100644 --- a/tests/ref_test.py +++ b/tests/ref_test.py @@ -18,7 +18,7 @@ def test_ref_resolution(petstore_expanded_spec): """ ref = petstore_expanded_spec.paths['/pets'].get.responses['default'].content['application/json'].schema_ - assert type(ref) == Schema + assert type(ref._target) == Schema assert ref.type == "object" assert len(ref.properties) == 2 assert 'code' in ref.properties From 9e1edc2c9a879a4c266974026048a87709be3e17 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Thu, 30 Dec 2021 07:24:22 +0100 Subject: [PATCH 031/125] runtimeexpression - test escaping / & ~ --- openapi3/expression/grammar.ebnf | 10 ++++++---- openapi3/expression/model.py | 17 ++++++++++++----- tests/test_runtimexpression.py | 19 +++++++++++++++++-- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/openapi3/expression/grammar.ebnf b/openapi3/expression/grammar.ebnf index 40a1354..6da7fab 100644 --- a/openapi3/expression/grammar.ebnf +++ b/openapi3/expression/grammar.ebnf @@ -46,6 +46,7 @@ reference_token = { unescaped | escaped }* ; # ; %x2F ('/') and %x7E ('~') are excluded from 'unescaped' unescaped = /[^\/~]/ ; + # escaped = "~" ( "0" / "1" ) #; representing '~' and '/', respectively escaped = "~" ( "0" | "1" ) ; @@ -54,8 +55,9 @@ escaped = "~" ( "0" | "1" ) ; name = /[\w]*/ ; # token = 1*tchar -token = {tchar}+; +# token = {tchar}+; +token = /[!#$%&'*+-\.^-`|~\w]+/ ; -#tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / -#"^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA" -tchar = /[^\(\),\/:;<=>?@[\]{}]/ ; +#tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA" +# tchar = /[^\(\),\/:;<=>?@[\]{}]/ ; +#tchar = /!#$%&'*+-\.^-`|~\w/ ; diff --git a/openapi3/expression/model.py b/openapi3/expression/model.py index 4f9243a..dbfa3b3 100644 --- a/openapi3/expression/model.py +++ b/openapi3/expression/model.py @@ -2,6 +2,8 @@ from yarl import URL import requests +import openapi3.general + from ._model import RuntimeExpressionModelBuilderSemantics as RuntimeExpressionModelBuilderSemanticsBase, \ JSONPointer as JSONPointerBase, \ Header as HeaderBase, \ @@ -17,9 +19,12 @@ class RuntimeExpressionModelBuilderSemantics(RuntimeExpressionModelBuilderSemant def reference_token(self, ast, name=None): return "".join(ast) - def token(self, ast, name=None): + def escaped(self, ast): return "".join(ast) +# def token(self, ast, name=None): +# return "".join(ast) + class RuntimeExpression(RuntimeExpressionBase): def __init__(self, ctx=None, ast=None, parseinfo=None, **kwargs): @@ -59,9 +64,11 @@ def eval(self, req, resp): class JSONPointer(JSONPointerBase): def __init__(self, ctx=None, ast=None, parseinfo=None, **kwargs): super().__init__(ctx, None, parseinfo, **kwargs) - self.tokens = ast.tokens - def eval(self, data): - pass + self._tokens = ast.tokens + @property + def tokens(self): + for i in self._tokens: + yield openapi3.general.JSONPointer.decode(i) class Header(HeaderBase): @@ -99,7 +106,7 @@ def __init__(self, ctx=None, ast=None, parseinfo=None, **kwargs): self.key = ast.key def eval(self, data): - return data.path.get(self.key) + return data.path.get(self.key, None) class Body(BodyBase): diff --git a/tests/test_runtimexpression.py b/tests/test_runtimexpression.py index 2d6f9ae..3360e0b 100644 --- a/tests/test_runtimexpression.py +++ b/tests/test_runtimexpression.py @@ -23,7 +23,18 @@ def test_parse(data): def test_parse_fail(): with pytest.raises(tatsu.exceptions.FailedParse): - loads("x") + loads("$request.body#/~test") + +def test_parse_escape(): + loads("$request.body#/~0test") + loads("$request.body#/~1test") + with pytest.raises(tatsu.exceptions.FailedParse): + loads("$request.body#/~2test") + with pytest.raises(tatsu.exceptions.FailedParse): + loads("$request.body#/test/~") + + + get_testdata = { "$url":"http://example.org/subscribe/myevent?queryUrl=http://clientdomain.com/stillrunning", @@ -34,6 +45,8 @@ def test_parse_fail(): "$request.body#/failedUrl":"http://clientdomain.com/failed", "$request.body#/successUrls/2":"http://clientdomain.com/medium", "$response.header.Location":"http://example.org/subscription/1" , + "$request.body#/escaped~1content/2/~0/~1/y":"yes", + "$request.body#/escaped~0content/2/~1/~0/x":"no", } @pytest.mark.parametrize("param, result", get_testdata.items()) def test_get(param, result): @@ -44,7 +57,9 @@ def test_get(param, result): "http://clientdomain.com/fast", "http://clientdomain.com/medium", "http://clientdomain.com/slow" - ] + ], + "escaped/content": [0, {"~": {"/": {"y": "yes"}}}], + "escaped~content": [0, {"/": {"~": {"x": "no" }}}] } with requests_mock.Mocker() as m: From 6c2e3de84d6e52c5db08cb37eb724ed9e7d39015 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Thu, 30 Dec 2021 11:22:33 +0100 Subject: [PATCH 032/125] schema - support multi level discrimination --- openapi3/schemas.py | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/openapi3/schemas.py b/openapi3/schemas.py index d4bd4db..261ef98 100644 --- a/openapi3/schemas.py +++ b/openapi3/schemas.py @@ -91,7 +91,7 @@ def validate_Schema_number_type(cls, values): values[i] = int(v) return values - def get_type(self, name=None, discriminator=None): + def get_type(self, names=None, discriminators=None): """ Returns the Type that this schema represents. This Type is created once per Schema and cached so that all instances of the same schema are the @@ -103,12 +103,12 @@ def get_type(self, name=None, discriminator=None): isinstance(object1, example._schema.get_type()) # true type(object1) == type(object2) # true """ - if discriminator and hasattr(self, "_model_type") and getattr(self._model_type, "discriminator", None) == None: - return Model.from_schema(self, name, discriminator) + if discriminators and hasattr(self, "_model_type") and getattr(self._model_type, "discriminator", None) == None: + return Model.from_schema(self, names, discriminators) try: return self._model_type except AttributeError: - self._model_type = Model.from_schema(self, name, discriminator) + self._model_type = Model.from_schema(self, names, discriminators) return self._model_type def model(self, data): @@ -139,7 +139,14 @@ class Config: extra: Extra.forbid @classmethod - def from_schema(cls, shma, shmanm=None, discriminator=None): + def from_schema(cls, shma, shmanm=None, discriminators=None): + + if shmanm is None: + shmanm = [] + + if discriminators is None: + discriminators = [] + def typeof(schema): r = None if schema.type == "string": @@ -160,12 +167,19 @@ def annotationsof(schema): if schema.type == "array": annos["__root__"] = typeof(schema) else: + for name, f in schema.properties.items(): - if discriminator and name == discriminator.propertyName: + r = None + for discriminator in discriminators: + if name != discriminator.propertyName: + continue for disc,v in discriminator.mapping.items(): - if v == shmanm: + if v in shmanm: r = Literal[disc] break + else: + raise ValueError(schema) + break else: r = typeof(f) if name not in schema.required: @@ -198,7 +212,7 @@ def fieldof(schema): for i in shma.allOf: annos.update(annotationsof(i)) elif shma.anyOf: - t = tuple([i.get_type(name=i.ref, discriminator=shma.discriminator) for i in shma.anyOf]) + t = tuple([i.get_type(names=shmanm + [i.ref], discriminators=discriminators + [shma.discriminator]) for i in shma.anyOf]) if shma.discriminator: annos["__root__"] = Annotated[Union[t], Field(discriminator=shma.discriminator.propertyName)] else: @@ -211,8 +225,8 @@ def fieldof(schema): namespace['__annotations__'] = annos - r = types.new_class(type_name, (BaseModel,), {}, lambda ns: ns.update(namespace)) - return r + m = types.new_class(type_name, (BaseModel,), {}, lambda ns: ns.update(namespace)) + return m Schema.update_forward_refs() \ No newline at end of file From 80c181e4b5038ab36274844e75fd00a399435025 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Thu, 30 Dec 2021 13:10:56 +0100 Subject: [PATCH 033/125] calling - restructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit move request handling from Operation to OperationCall, rename OperationCall to Request In OpenAPI use __getattr__ instead of __getattribute__ for the ….call_ interface introduce OpenAPI._. as alternate interface --- openapi3/openapi.py | 84 +++++++++---------- openapi3/paths.py | 192 -------------------------------------------- openapi3/request.py | 189 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 228 insertions(+), 237 deletions(-) create mode 100644 openapi3/request.py diff --git a/openapi3/openapi.py b/openapi3/openapi.py index eeeb4ac..7660d88 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -16,7 +16,9 @@ from .servers import Server from .schemas import Schema, Discriminator from .tag import Tag +from .request import Request +HTTP_METHODS = frozenset(["get","delete","head","post","put","patch","trace"]) class Loader: def load(self, name): @@ -135,14 +137,13 @@ def __init__( schema._identity = name for path,obj in self.paths.items(): - for m in obj.__fields_set__ & frozenset(["get","delete","head","post","put","patch","trace"]): + for m in obj.__fields_set__ & HTTP_METHODS: op = getattr(obj, m) - op._path, op._method, op._spec = path, m, self _validate_parameters(op, path) if op.operationId is None: continue formatted_operation_id = op.operationId.replace(" ", "_") - self._register_operation(formatted_operation_id, op) + self._register_operation(formatted_operation_id, (m, path, op)) for r, response in op.responses.items(): if isinstance(response, Reference): continue @@ -187,38 +188,35 @@ def errors(self): # private methods - def _register_operation(self, operation_id, operation): + def _register_operation(self, operation_id, opInfo): """ Adds an Operation to this spec's _operation_map, raising an error if the OperationId has already been registered. :param operation_id: The operation ID to register :type operation_id: str - :param operation: The operation to register - :type operation: Operation + :param opInfo: The operation to register + :type opInfo: Operation """ if operation_id in self._operation_map: - raise SpecError(f"Duplicate operationId {operation_id}", element=operation) - self._operation_map[operation_id] = operation + raise SpecError(f"Duplicate operationId {operation_id}", element=opInfo) + self._operation_map[operation_id] = opInfo - def _get_callable(self, operation): + def _get_callable(self, method, path, request:Operation): """ A helper function to create OperationCallable objects for __getattribute__, pre-initialized with the required values from this object. - :param operation: The Operation the callable should call - :type operation: callable (Operation.request) + :param request: The Operation the callable should call + :type request: callable (Operation.request) :returns: The callable that executes this operation with this object's configuration. - :rtype: OperationCallable + :rtype: Request """ - base_url = self.servers[0].url + return Request(self, method, path, request) - return OperationCallable(operation, base_url, self._security, self._ssl_verify, - self._session) - - def __getattribute__(self, attr): + def __getattr__(self, attr): """ Extended __getattribute__ function to allow resolving dynamic function names. The purpose of this is to call syntax like this:: @@ -239,19 +237,21 @@ def __getattribute__(self, attr): """ if attr.startswith('call_'): _, operationId = attr.split('_', 1) - if operationId in self._operation_map: - return self._get_callable(self._operation_map[operationId].request) - else: + if operationId not in self._operation_map: raise AttributeError('{} has no operation {}'.format( self.info.title, operationId)) - - return object.__getattribute__(self, attr) + method, path, op = self._operation_map[operationId] + return self._get_callable(method, path, op) + raise KeyError(attr) def _load(self, i): data = self.loader.load(i) return OpenAPISpec.parse_obj(data) + @property + def _(self): + return OperationIndex(self) def resolve_jr(self, root: "OpenAPISpec", obj, value: Reference): @@ -270,7 +270,6 @@ def resolve_jr(self, root: "OpenAPISpec", obj, value: Reference): raise - class OpenAPISpec(ObjectExtended): """ This class represents the root of the OpenAPI schema document, as defined @@ -378,27 +377,22 @@ def resolve_jp(self, jp): return node -class OperationCallable: - """ - This class is returned by instances of the OpenAPI class when members - formatted like call_operationId are accessed, and a valid Operation is - found, and allows calling the operation directly from the OpenAPI object - with the configured values included. This class is not intended to be used - directly. - """ - def __init__(self, operation: Operation.request, base_url, security, ssl_verify, session): - self.operation = operation - self.base_url = base_url - self.security = security - self.ssl_verify = ssl_verify - self.session = session - - def __call__(self, *args, **kwargs): - if self.ssl_verify is not None: - kwargs['verify'] = self.ssl_verify - if self.session: - kwargs['session'] = self.session - return self.operation(self.base_url, *args, security=self.security, - **kwargs) +class OperationIndex: + def __init__(self, api): + self._api = api + self._spec = api._spec + + def __getattr__(self, item): + pi: PathItem + for path,pi in self._spec.paths.items(): + op: Operation + for method in pi.__fields_set__ & HTTP_METHODS: + op = getattr(pi, method) + if op.operationId != item: + continue + return Request(self._api, method, path, op) + raise ValueError(item) + + OpenAPISpec.update_forward_refs() \ No newline at end of file diff --git a/openapi3/paths.py b/openapi3/paths.py index 4165dc1..5111354 100644 --- a/openapi3/paths.py +++ b/openapi3/paths.py @@ -127,202 +127,10 @@ class Operation(ObjectExtended): security: Optional[List[SecurityRequirement]] = Field(default_factory=list) servers: Optional[List[Server]] = Field(default=None) - - """ - The OpenAPISpec this is part of - """ - _spec: "OpenAPISpec" - - _path: str - _method: str - _request: object - _session: object - class Config: underscore_attrs_are_private = True - def _request_handle_secschemes(self, security_requirement, value): - ss = self._spec.components.securitySchemes[security_requirement.name] - - if ss.type == 'http' and ss.scheme_ == 'basic': - self._request.auth = requests.auth.HTTPBasicAuth(*value) - - if ss.type == 'http' and ss.scheme_ == 'digest': - self._request.auth = requests.auth.HTTPDigestAuth(*value) - - if ss.type == 'http' and ss.scheme_ == 'bearer': - header = ss.bearerFormat or 'Bearer {}' - self._request.headers['Authorization'] = header.format(value) - - if ss.type == 'mutualTLS': - # TLS Client certificates (mutualTLS) - self._request.cert = value - - if ss.type == 'apiKey': - if ss.in_ == 'query': - # apiKey in query parameter - self._request.params[ss.name] = value - - if ss.in_ == 'header': - # apiKey in query header data - self._request.headers[ss.name] = value - - if ss.in_ == 'cookie': - self._request.cookies ={ss.name:value} - - def _request_handle_parameters(self, parameters={}): - # Parameters - path_parameters = {} - accepted_parameters = {} - p = self.parameters + self._spec.paths[self._path].parameters - - for _ in list(p): - # TODO - make this work with $refs - can operations be $refs? - accepted_parameters.update({_.name: _}) - - for name, spec in accepted_parameters.items(): - try: - value = parameters[name] - except KeyError: - if spec.required and name not in parameters: - err_msg = 'Required parameter {} not provided'.format(name) - raise ValueError(err_msg) - - continue - - if spec.in_ == 'path': - # The string method `format` is incapable of partial updates, - # as such we need to collect all the path parameters before - # applying them to the format string. - path_parameters[name] = value - - if spec.in_ == 'query': - self._request.params[name] = value - - if spec.in_ == 'header': - self._request.headers[name] = value - - if spec.in_ == 'cookie': - self._request.cookies[name] = value - - self._request.url = self._request.url.format(**path_parameters) - - def _request_handle_body(self, data): - if 'application/json' in self.requestBody.content: - if isinstance(data, (dict, list)): - body = json.dumps(data) - - self._request.data = body - self._request.headers['Content-Type'] = 'application/json' - else: - raise NotImplementedError() - - def request(self, base_url, security={}, data=None, parameters={}, verify=True, - session=None, raw_response=False): - """ - Sends an HTTP request as described by this Path - - :param base_url: The URL to append this operation's path to when making - the call. - :type base_url: str - :param security: The security scheme to use, and the values it needs to - process successfully. - :type security: dict{str: str} - :param data: The request body to send. - :type data: any, should match content/type - :param parameters: The parameters used to create the path - :type parameters: dict{str: str} - :param verify: Should we do an ssl verification on the request or not, - In case str was provided, will use that as the CA. - :type verify: bool/str - :param session: a persistent request session - :type session: None, requests.Session - :param raw_response: If true, return the raw response instead of validating - and exterpolating it. - :type raw_response: bool - """ - # Set request method (e.g. 'GET') - self._request = requests.Request(self._method, cookies={}) - - # Set self._request.url to base_url w/ path - self._request.url = base_url + self._path - - if security and self.security: - security_requirement = None - for scheme, value in security.items(): - security_requirement = None - for r in self.security: - if r.name == scheme: - security_requirement = r - self._request_handle_secschemes(r, value) - - if security_requirement is None: - raise ValueError(f"No security requirement satisfied (accepts {', '.join(self.security.keys()) })") - - if self.requestBody: - if self.requestBody.required and data is None: - err_msg = 'Request Body is required but none was provided.' - raise ValueError(err_msg) - - self._request_handle_body(data) - - self._request_handle_parameters(parameters) - - if session is None: - session = self._session = requests.Session() - - # send the prepared request - result = session.send(self._request.prepare()) - - # spec enforces these are strings - status_code = str(result.status_code) - - # find the response model in spec we received - expected_response = None - if status_code in self.responses: - expected_response = self.responses[status_code] - elif 'default' in self.responses: - expected_response = self.responses['default'] - - if expected_response is None: - # TODO - custom exception class that has the response object in it - err_msg = '''Unexpected response {} from {} (expected one of {}, \ - no default is defined''' - err_var = result.status_code, self.operationId, ','.join(self.responses.keys()) - - raise RuntimeError(err_msg.format(*err_var)) - - if len(expected_response.content) == 0: - return None - - content_type = result.headers['Content-Type'] - expected_media = expected_response.content.get(content_type, None) - - if expected_media is None and '/' in content_type: - # accept media type ranges in the spec. the most specific matching - # type should always be chosen, but if we do not have a match here - # a generic range should be accepted if one if provided - # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#response-object - - generic_type = content_type.split('/')[0] + '/*' - expected_media = expected_response.content.get(generic_type, None) - - if expected_media is None: - err_msg = '''Unexpected Content-Type {} returned for operation {} \ - (expected one of {})''' - err_var = result.headers['Content-Type'], self.operationId, ','.join(expected_response.content.keys()) - - raise RuntimeError(err_msg.format(*err_var)) - - response_data = None - - if content_type.lower() == 'application/json': - return expected_media.schema_.model(result.json()) - else: - raise NotImplementedError() - - class PathItem(ObjectExtended): """ A Path Item, as defined `here`_. diff --git a/openapi3/request.py b/openapi3/request.py new file mode 100644 index 0000000..27b8d5b --- /dev/null +++ b/openapi3/request.py @@ -0,0 +1,189 @@ +import json + +import requests + + +class Request: + """ + This class is returned by instances of the OpenAPI class when members + formatted like call_operationId are accessed, and a valid Operation is + found, and allows calling the operation directly from the OpenAPI object + with the configured values included. This class is not intended to be used + directly. + """ + def __init__(self, api: "OpenAPI", method: str, path: str, operation: "Operation.request"): + self.api = api + self.spec = api._spec + self.method = method + self.path = path + self.operation = operation + self.session:requests.Session = self.api._session or requests.Session() + self.req:requests.Request = None + + def __call__(self, *args, **kwargs): + return self.request(*args, **kwargs) + + @property + def security(self): + return self.api._security + + + def args(self, content_type="application/json"): + op = self.operation + parameters = op.parameters + self.spec.paths[self.path].parameters + + return {"parameters":parameters, "data":op.requestBody.content[content_type].schema_._target} + + def _handle_secschemes(self, security_requirement, value): + ss = self.spec.components.securitySchemes[security_requirement.name] + + if ss.type == 'http' and ss.scheme_ == 'basic': + self.req.auth = requests.auth.HTTPBasicAuth(*value) + + if ss.type == 'http' and ss.scheme_ == 'digest': + self.req.auth = requests.auth.HTTPDigestAuth(*value) + + if ss.type == 'http' and ss.scheme_ == 'bearer': + header = ss.bearerFormat or 'Bearer {}' + self.req.headers['Authorization'] = header.format(value) + + if ss.type == 'mutualTLS': + # TLS Client certificates (mutualTLS) + self.req.cert = value + + if ss.type == 'apiKey': + if ss.in_ == 'query': + # apiKey in query parameter + self.req.params[ss.name] = value + + if ss.in_ == 'header': + # apiKey in query header data + self.req.headers[ss.name] = value + + if ss.in_ == 'cookie': + self.req.cookies = {ss.name: value} + + + def _handle_parameters(self, parameters): + # Parameters + path_parameters = {} + accepted_parameters = {} + p = self.operation.parameters + self.spec.paths[self.path].parameters + + for _ in list(p): + # TODO - make this work with $refs - can operations be $refs? + accepted_parameters.update({_.name: _}) + + for name, spec in accepted_parameters.items(): + if (parameters is None or name not in parameters): + if spec.required: + raise ValueError(f'Required parameter {name} not provided') + continue + + value = parameters[name] + + if spec.in_ == 'path': + # The string method `format` is incapable of partial updates, + # as such we need to collect all the path parameters before + # applying them to the format string. + path_parameters[name] = value + + if spec.in_ == 'query': + self.req.params[name] = value + + if spec.in_ == 'header': + self.req.headers[name] = value + + if spec.in_ == 'cookie': + self.req.cookies[name] = value + + self.req.url = self.req.url.format(**path_parameters) + + def _handle_body(self, data): + if 'application/json' in self.operation.requestBody.content: + if not isinstance(data, (dict, list)): + raise TypeError(data) + body = json.dumps(data) + self.req.data = body + self.req.headers['Content-Type'] = 'application/json' + else: + raise NotImplementedError() + + def request(self, data=None, parameters=None): + """ + Sends an HTTP request as described by this Path + + :param data: The request body to send. + :type data: any, should match content/type + :param parameters: The parameters used to create the path + :type parameters: dict{str: str} + """ + # Set request method (e.g. 'GET') + self.req = requests.Request(self.method, cookies={}) + + # Set self._request.url to base_url w/ path + self.req.url = self.spec.servers[0].url + self.path + + if self.security and self.operation.security: + for scheme, value in self.security.items(): + for r in filter(lambda x: x.name == scheme, self.operation.security): + self._handle_secschemes(r, value) + break + else: + continue + break + else: + raise ValueError(f"No security requirement satisfied (accepts {', '.join(self.operation.security.keys())})") + + if self.operation.requestBody: + if self.operation.requestBody.required and data is None: + raise ValueError('Request Body is required but none was provided.') + + self._handle_body(data) + + self._handle_parameters(parameters) + + # send the prepared request + result = self.session.send(self.req.prepare()) + + # spec enforces these are strings + status_code = str(result.status_code) + + # find the response model in spec we received + expected_response = None + if status_code in self.operation.responses: + expected_response = self.operation.responses[status_code] + elif 'default' in self.operation.responses: + expected_response = self.operation.responses['default'] + + if expected_response is None: + # TODO - custom exception class that has the response object in it + raise ValueError(f"""Unexpected response {result.status_code} from {self.operation.operationId} (expected one of { ",".join(self.operation.responses.keys()) }), no default is defined""") + + # defined as "no content" + if len(expected_response.content) == 0: + return None + + content_type = result.headers['Content-Type'] + expected_media = expected_response.content.get(content_type, None) + + if expected_media is None and '/' in content_type: + # accept media type ranges in the spec. the most specific matching + # type should always be chosen, but if we do not have a match here + # a generic range should be accepted if one if provided + # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#response-object + + generic_type = content_type.split('/')[0] + '/*' + expected_media = expected_response.content.get(generic_type, None) + + if expected_media is None: + err_msg = '''Unexpected Content-Type {} returned for operation {} \ + (expected one of {})''' + err_var = result.headers['Content-Type'], self.operation.operationId, ','.join(expected_response.content.keys()) + + raise RuntimeError(err_msg.format(*err_var)) + + if content_type.lower() == 'application/json': + return expected_media.schema_.model(result.json()) + else: + raise NotImplementedError() From 07e374f3a6edbbf3b858fc4e1f3c638eaa4a72fc Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Thu, 30 Dec 2021 13:12:19 +0100 Subject: [PATCH 034/125] discrimination - set default values --- openapi3/schemas.py | 30 ++++++++++-------------------- tests/api/main.py | 2 ++ tests/api/v2/schema.py | 15 +++++++++------ tests/model_test.py | 42 ++++++++++++++++++++++++++++-------------- tests/parsing_test.py | 2 +- tests/path_test.py | 4 ++-- 6 files changed, 52 insertions(+), 43 deletions(-) diff --git a/openapi3/schemas.py b/openapi3/schemas.py index 261ef98..d59b587 100644 --- a/openapi3/schemas.py +++ b/openapi3/schemas.py @@ -1,7 +1,7 @@ import types import uuid from typing import Union, List, Any, Optional, Dict, Literal, Annotated - +from functools import lru_cache from pydantic import Field, root_validator, Extra, BaseModel from .general import Reference # need this for Model below @@ -81,6 +81,10 @@ class Schema(ObjectExtended): """ _identity: str + class Config: +# keep_untouched = (lru_cache,) + extra = Extra.forbid + @root_validator def validate_Schema_number_type(cls, values): conv = ["minimum","maximum"] @@ -91,25 +95,9 @@ def validate_Schema_number_type(cls, values): values[i] = int(v) return values +# @lru_cache def get_type(self, names=None, discriminators=None): - """ - Returns the Type that this schema represents. This Type is created once - per Schema and cached so that all instances of the same schema are the - same Type. For example:: - - object1 = example_schema.model({"some":"json"}) - object2 = example_schema.model({"other":"json"}) - - isinstance(object1, example._schema.get_type()) # true - type(object1) == type(object2) # true - """ - if discriminators and hasattr(self, "_model_type") and getattr(self._model_type, "discriminator", None) == None: - return Model.from_schema(self, names, discriminators) - try: - return self._model_type - except AttributeError: - self._model_type = Model.from_schema(self, names, discriminators) - return self._model_type + return Model.from_schema(self, names, discriminators) def model(self, data): """ @@ -157,6 +145,8 @@ def typeof(schema): r = List[schema.items.get_type()] elif schema.type == 'object': return schema.get_type() + elif schema.type is None: # discriminated root + return None else: raise TypeError(schema.type) @@ -195,7 +185,7 @@ def fieldof(schema): else: for name, f in schema.properties.items(): args = dict() - for i in ["enum"]: + for i in ["enum","default"]: if (v:=getattr(f, i, None)): args[i] = v r[name] = Field(**args) diff --git a/tests/api/main.py b/tests/api/main.py index cb4a95f..32c9b9d 100644 --- a/tests/api/main.py +++ b/tests/api/main.py @@ -1,6 +1,7 @@ from __future__ import annotations import errno +import uuid from typing import Optional import starlette.status @@ -46,6 +47,7 @@ def createPet(response: Response, message=f"{pet.name} already exists" ).dict() ) + pet.identifier = str(uuid.uuid4()) ZOO[pet.name] = r = pet response.status_code = starlette.status.HTTP_201_CREATED return r diff --git a/tests/api/v2/schema.py b/tests/api/v2/schema.py index cbfd3f2..8097e13 100644 --- a/tests/api/v2/schema.py +++ b/tests/api/v2/schema.py @@ -12,14 +12,14 @@ class PetBase(BaseModel): class BlackCat(PetBase): - pet_type: Literal['cat'] - color: Literal['black'] + pet_type: Literal['cat'] = "cat" + color: Literal['black'] = "black" black_name: str class WhiteCat(PetBase): - pet_type: Literal['cat'] - color: Literal['white'] + pet_type: Literal['cat'] = "cat" + color: Literal['white'] = "white" white_name: str @@ -30,12 +30,14 @@ class Cat(BaseModel): def __getattr__(self, item): return getattr(self.__root__, item) + def __setattr__(self, item, value): + return setattr(self.__root__, item, value) #Cat = Annotated[Union[BlackCat, WhiteCat], Field(default=Undefined, discriminator='color')] class Dog(PetBase): - pet_type: Literal['dog'] + pet_type: Literal['dog'] = "dog" name: str @@ -45,7 +47,8 @@ class Pet(BaseModel): __root__: Annotated[Union[Cat, Dog], Field(discriminator='pet_type')] def __getattr__(self, item): return getattr(self.__root__, item) - + def __setattr__(self, item, value): + return setattr(self.__root__, item, value) class Pets(BaseModel): __root__: List[Pet] = Field(..., description='list of pet') diff --git a/tests/model_test.py b/tests/model_test.py index dcfa837..9426cdf 100644 --- a/tests/model_test.py +++ b/tests/model_test.py @@ -1,3 +1,4 @@ +import pydantic import pytest from pydantic import Extra @@ -81,42 +82,55 @@ async def test_model(event_loop, server, client): assert crea == orig -def randomPet(name=None): +def randomPet(client, name=None): if name: - return {"pet":Dog(name=name, pet_type="dog").dict()} + return {"pet": client.components.schemas["Dog"].model({"name":name}).dict()} else: - return {"pet":WhiteCat(name=str(uuid.uuid4()), pet_type="cat", white_name=str(uuid.uuid4()), color="white").dict()} + return {"pet": client.components.schemas["WhiteCat"].model({"name":str(uuid.uuid4()), "white_name":str(uuid.uuid4())}).dict()} @pytest.mark.asyncio async def test_createPet(event_loop, server, client): - r = await asyncio.to_thread(client.call_createPet, data=randomPet()) - assert type(r.__root__.__root__) == client.components.schemas["WhiteCat"].get_type() - - r = await asyncio.to_thread(client.call_createPet, data=randomPet(name=r.__root__.__root__.name)) - assert type(r) == client.components.schemas["Error"].get_type() + data = { + "pet": client.components.schemas["WhiteCat"].model( + { + "name":str(uuid.uuid4()), + "white_name":str(uuid.uuid4()) + }).dict() + } +# r = await asyncio.to_thread(client.call_createPet, data=data) + r = await asyncio.to_thread(client._.createPet, data=data) + assert type(r.__root__.__root__).schema() == client.components.schemas["WhiteCat"].get_type().schema() + + r = await asyncio.to_thread(client.call_createPet, data=randomPet(client, name=r.__root__.__root__.name)) + assert type(r).schema() == client.components.schemas["Error"].get_type().schema() + + with pytest.raises(pydantic.ValidationError): + args = client._.createPet.args() + cls = args['data'].get_type() + cls() @pytest.mark.asyncio async def test_listPet(event_loop, server, client): - r = await asyncio.to_thread(client.call_createPet, data=randomPet(str(uuid.uuid4()))) + r = await asyncio.to_thread(client.call_createPet, data=randomPet(client, str(uuid.uuid4()))) l = await asyncio.to_thread(client.call_listPet) assert len(l) > 0 @pytest.mark.asyncio async def test_getPet(event_loop, server, client): - pet = await asyncio.to_thread(client.call_createPet, data=randomPet(str(uuid.uuid4()))) + pet = await asyncio.to_thread(client.call_createPet, data=randomPet(client, str(uuid.uuid4()))) r = await asyncio.to_thread(client.call_getPet, parameters={"pet_id":pet.__root__.identifier}) - assert type(r.__root__) == type(pet.__root__) + assert type(r.__root__).schema() == type(pet.__root__).schema() r = await asyncio.to_thread(client.call_getPet, parameters={"pet_id":"-1"}) - assert type(r) == client.components.schemas["Error"].get_type() + assert type(r).schema() == client.components.schemas["Error"].get_type().schema() @pytest.mark.asyncio async def test_deletePet(event_loop, server, client): r = await asyncio.to_thread(client.call_deletePet, parameters={"pet_id":-1}) - assert type(r) == client.components.schemas["Error"].get_type() + assert type(r).schema() == client.components.schemas["Error"].get_type().schema() - await asyncio.to_thread(client.call_createPet, data=randomPet(str(uuid.uuid4()))) + await asyncio.to_thread(client.call_createPet, data=randomPet(client, str(uuid.uuid4()))) zoo = await asyncio.to_thread(client.call_listPet) for pet in zoo: while hasattr(pet, '__root__'): diff --git a/tests/parsing_test.py b/tests/parsing_test.py index a288799..9d19d89 100644 --- a/tests/parsing_test.py +++ b/tests/parsing_test.py @@ -95,7 +95,7 @@ def test_parsing_with_links(with_links): response_b = spec.paths["/with-links-two/{param}"].get.responses["200"] assert "exampleWithRef" in response_b.links - assert response_b.links["exampleWithRef"] == spec.components.links["exampleWithOperationRef"] + assert response_b.links["exampleWithRef"]._target == spec.components.links["exampleWithOperationRef"] def test_parsing_broken_links(with_broken_links): diff --git a/tests/path_test.py b/tests/path_test.py index 2823c50..8100332 100644 --- a/tests/path_test.py +++ b/tests/path_test.py @@ -86,7 +86,7 @@ def test_operation_populated(petstore_expanded_spec): assert con1.schema_ is not None assert con1.schema_.type == "array" # we're not going to test that the ref resolved correctly here - that's a separate test - assert type(con1.schema_.items) == Schema + assert type(con1.schema_.items._target) == Schema resp2 = op.responses['default'] assert resp2.description == "unexpected error" @@ -95,7 +95,7 @@ def test_operation_populated(petstore_expanded_spec): con2 = resp2.content['application/json'] assert con2.schema_ is not None # again, test ref resolution elsewhere - assert type(con2.schema_) == Schema + assert type(con2.schema_._target) == Schema def test_securityparameters(with_securityparameters): From 9e0649488f411ee1004b74071f75ce1eccea3d42 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Sat, 1 Jan 2022 22:39:21 +0100 Subject: [PATCH 035/125] loader - move --- openapi3/__init__.py | 5 +++-- openapi3/loader.py | 40 +++++++++++++++++++++++++++++++++++++++ openapi3/openapi.py | 45 +++++--------------------------------------- 3 files changed, 48 insertions(+), 42 deletions(-) create mode 100644 openapi3/loader.py diff --git a/openapi3/__init__.py b/openapi3/__init__.py index 39ae14d..2394469 100644 --- a/openapi3/__init__.py +++ b/openapi3/__init__.py @@ -1,7 +1,8 @@ -from .openapi import OpenAPI, FileSystemLoader +from .openapi import OpenAPI +from .loader import FileSystemLoader # these imports appear unused, but in fact load up the subclasses ObjectBase so # that they may be referenced throughout the schema without issue -from . import info, servers, paths, general, schemas, components, security, tag, example +#from . import info, servers, paths, general, schemas, components, security, tag, example from .errors import SpecError, ReferenceResolutionError __all__ = ['OpenAPI', 'SpecError', 'ReferenceResolutionError','FileSystemLoader'] diff --git a/openapi3/loader.py b/openapi3/loader.py new file mode 100644 index 0000000..29041b9 --- /dev/null +++ b/openapi3/loader.py @@ -0,0 +1,40 @@ +import abc +import pathlib +import yaml +import json + + +class Loader(abc.ABC): + @abc.abstractmethod + def load(self, name:str): + raise NotImplementedError("load") + + +class FileSystemLoader(Loader): + def __init__(self, base:str): + self.base = pathlib.Path(base) + + def load(self, file:str, codec=None): + file = pathlib.Path(file) + path = self.base / file + assert path.is_relative_to(self.base) + data = path.open("rb").read() + if codec is not None: + codecs = [codec] + else: + codecs = ["ascii","utf-8"] + for c in codecs: + try: + r = data.decode(c) + break + except UnicodeError: + continue + else: + raise ValueError("encoding") + if file.suffix == ".yaml": + data = yaml.safe_load(data) + elif file.suffix == ".json": + data = json.loads(data) + else: + raise ValueError(file.name) + return data diff --git a/openapi3/openapi.py b/openapi3/openapi.py index 7660d88..5f7e2e2 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -1,11 +1,10 @@ import datetime -import json import pathlib -from typing import Any, List, Optional, Dict +from typing import Any, List, Optional, Dict, Union, Callable -import requests -import yaml from pydantic import Field +import httpx +import yarl from .components import Components from .errors import ReferenceResolutionError, SpecError @@ -16,45 +15,11 @@ from .servers import Server from .schemas import Schema, Discriminator from .tag import Tag -from .request import Request +from .request import Request, AsyncRequest +from .loader import Loader HTTP_METHODS = frozenset(["get","delete","head","post","put","patch","trace"]) -class Loader: - def load(self, name): - raise NotImplementedError("load") - - -class FileSystemLoader(Loader): - def __init__(self, base): - self.base = pathlib.Path(base) - - def load(self, file, codec=None): - file = pathlib.Path(file) - path = self.base / file - assert path.is_relative_to(self.base) - data = path.open("rb").read() - if codec is not None: - codecs = [codec] - else: - codecs = ["ascii","utf-8"] - for c in codecs: - try: - r = data.decode(c) - break - except UnicodeError: - continue - else: - raise ValueError("encoding") - if file.suffix == ".yaml": - data = yaml.safe_load(data) - elif file.suffix == ".json": - data = json.loads(data) - else: - raise ValueError(file.name) - return data - - class OpenAPI: @property From e903a41e7cb481fff0e728e2dda1eb7e856019f6 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Sat, 1 Jan 2022 22:40:31 +0100 Subject: [PATCH 036/125] typing --- openapi3/schemas.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/openapi3/schemas.py b/openapi3/schemas.py index d59b587..22d7695 100644 --- a/openapi3/schemas.py +++ b/openapi3/schemas.py @@ -86,7 +86,7 @@ class Config: extra = Extra.forbid @root_validator - def validate_Schema_number_type(cls, values): + def validate_Schema_number_type(cls, values:Dict[str, object]): conv = ["minimum","maximum"] if values.get("type", None) == "integer": for i in conv: @@ -96,10 +96,10 @@ def validate_Schema_number_type(cls, values): return values # @lru_cache - def get_type(self, names=None, discriminators=None): + def get_type(self, names:List[str]=None, discriminators:List[Discriminator]=None): return Model.from_schema(self, names, discriminators) - def model(self, data): + def model(self, data:Dict): """ Generates a model representing this schema from the given data. @@ -127,7 +127,7 @@ class Config: extra: Extra.forbid @classmethod - def from_schema(cls, shma, shmanm=None, discriminators=None): + def from_schema(cls, shma:Schema, shmanm:List[str]=None, discriminators:List[Discriminator]=None): if shmanm is None: shmanm = [] @@ -135,7 +135,7 @@ def from_schema(cls, shma, shmanm=None, discriminators=None): if discriminators is None: discriminators = [] - def typeof(schema): + def typeof(schema:Schema): r = None if schema.type == "string": r = str @@ -152,7 +152,7 @@ def typeof(schema): return r - def annotationsof(schema): + def annotationsof(schema:Schema): annos = dict() if schema.type == "array": annos["__root__"] = typeof(schema) @@ -178,7 +178,7 @@ def annotationsof(schema): annos[name] = r return annos - def fieldof(schema): + def fieldof(schema:Schema): r = dict() if schema.type == "array": return r From fc93190f9548fb08bede0842703b7f24e3744ab4 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Sat, 1 Jan 2022 22:46:29 +0100 Subject: [PATCH 037/125] Request - use httpx, add async interface add OpenAPI.load_{async,sync} --- openapi3/openapi.py | 83 ++++++++++++++++++++--------------- openapi3/request.py | 95 +++++++++++++++++++++++++++------------- requirements.txt | 12 +++++ tests/conftest.py | 4 +- tests/model_test.py | 41 ++++++++++------- tests/parse_data_test.py | 10 +++-- tests/parsing_test.py | 19 ++++---- 7 files changed, 166 insertions(+), 98 deletions(-) create mode 100644 requirements.txt diff --git a/openapi3/openapi.py b/openapi3/openapi.py index 5f7e2e2..9f1241d 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -42,13 +42,31 @@ def openapi(self): def servers(self): return self._spec.servers + @classmethod + def load_sync(cls, + url, + ssl_verify=True, + session_factory: Callable[[], httpx.Client] = httpx.Client, + loader=None): + raw_document = session_factory().get(url) + return cls(url, raw_document.json(), ssl_verify, session_factory, loader) + + @classmethod + async def load_async(cls, + url, + ssl_verify=True, + session_factory: Callable[[], httpx.AsyncClient] = httpx.AsyncClient, + loader=None): + async with session_factory() as client: + raw_document = await client.get(url) + return cls(url, raw_document.json(), ssl_verify, session_factory, loader) + def __init__( self, + url, raw_document, - validate=False, ssl_verify=None, - use_session=False, - session_factory=requests.Session, + session_factory:Callable[[], Union[httpx.Client,httpx.AsyncClient]]=httpx.AsyncClient, loader=None): """ Creates a new OpenAPI document from a loaded spec file. This is @@ -57,50 +75,36 @@ def __init__( :param raw_document: The raw OpenAPI file loaded into python :type raw_document: dct - :param validate: If True, don't fail on errors, but instead capture all - errors, continuing along the spec as best as possible, - and make them available when parsing is complete. - :type validate: bool + :param session_factory: default uses new session for each call, supply your own if required otherwise. + :type session_factory: returns httpx.AsyncClient or http.Client :param ssl_verify: Decide if to use ssl verification to the requests or not, in case an str is passed, will be used as the CA. :type ssl_verify: bool, str, None - :param use_session: Should we use a consistant session between API calls - :type use_session: bool """ - self.loader = loader - self._validation_mode = validate - self._spec_error = None - self._operation_map = dict() - self._security = None - self._cached = dict() + self._base_url:yarl.URL = yarl.URL(url) + self.loader:Loader = loader self._ssl_verify = ssl_verify + self._session_factory = session_factory - self._session = None - if use_session: - self._session = session_factory() - + self._security:List[str] = None + self._cached:Dict[str, "OpenAPISpec"] = dict() - try: - self._spec = OpenAPISpec.parse_obj(raw_document) - except Exception as e: - if not self._validation_mode: - raise - self._spec_error = e - return - try: - self._spec._resolve_references(self) - except ValueError as e: - if not self._validation_mode: - raise - self._spec_error = e - return + self._spec = OpenAPISpec.parse_obj(raw_document) + self._spec._resolve_references(self) for name, schema in self.components.schemas.items(): schema._identity = name + operation_map = set() + + def test_operation(operation_id): + if operation_id in operation_map: + raise SpecError(f"Duplicate operationId {operation_id}", element=None) + operation_map.add(operation_id) + for path,obj in self.paths.items(): for m in obj.__fields_set__ & HTTP_METHODS: op = getattr(obj, m) @@ -108,7 +112,7 @@ def __init__( if op.operationId is None: continue formatted_operation_id = op.operationId.replace(" ", "_") - self._register_operation(formatted_operation_id, (m, path, op)) + test_operation(formatted_operation_id) for r, response in op.responses.items(): if isinstance(response, Reference): continue @@ -118,6 +122,10 @@ def __init__( if isinstance(content.schema_, Schema): content.schema_._identity = f"{path}.{m}.{r}.{c}" + @property + def url(self): + return self._base_url.join(yarl.URL(self._spec.servers[0].url)) + # public methods def authenticate(self, security_scheme, value): """ @@ -355,7 +363,12 @@ def __getattr__(self, item): op = getattr(pi, method) if op.operationId != item: continue - return Request(self._api, method, path, op) + + if issubclass(self._api._session_factory, httpx.Client): + return Request(self._api, method, path, op) + if issubclass(self._api._session_factory, httpx.AsyncClient): + return AsyncRequest(self._api, method, path, op) + raise ValueError(item) diff --git a/openapi3/request.py b/openapi3/request.py index 27b8d5b..5327c9f 100644 --- a/openapi3/request.py +++ b/openapi3/request.py @@ -1,6 +1,20 @@ +from typing import List, Union import json -import requests +import httpx +import yarl + +from .paths import SecurityRequirement + +class RequestParameter: + def __init__(self, url:yarl.URL): + self.url = str(url) + self.auth = None + self.cookies = {} + self.path = {} + self.params = {} + self.content = None + self.headers = {} class Request: @@ -17,8 +31,8 @@ def __init__(self, api: "OpenAPI", method: str, path: str, operation: "Operation self.method = method self.path = path self.operation = operation - self.session:requests.Session = self.api._session or requests.Session() - self.req:requests.Request = None +# self.session:Union[httpx.Client,httpx.AsyncClient] = + self.req:RequestParameter = RequestParameter(self.path) def __call__(self, *args, **kwargs): return self.request(*args, **kwargs) @@ -28,20 +42,20 @@ def security(self): return self.api._security - def args(self, content_type="application/json"): + def args(self, content_type:str="application/json"): op = self.operation parameters = op.parameters + self.spec.paths[self.path].parameters return {"parameters":parameters, "data":op.requestBody.content[content_type].schema_._target} - def _handle_secschemes(self, security_requirement, value): + def _prepare_secschemes(self, security_requirement:SecurityRequirement, value:List[str]): ss = self.spec.components.securitySchemes[security_requirement.name] if ss.type == 'http' and ss.scheme_ == 'basic': - self.req.auth = requests.auth.HTTPBasicAuth(*value) + self.req.auth = value if ss.type == 'http' and ss.scheme_ == 'digest': - self.req.auth = requests.auth.HTTPDigestAuth(*value) + self.req.auth = httpx.DigestAuth(*value) if ss.type == 'http' and ss.scheme_ == 'bearer': header = ss.bearerFormat or 'Bearer {}' @@ -64,7 +78,7 @@ def _handle_secschemes(self, security_requirement, value): self.req.cookies = {ss.name: value} - def _handle_parameters(self, parameters): + def _prepare_parameters(self, parameters): # Parameters path_parameters = {} accepted_parameters = {} @@ -99,53 +113,44 @@ def _handle_parameters(self, parameters): self.req.url = self.req.url.format(**path_parameters) - def _handle_body(self, data): + def _prepare_body(self, data): if 'application/json' in self.operation.requestBody.content: if not isinstance(data, (dict, list)): raise TypeError(data) body = json.dumps(data) - self.req.data = body + self.req.content = body.encode() self.req.headers['Content-Type'] = 'application/json' else: raise NotImplementedError() - def request(self, data=None, parameters=None): - """ - Sends an HTTP request as described by this Path - - :param data: The request body to send. - :type data: any, should match content/type - :param parameters: The parameters used to create the path - :type parameters: dict{str: str} - """ - # Set request method (e.g. 'GET') - self.req = requests.Request(self.method, cookies={}) - - # Set self._request.url to base_url w/ path - self.req.url = self.spec.servers[0].url + self.path - + def _prepare(self, data, parameters): if self.security and self.operation.security: for scheme, value in self.security.items(): for r in filter(lambda x: x.name == scheme, self.operation.security): - self._handle_secschemes(r, value) + self._prepare_secschemes(r, value) break else: continue break else: - raise ValueError(f"No security requirement satisfied (accepts {', '.join(self.operation.security.keys())})") + raise ValueError( + f"No security requirement satisfied (accepts {', '.join(self.operation.security.keys())})") if self.operation.requestBody: if self.operation.requestBody.required and data is None: raise ValueError('Request Body is required but none was provided.') - self._handle_body(data) + self._prepare_body(data) - self._handle_parameters(parameters) + self._prepare_parameters(parameters) - # send the prepared request - result = self.session.send(self.req.prepare()) + req = httpx.Request(self.method, str(self.api.url / self.req.url[1:]), + cookies=self.req.cookies, + params=self.req.params, + content=self.req.content) + return req + def _process(self, result): # spec enforces these are strings status_code = str(result.status_code) @@ -187,3 +192,31 @@ def request(self, data=None, parameters=None): return expected_media.schema_.model(result.json()) else: raise NotImplementedError() + + def request(self, data=None, parameters=None): + """ + Sends an HTTP request as described by this Path + + :param data: The request body to send. + :type data: any, should match content/type + :param parameters: The parameters used to create the path + :type parameters: dict{str: str} + """ + + req = self._prepare(data, parameters) + + result = self.api._session_factory().send(req) + return self._process(result) + + +class AsyncRequest(Request): + async def __call__(self, *args, ** kwargs): + return await self.request(*args, **kwargs) + + async def request(self, data=None, parameters=None): + req = self._prepare(data, parameters) + + async with self.api._session_factory() as client: + result = await client.send(req) + + return self._process(result) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..722a95d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +pydantic~=1.9.0a1 +starlette~=0.16.0 +fastapi~=0.70.1 +pytest~=6.2.5 +PyYAML~=6.0 +requests~=2.26.0 +httpx~=0.21.1 +uvloop~=0.16.0 +hypercorn~=0.13.0 +TatSu~=5.6.1 +yarl~=1.7.2 +setuptools~=57.4.0 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index e66f716..6f6faff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,7 @@ from openapi3 import OpenAPI LOADED_FILES = {} - +URLBASE = "/" def _get_parsed_yaml(filename): """ @@ -36,7 +36,7 @@ def _get_parsed_spec(filename): if "spec:"+filename not in LOADED_FILES: parsed = _get_parsed_yaml(filename) - spec = OpenAPI(parsed) + spec = OpenAPI(URLBASE, parsed) LOADED_FILES["spec:"+filename] = spec diff --git a/tests/model_test.py b/tests/model_test.py index 9426cdf..973baad 100644 --- a/tests/model_test.py +++ b/tests/model_test.py @@ -17,7 +17,7 @@ def test_Pet(): import pytest -import requests +import httpx import uvloop from hypercorn.asyncio import serve @@ -55,14 +55,21 @@ async def server(event_loop, config): def version(request): return f"v{request.param}" + @pytest.fixture(scope="session") async def client(event_loop, server, version): - data = await asyncio.to_thread(requests.get, f"http://{server.bind[0]}/{version}/openapi.json") - data = data.json() - data["servers"][0]["url"] = f"http://{server.bind[0]}/{version}" - api = openapi3.OpenAPI(data) + url = f"http://{server.bind[0]}/{version}/openapi.json" + api = await openapi3.OpenAPI.load_async(url) + return api + + +@pytest.mark.asyncio +async def test_sync(event_loop, server, version): + url = f"http://{server.bind[0]}/{version}/openapi.json" + api = await asyncio.to_thread(openapi3.OpenAPI.load_sync, url) return api + @pytest.mark.asyncio async def test_model(event_loop, server, client): orig = client.components.schemas["WhiteCat"].dict(exclude_unset=True) @@ -97,11 +104,11 @@ async def test_createPet(event_loop, server, client): "white_name":str(uuid.uuid4()) }).dict() } -# r = await asyncio.to_thread(client.call_createPet, data=data) - r = await asyncio.to_thread(client._.createPet, data=data) +# r = await client._.createPet( data=data) + r = await client._.createPet(data=data) assert type(r.__root__.__root__).schema() == client.components.schemas["WhiteCat"].get_type().schema() - r = await asyncio.to_thread(client.call_createPet, data=randomPet(client, name=r.__root__.__root__.name)) + r = await client._.createPet(data=randomPet(client, name=r.__root__.__root__.name)) assert type(r).schema() == client.components.schemas["Error"].get_type().schema() with pytest.raises(pydantic.ValidationError): @@ -112,28 +119,28 @@ async def test_createPet(event_loop, server, client): @pytest.mark.asyncio async def test_listPet(event_loop, server, client): - r = await asyncio.to_thread(client.call_createPet, data=randomPet(client, str(uuid.uuid4()))) - l = await asyncio.to_thread(client.call_listPet) + r = await client._.createPet( data=randomPet(client, str(uuid.uuid4()))) + l = await client._.listPet() assert len(l) > 0 @pytest.mark.asyncio async def test_getPet(event_loop, server, client): - pet = await asyncio.to_thread(client.call_createPet, data=randomPet(client, str(uuid.uuid4()))) - r = await asyncio.to_thread(client.call_getPet, parameters={"pet_id":pet.__root__.identifier}) + pet = await client._.createPet( data=randomPet(client, str(uuid.uuid4()))) + r = await client._.getPet( parameters={"pet_id":pet.__root__.identifier}) assert type(r.__root__).schema() == type(pet.__root__).schema() - r = await asyncio.to_thread(client.call_getPet, parameters={"pet_id":"-1"}) + r = await client._.getPet(parameters={"pet_id":"-1"}) assert type(r).schema() == client.components.schemas["Error"].get_type().schema() @pytest.mark.asyncio async def test_deletePet(event_loop, server, client): - r = await asyncio.to_thread(client.call_deletePet, parameters={"pet_id":-1}) + r = await client._.deletePet( parameters={"pet_id":-1}) assert type(r).schema() == client.components.schemas["Error"].get_type().schema() - await asyncio.to_thread(client.call_createPet, data=randomPet(client, str(uuid.uuid4()))) - zoo = await asyncio.to_thread(client.call_listPet) + await client._.createPet( data=randomPet(client, str(uuid.uuid4()))) + zoo = await client._.listPet() for pet in zoo: while hasattr(pet, '__root__'): pet = pet.__root__ - await asyncio.to_thread(client.call_deletePet, parameters={"pet_id":pet.identifier}) + await client._.deletePet(parameters={"pet_id":pet.identifier}) diff --git a/tests/parse_data_test.py b/tests/parse_data_test.py index 69a8fa1..1b62cb4 100644 --- a/tests/parse_data_test.py +++ b/tests/parse_data_test.py @@ -2,12 +2,13 @@ from openapi3 import FileSystemLoader,OpenAPI import pathlib +URLBASE = "http://127.1.1.1/open5gs" def pytest_generate_tests(metafunc): argnames, dir, filterfn = metafunc.cls.params[metafunc.function.__name__] - dir = pathlib.Path(dir) + dir = pathlib.Path(dir).expanduser() metafunc.parametrize( - argnames, [[dir, i.name] for i in filter(filterfn, dir.iterdir())] + argnames, [[dir, i.name] for i in sorted(filter(filterfn, dir.iterdir()), key=lambda x: x.name)] ) @@ -22,11 +23,12 @@ class TestParseData: def test_data(self, dir, file): loader = FileSystemLoader(pathlib.Path(dir)) data = loader.load(pathlib.Path(file).name) - spec = OpenAPI(data, loader=loader) + spec = OpenAPI(URLBASE, data, loader=loader) def test_data_open5gs(self, dir, file): loader = FileSystemLoader(pathlib.Path(dir)) data = loader.load(pathlib.Path(file).name) # if "servers" in "data": - spec = OpenAPI(data, loader=loader) + spec = OpenAPI(URLBASE, data, loader=loader) + diff --git a/tests/parsing_test.py b/tests/parsing_test.py index 9d19d89..e6955f9 100644 --- a/tests/parsing_test.py +++ b/tests/parsing_test.py @@ -6,12 +6,13 @@ from pydantic import ValidationError from openapi3 import OpenAPI, SpecError, ReferenceResolutionError, FileSystemLoader +URLBASE = "/" def test_parse_from_yaml(petstore_expanded): """ Tests that we can parse a valid yaml file """ - spec = OpenAPI(petstore_expanded) + spec = OpenAPI(URLBASE, petstore_expanded) def test_parsing_fails(broken): @@ -19,7 +20,7 @@ def test_parsing_fails(broken): Tests that broken specs fail to parse """ with pytest.raises(ValidationError) as e: - spec = OpenAPI(broken) + spec = OpenAPI(URLBASE, broken) def test_parsing_broken_refernece(broken_reference): @@ -27,7 +28,7 @@ def test_parsing_broken_refernece(broken_reference): Tests that parsing fails correctly when a reference is broken """ with pytest.raises(ReferenceResolutionError): - spec = OpenAPI(broken_reference) + spec = OpenAPI(URLBASE, broken_reference) def test_parsing_wrong_parameter_name(has_bad_parameter_name): @@ -36,7 +37,7 @@ def test_parsing_wrong_parameter_name(has_bad_parameter_name): actually in the path. """ with pytest.raises(SpecError, match="Parameter name not found in path: different"): - spec = OpenAPI(has_bad_parameter_name) + spec = OpenAPI(URLBASE, has_bad_parameter_name) def test_parsing_dupe_operation_id(dupe_op_id): @@ -44,21 +45,21 @@ def test_parsing_dupe_operation_id(dupe_op_id): Tests that duplicate operation Ids are an error """ with pytest.raises(SpecError, match="Duplicate operationId dupe"): - spec = OpenAPI(dupe_op_id) + spec = OpenAPI(URLBASE, dupe_op_id) def test_parsing_parameter_name_with_underscores(parameter_with_underscores): """ Tests that path parameters with underscores in them are accepted """ - spec = OpenAPI(parameter_with_underscores) + spec = OpenAPI(URLBASE, parameter_with_underscores) def test_object_example(obj_example_expanded): """ Tests that `example` exists. """ - spec = OpenAPI(obj_example_expanded) + spec = OpenAPI(URLBASE, obj_example_expanded) schema = spec.paths['/check-dict'].get.responses['200'].content['application/json'].schema_ assert isinstance(schema.example, dict) assert isinstance(schema.example['real'], float) @@ -71,7 +72,7 @@ def test_parsing_float_validation(float_validation_expanded): """ Tests that `minimum` and similar validators work with floats. """ - spec = OpenAPI(float_validation_expanded) + spec = OpenAPI(URLBASE, float_validation_expanded) properties = spec.paths['/foo'].get.responses['200'].content['application/json'].schema_.properties assert isinstance(properties['integer'].minimum, int) @@ -84,7 +85,7 @@ def test_parsing_with_links(with_links): """ Tests that "links" parses correctly """ - spec = OpenAPI(with_links) + spec = OpenAPI(URLBASE, with_links) assert "exampleWithOperationRef" in spec.components.links assert spec.components.links["exampleWithOperationRef"].operationRef == "/with-links" From a5f6c93f188ac27ed85bbab63dc1c858dc3a6f37 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Sat, 1 Jan 2022 22:49:19 +0100 Subject: [PATCH 038/125] =?UTF-8?q?OpenAPI=20-=20remove=20errors()=20&=20c?= =?UTF-8?q?all=5F=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openapi3/openapi.py | 74 ------------------------------------------- tests/parsing_test.py | 15 +++------ 2 files changed, 5 insertions(+), 84 deletions(-) diff --git a/openapi3/openapi.py b/openapi3/openapi.py index 9f1241d..f97a90c 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -145,88 +145,14 @@ def authenticate(self, security_scheme, value): self._security = {security_scheme: value} - - def errors(self): - """ - In Validation Mode, returns all errors encountered from parsing a spec. - This should not be called if not in Validation Mode. - - :returns: The errors encountered during the parsing of this spec. - :rtype: ValidationError - """ - if not self._validation_mode: - raise RuntimeError('This client is not in Validation Mode, cannot ' - 'return errors!') - return self._spec_error - - - # private methods - def _register_operation(self, operation_id, opInfo): - """ - Adds an Operation to this spec's _operation_map, raising an error if the - OperationId has already been registered. - - :param operation_id: The operation ID to register - :type operation_id: str - :param opInfo: The operation to register - :type opInfo: Operation - """ - if operation_id in self._operation_map: - raise SpecError(f"Duplicate operationId {operation_id}", element=opInfo) - self._operation_map[operation_id] = opInfo - - def _get_callable(self, method, path, request:Operation): - """ - A helper function to create OperationCallable objects for __getattribute__, - pre-initialized with the required values from this object. - - :param request: The Operation the callable should call - :type request: callable (Operation.request) - - :returns: The callable that executes this operation with this object's - configuration. - :rtype: Request - """ - return Request(self, method, path, request) - - def __getattr__(self, attr): - """ - Extended __getattribute__ function to allow resolving dynamic function - names. The purpose of this is to call syntax like this:: - - spec = OpenAPI(raw_spec) - spec.call_operationId() - - This method will intercept the dot notation above (spec.call_operationId) - and look up the requested operation, returning a callable object that - will then immediately be called by the parenthesis. - - :param attr: The attribute we're retrieving - :type attr: str - - :returns: The attribute requested - :rtype: any - :raises AttributeError: if the requested attribute does not exist - """ - if attr.startswith('call_'): - _, operationId = attr.split('_', 1) - if operationId not in self._operation_map: - raise AttributeError('{} has no operation {}'.format( - self.info.title, operationId)) - method, path, op = self._operation_map[operationId] - return self._get_callable(method, path, op) - raise KeyError(attr) - def _load(self, i): data = self.loader.load(i) return OpenAPISpec.parse_obj(data) - @property def _(self): return OperationIndex(self) - def resolve_jr(self, root: "OpenAPISpec", obj, value: Reference): url,jp = JSONReference.split(value.ref) if url != '': diff --git a/tests/parsing_test.py b/tests/parsing_test.py index e6955f9..b9757f2 100644 --- a/tests/parsing_test.py +++ b/tests/parsing_test.py @@ -103,22 +103,17 @@ def test_parsing_broken_links(with_broken_links): """ Tests that broken "links" values error properly """ - spec = OpenAPI(with_broken_links, validate=True) + with pytest.raises(ValidationError) as e: + spec = OpenAPI(URLBASE, with_broken_links) - errors = spec.errors() - error_strs = str(errors) - assert all([i in error_strs for i in [ + assert all([i in str(e.value) for i in [ "operationId and operationRef are mutually exclusive, only one of them is allowed", "operationId and operationRef are mutually exclusive, one of them must be specified", ]]) def test_securityparameters(with_securityparameters): - spec = OpenAPI(with_securityparameters, validate=True) - errors = spec.errors() - assert errors is None + spec = OpenAPI(URLBASE, with_securityparameters) def test_callback(with_callback): - spec = OpenAPI(with_callback, validate=True) - errors = spec.errors() - assert errors is None + spec = OpenAPI(URLBASE, with_callback) From 4ff54331ce57632dc09d2ba48a44ee9799f86b30 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Sat, 1 Jan 2022 23:32:33 +0100 Subject: [PATCH 039/125] ssl - disabling verification is not easy --- openapi3/openapi.py | 32 ++++++++------------------------ 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/openapi3/openapi.py b/openapi3/openapi.py index f97a90c..539b082 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -43,31 +43,19 @@ def servers(self): return self._spec.servers @classmethod - def load_sync(cls, - url, - ssl_verify=True, - session_factory: Callable[[], httpx.Client] = httpx.Client, - loader=None): + def load_sync(cls, url, session_factory: Callable[[], httpx.Client] = httpx.Client, loader=None): raw_document = session_factory().get(url) - return cls(url, raw_document.json(), ssl_verify, session_factory, loader) + return cls(url, raw_document.json(), session_factory, loader) @classmethod - async def load_async(cls, - url, - ssl_verify=True, - session_factory: Callable[[], httpx.AsyncClient] = httpx.AsyncClient, - loader=None): + async def load_async(cls, url, session_factory: Callable[[], httpx.AsyncClient] = httpx.AsyncClient, loader=None): async with session_factory() as client: raw_document = await client.get(url) - return cls(url, raw_document.json(), ssl_verify, session_factory, loader) - - def __init__( - self, - url, - raw_document, - ssl_verify=None, - session_factory:Callable[[], Union[httpx.Client,httpx.AsyncClient]]=httpx.AsyncClient, - loader=None): + return cls(url, raw_document.json(), session_factory, loader) + + def __init__(self, url, raw_document, + session_factory: Callable[[], Union[httpx.Client, httpx.AsyncClient]] = httpx.AsyncClient, + loader=None): """ Creates a new OpenAPI document from a loaded spec file. This is overridden here because we need to specify the path in the parent @@ -77,15 +65,11 @@ def __init__( :type raw_document: dct :param session_factory: default uses new session for each call, supply your own if required otherwise. :type session_factory: returns httpx.AsyncClient or http.Client - :param ssl_verify: Decide if to use ssl verification to the requests or not, - in case an str is passed, will be used as the CA. - :type ssl_verify: bool, str, None """ self._base_url:yarl.URL = yarl.URL(url) self.loader:Loader = loader - self._ssl_verify = ssl_verify self._session_factory = session_factory self._security:List[str] = None From ddec219d1065a6136d9dd59afaa1dccb5a7f31e9 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Sun, 2 Jan 2022 15:10:03 +0100 Subject: [PATCH 040/125] tests - httpx changes --- requirements.txt | 3 +- setup.py | 2 +- tests/path_test.py | 96 ++++++++++++++++++++++------------------------ 3 files changed, 49 insertions(+), 52 deletions(-) diff --git a/requirements.txt b/requirements.txt index 722a95d..6284feb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,5 @@ uvloop~=0.16.0 hypercorn~=0.13.0 TatSu~=5.6.1 yarl~=1.7.2 -setuptools~=57.4.0 \ No newline at end of file +setuptools~=57.4.0 +pytest-httpx \ No newline at end of file diff --git a/setup.py b/setup.py index d4019be..ab6d593 100755 --- a/setup.py +++ b/setup.py @@ -23,5 +23,5 @@ packages=['openapi3'], license="BSD 3-Clause License", install_requires=["PyYaml", "requests", "pydantic", "yarl"], - tests_require=["pytest"], + tests_require=["pytest", "fastapi-versioning", "requests_mock", "httpx_mock"], ) diff --git a/tests/path_test.py b/tests/path_test.py index 8100332..c59ce98 100644 --- a/tests/path_test.py +++ b/tests/path_test.py @@ -10,11 +10,15 @@ import pytest import requests.auth +import httpx +import yarl from openapi3 import OpenAPI from openapi3.schemas import Schema +URLBASE = "/" + def test_paths_exist(petstore_expanded_spec): """ Tests that paths are parsed correctly @@ -98,10 +102,11 @@ def test_operation_populated(petstore_expanded_spec): assert type(con2.schema_._target) == Schema -def test_securityparameters(with_securityparameters): - api = OpenAPI(with_securityparameters) - auth=str(uuid.uuid4()) +def test_securityparameters(httpx_mock, with_securityparameters): + api = OpenAPI(URLBASE, with_securityparameters, session_factory=httpx.Client) + httpx_mock.add_response(headers={"Content-Type":"application/json"}, content=b"[]") + auth=str(uuid.uuid4()) for i in api.paths.values(): if not i.post or not i.post.security: @@ -113,73 +118,64 @@ def test_securityparameters(with_securityparameters): else: assert False - with pytest.raises(ValueError, match="does not accept security scheme"): + with pytest.raises(ValueError, match="does not accept security scheme xAuth"): api.authenticate('xAuth', auth) - api.call_api_v1_auth_login_create(data={}, parameters={}) + api._.api_v1_auth_login_info(data={}, parameters={}) # global security api.authenticate('cookieAuth', auth) - resp = MagicMock(status_code=200, headers={"Content-Type":"application/json"}) - with patch("requests.sessions.Session.send", return_value=resp) as r: - api.call_api_v1_auth_login_create(data={}, parameters={}) - + api._.api_v1_auth_login_info(data={}, parameters={}) + request = httpx_mock.get_requests()[-1] # path api.authenticate('tokenAuth', auth) - resp = MagicMock(status_code=200, headers={"Content-Type": "application/json"}) - with patch("requests.sessions.Session.send", return_value=resp) as r: - api.call_api_v1_auth_login_create(data={}, parameters={}) - assert r.call_args.args[0].headers['Authorization'] == auth + api._.api_v1_auth_login_create(data={}, parameters={}) + request = httpx_mock.get_requests()[-1] + assert request.headers['Authorization'] == auth api.authenticate('paramAuth', auth) - resp = MagicMock(status_code=200, headers={"Content-Type": "application/json"}) - with patch("requests.sessions.Session.send", return_value=resp) as r: - api.call_api_v1_auth_login_create(data={}, parameters={}) - - parsed_url = urlparse(r.call_args.args[0].url) - assert parsed_url.query == f"auth={auth}" + api._.api_v1_auth_login_create(data={}, parameters={}) + request = httpx_mock.get_requests()[-1] + assert yarl.URL(str(request.url)).query["auth"] == auth api.authenticate('cookieAuth', auth) - resp = MagicMock(status_code=200, headers={"Content-Type":"application/json"}, json=lambda: []) - with patch("requests.sessions.Session.send", return_value=resp) as r: - api.call_api_v1_auth_login_create(data={}, parameters={}) - assert r.call_args.args[0].headers["Cookie"] == "Session=%s" % (auth,) + api._.api_v1_auth_login_create(data={}, parameters={}) + request = httpx_mock.get_requests()[-1] + assert request.headers["Cookie"] == "Session=%s" % (auth,) api.authenticate('basicAuth', (auth, auth)) - resp = MagicMock(status_code=200, headers={"Content-Type":"application/json"}, json=lambda: []) - with patch("requests.sessions.Session.send", return_value=resp) as r: - api.call_api_v1_auth_login_create(data={}, parameters={}) - r.call_args.args[0].headers["Authorization"].split(" ")[1] == base64.b64encode((auth + ':' + auth).encode()).decode() + api._.api_v1_auth_login_create(data={}, parameters={}) + request = httpx_mock.get_requests()[-1] + assert request.headers["Authorization"].split(" ")[1] == base64.b64encode((auth + ':' + auth).encode()).decode() api.authenticate('digestAuth', (auth,auth)) - resp = MagicMock(status_code=200, headers={"Content-Type":"application/json"}, json=lambda: []) - with patch("requests.sessions.Session.send", return_value=resp) as r: - api.call_api_v1_auth_login_create(data={}, parameters={}) - assert requests.auth.HTTPDigestAuth.handle_401 == r.call_args.args[0].hooks["response"][0].__func__ + api._.api_v1_auth_login_create(data={}, parameters={}) + request = httpx_mock.get_requests()[-1] + # can't test? api.authenticate('bearerAuth', auth) - resp = MagicMock(status_code=200, headers={"Content-Type":"application/json"}, json=lambda: []) - with patch("requests.sessions.Session.send", return_value=resp) as r: - api.call_api_v1_auth_login_create(data={}, parameters={}) - assert r.call_args.args[0].headers["Authorization"] == "Bearer %s" % (auth,) + api._.api_v1_auth_login_create(data={}, parameters={}) + request = httpx_mock.get_requests()[-1] + assert request.headers["Authorization"] == "Bearer %s" % (auth,) + # null session api.authenticate(None, None) - resp = MagicMock(status_code=200, headers={"Content-Type":"application/json"}) - with patch("requests.sessions.Session.send", return_value=resp) as r: - api.call_api_v1_auth_login_info(data={}, parameters={}) + api._.api_v1_auth_login_info(data={}, parameters={}) + -def test_parameters(with_parameters): - api = OpenAPI(with_parameters) +def test_parameters(httpx_mock, with_parameters): + httpx_mock.add_response(headers={"Content-Type": "application/json"}, content=b"[]") + api = OpenAPI(URLBASE, with_parameters, session_factory=httpx.Client) with pytest.raises(ValueError, match="Required parameter \w+ not provided"): - api.call_getTest(data={}, parameters={}) - - resp = MagicMock(status_code=200, headers={"Content-Type":"application/json"}) - with patch("requests.sessions.Session.send", return_value=resp) as r: - Header = str([i ** i for i in range(3)]) - api.call_getTest(data={}, parameters={"Cookie":"Cookie", "Path":"Path", "Header":Header, "Query":"Query"}) - assert r.call_args.args[0].headers["Header"] == Header - assert r.call_args.args[0].headers["Cookie"] == "Cookie=Cookie" - assert pathlib.Path(urlparse(r.call_args.args[0].path_url).path).name == "Path" - assert urlparse(r.call_args.args[0].path_url).query == "Query=Query" + api._.getTest(data={}, parameters={}) + + Header = str([i ** i for i in range(3)]) + api._.getTest(data={}, parameters={"Cookie":"Cookie", "Path":"Path", "Header":Header, "Query":"Query"}) + request = httpx_mock.get_requests()[-1] + + assert request.headers["Header"] == Header + assert request.headers["Cookie"] == "Cookie=Cookie" + assert pathlib.Path(request.url.path).name == "Path" + assert yarl.URL(str(request.url)).query["Query"] == "Query" From 86f0f7c0a8465b0ed83c4db1957f44eb743dff22 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Sun, 2 Jan 2022 15:11:28 +0100 Subject: [PATCH 041/125] tests - fastapi based tests using sync api --- tests/api/main.py | 92 +++---------------------------------------- tests/api/v1/main.py | 86 ++++++++++++++++++++++++++++++++++++++++ tests/api/v2/main.py | 89 +++++++++++++++++++++++++++++++++++++++++ tests/fastapi_test.py | 42 ++++++++++---------- 4 files changed, 200 insertions(+), 109 deletions(-) create mode 100644 tests/api/v1/main.py create mode 100644 tests/api/v2/main.py diff --git a/tests/api/main.py b/tests/api/main.py index 32c9b9d..1c7a6da 100644 --- a/tests/api/main.py +++ b/tests/api/main.py @@ -1,100 +1,18 @@ from __future__ import annotations -import errno -import uuid -from typing import Optional - -import starlette.status -from fastapi import FastAPI, Query, Body, Response -from fastapi.responses import JSONResponse - +from fastapi import FastAPI from fastapi_versioning import VersionedFastAPI, version -import api.v1.schema as v1 -import api.v2.schema as v2 +from api.v1.main import router as v1 +from api.v2.main import router as v2 app = FastAPI(version="1.0.0", title="Dorthu's Petstore", servers=[{"url": "/", "description": "Default, relative server"}]) -ZOO = dict() - -def _idx(l): - for i in range(l): - yield i - -idx = _idx(100) - - - -@app.post('/pet', - operation_id="createPet", - response_model=v2.Pet, - responses={201: {"model": v2.Pet}, - 409: {"model": v2.Error}} - ) -@version(2) -def createPet(response: Response, - pet: v2.Pet = Body(..., embed=True), - ) -> None: - # if isinstance(pet, Cat): - # pet = pet.__root__ - # elif isinstance(pet, Dog): - # pass - if pet.name in ZOO: - return JSONResponse(status_code=starlette.status.HTTP_409_CONFLICT, - content=v2.Error(code=errno.EEXIST, - message=f"{pet.name} already exists" - ).dict() - ) - pet.identifier = str(uuid.uuid4()) - ZOO[pet.name] = r = pet - response.status_code = starlette.status.HTTP_201_CREATED - return r - - -@app.get('/pet', - operation_id="listPet", - response_model=v2.Pets) -@version(2) -def listPet(limit: Optional[int] = None) -> v2.Pets: - return list(ZOO.values()) - - -@app.get('/pets/{pet_id}', - operation_id="getPet", - response_model=v2.Pet, - responses={ - 404: {"model": v2.Error} - } - ) -@version(2) -def getPet(pet_id: str = Query(..., alias='petId')) -> v2.Pets: - for k, v in ZOO.items(): - if pet_id == v.identifier: - return v - else: - return JSONResponse(status_code=starlette.status.HTTP_404_NOT_FOUND, - content=v2.Error(code=errno.ENOENT, message=f"{pet_id} not found").dict()) - -@app.delete('/pets/{pet_id}', - operation_id="deletePet", - responses={ - 204: {"model": None}, - 404: {"model": v2.Error} - }) -@version(2) -def deletePet(response: Response, - pet_id: int = Query(..., alias='petId')) -> v2.Pets: - for k, v in ZOO.items(): - if pet_id == v.identifier: - del ZOO[k] - response.status_code = starlette.status.HTTP_204_NO_CONTENT - return response - else: - return JSONResponse(status_code=starlette.status.HTTP_404_NOT_FOUND, - content=v2.Error(code=errno.ENOENT, message=f"{pet_id} not found").dict()) +app.include_router(v1) +app.include_router(v2) app = VersionedFastAPI(app, version_format='{major}', diff --git a/tests/api/v1/main.py b/tests/api/v1/main.py new file mode 100644 index 0000000..3d44030 --- /dev/null +++ b/tests/api/v1/main.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import errno +from typing import Optional + +import starlette.status +from fastapi import FastAPI, APIRouter, Query, Body, Response +from fastapi.responses import JSONResponse + +from .schema import Pets, Pet, PetCreate, Error + + +from fastapi_versioning import versioned_api_route, version + +router = APIRouter(route_class=versioned_api_route(1)) + + + +ZOO = dict() + +def _idx(l): + for i in range(l): + yield i + +idx = _idx(100) + + +@router.post('/pet', + operation_id="createPet", + response_model=Pet, + responses={201: {"model": Pet}, + 409: {"model": Error}} + ) +def createPet(response: Response, + pet: PetCreate = Body(..., embed=True), + ) -> None: + if pet.name in ZOO: + return JSONResponse(status_code=starlette.status.HTTP_409_CONFLICT, + content=Error(code=errno.EEXIST, + message=f"{pet.name} already exists" + ).dict() + ) + ZOO[pet.name] = r = Pet(id=next(idx), **pet.dict()) + response.status_code = starlette.status.HTTP_201_CREATED + return r + + +@router.get('/pet', + operation_id="listPet", + response_model=Pets) +def listPet(limit: Optional[int] = None) -> Pets: + return list(ZOO.values()) + + +@router.get('/pets/{pet_id}', + operation_id="getPet", + response_model=Pet, + responses={ + 404: {"model": Error} + } + ) +def getPet(pet_id: int = Query(..., alias='petId')) -> Pets: + for k, v in ZOO.items(): + if pet_id == v.id: + return v + else: + return JSONResponse(status_code=starlette.status.HTTP_404_NOT_FOUND, + content=Error(code=errno.ENOENT, message=f"{pet_id} not found").dict()) + + +@router.delete('/pets/{pet_id}', + operation_id="deletePet", + responses={ + 204: {"model": None}, + 404: {"model": Error} + }) +def deletePet(response: Response, + pet_id: int = Query(..., alias='petId')) -> Pets: + for k, v in ZOO.items(): + if pet_id == v.id: + del ZOO[k] + response.status_code = starlette.status.HTTP_204_NO_CONTENT + return response + else: + return JSONResponse(status_code=starlette.status.HTTP_404_NOT_FOUND, + content=Error(code=errno.ENOENT, message=f"{pet_id} not found").dict()) diff --git a/tests/api/v2/main.py b/tests/api/v2/main.py new file mode 100644 index 0000000..5100105 --- /dev/null +++ b/tests/api/v2/main.py @@ -0,0 +1,89 @@ +import errno +import uuid +from typing import Optional + +import starlette.status +from fastapi import Query, Body, Response, APIRouter +from fastapi.responses import JSONResponse +from fastapi_versioning import version + +from . import schema + +from fastapi_versioning import versioned_api_route + +router = APIRouter(route_class=versioned_api_route(2)) + +ZOO = dict() + +def _idx(l): + for i in range(l): + yield i + +idx = _idx(100) + + + +@router.post('/pet', + operation_id="createPet", + response_model=schema.Pet, + responses={201: {"model": schema.Pet}, + 409: {"model": schema.Error}} + ) +def createPet(response: Response, + pet: schema.Pet = Body(..., embed=True), + ) -> None: + # if isinstance(pet, Cat): + # pet = pet.__root__ + # elif isinstance(pet, Dog): + # pass + if pet.name in ZOO: + return JSONResponse(status_code=starlette.status.HTTP_409_CONFLICT, + content=schema.Error(code=errno.EEXIST, + message=f"{pet.name} already exists" + ).dict() + ) + pet.identifier = str(uuid.uuid4()) + ZOO[pet.name] = r = pet + response.status_code = starlette.status.HTTP_201_CREATED + return r + + +@router.get('/pet', + operation_id="listPet", + response_model=schema.Pets) +def listPet(limit: Optional[int] = None) -> schema.Pets: + return list(ZOO.values()) + + +@router.get('/pets/{pet_id}', + operation_id="getPet", + response_model=schema.Pet, + responses={ + 404: {"model": schema.Error} + } + ) +def getPet(pet_id: str = Query(..., alias='petId')) -> schema.Pets: + for k, v in ZOO.items(): + if pet_id == v.identifier: + return v + else: + return JSONResponse(status_code=starlette.status.HTTP_404_NOT_FOUND, + content=schema.Error(code=errno.ENOENT, message=f"{pet_id} not found").dict()) + + +@router.delete('/pets/{pet_id}', + operation_id="deletePet", + responses={ + 204: {"model": None}, + 404: {"model": schema.Error} + }) +def deletePet(response: Response, + pet_id: int = Query(..., alias='petId')) -> schema.Pets: + for k, v in ZOO.items(): + if pet_id == v.identifier: + del ZOO[k] + response.status_code = starlette.status.HTTP_204_NO_CONTENT + return response + else: + return JSONResponse(status_code=starlette.status.HTTP_404_NOT_FOUND, + content=schema.Error(code=errno.ENOENT, message=f"{pet_id} not found").dict()) diff --git a/tests/fastapi_test.py b/tests/fastapi_test.py index e8eb85f..55380f5 100644 --- a/tests/fastapi_test.py +++ b/tests/fastapi_test.py @@ -1,10 +1,10 @@ import asyncio -import random import uuid import pytest import requests +import httpx import uvloop from hypercorn.asyncio import serve @@ -40,47 +40,45 @@ async def server(event_loop, config): @pytest.fixture(scope="session") async def client(event_loop, server): - data = await asyncio.to_thread(requests.get, f"http://{server.bind[0]}/openapi.json") - data = data.json() - data["servers"][0]["url"] = f"http://{server.bind[0]}" - api = openapi3.OpenAPI(data) + api = await asyncio.to_thread(openapi3.OpenAPI.load_sync, f"http://{server.bind[0]}/v1/openapi.json") return api def randomPet(name=None): - return {"data":{"pet":{"name":str(name) or random.choice(["dog","cat","mouse","eagle"])}}} + return {"data":{"pet":{"name":str(name or uuid.uuid4()), "pet_type":"dog"}}} + @pytest.mark.asyncio async def test_createPet(event_loop, server, client): - r = await asyncio.to_thread(client.call_createPet, **randomPet()) - assert type(r) == client.components.schemas["Pet"].get_type() + r = await asyncio.to_thread(client._.createPet, **randomPet()) + assert type(r).schema() == client.components.schemas["Pet"].get_type().schema() - r = await asyncio.to_thread(client.call_createPet, data={"pet":{"name":r.name}}) - assert type(r) == client.components.schemas["Error"].get_type() + r = await asyncio.to_thread(client._.createPet, data={"pet":{"name":r.name}}) + assert type(r).schema() == client.components.schemas["Error"].get_type().schema() @pytest.mark.asyncio async def test_listPet(event_loop, server, client): - r = await asyncio.to_thread(client.call_createPet, **randomPet(uuid.uuid4())) - l = await asyncio.to_thread(client.call_listPet) + r = await asyncio.to_thread(client._.createPet, **randomPet(uuid.uuid4())) + l = await asyncio.to_thread(client._.listPet) assert len(l) > 0 @pytest.mark.asyncio async def test_getPet(event_loop, server, client): - pet = await asyncio.to_thread(client.call_createPet, **randomPet(uuid.uuid4())) - r = await asyncio.to_thread(client.call_getPet, parameters={"pet_id":pet.id}) - assert type(r) == type(pet) + pet = await asyncio.to_thread(client._.createPet, **randomPet(uuid.uuid4())) + r = await asyncio.to_thread(client._.getPet, parameters={"pet_id":pet.id}) + assert type(r).schema() == type(pet).schema() assert r.id == pet.id - r = await asyncio.to_thread(client.call_getPet, parameters={"pet_id":-1}) - assert type(r) == client.components.schemas["Error"].get_type() + r = await asyncio.to_thread(client._.getPet, parameters={"pet_id":-1}) + assert type(r).schema() == client.components.schemas["Error"].get_type().schema() @pytest.mark.asyncio async def test_deletePet(event_loop, server, client): - r = await asyncio.to_thread(client.call_deletePet, parameters={"pet_id":-1}) - assert type(r) == client.components.schemas["Error"].get_type() + r = await asyncio.to_thread(client._.deletePet, parameters={"pet_id":-1}) + assert type(r).schema() == client.components.schemas["Error"].get_type().schema() - await asyncio.to_thread(client.call_createPet, **randomPet(uuid.uuid4())) - zoo = await asyncio.to_thread(client.call_listPet) + await asyncio.to_thread(client._.createPet, **randomPet(uuid.uuid4())) + zoo = await asyncio.to_thread(client._.listPet) for pet in zoo: - await asyncio.to_thread(client.call_deletePet, parameters={"pet_id":pet.id}) + await asyncio.to_thread(client._.deletePet, parameters={"pet_id":pet.id}) From 41a96b368e75a4408a0fa1915bd36f4c55b4e8f5 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Sun, 2 Jan 2022 15:13:25 +0100 Subject: [PATCH 042/125] request - send auth & headers --- openapi3/request.py | 79 ++++++++++++++++++++++++--------------------- 1 file changed, 43 insertions(+), 36 deletions(-) diff --git a/openapi3/request.py b/openapi3/request.py index 5327c9f..434d5d7 100644 --- a/openapi3/request.py +++ b/openapi3/request.py @@ -48,6 +48,23 @@ def args(self, content_type:str="application/json"): return {"parameters":parameters, "data":op.requestBody.content[content_type].schema_._target} + def return_value(self, http_status=200, content_type="application/json"): + return self.operation.responses[str(http_status)].content[content_type].schema_ + + + def _prepare_security(self): + if self.security and self.operation.security: + for scheme, value in self.security.items(): + for r in filter(lambda x: x.name == scheme, self.operation.security): + self._prepare_secschemes(r, value) + break + else: + continue + break + else: + raise ValueError( + f"No security requirement satisfied (accepts {', '.join(self.operation.security.keys())})") + def _prepare_secschemes(self, security_requirement:SecurityRequirement, value:List[str]): ss = self.spec.components.securitySchemes[security_requirement.name] @@ -114,6 +131,12 @@ def _prepare_parameters(self, parameters): self.req.url = self.req.url.format(**path_parameters) def _prepare_body(self, data): + if not self.operation.requestBody: + return + + if data is None and self.operation.requestBody.required: + raise ValueError('Request Body is required but none was provided.') + if 'application/json' in self.operation.requestBody.content: if not isinstance(data, (dict, list)): raise TypeError(data) @@ -124,27 +147,12 @@ def _prepare_body(self, data): raise NotImplementedError() def _prepare(self, data, parameters): - if self.security and self.operation.security: - for scheme, value in self.security.items(): - for r in filter(lambda x: x.name == scheme, self.operation.security): - self._prepare_secschemes(r, value) - break - else: - continue - break - else: - raise ValueError( - f"No security requirement satisfied (accepts {', '.join(self.operation.security.keys())})") - - if self.operation.requestBody: - if self.operation.requestBody.required and data is None: - raise ValueError('Request Body is required but none was provided.') - - self._prepare_body(data) - + self._prepare_security() self._prepare_parameters(parameters) + self._prepare_body(data) req = httpx.Request(self.method, str(self.api.url / self.req.url[1:]), + headers=self.req.headers, cookies=self.req.cookies, params=self.req.params, content=self.req.content) @@ -169,24 +177,23 @@ def _process(self, result): if len(expected_response.content) == 0: return None - content_type = result.headers['Content-Type'] - expected_media = expected_response.content.get(content_type, None) - - if expected_media is None and '/' in content_type: - # accept media type ranges in the spec. the most specific matching - # type should always be chosen, but if we do not have a match here - # a generic range should be accepted if one if provided - # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#response-object - - generic_type = content_type.split('/')[0] + '/*' - expected_media = expected_response.content.get(generic_type, None) + content_type = result.headers.get('Content-Type', None) + if content_type: + expected_media = expected_response.content.get(content_type, None) + if expected_media is None and '/' in content_type: + # accept media type ranges in the spec. the most specific matching + # type should always be chosen, but if we do not have a match here + # a generic range should be accepted if one if provided + # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#response-object + + generic_type = content_type.split('/')[0] + '/*' + expected_media = expected_response.content.get(generic_type, None) + else: + expected_media = None if expected_media is None: - err_msg = '''Unexpected Content-Type {} returned for operation {} \ - (expected one of {})''' - err_var = result.headers['Content-Type'], self.operation.operationId, ','.join(expected_response.content.keys()) - - raise RuntimeError(err_msg.format(*err_var)) + raise RuntimeError(f"Unexpected Content-Type {content_type} returned for operation {self.operation.operationId} \ + (expected one of {','.join(expected_response.content.keys())})") if content_type.lower() == 'application/json': return expected_media.schema_.model(result.json()) @@ -205,7 +212,7 @@ def request(self, data=None, parameters=None): req = self._prepare(data, parameters) - result = self.api._session_factory().send(req) + result = self.api._session_factory(auth=self.req.auth).send(req) return self._process(result) @@ -216,7 +223,7 @@ async def __call__(self, *args, ** kwargs): async def request(self, data=None, parameters=None): req = self._prepare(data, parameters) - async with self.api._session_factory() as client: + async with self.api._session_factory(auth=self.req.auth) as client: result = await client.send(req) return self._process(result) From dca487cbdd1e0a11fe29b8c77b9167a2f70ad377 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Sun, 2 Jan 2022 15:15:14 +0100 Subject: [PATCH 043/125] cleanups --- openapi3/__init__.py | 5 +---- openapi3/__main__.py | 27 ++++++++++----------------- openapi3/loader.py | 11 ++++++----- openapi3/openapi.py | 4 ---- 4 files changed, 17 insertions(+), 30 deletions(-) diff --git a/openapi3/__init__.py b/openapi3/__init__.py index 2394469..abe2a19 100644 --- a/openapi3/__init__.py +++ b/openapi3/__init__.py @@ -1,9 +1,6 @@ from .openapi import OpenAPI from .loader import FileSystemLoader -# these imports appear unused, but in fact load up the subclasses ObjectBase so -# that they may be referenced throughout the schema without issue -#from . import info, servers, paths, general, schemas, components, security, tag, example from .errors import SpecError, ReferenceResolutionError -__all__ = ['OpenAPI', 'SpecError', 'ReferenceResolutionError','FileSystemLoader'] +__all__ = ['OpenAPI', 'FileSystemLoader', 'SpecError', 'ReferenceResolutionError'] diff --git a/openapi3/__main__.py b/openapi3/__main__.py index a8e2267..979740e 100644 --- a/openapi3/__main__.py +++ b/openapi3/__main__.py @@ -1,29 +1,22 @@ import sys import yaml +from pathlib import Path from .openapi import OpenAPI +from .loader import FileSystemLoader def main(): - specfile = sys.argv[1] - - with open(specfile) as f: - spec = yaml.safe_load(f.read()) - - o = OpenAPI(spec, validate=True) - - errors = o.errors() - - if errors: - # print errors - for e in errors: - print("{}: {}".format(".".join(e.path), e.message[:300])) - print() - print("{} errors".format(len(errors))) - sys.exit(1) # exit with error status + name = sys.argv[1] + loader = FileSystemLoader(Path().cwd()) + spec = loader.load(name) + try: + OpenAPI(name, spec, loader=loader) + except ValueError as e: + print(e) else: print("OK") -if __name__ == "__main__": +if __name__ == '__main__': main() diff --git a/openapi3/loader.py b/openapi3/loader.py index 29041b9..9f96f4f 100644 --- a/openapi3/loader.py +++ b/openapi3/loader.py @@ -1,5 +1,6 @@ import abc import pathlib +from pathlib import Path import yaml import json @@ -11,11 +12,11 @@ def load(self, name:str): class FileSystemLoader(Loader): - def __init__(self, base:str): - self.base = pathlib.Path(base) + def __init__(self, base:Path): + assert isinstance(base, Path) + self.base = base def load(self, file:str, codec=None): - file = pathlib.Path(file) path = self.base / file assert path.is_relative_to(self.base) data = path.open("rb").read() @@ -31,9 +32,9 @@ def load(self, file:str, codec=None): continue else: raise ValueError("encoding") - if file.suffix == ".yaml": + if path.suffix == ".yaml": data = yaml.safe_load(data) - elif file.suffix == ".json": + elif path.suffix == ".json": data = json.loads(data) else: raise ValueError(file.name) diff --git a/openapi3/openapi.py b/openapi3/openapi.py index 539b082..2c7fe69 100644 --- a/openapi3/openapi.py +++ b/openapi3/openapi.py @@ -170,10 +170,6 @@ class OpenAPISpec(ObjectExtended): tags: Optional[List[Tag]] = Field(default=None) externalDocs: Optional[Dict[Any, Any]] = Field(default_factory=dict) - class Config: - underscore_attrs_are_private = True - arbitrary_types_allowed = True - def _resolve_references(self, api): """ Resolves all reference objects below this object and notes their original From 7583e9d1ea6b763ad2463b92b8a4969bab154ca3 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Sun, 2 Jan 2022 19:16:13 +0100 Subject: [PATCH 044/125] rename to aiopenapi3 --- LICENSE | 1 + README.md | 202 ++++++++++++++++++ README.rst | 85 -------- {openapi3 => aiopenapi3}/__init__.py | 0 {openapi3 => aiopenapi3}/__main__.py | 0 {openapi3 => aiopenapi3}/components.py | 0 {openapi3 => aiopenapi3}/errors.py | 0 {openapi3 => aiopenapi3}/example.py | 0 {openapi3 => aiopenapi3}/expression/Makefile | 0 .../expression/grammar.ebnf | 0 .../expression/grammar.py | 0 {openapi3 => aiopenapi3}/expression/model.py | 23 +- {openapi3 => aiopenapi3}/general.py | 0 {openapi3 => aiopenapi3}/info.py | 0 {openapi3 => aiopenapi3}/loader.py | 4 +- {openapi3 => aiopenapi3}/media.py | 0 {openapi3 => aiopenapi3}/object_base.py | 0 {openapi3 => aiopenapi3}/openapi.py | 22 +- {openapi3 => aiopenapi3}/parameter.py | 0 {openapi3 => aiopenapi3}/paths.py | 2 - {openapi3 => aiopenapi3}/request.py | 8 +- {openapi3 => aiopenapi3}/schemas.py | 17 +- {openapi3 => aiopenapi3}/security.py | 0 {openapi3 => aiopenapi3}/servers.py | 0 {openapi3 => aiopenapi3}/tag.py | 0 {openapi3 => aiopenapi3}/xml.py | 0 pyproject.toml | 3 + requirements.txt | 5 +- setup.cfg | 52 +++++ setup.py | 27 --- tests/conftest.py | 2 +- tests/fastapi_test.py | 4 +- tests/model_test.py | 21 +- tests/parse_data_test.py | 2 +- tests/parsing_test.py | 2 +- tests/path_test.py | 8 +- tests/ref_test.py | 4 +- tests/test_runtimexpression.py | 20 +- 38 files changed, 338 insertions(+), 176 deletions(-) create mode 100644 README.md delete mode 100644 README.rst rename {openapi3 => aiopenapi3}/__init__.py (100%) rename {openapi3 => aiopenapi3}/__main__.py (100%) rename {openapi3 => aiopenapi3}/components.py (100%) rename {openapi3 => aiopenapi3}/errors.py (100%) rename {openapi3 => aiopenapi3}/example.py (100%) rename {openapi3 => aiopenapi3}/expression/Makefile (100%) rename {openapi3 => aiopenapi3}/expression/grammar.ebnf (100%) rename {openapi3 => aiopenapi3}/expression/grammar.py (100%) rename {openapi3 => aiopenapi3}/expression/model.py (87%) rename {openapi3 => aiopenapi3}/general.py (100%) rename {openapi3 => aiopenapi3}/info.py (100%) rename {openapi3 => aiopenapi3}/loader.py (98%) rename {openapi3 => aiopenapi3}/media.py (100%) rename {openapi3 => aiopenapi3}/object_base.py (100%) rename {openapi3 => aiopenapi3}/openapi.py (94%) rename {openapi3 => aiopenapi3}/parameter.py (100%) rename {openapi3 => aiopenapi3}/paths.py (99%) rename {openapi3 => aiopenapi3}/request.py (97%) rename {openapi3 => aiopenapi3}/schemas.py (92%) rename {openapi3 => aiopenapi3}/security.py (100%) rename {openapi3 => aiopenapi3}/servers.py (100%) rename {openapi3 => aiopenapi3}/tag.py (100%) rename {openapi3 => aiopenapi3}/xml.py (100%) create mode 100644 pyproject.toml create mode 100644 setup.cfg delete mode 100755 setup.py diff --git a/LICENSE b/LICENSE index 260240c..14372d7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,5 @@ Copyright (c) 2019, William Smith +Copyright (c) 2022, Markus Kötter All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/README.md b/README.md new file mode 100644 index 0000000..b157dea --- /dev/null +++ b/README.md @@ -0,0 +1,202 @@ +# aiopenapi3 + +A Python `OpenAPI 3 Specification`_ client and validator for Python 3. +This project is based on Dorthu/openapi3. + +## Features + * implements OpenAPI 3.0.3 + * object parsing via pydantic + * request body model creation via pydantic + * blocking and nonblocking (asyncio) interface via httpx + + +## Usage as a Client + +This library also functions as an interactive client for arbitrary OpenAPI 3 +specs. For example, using `Linode's OpenAPI 3 Specification`_ for reference: + +*Unfortunately I do not have access to the Linode API to validate object creation* + +### asyncio +```python +from aiopenapi3 import OpenAPI +url = "https://www.linode.com/docs/api/openapi.yaml" + +api = await OpenAPI.load_async(url) + +# call operations and receive result models +regions = await api._.getRegions() +``` + +### blocking io +```python +from aiopenapi3 import OpenAPI +url = "https://www.linode.com/docs/api/openapi.yaml" +my_token = "Gae6aikaegainoor" +api = OpenAPI.load_sync(url) + +# call operations and receive result models +regions = api._.getRegions() + + +``` + +### objects +pydantic is used for the models. +https://pydantic-docs.helpmanual.io/usage/exporting_models/ + +```python +from aiopenapi3 import OpenAPI +url = "https://www.linode.com/docs/api/openapi.yaml" + +api = await OpenAPI.load_sync(url) + +# call operations and receive result models +regions = await api._.getRegions() + +regions.__fields_set__ +{'results', 'page', 'pages', 'data'} + +import json +print(json.dumps((list(filter(lambda x: 'eu-west' in x.id, regions.data))[0]).dict(), indent=2)) +{ + "id": "eu-west", + "country": "uk", + "capabilities": [ + "Linodes", + "NodeBalancers", + "Block Storage", + "Kubernetes", + "Cloud Firewall" + ], + "status": "ok", + "resolvers": { + "ipv4": "178.79.182.5,176.58.107.5,176.58.116.5,176.58.121.5,151.236.220.5,212.71.252.5,212.71.253.5,109.74.192.20,109.74.193.20,109.74.194.20", + "ipv6": "2a01:7e00::9,2a01:7e00::3,2a01:7e00::c,2a01:7e00::5,2a01:7e00::6,2a01:7e00::8,2a01:7e00::b,2a01:7e00::4,2a01:7e00::7,2a01:7e00::2" + } +} +``` + +#### discriminators +discriminators are supported as well, but the linode api can't be used to show how to use them. +```python +import aiopenapi3 +api = aiopenapi3.OpenAPI.load_sync("https://www.linode.com/docs/api/openapi.yaml") +api._.getManagedStats.return_value().model({}).dict() + +Traceback (most recent call last): + File "pydantic/utils.py", line 733, in pydantic.utils.get_discriminator_alias_and_values +KeyError: 'x-linode-ref-name' + +The above exception was the direct cause of the following exception: + +Traceback (most recent call last): +… + m = types.new_class(type_name, (BaseModel,), {}, lambda ns: ns.update(namespace)) + File "/usr/lib/python3.9/types.py", line 77, in new_class + return meta(name, resolved_bases, ns, **kwds) + File "pydantic/main.py", line 204, in pydantic.main.ModelMetaclass.__new__ + File "pydantic/fields.py", line 488, in pydantic.fields.ModelField.infer + File "pydantic/fields.py", line 419, in pydantic.fields.ModelField.__init__ + File "pydantic/fields.py", line 534, in pydantic.fields.ModelField.prepare + File "pydantic/fields.py", line 599, in pydantic.fields.ModelField._type_analysis + File "pydantic/fields.py", line 636, in pydantic.fields.ModelField._type_analysis + File "pydantic/fields.py", line 750, in pydantic.fields.ModelField.prepare_discriminated_union_sub_fields + File "pydantic/utils.py", line 737, in pydantic.utils.get_discriminator_alias_and_values +pydantic.errors.ConfigError: Model 'StatsDataAvailable' needs a discriminator field for key 'x-linode-ref-name' +``` + +look at [tests/model_test.py] test_model. + +### authentication +```python +my_token = "Gae6aikaegainoor" +api.authenticate('personalAccessToken', my_token) + +# call an operation that requires authentication +linodes = api._.getLinodeInstances() +``` + +HTTP basic authentication and HTTP digest authentication works like this: +```python +# authenticate using a securityScheme defined in the spec's components.securitySchemes +# Tuple with (username, password) as second argument +api.authenticate('basicAuth', ('username', 'password')) +``` + +### parameters + +```python +# call an opertaion with parameters +linode = api._.getLinodeInstance(parameters={"linodeId": 123}) +``` + +### body +```python +body = api._.createLinodeInstance.args()["data"].model({"region":"us-east", "type":"g6-standard-2"}) +print(json.dumps(body.dict(), indent=2)) +{ + "image": null, + "root_pass": null, + "authorized_keys": null, + "authorized_users": null, + "stackscript_id": null, + "stackscript_data": null, + "booted": null, + "backup_id": null, + "backups_enabled": null, + "swap_size": null, + "type": "g6-standard-2", + "region": "us-east", + "label": null, + "tags": null, + "group": null, + "private_ip": null, + "interfaces": null +} + +print(json.dumps(body.dict(exclude_unset=True), indent=2)) +{ + "type": "g6-standard-2", + "region": "us-east" +} + + +>>> +new_linode = api._.createLinodeInstance(data=body) +``` + +## Validation Mode + + +This module can be run against a spec file to validate it like so:: + +``` +python3 -m aiopenapi3 tests/fixtures/with-broken-links.yaml + +6 validation errors for OpenAPISpec +paths -> /with-links -> get -> responses -> 200 -> links -> exampleWithBoth -> __root__ + operationId and operationRef are mutually exclusive, only one of them is allowed (type=value_error.spec; message=operationId and operationRef are mutually exclusive, only one of them is allowed; element=None) +paths -> /with-links -> get -> responses -> 200 -> links -> exampleWithBoth -> $ref + field required (type=value_error.missing) +paths -> /with-links -> get -> responses -> 200 -> $ref + field required (type=value_error.missing) +paths -> /with-links-two -> get -> responses -> 200 -> links -> exampleWithNeither -> __root__ + operationId and operationRef are mutually exclusive, one of them must be specified (type=value_error.spec; message=operationId and operationRef are mutually exclusive, one of them must be specified; element=None) +paths -> /with-links-two -> get -> responses -> 200 -> links -> exampleWithNeither -> $ref + field required (type=value_error.missing) +paths -> /with-links-two -> get -> responses -> 200 -> $ref + field required (type=value_error.missing) +``` + +## Running Tests + +This project includes a test suite, run via ``pytest``. To run the test suite, +ensure that you've installed the dependencies and then run ``pytest`` in the root +of this project. + + + + + + diff --git a/README.rst b/README.rst deleted file mode 100644 index d3bff73..0000000 --- a/README.rst +++ /dev/null @@ -1,85 +0,0 @@ -openapi3 -======== - -A Python `OpenAPI 3 Specification`_ client and validator for Python 3. - -.. image:: https://travis-ci.org/Dorthu/openapi3.svg?branch=master - :target: https://travis-ci.org/Dorthu/openapi3 - - -.. image:: https://badge.fury.io/py/openapi3.svg - :target: https://badge.fury.io/py/openapi3 - - -Validation Mode ---------------- - -This module can be run against a spec file to validate it like so:: - - python3 -m openapi3 /path/to/spec - -Usage as a Client ------------------ - -This library also functions as an interactive client for arbitrary OpenAPI 3 -specs. For example, using `Linode's OpenAPI 3 Specification`_ for reference:: - - from openapi3 import OpenAPI - import yaml - - # load the spec file and read the yaml - with open('openapi.yaml') as f: - spec = yaml.safe_load(f.read()) - - # parse the spec into python - this will raise if the spec is invalid - api = OpenAPI(spec) - - # call operations and receive result models - regions = api.call_getRegions() - - # authenticate using a securityScheme defined in the spec's components.securitySchemes - api.authenticate('personalAccessToken', my_token) - - # call an operation that requires authentication - linodes = api.call_getLinodeInstances() - - # call an opertaion with parameters - linode = api.call_getLinodeInstance(parameters={"linodeId": 123}) - - # the models returns are all of the same (generated) type - print(type(linode)) # openapi.schemas.Linode - type(linode) == type(linodes.data[0]) # True - - # call an operation with a request body - new_linode = api.call_createLinodeInstance(data={"region":"us-east","type":"g6-standard-2"}) - - # the returned models is still of the correct type - type(new_linode) == type(linode) # True - -HTTP basic authentication and HTTP digest authentication works like this:: - - # authenticate using a securityScheme defined in the spec's components.securitySchemes - # Tuple with (username, password) as second argument - api.authenticate('basicAuth', ('username', 'password')) - -Running Tests -------------- - -This project includes a test suite, run via ``pytest``. To run the test suite, -ensure that you've installed the dependencies and then run ``pytest`` in the root -of this project. - -Roadmap -------- - -The following features are planned for the future: - -* Request body models, creation, and validation. -* Parameters interface with validation and explicit typing. -* Support for more authentication types. -* Support for non-json request/response content. -* Full support for all objects defined in the specification. - -.. _OpenAPI 3 Specification: https://openapis.org -.. _Linode's OpenAPI 3 Specification: https://developers.linode.com/api/v4 - diff --git a/openapi3/__init__.py b/aiopenapi3/__init__.py similarity index 100% rename from openapi3/__init__.py rename to aiopenapi3/__init__.py diff --git a/openapi3/__main__.py b/aiopenapi3/__main__.py similarity index 100% rename from openapi3/__main__.py rename to aiopenapi3/__main__.py diff --git a/openapi3/components.py b/aiopenapi3/components.py similarity index 100% rename from openapi3/components.py rename to aiopenapi3/components.py diff --git a/openapi3/errors.py b/aiopenapi3/errors.py similarity index 100% rename from openapi3/errors.py rename to aiopenapi3/errors.py diff --git a/openapi3/example.py b/aiopenapi3/example.py similarity index 100% rename from openapi3/example.py rename to aiopenapi3/example.py diff --git a/openapi3/expression/Makefile b/aiopenapi3/expression/Makefile similarity index 100% rename from openapi3/expression/Makefile rename to aiopenapi3/expression/Makefile diff --git a/openapi3/expression/grammar.ebnf b/aiopenapi3/expression/grammar.ebnf similarity index 100% rename from openapi3/expression/grammar.ebnf rename to aiopenapi3/expression/grammar.ebnf diff --git a/openapi3/expression/grammar.py b/aiopenapi3/expression/grammar.py similarity index 100% rename from openapi3/expression/grammar.py rename to aiopenapi3/expression/grammar.py diff --git a/openapi3/expression/model.py b/aiopenapi3/expression/model.py similarity index 87% rename from openapi3/expression/model.py rename to aiopenapi3/expression/model.py index dbfa3b3..0750a22 100644 --- a/openapi3/expression/model.py +++ b/aiopenapi3/expression/model.py @@ -1,8 +1,9 @@ import json + +import httpx from yarl import URL -import requests -import openapi3.general +import aiopenapi3.general from ._model import RuntimeExpressionModelBuilderSemantics as RuntimeExpressionModelBuilderSemanticsBase, \ JSONPointer as JSONPointerBase, \ @@ -68,7 +69,7 @@ def __init__(self, ctx=None, ast=None, parseinfo=None, **kwargs): @property def tokens(self): for i in self._tokens: - yield openapi3.general.JSONPointer.decode(i) + yield aiopenapi3.general.JSONPointer.decode(i) class Header(HeaderBase): @@ -78,9 +79,9 @@ def __init__(self, ctx=None, ast=None, parseinfo=None, **kwargs): def eval(self, data): headers = None - if isinstance(data, requests.PreparedRequest): + if isinstance(data, httpx.Request): headers = data.headers - elif isinstance(data, requests.Response): + elif isinstance(data, httpx.Response): headers = data.headers if headers is None: return None @@ -93,9 +94,9 @@ def __init__(self, ctx=None, ast=None, parseinfo=None, **kwargs): self.key = ast.key def eval(self, data): - if isinstance(data, requests.PreparedRequest): - url = URL(data.url) - elif isinstance(data, requests.Response): + if isinstance(data, httpx.Request): + url = URL(str(data.url)) + elif isinstance(data, httpx.Response): url = None return url.query.get(self.key, None) @@ -116,9 +117,9 @@ def __init__(self, ctx=None, ast=None, parseinfo=None, **kwargs): def eval(self, data): try: - if isinstance(data, requests.PreparedRequest): - body = json.loads(data.body) - elif isinstance(data, requests.Response): + if isinstance(data, httpx.Request): + body = json.loads(data.content) + elif isinstance(data, httpx.Response): body = data.json() except Exception: return None diff --git a/openapi3/general.py b/aiopenapi3/general.py similarity index 100% rename from openapi3/general.py rename to aiopenapi3/general.py diff --git a/openapi3/info.py b/aiopenapi3/info.py similarity index 100% rename from openapi3/info.py rename to aiopenapi3/info.py diff --git a/openapi3/loader.py b/aiopenapi3/loader.py similarity index 98% rename from openapi3/loader.py rename to aiopenapi3/loader.py index 9f96f4f..ed88547 100644 --- a/openapi3/loader.py +++ b/aiopenapi3/loader.py @@ -1,8 +1,8 @@ import abc -import pathlib +import json from pathlib import Path + import yaml -import json class Loader(abc.ABC): diff --git a/openapi3/media.py b/aiopenapi3/media.py similarity index 100% rename from openapi3/media.py rename to aiopenapi3/media.py diff --git a/openapi3/object_base.py b/aiopenapi3/object_base.py similarity index 100% rename from openapi3/object_base.py rename to aiopenapi3/object_base.py diff --git a/openapi3/openapi.py b/aiopenapi3/openapi.py similarity index 94% rename from openapi3/openapi.py rename to aiopenapi3/openapi.py index 2c7fe69..0ab5581 100644 --- a/openapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -1,7 +1,9 @@ import datetime import pathlib +import json from typing import Any, List, Optional, Dict, Union, Callable +import yaml from pydantic import Field import httpx import yarl @@ -42,16 +44,28 @@ def openapi(self): def servers(self): return self._spec.servers + + + @classmethod def load_sync(cls, url, session_factory: Callable[[], httpx.Client] = httpx.Client, loader=None): - raw_document = session_factory().get(url) - return cls(url, raw_document.json(), session_factory, loader) + resp = session_factory().get(url) + return cls.loads(url, resp.text, session_factory, loader) @classmethod async def load_async(cls, url, session_factory: Callable[[], httpx.AsyncClient] = httpx.AsyncClient, loader=None): async with session_factory() as client: - raw_document = await client.get(url) - return cls(url, raw_document.json(), session_factory, loader) + resp = await client.get(url) + return cls.loads(url, resp.text, session_factory, loader) + + @classmethod + def loads(cls, url, data, session_factory: Callable[[], httpx.AsyncClient] = httpx.AsyncClient, loader=None): + if url.endswith(".json"): + data = json.loads(data) + elif url.endswith(".yaml") or url.endswith(".yml"): + data = yaml.safe_load(data) + + return cls(url, data, session_factory, loader) def __init__(self, url, raw_document, session_factory: Callable[[], Union[httpx.Client, httpx.AsyncClient]] = httpx.AsyncClient, diff --git a/openapi3/parameter.py b/aiopenapi3/parameter.py similarity index 100% rename from openapi3/parameter.py rename to aiopenapi3/parameter.py diff --git a/openapi3/paths.py b/aiopenapi3/paths.py similarity index 99% rename from openapi3/paths.py rename to aiopenapi3/paths.py index 5111354..5e369b6 100644 --- a/openapi3/paths.py +++ b/aiopenapi3/paths.py @@ -1,8 +1,6 @@ -import json import re from typing import Union, List, Optional, Dict -import requests from pydantic import Field, BaseModel, root_validator from .errors import SpecError diff --git a/openapi3/request.py b/aiopenapi3/request.py similarity index 97% rename from openapi3/request.py rename to aiopenapi3/request.py index 434d5d7..e397317 100644 --- a/openapi3/request.py +++ b/aiopenapi3/request.py @@ -1,11 +1,12 @@ -from typing import List, Union import json +from typing import List import httpx import yarl from .paths import SecurityRequirement + class RequestParameter: def __init__(self, url:yarl.URL): self.url = str(url) @@ -46,7 +47,10 @@ def args(self, content_type:str="application/json"): op = self.operation parameters = op.parameters + self.spec.paths[self.path].parameters - return {"parameters":parameters, "data":op.requestBody.content[content_type].schema_._target} + schema = op.requestBody.content[content_type].schema_ +# if isinstance(schema, Reference): +# schema = schema._target + return {"parameters":parameters, "data":schema} def return_value(self, http_status=200, content_type="application/json"): return self.operation.responses[str(http_status)].content[content_type].schema_ diff --git a/openapi3/schemas.py b/aiopenapi3/schemas.py similarity index 92% rename from openapi3/schemas.py rename to aiopenapi3/schemas.py index 22d7695..0d36fc6 100644 --- a/openapi3/schemas.py +++ b/aiopenapi3/schemas.py @@ -1,7 +1,7 @@ import types import uuid from typing import Union, List, Any, Optional, Dict, Literal, Annotated -from functools import lru_cache + from pydantic import Field, root_validator, Extra, BaseModel from .general import Reference # need this for Model below @@ -52,8 +52,8 @@ class Schema(ObjectExtended): type: Optional[str] = Field(default=None) allOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) - oneOf: Optional[list] = Field(default=None) - anyOf: Optional[List[Union["Schema", Reference]]] = Field(default=None) + oneOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) + anyOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) not_: Optional[Union["Schema", Reference]] = Field(default=None, alias="not") items: Optional[Union['Schema', Reference]] = Field(default=None) properties: Optional[Dict[str, Union['Schema', Reference]]] = Field(default_factory=dict) @@ -141,6 +141,8 @@ def typeof(schema:Schema): r = str elif schema.type == "integer": r = int + elif schema.type == "boolean": + r = bool elif schema.type == "array": r = List[schema.items.get_type()] elif schema.type == 'object': @@ -203,12 +205,16 @@ def fieldof(schema:Schema): annos.update(annotationsof(i)) elif shma.anyOf: t = tuple([i.get_type(names=shmanm + [i.ref], discriminators=discriminators + [shma.discriminator]) for i in shma.anyOf]) - if shma.discriminator: + if shma.discriminator and shma.discriminator.mapping: annos["__root__"] = Annotated[Union[t], Field(discriminator=shma.discriminator.propertyName)] else: annos["__root__"] = Union[t] elif shma.oneOf: - raise NotImplementedError("oneOf") + t = tuple([i.get_type(names=shmanm + [i.ref], discriminators=discriminators + [shma.discriminator]) for i in shma.oneOf]) + if shma.discriminator and shma.discriminator.mapping: + annos["__root__"] = Annotated[Union[t], Field(discriminator=shma.discriminator.propertyName)] + else: + annos["__root__"] = Union[t] else: annos = annotationsof(shma) namespace.update(fieldof(shma)) @@ -216,6 +222,7 @@ def fieldof(schema:Schema): namespace['__annotations__'] = annos m = types.new_class(type_name, (BaseModel,), {}, lambda ns: ns.update(namespace)) + m.update_forward_refs() return m diff --git a/openapi3/security.py b/aiopenapi3/security.py similarity index 100% rename from openapi3/security.py rename to aiopenapi3/security.py diff --git a/openapi3/servers.py b/aiopenapi3/servers.py similarity index 100% rename from openapi3/servers.py rename to aiopenapi3/servers.py diff --git a/openapi3/tag.py b/aiopenapi3/tag.py similarity index 100% rename from openapi3/tag.py rename to aiopenapi3/tag.py diff --git a/openapi3/xml.py b/aiopenapi3/xml.py similarity index 100% rename from openapi3/xml.py rename to aiopenapi3/xml.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7fd26b9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 6284feb..f875d08 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,11 +3,8 @@ starlette~=0.16.0 fastapi~=0.70.1 pytest~=6.2.5 PyYAML~=6.0 -requests~=2.26.0 httpx~=0.21.1 uvloop~=0.16.0 hypercorn~=0.13.0 -TatSu~=5.6.1 yarl~=1.7.2 -setuptools~=57.4.0 -pytest-httpx \ No newline at end of file + diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..c57c7b9 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,52 @@ +[metadata] +name = aiopenapi3 +version = 0.1.0 + +description = OpenAPI3 3.0.3 client / validator based on pydantic & httpx +long_description = file: README.rst, CHANGELOG.rst, LICENSE.rst +keywords = openapi openapi3 swagger +license = +classifiers = + "Development Status :: 3 - Alpha", + "Environment :: Web Environment", + "Framework :: AsyncIO", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: BSD 3-Clause License", + "Operating System :: OS Independent", + "Topic :: Internet", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Software Development", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + + + +[options] +packages = aiopenapi3 +install_requires = + PyYaml + pydantic + yarl + httpx + +[options.extras_require] +tests = + pytest + pytest-asyncio + pytest-httpx + fastapi-versioning + hypercorn + uvloop +expression = + tatsu + diff --git a/setup.py b/setup.py deleted file mode 100755 index ab6d593..0000000 --- a/setup.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -from io import open -from setuptools import setup -from os import path -import subprocess - - -here = path.abspath(path.dirname(__file__)) - - -# get the long description from the README.rst -with open(path.join(here, 'README.rst'), encoding='utf-8') as f: - long_description = f.read() - - -setup( - name="openapi3", - version='1.5.0', - description="Client and Validator of OpenAPI 3 Specifications", - long_description=long_description, - author="dorthu", - url="https://github.com/dorthu/openapi3", - packages=['openapi3'], - license="BSD 3-Clause License", - install_requires=["PyYaml", "requests", "pydantic", "yarl"], - tests_require=["pytest", "fastapi-versioning", "requests_mock", "httpx_mock"], -) diff --git a/tests/conftest.py b/tests/conftest.py index 6f6faff..ca89960 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ import pytest from yaml import safe_load -from openapi3 import OpenAPI +from aiopenapi3 import OpenAPI LOADED_FILES = {} URLBASE = "/" diff --git a/tests/fastapi_test.py b/tests/fastapi_test.py index 55380f5..de7ac6f 100644 --- a/tests/fastapi_test.py +++ b/tests/fastapi_test.py @@ -10,7 +10,7 @@ from hypercorn.asyncio import serve from hypercorn.config import Config -import openapi3 +import aiopenapi3 from api.main import app @@ -40,7 +40,7 @@ async def server(event_loop, config): @pytest.fixture(scope="session") async def client(event_loop, server): - api = await asyncio.to_thread(openapi3.OpenAPI.load_sync, f"http://{server.bind[0]}/v1/openapi.json") + api = await asyncio.to_thread(aiopenapi3.OpenAPI.load_sync, f"http://{server.bind[0]}/v1/openapi.json") return api def randomPet(name=None): diff --git a/tests/model_test.py b/tests/model_test.py index 973baad..f5f376c 100644 --- a/tests/model_test.py +++ b/tests/model_test.py @@ -4,13 +4,6 @@ from pydantic import Extra from tests.api.v2.schema import Pet, Dog, Cat, WhiteCat, BlackCat -from openapi3.schemas import Schema - -def test_Pet(): - data = Dog.schema() - shma = Schema.parse_obj(data) - shma._identity = "Dog" - assert shma.get_type().schema() == data import asyncio import uuid @@ -23,7 +16,8 @@ def test_Pet(): from hypercorn.asyncio import serve from hypercorn.config import Config -import openapi3 +import aiopenapi3 +from aiopenapi3.schemas import Schema from tests.api.main import app @@ -59,14 +53,21 @@ def version(request): @pytest.fixture(scope="session") async def client(event_loop, server, version): url = f"http://{server.bind[0]}/{version}/openapi.json" - api = await openapi3.OpenAPI.load_async(url) + api = await aiopenapi3.OpenAPI.load_async(url) return api +def test_Pet(): + data = Dog.schema() + shma = Schema.parse_obj(data) + shma._identity = "Dog" + assert shma.get_type().schema() == data + + @pytest.mark.asyncio async def test_sync(event_loop, server, version): url = f"http://{server.bind[0]}/{version}/openapi.json" - api = await asyncio.to_thread(openapi3.OpenAPI.load_sync, url) + api = await asyncio.to_thread(aiopenapi3.OpenAPI.load_sync, url) return api diff --git a/tests/parse_data_test.py b/tests/parse_data_test.py index 1b62cb4..0413921 100644 --- a/tests/parse_data_test.py +++ b/tests/parse_data_test.py @@ -1,5 +1,5 @@ import pytest -from openapi3 import FileSystemLoader,OpenAPI +from aiopenapi3 import FileSystemLoader,OpenAPI import pathlib URLBASE = "http://127.1.1.1/open5gs" diff --git a/tests/parsing_test.py b/tests/parsing_test.py index b9757f2..fbfb3df 100644 --- a/tests/parsing_test.py +++ b/tests/parsing_test.py @@ -4,7 +4,7 @@ import pytest from pydantic import ValidationError -from openapi3 import OpenAPI, SpecError, ReferenceResolutionError, FileSystemLoader +from aiopenapi3 import OpenAPI, SpecError, ReferenceResolutionError, FileSystemLoader URLBASE = "/" diff --git a/tests/path_test.py b/tests/path_test.py index c59ce98..a82796c 100644 --- a/tests/path_test.py +++ b/tests/path_test.py @@ -5,16 +5,12 @@ import uuid import pathlib -from unittest.mock import patch, MagicMock -from urllib.parse import urlparse - import pytest -import requests.auth import httpx import yarl -from openapi3 import OpenAPI -from openapi3.schemas import Schema +from aiopenapi3 import OpenAPI +from aiopenapi3.schemas import Schema URLBASE = "/" diff --git a/tests/ref_test.py b/tests/ref_test.py index 6865acc..281dd1e 100644 --- a/tests/ref_test.py +++ b/tests/ref_test.py @@ -7,8 +7,8 @@ import pytest -from openapi3 import OpenAPI -from openapi3.schemas import Schema +from aiopenapi3 import OpenAPI +from aiopenapi3.schemas import Schema from pydantic.main import ModelMetaclass diff --git a/tests/test_runtimexpression.py b/tests/test_runtimexpression.py index 3360e0b..6d5472f 100644 --- a/tests/test_runtimexpression.py +++ b/tests/test_runtimexpression.py @@ -1,12 +1,10 @@ import json +import httpx import pytest -import requests -import requests_mock - import tatsu -from openapi3.expression.grammar import loads +from aiopenapi3.expression.grammar import loads parse_testdata = [ "$url", @@ -49,7 +47,7 @@ def test_parse_escape(): "$request.body#/escaped~0content/2/~1/~0/x":"no", } @pytest.mark.parametrize("param, result", get_testdata.items()) -def test_get(param, result): +def test_get(httpx_mock, param, result): url = "http://example.org/subscribe/myevent?queryUrl=http://clientdomain.com/stillrunning" data = { "failedUrl": "http://clientdomain.com/failed", @@ -62,12 +60,12 @@ def test_get(param, result): "escaped~content": [0, {"/": {"~": {"x": "no" }}}] } - with requests_mock.Mocker() as m: - m.post(url, headers={"Location":"http://example.org/subscription/1"}) - req = requests.Request(method="POST", url=url, data=json.dumps(data), headers={"Content-Type":"application/json"}) - req = req.prepare() - req.path = {"eventType":"myevent"} - resp = requests.Session().send(req) + httpx_mock.add_response(headers={"Location":"http://example.org/subscription/1"},) + client = httpx.Client() + resp = client.post(url, json=data, headers={"Location":"http://example.org/subscription/1", "Content-Type":"application/json"}) + + req = httpx_mock.get_requests()[-1] + req.path = {"eventType":"myevent"} m = loads(param) r = m.eval(req, resp) From a08da4488a202a632150818741edf6e4474e115a Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Sun, 2 Jan 2022 20:00:07 +0100 Subject: [PATCH 045/125] codecov --- .github/workflows/codecov.yml | 34 ++++ aiopenapi3/expression/_grammar.py | 309 ++++++++++++++++++++++++++++++ aiopenapi3/expression/_model.py | 57 ++++++ setup.cfg | 5 +- tests/fastapi_test.py | 3 - 5 files changed, 404 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/codecov.yml create mode 100644 aiopenapi3/expression/_grammar.py create mode 100644 aiopenapi3/expression/_model.py diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml new file mode 100644 index 0000000..de16fa4 --- /dev/null +++ b/.github/workflows/codecov.yml @@ -0,0 +1,34 @@ +name: Codecov +on: [push, pull_request] +jobs: + run: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest] + env: + OS: ${{ matrix.os }} + PYTHON: '3.7' + PYTHONPATH: "." + steps: + - uses: actions/checkout@master + - name: Setup Python + uses: actions/setup-python@master + with: + python-version: 3.7 + - name: Generate coverage report + run: | + pip install '.[tests]' + pytest --cov=./ --cov-report=xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + directory: ./coverage/reports/ + env_vars: OS,PYTHON + fail_ci_if_error: false + files: ./coverage.xml + flags: unittests + name: codecov-aiopenapi3 + path_to_write_report: ./coverage/codecov_report.txt + verbose: true \ No newline at end of file diff --git a/aiopenapi3/expression/_grammar.py b/aiopenapi3/expression/_grammar.py new file mode 100644 index 0000000..67530cc --- /dev/null +++ b/aiopenapi3/expression/_grammar.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python + +# CAVEAT UTILITOR +# +# This file was automatically generated by TatSu. +# +# https://pypi.python.org/pypi/tatsu/ +# +# Any changes you make to it will be overwritten the next time +# the file is generated. + +from __future__ import annotations + +import sys + +from tatsu.buffering import Buffer +from tatsu.parsing import Parser +from tatsu.parsing import tatsumasu +from tatsu.parsing import leftrec, nomemo, isname # noqa +from tatsu.util import re, generic_main # noqa + + +KEYWORDS = { + 'pool', + 'elsif', + 'subclass', + 'subnet', + 'shared-network', + 'if', + 'class', + 'group', + 'host', +} # type: ignore + + +class RuntimeExpressionBuffer(Buffer): + def __init__( + self, + text, + whitespace=None, + nameguard=None, + comments_re='', + eol_comments_re='', + ignorecase=None, + namechars='', + **kwargs + ): + super().__init__( + text, + whitespace=whitespace, + nameguard=nameguard, + comments_re=comments_re, + eol_comments_re=eol_comments_re, + ignorecase=ignorecase, + namechars=namechars, + **kwargs + ) + + +class RuntimeExpressionParser(Parser): + def __init__( + self, + whitespace=None, + nameguard=None, + comments_re='', + eol_comments_re='', + ignorecase=None, + left_recursion=True, + parseinfo=True, + keywords=None, + namechars='', + tokenizercls=RuntimeExpressionBuffer, + **kwargs + ): + if keywords is None: + keywords = KEYWORDS + super().__init__( + whitespace=whitespace, + nameguard=nameguard, + comments_re=comments_re, + eol_comments_re=eol_comments_re, + ignorecase=ignorecase, + left_recursion=left_recursion, + parseinfo=parseinfo, + keywords=keywords, + namechars=namechars, + tokenizercls=tokenizercls, + **kwargs + ) + + @tatsumasu('RuntimeExpression') + def _start_(self): # noqa + self._expression_() + self._check_eof() + + @tatsumasu('Expression') + def _expression_(self): # noqa + with self._choice(): + with self._option(): + self._token('$url') + self.name_last_node('root') + with self._option(): + self._token('$method') + self.name_last_node('root') + with self._option(): + self._token('$statusCode') + self.name_last_node('root') + with self._option(): + self._token('$request.') + self.name_last_node('root') + self._source_() + self.name_last_node('next') + with self._option(): + self._token('$response.') + self.name_last_node('root') + self._source_() + self.name_last_node('next') + self._error( + 'expecting one of: ' + "'$url' '$method' '$statusCode'" + "'$request.' '$response.'" + ) + self._define( + ['next', 'root'], + [] + ) + + @tatsumasu() + def _source_(self): # noqa + with self._choice(): + with self._option(): + self._header_reference_() + with self._option(): + self._query_reference_() + with self._option(): + self._path_reference_() + with self._option(): + self._body_reference_() + self._error( + 'expecting one of: ' + "'header.' 'query.'" + " 'path.'" + " 'body' " + ) + + @tatsumasu('Header') + def _header_reference_(self): # noqa + self._token('header.') + self._token_() + self.name_last_node('key') + self._define( + ['key'], + [] + ) + + @tatsumasu('Query') + def _query_reference_(self): # noqa + self._token('query.') + self._name_() + self.name_last_node('key') + self._define( + ['key'], + [] + ) + + @tatsumasu('Path') + def _path_reference_(self): # noqa + self._token('path.') + self._name_() + self.name_last_node('key') + self._define( + ['key'], + [] + ) + + @tatsumasu('Body') + def _body_reference_(self): # noqa + self._token('body') + with self._optional(): + self._json_pointer_() + self.name_last_node('fragment') + self._define( + ['fragment'], + [] + ) + + @tatsumasu('JSONPointer') + def _json_pointer_(self): # noqa + self._token('#/') + + def sep1(): + self._token('/') + + def block1(): + self._reference_token_() + self._gather(block1, sep1) + self.name_last_node('tokens') + self._define( + ['tokens'], + [] + ) + + @tatsumasu() + def _reference_token_(self): # noqa + + def block0(): + with self._choice(): + with self._option(): + self._unescaped_() + with self._option(): + self._escaped_() + self._error( + 'expecting one of: ' + "[^\\/~] '~' " + ) + self._closure(block0) + + @tatsumasu() + def _unescaped_(self): # noqa + self._pattern('[^\\/~]') + + @tatsumasu() + def _escaped_(self): # noqa + self._token('~') + with self._group(): + with self._choice(): + with self._option(): + self._token('0') + with self._option(): + self._token('1') + self._error( + 'expecting one of: ' + "'0' '1'" + ) + + @tatsumasu() + def _name_(self): # noqa + self._pattern('[\\w]*') + + @tatsumasu() + def _token_(self): # noqa + self._pattern("[!#$%&'*+-\\.^-`|~\\w]+") + + +class RuntimeExpressionSemantics(object): + def start(self, ast): # noqa + return ast + + def expression(self, ast): # noqa + return ast + + def source(self, ast): # noqa + return ast + + def header_reference(self, ast): # noqa + return ast + + def query_reference(self, ast): # noqa + return ast + + def path_reference(self, ast): # noqa + return ast + + def body_reference(self, ast): # noqa + return ast + + def json_pointer(self, ast): # noqa + return ast + + def reference_token(self, ast): # noqa + return ast + + def unescaped(self, ast): # noqa + return ast + + def escaped(self, ast): # noqa + return ast + + def name(self, ast): # noqa + return ast + + def token(self, ast): # noqa + return ast + + +def main(filename, start=None, **kwargs): + if start is None: + start = 'start' + if not filename or filename == '-': + text = sys.stdin.read() + else: + with open(filename) as f: + text = f.read() + parser = RuntimeExpressionParser() + return parser.parse( + text, + rule_name=start, + filename=filename, + **kwargs + ) + + +if __name__ == '__main__': + import json + from tatsu.util import asjson + + ast = generic_main(main, RuntimeExpressionParser, name='RuntimeExpression') + data = asjson(ast) + print(json.dumps(data, indent=2)) diff --git a/aiopenapi3/expression/_model.py b/aiopenapi3/expression/_model.py new file mode 100644 index 0000000..18fac8c --- /dev/null +++ b/aiopenapi3/expression/_model.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python + +# CAVEAT UTILITOR +# +# This file was automatically generated by TatSu. +# +# https://pypi.python.org/pypi/tatsu/ +# +# Any changes you make to it will be overwritten the next time +# the file is generated. + +from __future__ import annotations + +from tatsu.objectmodel import Node +from tatsu.semantics import ModelBuilderSemantics + + +class ModelBase(Node): + pass + + +class RuntimeExpressionModelBuilderSemantics(ModelBuilderSemantics): + def __init__(self, context=None, types=None): + types = [ + t for t in globals().values() + if type(t) is type and issubclass(t, ModelBase) + ] + (types or []) + super(RuntimeExpressionModelBuilderSemantics, self).__init__(context=context, types=types) + + +class RuntimeExpression(ModelBase): + pass + + +class Expression(ModelBase): + next = None + root = None + + +class Header(ModelBase): + key = None + + +class Query(ModelBase): + key = None + + +class Path(ModelBase): + key = None + + +class Body(ModelBase): + fragment = None + + +class JSONPointer(ModelBase): + tokens = None diff --git a/setup.cfg b/setup.cfg index c57c7b9..f3ddd15 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,7 +32,9 @@ classifiers = [options] -packages = aiopenapi3 +packages = + aiopenapi3 + aiopenapi3.expression install_requires = PyYaml pydantic @@ -44,6 +46,7 @@ tests = pytest pytest-asyncio pytest-httpx + pytest-coverage fastapi-versioning hypercorn uvloop diff --git a/tests/fastapi_test.py b/tests/fastapi_test.py index de7ac6f..32fc9d0 100644 --- a/tests/fastapi_test.py +++ b/tests/fastapi_test.py @@ -3,9 +3,6 @@ import pytest -import requests -import httpx - import uvloop from hypercorn.asyncio import serve from hypercorn.config import Config From 95fbc73d7d9d553b251139e31448731fdc9fb646 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Sun, 2 Jan 2022 20:04:40 +0100 Subject: [PATCH 046/125] codecov trigger --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b157dea..1641b66 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,9 @@ This project includes a test suite, run via ``pytest``. To run the test suite, ensure that you've installed the dependencies and then run ``pytest`` in the root of this project. - +```shell +PYTHONPATH=. pytest --cov=./ --cov-report=xml . +``` From d5d7022c3d6cb704962456ff052479126b52b179 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Sun, 2 Jan 2022 20:06:01 +0100 Subject: [PATCH 047/125] README - include codecov banner in README --- .github/workflows/codecov.yml | 4 ++-- README.md | 41 +++++++++-------------------------- setup.cfg | 1 + tests/parse_data_test.py | 2 +- 4 files changed, 14 insertions(+), 34 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index de16fa4..3cc302f 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -8,14 +8,14 @@ jobs: os: [ubuntu-latest] env: OS: ${{ matrix.os }} - PYTHON: '3.7' + PYTHON: '3.9' PYTHONPATH: "." steps: - uses: actions/checkout@master - name: Setup Python uses: actions/setup-python@master with: - python-version: 3.7 + python-version: 3.9 - name: Generate coverage report run: | pip install '.[tests]' diff --git a/README.md b/README.md index 1641b66..fb338d6 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,19 @@ # aiopenapi3 -A Python `OpenAPI 3 Specification`_ client and validator for Python 3. -This project is based on Dorthu/openapi3. +A Python [OpenAPI 3 Specification](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md) client and validator for Python 3. + + + Coverage + + + +This project is based on [Dorthu/openapi3](github.com/Dorthu/openapi3/). ## Features * implements OpenAPI 3.0.3 * object parsing via pydantic - * request body model creation via pydantic - * blocking and nonblocking (asyncio) interface via httpx + * request body model creation via [pydantic](https://github.com/samuelcolvin/pydantic) + * blocking and nonblocking (asyncio) interface via [httpx](https://www.python-httpx.org/) ## Usage as a Client @@ -79,33 +85,6 @@ print(json.dumps((list(filter(lambda x: 'eu-west' in x.id, regions.data))[0]).di #### discriminators discriminators are supported as well, but the linode api can't be used to show how to use them. -```python -import aiopenapi3 -api = aiopenapi3.OpenAPI.load_sync("https://www.linode.com/docs/api/openapi.yaml") -api._.getManagedStats.return_value().model({}).dict() - -Traceback (most recent call last): - File "pydantic/utils.py", line 733, in pydantic.utils.get_discriminator_alias_and_values -KeyError: 'x-linode-ref-name' - -The above exception was the direct cause of the following exception: - -Traceback (most recent call last): -… - m = types.new_class(type_name, (BaseModel,), {}, lambda ns: ns.update(namespace)) - File "/usr/lib/python3.9/types.py", line 77, in new_class - return meta(name, resolved_bases, ns, **kwds) - File "pydantic/main.py", line 204, in pydantic.main.ModelMetaclass.__new__ - File "pydantic/fields.py", line 488, in pydantic.fields.ModelField.infer - File "pydantic/fields.py", line 419, in pydantic.fields.ModelField.__init__ - File "pydantic/fields.py", line 534, in pydantic.fields.ModelField.prepare - File "pydantic/fields.py", line 599, in pydantic.fields.ModelField._type_analysis - File "pydantic/fields.py", line 636, in pydantic.fields.ModelField._type_analysis - File "pydantic/fields.py", line 750, in pydantic.fields.ModelField.prepare_discriminated_union_sub_fields - File "pydantic/utils.py", line 737, in pydantic.utils.get_discriminator_alias_and_values -pydantic.errors.ConfigError: Model 'StatsDataAvailable' needs a discriminator field for key 'x-linode-ref-name' -``` - look at [tests/model_test.py] test_model. ### authentication diff --git a/setup.cfg b/setup.cfg index f3ddd15..1f82b47 100644 --- a/setup.cfg +++ b/setup.cfg @@ -50,6 +50,7 @@ tests = fastapi-versioning hypercorn uvloop + tatsu expression = tatsu diff --git a/tests/parse_data_test.py b/tests/parse_data_test.py index 0413921..762ece4 100644 --- a/tests/parse_data_test.py +++ b/tests/parse_data_test.py @@ -8,7 +8,7 @@ def pytest_generate_tests(metafunc): argnames, dir, filterfn = metafunc.cls.params[metafunc.function.__name__] dir = pathlib.Path(dir).expanduser() metafunc.parametrize( - argnames, [[dir, i.name] for i in sorted(filter(filterfn, dir.iterdir()), key=lambda x: x.name)] + argnames, [[dir, i.name] for i in sorted(filter(filterfn, dir.iterdir() if dir.exists() else []), key=lambda x: x.name)] ) From 0876038a4a60c3ee971d878377dfc0c2a81139c0 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 3 Jan 2022 08:40:59 +0100 Subject: [PATCH 048/125] python 3.7 compatiblity --- .github/workflows/codecov.yml | 4 ++-- aiopenapi3/schemas.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 3cc302f..de16fa4 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -8,14 +8,14 @@ jobs: os: [ubuntu-latest] env: OS: ${{ matrix.os }} - PYTHON: '3.9' + PYTHON: '3.7' PYTHONPATH: "." steps: - uses: actions/checkout@master - name: Setup Python uses: actions/setup-python@master with: - python-version: 3.9 + python-version: 3.7 - name: Generate coverage report run: | pip install '.[tests]' diff --git a/aiopenapi3/schemas.py b/aiopenapi3/schemas.py index 0d36fc6..86b38f4 100644 --- a/aiopenapi3/schemas.py +++ b/aiopenapi3/schemas.py @@ -188,7 +188,8 @@ def fieldof(schema:Schema): for name, f in schema.properties.items(): args = dict() for i in ["enum","default"]: - if (v:=getattr(f, i, None)): + v = getattr(f, i, None) + if v: args[i] = v r[name] = Field(**args) return r From 84d0fec374696556c7144544f41b26098093fba7 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 3 Jan 2022 08:41:32 +0100 Subject: [PATCH 049/125] Loader - tests --- aiopenapi3/loader.py | 37 +++++++++++++++++--------- aiopenapi3/openapi.py | 6 +---- tests/loader_test.py | 61 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 18 deletions(-) create mode 100644 tests/loader_test.py diff --git a/aiopenapi3/loader.py b/aiopenapi3/loader.py index ed88547..2c95c6c 100644 --- a/aiopenapi3/loader.py +++ b/aiopenapi3/loader.py @@ -10,32 +10,43 @@ class Loader(abc.ABC): def load(self, name:str): raise NotImplementedError("load") - -class FileSystemLoader(Loader): - def __init__(self, base:Path): - assert isinstance(base, Path) - self.base = base - - def load(self, file:str, codec=None): - path = self.base / file - assert path.is_relative_to(self.base) - data = path.open("rb").read() + @classmethod + def decode(cls, data, codec): if codec is not None: codecs = [codec] else: codecs = ["ascii","utf-8"] for c in codecs: try: - r = data.decode(c) + data = data.decode(c) break except UnicodeError: continue else: raise ValueError("encoding") - if path.suffix == ".yaml": + return data + + @classmethod + def dict(cls, file, data): + if file.suffix == ".yaml": data = yaml.safe_load(data) - elif path.suffix == ".json": + elif file.suffix == ".json": data = json.loads(data) else: raise ValueError(file.name) return data + + + +class FileSystemLoader(Loader): + def __init__(self, base:Path): + assert isinstance(base, Path) + self.base = base + + def load(self, file:str, codec=None): + path = self.base / file + assert path.is_relative_to(self.base) + data = path.open("rb").read() + data = self.decode(data, codec) + data = self.dict(path, data) + return data \ No newline at end of file diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index 0ab5581..eee9529 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -60,11 +60,7 @@ async def load_async(cls, url, session_factory: Callable[[], httpx.AsyncClient] @classmethod def loads(cls, url, data, session_factory: Callable[[], httpx.AsyncClient] = httpx.AsyncClient, loader=None): - if url.endswith(".json"): - data = json.loads(data) - elif url.endswith(".yaml") or url.endswith(".yml"): - data = yaml.safe_load(data) - + data = Loader.dict(pathlib.Path(url), data) return cls(url, data, session_factory, loader) def __init__(self, url, raw_document, diff --git a/tests/loader_test.py b/tests/loader_test.py new file mode 100644 index 0000000..0cf5436 --- /dev/null +++ b/tests/loader_test.py @@ -0,0 +1,61 @@ +from pathlib import Path +import json + +import pytest +from aiopenapi3 import OpenAPI, FileSystemLoader, ReferenceResolutionError +from aiopenapi3.loader import Loader + +SPECTPL = """ +openapi: "3.0.0" +info: + title: spec01 + version: 1.0.0 + description: | + {description} + +paths: + /load: + get: + responses: + '200': + $ref: {jsonref} +components: + schemas: + Example: + type: str + Object: + type: object + properties: + name: + type: string + value: + type: boolean +""" + +data = [("petstore-expanded.yaml#/components/schemas/Pet", None), + ("no-such.file.yaml#/components/schemas/Pet", FileNotFoundError), + ("petstore-expanded.yaml#/components/schemas/NoSuchPet", ReferenceResolutionError),] + +@pytest.mark.parametrize("jsonref, exception", data) +def test_loader_jsonref(jsonref, exception): + loader = FileSystemLoader(Path("tests/fixtures")) + values = {"jsonref":jsonref, "description":""} + if exception is None: + api = OpenAPI.loads("loader.yaml", SPECTPL.format(**values), session_factory=None, loader=loader) + else: + with pytest.raises(exception): + api = OpenAPI.loads("loader.yaml", SPECTPL.format(**values), session_factory=None, loader=loader) + + +def test_loader_decode(): + with pytest.raises(ValueError, match="encoding"): + Loader.decode(b'rvice.\r\n \xa9 2020, 3GPP Organ', codec="utf-8") + +def test_loader_format(): + values = {"jsonref":"'#/components/schemas/Example'", "description":""} + spec = SPECTPL.format(**values) + api = OpenAPI.loads("loader.yaml", spec) + + spec = Loader.dict(Path("loader.yaml"), spec) + spec = json.dumps(spec) + api = OpenAPI.loads("loader.json", spec) \ No newline at end of file From 3d00bddf04527e05692e196f61cb048cace544c3 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 3 Jan 2022 08:52:54 +0100 Subject: [PATCH 050/125] python 3.8 required due to Literal ignoring existing workaround https://github.com/python/typing/issues/707 for python 3.7 --- .github/workflows/codecov.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index de16fa4..ffd9279 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -8,14 +8,14 @@ jobs: os: [ubuntu-latest] env: OS: ${{ matrix.os }} - PYTHON: '3.7' + PYTHON: '3.8' PYTHONPATH: "." steps: - uses: actions/checkout@master - name: Setup Python uses: actions/setup-python@master with: - python-version: 3.7 + python-version: 3.8 - name: Generate coverage report run: | pip install '.[tests]' From 0a692601eb5ed0ea257c5fb751a2060e80d9a7a0 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 3 Jan 2022 08:58:07 +0100 Subject: [PATCH 051/125] python 3.9 required for Annotated --- .github/workflows/codecov.yml | 5 +++-- setup.cfg | 5 +---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index ffd9279..6289ab0 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -6,16 +6,17 @@ jobs: strategy: matrix: os: [ubuntu-latest] + python: ["3.9","3.10"] env: OS: ${{ matrix.os }} - PYTHON: '3.8' + PYTHON: ${{ matrix.python }} PYTHONPATH: "." steps: - uses: actions/checkout@master - name: Setup Python uses: actions/setup-python@master with: - python-version: 3.8 + python-version: ${{ matrix.python }} - name: Generate coverage report run: | pip install '.[tests]' diff --git a/setup.cfg b/setup.cfg index 1f82b47..6450290 100644 --- a/setup.cfg +++ b/setup.cfg @@ -24,11 +24,8 @@ classifiers = "Typing :: Typed", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", - + "Programming Language :: Python :: 3.10", [options] From a3a9694d4f5688d14dfbd5230f553696916d6cc7 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 3 Jan 2022 09:10:16 +0100 Subject: [PATCH 052/125] runtimeexpression - skip tests for >= 3.10 --- tests/test_runtimexpression.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_runtimexpression.py b/tests/test_runtimexpression.py index 6d5472f..9d6a1ef 100644 --- a/tests/test_runtimexpression.py +++ b/tests/test_runtimexpression.py @@ -1,4 +1,5 @@ import json +import sys import httpx import pytest @@ -6,6 +7,10 @@ import tatsu from aiopenapi3.expression.grammar import loads +not310 = pytest.mark.skipif( + sys.version_info >= (3, 10, 0), reason="tatsu 5.7 requires 3.10, we rely on 5.6" +) + parse_testdata = [ "$url", "$method", @@ -14,15 +19,18 @@ "$request.body#/url", ] +@not310 @pytest.mark.parametrize("data", parse_testdata) def test_parse(data): m = loads(data) assert m is not None +@not310 def test_parse_fail(): with pytest.raises(tatsu.exceptions.FailedParse): loads("$request.body#/~test") +@not310 def test_parse_escape(): loads("$request.body#/~0test") loads("$request.body#/~1test") @@ -46,6 +54,8 @@ def test_parse_escape(): "$request.body#/escaped~1content/2/~0/~1/y":"yes", "$request.body#/escaped~0content/2/~1/~0/x":"no", } + +@not310 @pytest.mark.parametrize("param, result", get_testdata.items()) def test_get(httpx_mock, param, result): url = "http://example.org/subscribe/myevent?queryUrl=http://clientdomain.com/stillrunning" From c7cfda2476079bdf3bd3ab10d72e0bc256cf4c2e Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 3 Jan 2022 13:54:04 +0100 Subject: [PATCH 053/125] README - link Python versions & pre-commit.ci --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fb338d6..706063b 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,10 @@ A Python [OpenAPI 3 Specification](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md) client and validator for Python 3. - - Coverage - +[![Test](https://github.com/commonism/aiopenapi3/workflows/Codecov/badge.svg?event=push&branch=master)](https://github.com/commonism/aiopenapi3/actions?query=workflow%3ACodecov+event%3Apush+branch%3Amaster) +[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/commonism/aiopenapi3/master.svg)](https://results.pre-commit.ci/latest/github/commonism/aiopenapi3/master) +[![Coverage](https://img.shields.io/codecov/c/github/commonism/aiopenapi3)](https://codecov.io/gh/commonism/aiopenapi3) +[![Supported Python versions](https://img.shields.io/pypi/pyversions/aiopenapi3.svg)](https://pypi.org/project/aiopenapi3) This project is based on [Dorthu/openapi3](github.com/Dorthu/openapi3/). From 6c86e859bae744332f0b2b54547483248154fc6c Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 3 Jan 2022 13:54:52 +0100 Subject: [PATCH 054/125] setup.cfg - pypi validation --- aiopenapi3/__init__.py | 4 +++- setup.cfg | 46 ++++++++++++++++++++++-------------------- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/aiopenapi3/__init__.py b/aiopenapi3/__init__.py index abe2a19..53e559a 100644 --- a/aiopenapi3/__init__.py +++ b/aiopenapi3/__init__.py @@ -2,5 +2,7 @@ from .loader import FileSystemLoader from .errors import SpecError, ReferenceResolutionError -__all__ = ['OpenAPI', 'FileSystemLoader', 'SpecError', 'ReferenceResolutionError'] + +__version__ = "0.1.0" +__all__ = ['__version__', 'OpenAPI', 'FileSystemLoader', 'SpecError', 'ReferenceResolutionError'] diff --git a/setup.cfg b/setup.cfg index 6450290..cfa70de 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,37 +1,39 @@ [metadata] name = aiopenapi3 -version = 0.1.0 +version = attr: aiopenapi3.__version__ description = OpenAPI3 3.0.3 client / validator based on pydantic & httpx -long_description = file: README.rst, CHANGELOG.rst, LICENSE.rst -keywords = openapi openapi3 swagger +long_description = file: README.md +long_description_content_type = text/markdown +keywords = openapi openapi3 license = classifiers = - "Development Status :: 3 - Alpha", - "Environment :: Web Environment", - "Framework :: AsyncIO", - "Intended Audience :: Developers", - "Intended Audience :: Information Technology", - "Intended Audience :: System Administrators", - "License :: OSI Approved :: BSD 3-Clause License", - "Operating System :: OS Independent", - "Topic :: Internet", - "Topic :: Internet :: WWW/HTTP", - "Topic :: Software Development", - "Topic :: Software Development :: Libraries", - "Topic :: Software Development :: Libraries :: Application Frameworks", - "Topic :: Software Development :: Libraries :: Python Modules", - "Typing :: Typed", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", + Development Status :: 3 - Alpha + Environment :: Web Environment + Framework :: AsyncIO + Intended Audience :: Developers + Intended Audience :: Information Technology + Intended Audience :: System Administrators + License :: OSI Approved :: BSD License + Operating System :: OS Independent + Topic :: Internet + Topic :: Internet :: WWW/HTTP + Topic :: Software Development + Topic :: Software Development :: Libraries + Topic :: Software Development :: Libraries :: Application Frameworks + Topic :: Software Development :: Libraries :: Python Modules + Typing :: Typed + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 [options] packages = aiopenapi3 aiopenapi3.expression + install_requires = PyYaml pydantic From c58b5a966cc9e854be744972ae5db61a80375d5e Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 3 Jan 2022 13:55:18 +0100 Subject: [PATCH 055/125] schemas - missing type "number" --- aiopenapi3/schemas.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/aiopenapi3/schemas.py b/aiopenapi3/schemas.py index 86b38f4..1af7b8f 100644 --- a/aiopenapi3/schemas.py +++ b/aiopenapi3/schemas.py @@ -137,10 +137,12 @@ def from_schema(cls, shma:Schema, shmanm:List[str]=None, discriminators:List[Dis def typeof(schema:Schema): r = None - if schema.type == "string": - r = str - elif schema.type == "integer": + if schema.type == "integer": r = int + elif schema.type == "number": + r = float + elif schema.type == "string": + r = str elif schema.type == "boolean": r = bool elif schema.type == "array": From f769e58ce907f15c2bca96e78e05cdbeda3d46aa Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 3 Jan 2022 13:55:58 +0100 Subject: [PATCH 056/125] OperationIndex - Iter the operationIds --- aiopenapi3/openapi.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index eee9529..6f451f3 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -267,6 +267,27 @@ def resolve_jp(self, jp): class OperationIndex: + class Iter: + def __init__(self, spec): + self.operations = [] + self.r = 0 + pi: PathItem + for path,pi in spec.paths.items(): + op: Operation + for method in pi.__fields_set__ & HTTP_METHODS: + op = getattr(pi, method) + if op.operationId is None: + continue + self.operations.append(op.operationId) + self.r = iter(range(len(self.operations))) + + def __iter__(self): + return self + + def __next__(self): + return self.operations[next(self.r)] + + def __init__(self, api): self._api = api self._spec = api._spec @@ -287,6 +308,9 @@ def __getattr__(self, item): raise ValueError(item) + def __iter__(self): + return self.Iter(self._spec) + OpenAPISpec.update_forward_refs() \ No newline at end of file From f159ab28ab8ff86f4b3c72e96ecf1c44525ee185 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 3 Jan 2022 13:56:30 +0100 Subject: [PATCH 057/125] tests - iter the linode api spec --- tests/linode_test.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/linode_test.py diff --git a/tests/linode_test.py b/tests/linode_test.py new file mode 100644 index 0000000..3868bee --- /dev/null +++ b/tests/linode_test.py @@ -0,0 +1,30 @@ +import asyncio + +from aiopenapi3 import OpenAPI +import pytest + +@pytest.fixture(scope="session") +def event_loop(request): + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + +@pytest.fixture(scope="session") +async def api(): + return await OpenAPI.load_async("https://www.linode.com/docs/api/openapi.yaml") + +@pytest.mark.asyncio +async def test_linode_components_schemas(api): + for name,schema in api.components.schemas.items(): + schema.get_type().construct() + +@pytest.mark.asyncio +async def test_linode_return_values(api): + for i in api._: + call = getattr(api._, i) + try: + a = call.return_value() + except KeyError: + pass + else: + a.get_type().construct() From 8ad8018f75aca2386de9209d71ea27749dea97bb Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 3 Jan 2022 14:02:00 +0100 Subject: [PATCH 058/125] pre-commit.ci --- .pre-commit-config.yaml | 18 ++-- aiopenapi3/__init__.py | 3 +- aiopenapi3/__main__.py | 3 +- aiopenapi3/components.py | 5 +- aiopenapi3/errors.py | 1 + aiopenapi3/example.py | 2 +- aiopenapi3/expression/_grammar.py | 168 ++++++++++++------------------ aiopenapi3/expression/_model.py | 5 +- aiopenapi3/expression/grammar.py | 30 ++++-- aiopenapi3/expression/model.py | 24 +++-- aiopenapi3/general.py | 9 +- aiopenapi3/info.py | 3 - aiopenapi3/loader.py | 11 +- aiopenapi3/media.py | 5 +- aiopenapi3/object_base.py | 2 +- aiopenapi3/openapi.py | 62 +++++------ aiopenapi3/parameter.py | 11 +- aiopenapi3/paths.py | 11 +- aiopenapi3/request.py | 98 +++++++++-------- aiopenapi3/schemas.py | 77 ++++++++------ aiopenapi3/security.py | 8 +- aiopenapi3/servers.py | 2 - aiopenapi3/xml.py | 2 + 23 files changed, 285 insertions(+), 275 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 22b28da..b2d9000 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,20 +20,12 @@ repos: files: | .gitignore - id: check-case-conflict - - id: check-json - id: check-xml - id: check-executables-have-shebangs - - id: check-toml - - id: check-xml - - id: check-yaml - id: debug-statements - id: check-added-large-files - id: check-symlinks - id: debug-statements - - id: detect-aws-credentials - args: - - '--allow-missing-credentials' - - id: detect-private-key - repo: 'https://gitlab.com/pycqa/flake8' rev: 3.9.2 hooks: @@ -42,3 +34,13 @@ repos: - "--max-line-length=120" - "--ignore=E203,W503" - "--select=W504" + +ci: + autofix_commit_msg: | + [pre-commit.ci] auto fixes from pre-commit.ci hooks + autofix_prs: true + autoupdate_branch: '' + autoupdate_commit_msg: '[pre-commit.ci] pre-commit autoupdate' + autoupdate_schedule: weekly + skip: [] + submodules: false diff --git a/aiopenapi3/__init__.py b/aiopenapi3/__init__.py index 53e559a..4f09c43 100644 --- a/aiopenapi3/__init__.py +++ b/aiopenapi3/__init__.py @@ -4,5 +4,4 @@ __version__ = "0.1.0" -__all__ = ['__version__', 'OpenAPI', 'FileSystemLoader', 'SpecError', 'ReferenceResolutionError'] - +__all__ = ["__version__", "OpenAPI", "FileSystemLoader", "SpecError", "ReferenceResolutionError"] diff --git a/aiopenapi3/__main__.py b/aiopenapi3/__main__.py index 979740e..ba2ff71 100644 --- a/aiopenapi3/__main__.py +++ b/aiopenapi3/__main__.py @@ -6,6 +6,7 @@ from .loader import FileSystemLoader + def main(): name = sys.argv[1] loader = FileSystemLoader(Path().cwd()) @@ -18,5 +19,5 @@ def main(): print("OK") -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/aiopenapi3/components.py b/aiopenapi3/components.py index f32f5be..8dae4f0 100644 --- a/aiopenapi3/components.py +++ b/aiopenapi3/components.py @@ -16,6 +16,7 @@ class Components(ObjectExtended): .. _Components Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#components-object """ + schemas: Optional[Dict[str, Union[Schema, Reference]]] = Field(default_factory=dict) responses: Optional[Dict[str, Union[Response, Reference]]] = Field(default_factory=dict) parameters: Optional[Dict[str, Union[Parameter, Reference]]] = Field(default_factory=dict) @@ -25,6 +26,8 @@ class Components(ObjectExtended): securitySchemes: Optional[Dict[str, Union[SecurityScheme, Reference]]] = Field(default_factory=dict) links: Optional[Dict[str, Union[Link, Reference]]] = Field(default_factory=dict) callbacks: Optional[Dict[str, Union[Callback, Reference]]] = Field(default_factory=dict) + + # pathItems: Optional[Dict[str, Union[PathItem, Reference]]] = Field(default_factory=dict) #v3.1 -Components.update_forward_refs() \ No newline at end of file +Components.update_forward_refs() diff --git a/aiopenapi3/errors.py b/aiopenapi3/errors.py index c5d185c..6dce23d 100644 --- a/aiopenapi3/errors.py +++ b/aiopenapi3/errors.py @@ -3,6 +3,7 @@ class SpecError(ValueError): This error class is used when an invalid format is found while parsing an object in the spec. """ + def __init__(self, message, element=None): self.message = message self.element = element diff --git a/aiopenapi3/example.py b/aiopenapi3/example.py index 5c2cec6..b2b95b6 100644 --- a/aiopenapi3/example.py +++ b/aiopenapi3/example.py @@ -16,5 +16,5 @@ class Example(ObjectExtended): summary: Optional[str] = Field(default=None) description: Optional[str] = Field(default=None) - value: Optional[Union[Reference, dict, str]] = Field(default=None) # 'any' type + value: Optional[Union[Reference, dict, str]] = Field(default=None) # 'any' type externalValue: Optional[str] = Field(default=None) diff --git a/aiopenapi3/expression/_grammar.py b/aiopenapi3/expression/_grammar.py index 67530cc..7e28384 100644 --- a/aiopenapi3/expression/_grammar.py +++ b/aiopenapi3/expression/_grammar.py @@ -16,20 +16,20 @@ from tatsu.buffering import Buffer from tatsu.parsing import Parser from tatsu.parsing import tatsumasu -from tatsu.parsing import leftrec, nomemo, isname # noqa +from tatsu.parsing import leftrec, nomemo, isname # noqa from tatsu.util import re, generic_main # noqa KEYWORDS = { - 'pool', - 'elsif', - 'subclass', - 'subnet', - 'shared-network', - 'if', - 'class', - 'group', - 'host', + "pool", + "elsif", + "subclass", + "subnet", + "shared-network", + "if", + "class", + "group", + "host", } # type: ignore @@ -39,10 +39,10 @@ def __init__( text, whitespace=None, nameguard=None, - comments_re='', - eol_comments_re='', + comments_re="", + eol_comments_re="", ignorecase=None, - namechars='', + namechars="", **kwargs ): super().__init__( @@ -62,13 +62,13 @@ def __init__( self, whitespace=None, nameguard=None, - comments_re='', - eol_comments_re='', + comments_re="", + eol_comments_re="", ignorecase=None, left_recursion=True, parseinfo=True, keywords=None, - namechars='', + namechars="", tokenizercls=RuntimeExpressionBuffer, **kwargs ): @@ -88,42 +88,35 @@ def __init__( **kwargs ) - @tatsumasu('RuntimeExpression') + @tatsumasu("RuntimeExpression") def _start_(self): # noqa self._expression_() self._check_eof() - @tatsumasu('Expression') + @tatsumasu("Expression") def _expression_(self): # noqa with self._choice(): with self._option(): - self._token('$url') - self.name_last_node('root') + self._token("$url") + self.name_last_node("root") with self._option(): - self._token('$method') - self.name_last_node('root') + self._token("$method") + self.name_last_node("root") with self._option(): - self._token('$statusCode') - self.name_last_node('root') + self._token("$statusCode") + self.name_last_node("root") with self._option(): - self._token('$request.') - self.name_last_node('root') + self._token("$request.") + self.name_last_node("root") self._source_() - self.name_last_node('next') + self.name_last_node("next") with self._option(): - self._token('$response.') - self.name_last_node('root') + self._token("$response.") + self.name_last_node("root") self._source_() - self.name_last_node('next') - self._error( - 'expecting one of: ' - "'$url' '$method' '$statusCode'" - "'$request.' '$response.'" - ) - self._define( - ['next', 'root'], - [] - ) + self.name_last_node("next") + self._error("expecting one of: " "'$url' '$method' '$statusCode'" "'$request.' '$response.'") + self._define(["next", "root"], []) @tatsumasu() def _source_(self): # noqa @@ -137,105 +130,85 @@ def _source_(self): # noqa with self._option(): self._body_reference_() self._error( - 'expecting one of: ' + "expecting one of: " "'header.' 'query.'" " 'path.'" " 'body' " ) - @tatsumasu('Header') + @tatsumasu("Header") def _header_reference_(self): # noqa - self._token('header.') + self._token("header.") self._token_() - self.name_last_node('key') - self._define( - ['key'], - [] - ) + self.name_last_node("key") + self._define(["key"], []) - @tatsumasu('Query') + @tatsumasu("Query") def _query_reference_(self): # noqa - self._token('query.') + self._token("query.") self._name_() - self.name_last_node('key') - self._define( - ['key'], - [] - ) + self.name_last_node("key") + self._define(["key"], []) - @tatsumasu('Path') + @tatsumasu("Path") def _path_reference_(self): # noqa - self._token('path.') + self._token("path.") self._name_() - self.name_last_node('key') - self._define( - ['key'], - [] - ) + self.name_last_node("key") + self._define(["key"], []) - @tatsumasu('Body') + @tatsumasu("Body") def _body_reference_(self): # noqa - self._token('body') + self._token("body") with self._optional(): self._json_pointer_() - self.name_last_node('fragment') - self._define( - ['fragment'], - [] - ) + self.name_last_node("fragment") + self._define(["fragment"], []) - @tatsumasu('JSONPointer') + @tatsumasu("JSONPointer") def _json_pointer_(self): # noqa - self._token('#/') + self._token("#/") def sep1(): - self._token('/') + self._token("/") def block1(): self._reference_token_() + self._gather(block1, sep1) - self.name_last_node('tokens') - self._define( - ['tokens'], - [] - ) + self.name_last_node("tokens") + self._define(["tokens"], []) @tatsumasu() def _reference_token_(self): # noqa - def block0(): with self._choice(): with self._option(): self._unescaped_() with self._option(): self._escaped_() - self._error( - 'expecting one of: ' - "[^\\/~] '~' " - ) + self._error("expecting one of: " "[^\\/~] '~' ") + self._closure(block0) @tatsumasu() def _unescaped_(self): # noqa - self._pattern('[^\\/~]') + self._pattern("[^\\/~]") @tatsumasu() def _escaped_(self): # noqa - self._token('~') + self._token("~") with self._group(): with self._choice(): with self._option(): - self._token('0') + self._token("0") with self._option(): - self._token('1') - self._error( - 'expecting one of: ' - "'0' '1'" - ) + self._token("1") + self._error("expecting one of: " "'0' '1'") @tatsumasu() def _name_(self): # noqa - self._pattern('[\\w]*') + self._pattern("[\\w]*") @tatsumasu() def _token_(self): # noqa @@ -285,25 +258,20 @@ def token(self, ast): # noqa def main(filename, start=None, **kwargs): if start is None: - start = 'start' - if not filename or filename == '-': + start = "start" + if not filename or filename == "-": text = sys.stdin.read() else: with open(filename) as f: text = f.read() parser = RuntimeExpressionParser() - return parser.parse( - text, - rule_name=start, - filename=filename, - **kwargs - ) + return parser.parse(text, rule_name=start, filename=filename, **kwargs) -if __name__ == '__main__': +if __name__ == "__main__": import json from tatsu.util import asjson - ast = generic_main(main, RuntimeExpressionParser, name='RuntimeExpression') + ast = generic_main(main, RuntimeExpressionParser, name="RuntimeExpression") data = asjson(ast) print(json.dumps(data, indent=2)) diff --git a/aiopenapi3/expression/_model.py b/aiopenapi3/expression/_model.py index 18fac8c..9a823c6 100644 --- a/aiopenapi3/expression/_model.py +++ b/aiopenapi3/expression/_model.py @@ -21,10 +21,7 @@ class ModelBase(Node): class RuntimeExpressionModelBuilderSemantics(ModelBuilderSemantics): def __init__(self, context=None, types=None): - types = [ - t for t in globals().values() - if type(t) is type and issubclass(t, ModelBase) - ] + (types or []) + types = [t for t in globals().values() if type(t) is type and issubclass(t, ModelBase)] + (types or []) super(RuntimeExpressionModelBuilderSemantics, self).__init__(context=context, types=types) diff --git a/aiopenapi3/expression/grammar.py b/aiopenapi3/expression/grammar.py index c3620c4..c6de291 100644 --- a/aiopenapi3/expression/grammar.py +++ b/aiopenapi3/expression/grammar.py @@ -1,11 +1,27 @@ from ._grammar import RuntimeExpressionBuffer, RuntimeExpressionParser + def loads(data, trace=False, colorize=False): - from .model import RuntimeExpressionModelBuilderSemantics, RuntimeExpression, Expression, JSONPointer, Body, Header, Query, Path + from .model import ( + RuntimeExpressionModelBuilderSemantics, + RuntimeExpression, + Expression, + JSONPointer, + Body, + Header, + Query, + Path, + ) + parser = RuntimeExpressionParser() - model = parser.parse(data, - trace=trace, colorize=colorize, - tokenizercls=RuntimeExpressionBuffer, - semantics=RuntimeExpressionModelBuilderSemantics(types=[RuntimeExpression, Expression, JSONPointer, Body, Header, Query, Path]), - filename='…') - return model \ No newline at end of file + model = parser.parse( + data, + trace=trace, + colorize=colorize, + tokenizercls=RuntimeExpressionBuffer, + semantics=RuntimeExpressionModelBuilderSemantics( + types=[RuntimeExpression, Expression, JSONPointer, Body, Header, Query, Path] + ), + filename="…", + ) + return model diff --git a/aiopenapi3/expression/model.py b/aiopenapi3/expression/model.py index 0750a22..61091d5 100644 --- a/aiopenapi3/expression/model.py +++ b/aiopenapi3/expression/model.py @@ -5,24 +5,26 @@ import aiopenapi3.general -from ._model import RuntimeExpressionModelBuilderSemantics as RuntimeExpressionModelBuilderSemanticsBase, \ - JSONPointer as JSONPointerBase, \ - Header as HeaderBase, \ - Query as QueryBase, \ - Path as PathBase, \ - Body as BodyBase, \ - RuntimeExpression as RuntimeExpressionBase, \ - Expression as ExpressionBase +from ._model import ( + RuntimeExpressionModelBuilderSemantics as RuntimeExpressionModelBuilderSemanticsBase, + JSONPointer as JSONPointerBase, + Header as HeaderBase, + Query as QueryBase, + Path as PathBase, + Body as BodyBase, + RuntimeExpression as RuntimeExpressionBase, + Expression as ExpressionBase, +) class RuntimeExpressionModelBuilderSemantics(RuntimeExpressionModelBuilderSemanticsBase): - def reference_token(self, ast, name=None): return "".join(ast) def escaped(self, ast): return "".join(ast) + # def token(self, ast, name=None): # return "".join(ast) @@ -61,11 +63,11 @@ def eval(self, req, resp): return resp.status_code - class JSONPointer(JSONPointerBase): def __init__(self, ctx=None, ast=None, parseinfo=None, **kwargs): super().__init__(ctx, None, parseinfo, **kwargs) self._tokens = ast.tokens + @property def tokens(self): for i in self._tokens: @@ -128,7 +130,7 @@ def eval(self, data): try: for i in self.fragment.tokens: if isinstance(data, list): - i = int(i)-1 + i = int(i) - 1 data = data[i] return data except KeyError: diff --git a/aiopenapi3/general.py b/aiopenapi3/general.py index 37539fe..e4268b1 100644 --- a/aiopenapi3/general.py +++ b/aiopenapi3/general.py @@ -28,8 +28,9 @@ def decode(part): :param part: """ part = urllib.parse.unquote(part) - part = part.replace('~1', '/') - return part.replace('~0', '~') + part = part.replace("~1", "/") + return part.replace("~0", "~") + class JSONReference: @staticmethod @@ -37,17 +38,21 @@ def split(url): u = URL(url) return str(u.with_fragment("")), u.raw_fragment + class Reference(ObjectBase): """ A `Reference Object`_ designates a reference to another node in the specification. .. _Reference Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#reference-object """ + ref: str = Field(alias="$ref") _target: object = None + class Config: """This object cannot be extended with additional properties and any properties added SHALL be ignored.""" + extra = Extra.ignore def __getattr__(self, item): diff --git a/aiopenapi3/info.py b/aiopenapi3/info.py index 6e0a019..ff72eb7 100644 --- a/aiopenapi3/info.py +++ b/aiopenapi3/info.py @@ -41,6 +41,3 @@ class Info(ObjectExtended): license: Optional[License] = Field(default=None) contact: Optional[Contact] = Field(default=None) version: str = Field(...) - - - diff --git a/aiopenapi3/loader.py b/aiopenapi3/loader.py index 2c95c6c..6a36642 100644 --- a/aiopenapi3/loader.py +++ b/aiopenapi3/loader.py @@ -7,7 +7,7 @@ class Loader(abc.ABC): @abc.abstractmethod - def load(self, name:str): + def load(self, name: str): raise NotImplementedError("load") @classmethod @@ -15,7 +15,7 @@ def decode(cls, data, codec): if codec is not None: codecs = [codec] else: - codecs = ["ascii","utf-8"] + codecs = ["ascii", "utf-8"] for c in codecs: try: data = data.decode(c) @@ -37,16 +37,15 @@ def dict(cls, file, data): return data - class FileSystemLoader(Loader): - def __init__(self, base:Path): + def __init__(self, base: Path): assert isinstance(base, Path) self.base = base - def load(self, file:str, codec=None): + def load(self, file: str, codec=None): path = self.base / file assert path.is_relative_to(self.base) data = path.open("rb").read() data = self.decode(data, codec) data = self.dict(path, data) - return data \ No newline at end of file + return data diff --git a/aiopenapi3/media.py b/aiopenapi3/media.py index 2d82c6a..41a26d9 100644 --- a/aiopenapi3/media.py +++ b/aiopenapi3/media.py @@ -14,6 +14,7 @@ class Encoding(ObjectExtended): .. _Encoding: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#encoding-object """ + contentType: Optional[str] = Field(default=None) headers: Optional[Dict[str, Union["Header", Reference]]] = Field(default_factory=dict) style: Optional[str] = Field(default=None) @@ -34,5 +35,7 @@ class MediaType(ObjectExtended): examples: Optional[Dict[str, Union[Example, Reference]]] = Field(default_factory=dict) encoding: Optional[Dict[str, Encoding]] = Field(default_factory=dict) + from .parameter import Header -Encoding.update_forward_refs() \ No newline at end of file + +Encoding.update_forward_refs() diff --git a/aiopenapi3/object_base.py b/aiopenapi3/object_base.py index 1267c20..455965f 100644 --- a/aiopenapi3/object_base.py +++ b/aiopenapi3/object_base.py @@ -20,7 +20,7 @@ class ObjectExtended(ObjectBase): @root_validator(pre=True) def validate_ObjectExtended_extensions(cls, values): - """ FIXME + """FIXME https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#specification-extensions :param values: :return: values diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index 6f451f3..3e81c90 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -1,6 +1,5 @@ import datetime import pathlib -import json from typing import Any, List, Optional, Dict, Union, Callable import yaml @@ -20,10 +19,10 @@ from .request import Request, AsyncRequest from .loader import Loader -HTTP_METHODS = frozenset(["get","delete","head","post","put","patch","trace"]) +HTTP_METHODS = frozenset(["get", "delete", "head", "post", "put", "patch", "trace"]) -class OpenAPI: +class OpenAPI: @property def paths(self): return self._spec.paths @@ -44,9 +43,6 @@ def openapi(self): def servers(self): return self._spec.servers - - - @classmethod def load_sync(cls, url, session_factory: Callable[[], httpx.Client] = httpx.Client, loader=None): resp = session_factory().get(url) @@ -63,9 +59,13 @@ def loads(cls, url, data, session_factory: Callable[[], httpx.AsyncClient] = htt data = Loader.dict(pathlib.Path(url), data) return cls(url, data, session_factory, loader) - def __init__(self, url, raw_document, - session_factory: Callable[[], Union[httpx.Client, httpx.AsyncClient]] = httpx.AsyncClient, - loader=None): + def __init__( + self, + url, + raw_document, + session_factory: Callable[[], Union[httpx.Client, httpx.AsyncClient]] = httpx.AsyncClient, + loader=None, + ): """ Creates a new OpenAPI document from a loaded spec file. This is overridden here because we need to specify the path in the parent @@ -77,14 +77,12 @@ def __init__(self, url, raw_document, :type session_factory: returns httpx.AsyncClient or http.Client """ - - self._base_url:yarl.URL = yarl.URL(url) - self.loader:Loader = loader + self._base_url: yarl.URL = yarl.URL(url) + self.loader: Loader = loader self._session_factory = session_factory - self._security:List[str] = None - self._cached:Dict[str, "OpenAPISpec"] = dict() - + self._security: List[str] = None + self._cached: Dict[str, "OpenAPISpec"] = dict() self._spec = OpenAPISpec.parse_obj(raw_document) self._spec._resolve_references(self) @@ -99,7 +97,7 @@ def test_operation(operation_id): raise SpecError(f"Duplicate operationId {operation_id}", element=None) operation_map.add(operation_id) - for path,obj in self.paths.items(): + for path, obj in self.paths.items(): for m in obj.__fields_set__ & HTTP_METHODS: op = getattr(obj, m) _validate_parameters(op, path) @@ -134,8 +132,7 @@ def authenticate(self, security_scheme, value): return if security_scheme not in self._spec.components.securitySchemes: - raise ValueError('{} does not accept security scheme {}'.format( - self.info.title, security_scheme)) + raise ValueError("{} does not accept security scheme {}".format(self.info.title, security_scheme)) self._security = {security_scheme: value} @@ -148,8 +145,8 @@ def _(self): return OperationIndex(self) def resolve_jr(self, root: "OpenAPISpec", obj, value: Reference): - url,jp = JSONReference.split(value.ref) - if url != '': + url, jp = JSONReference.split(value.ref) + if url != "": url = pathlib.Path(url) if url not in self._cached: self._cached[url] = self._load(url) @@ -201,16 +198,16 @@ def resolve(obj): ref._target = api.resolve_jr(root, obj, ref) setattr(obj, slot, ref) -# if isinstance(obj, Discriminator) and slot == "mapping": -# mapping = dict() -# for k,v in value.items(): -# mapping[k] = Reference.construct(ref=v) -# setattr(obj, slot, mapping) + # if isinstance(obj, Discriminator) and slot == "mapping": + # mapping = dict() + # for k,v in value.items(): + # mapping[k] = Reference.construct(ref=v) + # setattr(obj, slot, mapping) value = getattr(obj, slot) if isinstance(value, Reference): value._target = api.resolve_jr(root, obj, value) -# setattr(obj, slot, resolved_value) + # setattr(obj, slot, resolved_value) elif issubclass(type(value), ObjectBase): # otherwise, continue resolving down the tree resolve(value) @@ -237,7 +234,6 @@ def resolve(obj): resolve(self) - def resolve_jp(self, jp): """ Given a $ref path, follows the document tree and returns the given attribute. @@ -256,11 +252,11 @@ def resolve_jp(self, jp): part = JSONPointer.decode(part) if isinstance(node, dict): if part not in node: # pylint: disable=unsupported-membership-test - raise ReferenceResolutionError(f'Invalid path {path} in Reference') + raise ReferenceResolutionError(f"Invalid path {path} in Reference") node = node.get(part) else: if not hasattr(node, part): - raise ReferenceResolutionError(f'Invalid path {path} in Reference') + raise ReferenceResolutionError(f"Invalid path {path} in Reference") node = getattr(node, part) return node @@ -272,7 +268,7 @@ def __init__(self, spec): self.operations = [] self.r = 0 pi: PathItem - for path,pi in spec.paths.items(): + for path, pi in spec.paths.items(): op: Operation for method in pi.__fields_set__ & HTTP_METHODS: op = getattr(pi, method) @@ -287,14 +283,13 @@ def __iter__(self): def __next__(self): return self.operations[next(self.r)] - def __init__(self, api): self._api = api self._spec = api._spec def __getattr__(self, item): pi: PathItem - for path,pi in self._spec.paths.items(): + for path, pi in self._spec.paths.items(): op: Operation for method in pi.__fields_set__ & HTTP_METHODS: op = getattr(pi, method) @@ -312,5 +307,4 @@ def __iter__(self): return self.Iter(self._spec) - -OpenAPISpec.update_forward_refs() \ No newline at end of file +OpenAPISpec.update_forward_refs() diff --git a/aiopenapi3/parameter.py b/aiopenapi3/parameter.py index 7675d15..d6691d9 100644 --- a/aiopenapi3/parameter.py +++ b/aiopenapi3/parameter.py @@ -8,6 +8,7 @@ from .schemas import Schema from .media import MediaType + class ParameterBase(ObjectExtended): """ A `Parameter Object`_ defines a single operation parameter. @@ -25,16 +26,16 @@ class ParameterBase(ObjectExtended): allowReserved: Optional[bool] = Field(default=None) schema_: Optional[Union[Schema, Reference]] = Field(default=None, alias="schema") example: Optional[Any] = Field(default=None) - examples: Optional[Dict[str, Union['Example',Reference]]] = Field(default_factory=dict) + examples: Optional[Dict[str, Union["Example", Reference]]] = Field(default_factory=dict) content: Optional[Dict[str, "MediaType"]] @root_validator def validate_ParameterBase(cls, values): -# if values["in_"] == -# if self.in_ == "path" and self.required is not True: -# err_msg = 'Parameter {} must be required since it is in the path' -# raise SpecError(err_msg.format(self.get_path()), path=self._path) + # if values["in_"] == + # if self.in_ == "path" and self.required is not True: + # err_msg = 'Parameter {} must be required since it is in the path' + # raise SpecError(err_msg.format(self.get_path()), path=self._path) return values diff --git a/aiopenapi3/paths.py b/aiopenapi3/paths.py index 5e369b6..8ebe14c 100644 --- a/aiopenapi3/paths.py +++ b/aiopenapi3/paths.py @@ -17,12 +17,12 @@ def _validate_parameters(op: "Operation", path): Ensures that all parameters for this path are valid """ assert isinstance(path, str) - allowed_path_parameters = re.findall(r'{([a-zA-Z0-9\-\._~]+)}', path) + allowed_path_parameters = re.findall(r"{([a-zA-Z0-9\-\._~]+)}", path) for c in op.parameters: - if c.in_ == 'path': + if c.in_ == "path": if c.name not in allowed_path_parameters: - raise SpecError('Parameter name not found in path: {}'.format(c.name)) + raise SpecError("Parameter name not found in path: {}".format(c.name)) class SecurityRequirement(BaseModel): @@ -31,6 +31,7 @@ class SecurityRequirement(BaseModel): .. _SecurityRequirement: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#security-requirement-object """ + __root__: Dict[str, List[str]] @root_validator @@ -40,7 +41,6 @@ def validate_SecurityRequirement(cls, values): raise ValueError(root) return values - @property def name(self): if len(self.__root__.keys()): @@ -136,6 +136,7 @@ class PathItem(ObjectExtended): .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#paths-object """ + ref: Optional[str] = Field(default=None, alias="$ref") summary: Optional[str] = Field(default=None) description: Optional[str] = Field(default=None) @@ -159,6 +160,7 @@ class Callback(ObjectBase): This object MAY be extended with Specification Extensions. """ + __root__: Dict[str, PathItem] @@ -168,6 +170,7 @@ class RuntimeExpression(ObjectBase): .. Runtime Expression: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#runtime-expressions """ + __root__: str = Field(...) diff --git a/aiopenapi3/request.py b/aiopenapi3/request.py index e397317..3c9b8b0 100644 --- a/aiopenapi3/request.py +++ b/aiopenapi3/request.py @@ -8,7 +8,7 @@ class RequestParameter: - def __init__(self, url:yarl.URL): + def __init__(self, url: yarl.URL): self.url = str(url) self.auth = None self.cookies = {} @@ -26,14 +26,15 @@ class Request: with the configured values included. This class is not intended to be used directly. """ + def __init__(self, api: "OpenAPI", method: str, path: str, operation: "Operation.request"): self.api = api self.spec = api._spec self.method = method self.path = path self.operation = operation -# self.session:Union[httpx.Client,httpx.AsyncClient] = - self.req:RequestParameter = RequestParameter(self.path) + # self.session:Union[httpx.Client,httpx.AsyncClient] = + self.req: RequestParameter = RequestParameter(self.path) def __call__(self, *args, **kwargs): return self.request(*args, **kwargs) @@ -42,20 +43,18 @@ def __call__(self, *args, **kwargs): def security(self): return self.api._security - - def args(self, content_type:str="application/json"): + def args(self, content_type: str = "application/json"): op = self.operation parameters = op.parameters + self.spec.paths[self.path].parameters schema = op.requestBody.content[content_type].schema_ -# if isinstance(schema, Reference): -# schema = schema._target - return {"parameters":parameters, "data":schema} + # if isinstance(schema, Reference): + # schema = schema._target + return {"parameters": parameters, "data": schema} def return_value(self, http_status=200, content_type="application/json"): return self.operation.responses[str(http_status)].content[content_type].schema_ - def _prepare_security(self): if self.security and self.operation.security: for scheme, value in self.security.items(): @@ -67,38 +66,38 @@ def _prepare_security(self): break else: raise ValueError( - f"No security requirement satisfied (accepts {', '.join(self.operation.security.keys())})") + f"No security requirement satisfied (accepts {', '.join(self.operation.security.keys())})" + ) - def _prepare_secschemes(self, security_requirement:SecurityRequirement, value:List[str]): + def _prepare_secschemes(self, security_requirement: SecurityRequirement, value: List[str]): ss = self.spec.components.securitySchemes[security_requirement.name] - if ss.type == 'http' and ss.scheme_ == 'basic': + if ss.type == "http" and ss.scheme_ == "basic": self.req.auth = value - if ss.type == 'http' and ss.scheme_ == 'digest': + if ss.type == "http" and ss.scheme_ == "digest": self.req.auth = httpx.DigestAuth(*value) - if ss.type == 'http' and ss.scheme_ == 'bearer': - header = ss.bearerFormat or 'Bearer {}' - self.req.headers['Authorization'] = header.format(value) + if ss.type == "http" and ss.scheme_ == "bearer": + header = ss.bearerFormat or "Bearer {}" + self.req.headers["Authorization"] = header.format(value) - if ss.type == 'mutualTLS': + if ss.type == "mutualTLS": # TLS Client certificates (mutualTLS) self.req.cert = value - if ss.type == 'apiKey': - if ss.in_ == 'query': + if ss.type == "apiKey": + if ss.in_ == "query": # apiKey in query parameter self.req.params[ss.name] = value - if ss.in_ == 'header': + if ss.in_ == "header": # apiKey in query header data self.req.headers[ss.name] = value - if ss.in_ == 'cookie': + if ss.in_ == "cookie": self.req.cookies = {ss.name: value} - def _prepare_parameters(self, parameters): # Parameters path_parameters = {} @@ -110,26 +109,26 @@ def _prepare_parameters(self, parameters): accepted_parameters.update({_.name: _}) for name, spec in accepted_parameters.items(): - if (parameters is None or name not in parameters): + if parameters is None or name not in parameters: if spec.required: - raise ValueError(f'Required parameter {name} not provided') + raise ValueError(f"Required parameter {name} not provided") continue value = parameters[name] - if spec.in_ == 'path': + if spec.in_ == "path": # The string method `format` is incapable of partial updates, # as such we need to collect all the path parameters before # applying them to the format string. path_parameters[name] = value - if spec.in_ == 'query': + if spec.in_ == "query": self.req.params[name] = value - if spec.in_ == 'header': + if spec.in_ == "header": self.req.headers[name] = value - if spec.in_ == 'cookie': + if spec.in_ == "cookie": self.req.cookies[name] = value self.req.url = self.req.url.format(**path_parameters) @@ -139,14 +138,14 @@ def _prepare_body(self, data): return if data is None and self.operation.requestBody.required: - raise ValueError('Request Body is required but none was provided.') + raise ValueError("Request Body is required but none was provided.") - if 'application/json' in self.operation.requestBody.content: + if "application/json" in self.operation.requestBody.content: if not isinstance(data, (dict, list)): raise TypeError(data) body = json.dumps(data) self.req.content = body.encode() - self.req.headers['Content-Type'] = 'application/json' + self.req.headers["Content-Type"] = "application/json" else: raise NotImplementedError() @@ -155,11 +154,14 @@ def _prepare(self, data, parameters): self._prepare_parameters(parameters) self._prepare_body(data) - req = httpx.Request(self.method, str(self.api.url / self.req.url[1:]), - headers=self.req.headers, - cookies=self.req.cookies, - params=self.req.params, - content=self.req.content) + req = httpx.Request( + self.method, + str(self.api.url / self.req.url[1:]), + headers=self.req.headers, + cookies=self.req.cookies, + params=self.req.params, + content=self.req.content, + ) return req def _process(self, result): @@ -170,36 +172,40 @@ def _process(self, result): expected_response = None if status_code in self.operation.responses: expected_response = self.operation.responses[status_code] - elif 'default' in self.operation.responses: - expected_response = self.operation.responses['default'] + elif "default" in self.operation.responses: + expected_response = self.operation.responses["default"] if expected_response is None: # TODO - custom exception class that has the response object in it - raise ValueError(f"""Unexpected response {result.status_code} from {self.operation.operationId} (expected one of { ",".join(self.operation.responses.keys()) }), no default is defined""") + raise ValueError( + f"""Unexpected response {result.status_code} from {self.operation.operationId} (expected one of { ",".join(self.operation.responses.keys()) }), no default is defined""" + ) # defined as "no content" if len(expected_response.content) == 0: return None - content_type = result.headers.get('Content-Type', None) + content_type = result.headers.get("Content-Type", None) if content_type: expected_media = expected_response.content.get(content_type, None) - if expected_media is None and '/' in content_type: + if expected_media is None and "/" in content_type: # accept media type ranges in the spec. the most specific matching # type should always be chosen, but if we do not have a match here # a generic range should be accepted if one if provided # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#response-object - generic_type = content_type.split('/')[0] + '/*' + generic_type = content_type.split("/")[0] + "/*" expected_media = expected_response.content.get(generic_type, None) else: expected_media = None if expected_media is None: - raise RuntimeError(f"Unexpected Content-Type {content_type} returned for operation {self.operation.operationId} \ - (expected one of {','.join(expected_response.content.keys())})") + raise RuntimeError( + f"Unexpected Content-Type {content_type} returned for operation {self.operation.operationId} \ + (expected one of {','.join(expected_response.content.keys())})" + ) - if content_type.lower() == 'application/json': + if content_type.lower() == "application/json": return expected_media.schema_.model(result.json()) else: raise NotImplementedError() @@ -221,7 +227,7 @@ def request(self, data=None, parameters=None): class AsyncRequest(Request): - async def __call__(self, *args, ** kwargs): + async def __call__(self, *args, **kwargs): return await self.request(*args, **kwargs) async def request(self, data=None, parameters=None): diff --git a/aiopenapi3/schemas.py b/aiopenapi3/schemas.py index 1af7b8f..79ebbb3 100644 --- a/aiopenapi3/schemas.py +++ b/aiopenapi3/schemas.py @@ -9,11 +9,11 @@ from .xml import XML TYPE_LOOKUP = { - 'array': list, - 'integer': int, - 'object': dict, - 'string': str, - 'boolean': bool, + "array": list, + "integer": int, + "object": dict, + "string": str, + "boolean": bool, } @@ -22,6 +22,7 @@ class Discriminator(ObjectExtended): .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#discriminator-object """ + propertyName: str = Field(...) mapping: Optional[Dict[str, str]] = Field(default_factory=dict) @@ -35,7 +36,7 @@ class Schema(ObjectExtended): title: Optional[str] = Field(default=None) multipleOf: Optional[int] = Field(default=None) - maximum: Optional[float] = Field(default=None) # FIXME Field(discriminator='type') would be better + maximum: Optional[float] = Field(default=None) # FIXME Field(discriminator='type') would be better exclusiveMaximum: Optional[bool] = Field(default=None) minimum: Optional[float] = Field(default=None) exclusiveMinimum: Optional[bool] = Field(default=None) @@ -55,9 +56,9 @@ class Schema(ObjectExtended): oneOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) anyOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) not_: Optional[Union["Schema", Reference]] = Field(default=None, alias="not") - items: Optional[Union['Schema', Reference]] = Field(default=None) - properties: Optional[Dict[str, Union['Schema', Reference]]] = Field(default_factory=dict) - additionalProperties: Optional[Union[bool, 'Schema', Reference]] = Field(default=None) + items: Optional[Union["Schema", Reference]] = Field(default=None) + properties: Optional[Dict[str, Union["Schema", Reference]]] = Field(default_factory=dict) + additionalProperties: Optional[Union[bool, "Schema", Reference]] = Field(default=None) description: Optional[str] = Field(default=None) format: Optional[str] = Field(default=None) default: Optional[str] = Field(default=None) # TODO - str as a default? @@ -69,9 +70,9 @@ class Schema(ObjectExtended): externalDocs: Optional[dict] = Field(default=None) # 'ExternalDocs' example: Optional[Any] = Field(default=None) deprecated: Optional[bool] = Field(default=None) -# contentEncoding: Optional[str] = Field(default=None) -# contentMediaType: Optional[str] = Field(default=None) -# contentSchema: Optional[str] = Field(default=None) + # contentEncoding: Optional[str] = Field(default=None) + # contentMediaType: Optional[str] = Field(default=None) + # contentSchema: Optional[str] = Field(default=None) _model_type: object _request_model_type: object @@ -82,12 +83,12 @@ class Schema(ObjectExtended): _identity: str class Config: -# keep_untouched = (lru_cache,) + # keep_untouched = (lru_cache,) extra = Extra.forbid @root_validator - def validate_Schema_number_type(cls, values:Dict[str, object]): - conv = ["minimum","maximum"] + def validate_Schema_number_type(cls, values: Dict[str, object]): + conv = ["minimum", "maximum"] if values.get("type", None) == "integer": for i in conv: v = values.get(i, None) @@ -95,11 +96,11 @@ def validate_Schema_number_type(cls, values:Dict[str, object]): values[i] = int(v) return values -# @lru_cache - def get_type(self, names:List[str]=None, discriminators:List[Discriminator]=None): + # @lru_cache + def get_type(self, names: List[str] = None, discriminators: List[Discriminator] = None): return Model.from_schema(self, names, discriminators) - def model(self, data:Dict): + def model(self, data: Dict): """ Generates a model representing this schema from the given data. @@ -109,7 +110,7 @@ def model(self, data:Dict): :returns: A new :any:`Model` created in this Schema's type from the data. :rtype: self.get_type() """ - if self.type in ('string', 'number'): + if self.type in ("string", "number"): assert len(self.properties) == 0 # more simple types # if this schema represents a simple type, simply return the data @@ -127,7 +128,7 @@ class Config: extra: Extra.forbid @classmethod - def from_schema(cls, shma:Schema, shmanm:List[str]=None, discriminators:List[Discriminator]=None): + def from_schema(cls, shma: Schema, shmanm: List[str] = None, discriminators: List[Discriminator] = None): if shmanm is None: shmanm = [] @@ -135,7 +136,7 @@ def from_schema(cls, shma:Schema, shmanm:List[str]=None, discriminators:List[Dis if discriminators is None: discriminators = [] - def typeof(schema:Schema): + def typeof(schema: Schema): r = None if schema.type == "integer": r = int @@ -147,16 +148,16 @@ def typeof(schema:Schema): r = bool elif schema.type == "array": r = List[schema.items.get_type()] - elif schema.type == 'object': + elif schema.type == "object": return schema.get_type() - elif schema.type is None: # discriminated root + elif schema.type is None: # discriminated root return None else: raise TypeError(schema.type) return r - def annotationsof(schema:Schema): + def annotationsof(schema: Schema): annos = dict() if schema.type == "array": annos["__root__"] = typeof(schema) @@ -167,7 +168,7 @@ def annotationsof(schema:Schema): for discriminator in discriminators: if name != discriminator.propertyName: continue - for disc,v in discriminator.mapping.items(): + for disc, v in discriminator.mapping.items(): if v in shmanm: r = Literal[disc] break @@ -182,14 +183,14 @@ def annotationsof(schema:Schema): annos[name] = r return annos - def fieldof(schema:Schema): + def fieldof(schema: Schema): r = dict() if schema.type == "array": return r else: for name, f in schema.properties.items(): args = dict() - for i in ["enum","default"]: + for i in ["enum", "default"]: v = getattr(f, i, None) if v: args[i] = v @@ -197,23 +198,33 @@ def fieldof(schema:Schema): return r # do not create models for primitive types - if shma.type in ("string","integer"): + if shma.type in ("string", "integer"): return typeof(shma) - type_name = shma.title or shma._identity if hasattr(shma, '_identity') else str(uuid.uuid4()) + type_name = shma.title or shma._identity if hasattr(shma, "_identity") else str(uuid.uuid4()) namespace = dict() annos = dict() if shma.allOf: for i in shma.allOf: annos.update(annotationsof(i)) elif shma.anyOf: - t = tuple([i.get_type(names=shmanm + [i.ref], discriminators=discriminators + [shma.discriminator]) for i in shma.anyOf]) + t = tuple( + [ + i.get_type(names=shmanm + [i.ref], discriminators=discriminators + [shma.discriminator]) + for i in shma.anyOf + ] + ) if shma.discriminator and shma.discriminator.mapping: annos["__root__"] = Annotated[Union[t], Field(discriminator=shma.discriminator.propertyName)] else: annos["__root__"] = Union[t] elif shma.oneOf: - t = tuple([i.get_type(names=shmanm + [i.ref], discriminators=discriminators + [shma.discriminator]) for i in shma.oneOf]) + t = tuple( + [ + i.get_type(names=shmanm + [i.ref], discriminators=discriminators + [shma.discriminator]) + for i in shma.oneOf + ] + ) if shma.discriminator and shma.discriminator.mapping: annos["__root__"] = Annotated[Union[t], Field(discriminator=shma.discriminator.propertyName)] else: @@ -222,11 +233,11 @@ def fieldof(schema:Schema): annos = annotationsof(shma) namespace.update(fieldof(shma)) - namespace['__annotations__'] = annos + namespace["__annotations__"] = annos m = types.new_class(type_name, (BaseModel,), {}, lambda ns: ns.update(namespace)) m.update_forward_refs() return m -Schema.update_forward_refs() \ No newline at end of file +Schema.update_forward_refs() diff --git a/aiopenapi3/security.py b/aiopenapi3/security.py index 7ca4116..9b29cb3 100644 --- a/aiopenapi3/security.py +++ b/aiopenapi3/security.py @@ -11,6 +11,7 @@ class OAuthFlow(ObjectExtended): .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#oauth-flow-object """ + authorizationUrl: Optional[str] = Field(default=None) tokenUrl: Optional[str] = Field(default=None) refreshUrl: Optional[str] = Field(default=None) @@ -23,6 +24,7 @@ class OAuthFlows(ObjectExtended): .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#oauth-flows-object """ + implicit: Optional[OAuthFlow] = Field(default=None) password: Optional[OAuthFlow] = Field(default=None) clientCredentials: Optional[OAuthFlow] = Field(default=None) @@ -49,11 +51,11 @@ class SecurityScheme(ObjectExtended): def validate_SecurityScheme(cls, values): t = values.get("type", None) keys = set(map(lambda x: x[0], filter(lambda x: x[1] is not None, values.items()))) - keys -= frozenset(["type","description"]) + keys -= frozenset(["type", "description"]) if t == "apikey": - assert keys == set(["in_","name"]) + assert keys == set(["in_", "name"]) if t == "http": - assert keys - frozenset(["scheme_","bearerFormat"]) == set([]) + assert keys - frozenset(["scheme_", "bearerFormat"]) == set([]) if t == "oauth2": assert keys == frozenset(["flows"]) if t == "openIdConnect": diff --git a/aiopenapi3/servers.py b/aiopenapi3/servers.py index 35270ee..0fd2cb4 100644 --- a/aiopenapi3/servers.py +++ b/aiopenapi3/servers.py @@ -17,7 +17,6 @@ class ServerVariable(ObjectExtended): description: Optional[str] = Field(default=None) - class Server(ObjectExtended): """ The Server object, as described `here`_ @@ -28,4 +27,3 @@ class Server(ObjectExtended): url: str = Field(...) description: Optional[str] = Field(default=None) variables: Optional[Dict[str, ServerVariable]] = Field(default_factory=dict) - diff --git a/aiopenapi3/xml.py b/aiopenapi3/xml.py index d52df37..caa8d6d 100644 --- a/aiopenapi3/xml.py +++ b/aiopenapi3/xml.py @@ -2,11 +2,13 @@ from .general import ObjectExtended + class XML(ObjectExtended): """ .. XML Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#xml-object """ + name: str = Field(default=None) namespace: str = Field(default=None) prefix: str = Field(default=None) From cbde1b743573a760d88526c734457304f91977c7 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 3 Jan 2022 14:07:12 +0100 Subject: [PATCH 059/125] remove the runtime expression code - it's server side --- aiopenapi3/expression/Makefile | 8 - aiopenapi3/expression/_grammar.py | 277 ----------------------------- aiopenapi3/expression/_model.py | 54 ------ aiopenapi3/expression/grammar.ebnf | 63 ------- aiopenapi3/expression/grammar.py | 27 --- aiopenapi3/expression/model.py | 137 -------------- 6 files changed, 566 deletions(-) delete mode 100644 aiopenapi3/expression/Makefile delete mode 100644 aiopenapi3/expression/_grammar.py delete mode 100644 aiopenapi3/expression/_model.py delete mode 100644 aiopenapi3/expression/grammar.ebnf delete mode 100644 aiopenapi3/expression/grammar.py delete mode 100644 aiopenapi3/expression/model.py diff --git a/aiopenapi3/expression/Makefile b/aiopenapi3/expression/Makefile deleted file mode 100644 index 00e2b62..0000000 --- a/aiopenapi3/expression/Makefile +++ /dev/null @@ -1,8 +0,0 @@ -_model.py: generate - -_grammar.py: generate - -generate: - python3 -m tatsu grammar.ebnf --name RuntimeExpression --outfile _grammar.py --object-model-outfile _model.py - -all: generate \ No newline at end of file diff --git a/aiopenapi3/expression/_grammar.py b/aiopenapi3/expression/_grammar.py deleted file mode 100644 index 7e28384..0000000 --- a/aiopenapi3/expression/_grammar.py +++ /dev/null @@ -1,277 +0,0 @@ -#!/usr/bin/env python - -# CAVEAT UTILITOR -# -# This file was automatically generated by TatSu. -# -# https://pypi.python.org/pypi/tatsu/ -# -# Any changes you make to it will be overwritten the next time -# the file is generated. - -from __future__ import annotations - -import sys - -from tatsu.buffering import Buffer -from tatsu.parsing import Parser -from tatsu.parsing import tatsumasu -from tatsu.parsing import leftrec, nomemo, isname # noqa -from tatsu.util import re, generic_main # noqa - - -KEYWORDS = { - "pool", - "elsif", - "subclass", - "subnet", - "shared-network", - "if", - "class", - "group", - "host", -} # type: ignore - - -class RuntimeExpressionBuffer(Buffer): - def __init__( - self, - text, - whitespace=None, - nameguard=None, - comments_re="", - eol_comments_re="", - ignorecase=None, - namechars="", - **kwargs - ): - super().__init__( - text, - whitespace=whitespace, - nameguard=nameguard, - comments_re=comments_re, - eol_comments_re=eol_comments_re, - ignorecase=ignorecase, - namechars=namechars, - **kwargs - ) - - -class RuntimeExpressionParser(Parser): - def __init__( - self, - whitespace=None, - nameguard=None, - comments_re="", - eol_comments_re="", - ignorecase=None, - left_recursion=True, - parseinfo=True, - keywords=None, - namechars="", - tokenizercls=RuntimeExpressionBuffer, - **kwargs - ): - if keywords is None: - keywords = KEYWORDS - super().__init__( - whitespace=whitespace, - nameguard=nameguard, - comments_re=comments_re, - eol_comments_re=eol_comments_re, - ignorecase=ignorecase, - left_recursion=left_recursion, - parseinfo=parseinfo, - keywords=keywords, - namechars=namechars, - tokenizercls=tokenizercls, - **kwargs - ) - - @tatsumasu("RuntimeExpression") - def _start_(self): # noqa - self._expression_() - self._check_eof() - - @tatsumasu("Expression") - def _expression_(self): # noqa - with self._choice(): - with self._option(): - self._token("$url") - self.name_last_node("root") - with self._option(): - self._token("$method") - self.name_last_node("root") - with self._option(): - self._token("$statusCode") - self.name_last_node("root") - with self._option(): - self._token("$request.") - self.name_last_node("root") - self._source_() - self.name_last_node("next") - with self._option(): - self._token("$response.") - self.name_last_node("root") - self._source_() - self.name_last_node("next") - self._error("expecting one of: " "'$url' '$method' '$statusCode'" "'$request.' '$response.'") - self._define(["next", "root"], []) - - @tatsumasu() - def _source_(self): # noqa - with self._choice(): - with self._option(): - self._header_reference_() - with self._option(): - self._query_reference_() - with self._option(): - self._path_reference_() - with self._option(): - self._body_reference_() - self._error( - "expecting one of: " - "'header.' 'query.'" - " 'path.'" - " 'body' " - ) - - @tatsumasu("Header") - def _header_reference_(self): # noqa - self._token("header.") - self._token_() - self.name_last_node("key") - self._define(["key"], []) - - @tatsumasu("Query") - def _query_reference_(self): # noqa - self._token("query.") - self._name_() - self.name_last_node("key") - self._define(["key"], []) - - @tatsumasu("Path") - def _path_reference_(self): # noqa - self._token("path.") - self._name_() - self.name_last_node("key") - self._define(["key"], []) - - @tatsumasu("Body") - def _body_reference_(self): # noqa - self._token("body") - with self._optional(): - self._json_pointer_() - self.name_last_node("fragment") - self._define(["fragment"], []) - - @tatsumasu("JSONPointer") - def _json_pointer_(self): # noqa - self._token("#/") - - def sep1(): - self._token("/") - - def block1(): - self._reference_token_() - - self._gather(block1, sep1) - self.name_last_node("tokens") - self._define(["tokens"], []) - - @tatsumasu() - def _reference_token_(self): # noqa - def block0(): - with self._choice(): - with self._option(): - self._unescaped_() - with self._option(): - self._escaped_() - self._error("expecting one of: " "[^\\/~] '~' ") - - self._closure(block0) - - @tatsumasu() - def _unescaped_(self): # noqa - self._pattern("[^\\/~]") - - @tatsumasu() - def _escaped_(self): # noqa - self._token("~") - with self._group(): - with self._choice(): - with self._option(): - self._token("0") - with self._option(): - self._token("1") - self._error("expecting one of: " "'0' '1'") - - @tatsumasu() - def _name_(self): # noqa - self._pattern("[\\w]*") - - @tatsumasu() - def _token_(self): # noqa - self._pattern("[!#$%&'*+-\\.^-`|~\\w]+") - - -class RuntimeExpressionSemantics(object): - def start(self, ast): # noqa - return ast - - def expression(self, ast): # noqa - return ast - - def source(self, ast): # noqa - return ast - - def header_reference(self, ast): # noqa - return ast - - def query_reference(self, ast): # noqa - return ast - - def path_reference(self, ast): # noqa - return ast - - def body_reference(self, ast): # noqa - return ast - - def json_pointer(self, ast): # noqa - return ast - - def reference_token(self, ast): # noqa - return ast - - def unescaped(self, ast): # noqa - return ast - - def escaped(self, ast): # noqa - return ast - - def name(self, ast): # noqa - return ast - - def token(self, ast): # noqa - return ast - - -def main(filename, start=None, **kwargs): - if start is None: - start = "start" - if not filename or filename == "-": - text = sys.stdin.read() - else: - with open(filename) as f: - text = f.read() - parser = RuntimeExpressionParser() - return parser.parse(text, rule_name=start, filename=filename, **kwargs) - - -if __name__ == "__main__": - import json - from tatsu.util import asjson - - ast = generic_main(main, RuntimeExpressionParser, name="RuntimeExpression") - data = asjson(ast) - print(json.dumps(data, indent=2)) diff --git a/aiopenapi3/expression/_model.py b/aiopenapi3/expression/_model.py deleted file mode 100644 index 9a823c6..0000000 --- a/aiopenapi3/expression/_model.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python - -# CAVEAT UTILITOR -# -# This file was automatically generated by TatSu. -# -# https://pypi.python.org/pypi/tatsu/ -# -# Any changes you make to it will be overwritten the next time -# the file is generated. - -from __future__ import annotations - -from tatsu.objectmodel import Node -from tatsu.semantics import ModelBuilderSemantics - - -class ModelBase(Node): - pass - - -class RuntimeExpressionModelBuilderSemantics(ModelBuilderSemantics): - def __init__(self, context=None, types=None): - types = [t for t in globals().values() if type(t) is type and issubclass(t, ModelBase)] + (types or []) - super(RuntimeExpressionModelBuilderSemantics, self).__init__(context=context, types=types) - - -class RuntimeExpression(ModelBase): - pass - - -class Expression(ModelBase): - next = None - root = None - - -class Header(ModelBase): - key = None - - -class Query(ModelBase): - key = None - - -class Path(ModelBase): - key = None - - -class Body(ModelBase): - fragment = None - - -class JSONPointer(ModelBase): - tokens = None diff --git a/aiopenapi3/expression/grammar.ebnf b/aiopenapi3/expression/grammar.ebnf deleted file mode 100644 index 6da7fab..0000000 --- a/aiopenapi3/expression/grammar.ebnf +++ /dev/null @@ -1,63 +0,0 @@ -@@grammar::RuntimeExpression -@@whitespace :: // -@@comments :: // -@@eol_comments :: // -@@keyword :: if elsif "shared-network" group host subnet pool class subclass -@@parseinfo :: True - -start::RuntimeExpression = expression $ ; - -# expression = ( "$url" / "$method" / "$statusCode" / "$request." source / "$response." source ) -expression::Expression - = root:"$url" - | root:"$method" - | root:"$statusCode" - | root:"$request." next:source - | root:"$response." next:source - ; - -# source = ( header-reference / query-reference / path-reference / body-reference ) -source - = header_reference - | query_reference - | path_reference - | body_reference - ; - -# header-reference = "header." token -header_reference::Header = "header." key:token ; - -# query-reference = "query." name -query_reference::Query = "query." key:name ; - -# path-reference = "path." name -path_reference::Path = "path." key:name ; - -# body-reference = "body" ["#" json-pointer ] -body_reference::Body = "body" fragment:[ json_pointer ] ; - -# json-pointer = *( "/" reference-token ) -json_pointer::JSONPointer = "#/" tokens:"/".{ reference_token }*; - -# reference-token = *( unescaped / escaped ) -reference_token = { unescaped | escaped }* ; - -# unescaped = %x00-2E / %x30-7D / %x7F-10FFFF -# ; %x2F ('/') and %x7E ('~') are excluded from 'unescaped' -unescaped = /[^\/~]/ ; - - -# escaped = "~" ( "0" / "1" ) -#; representing '~' and '/', respectively -escaped = "~" ( "0" | "1" ) ; - -# name = *( CHAR ) -name = /[\w]*/ ; - -# token = 1*tchar -# token = {tchar}+; -token = /[!#$%&'*+-\.^-`|~\w]+/ ; - -#tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA" -# tchar = /[^\(\),\/:;<=>?@[\]{}]/ ; -#tchar = /!#$%&'*+-\.^-`|~\w/ ; diff --git a/aiopenapi3/expression/grammar.py b/aiopenapi3/expression/grammar.py deleted file mode 100644 index c6de291..0000000 --- a/aiopenapi3/expression/grammar.py +++ /dev/null @@ -1,27 +0,0 @@ -from ._grammar import RuntimeExpressionBuffer, RuntimeExpressionParser - - -def loads(data, trace=False, colorize=False): - from .model import ( - RuntimeExpressionModelBuilderSemantics, - RuntimeExpression, - Expression, - JSONPointer, - Body, - Header, - Query, - Path, - ) - - parser = RuntimeExpressionParser() - model = parser.parse( - data, - trace=trace, - colorize=colorize, - tokenizercls=RuntimeExpressionBuffer, - semantics=RuntimeExpressionModelBuilderSemantics( - types=[RuntimeExpression, Expression, JSONPointer, Body, Header, Query, Path] - ), - filename="…", - ) - return model diff --git a/aiopenapi3/expression/model.py b/aiopenapi3/expression/model.py deleted file mode 100644 index 61091d5..0000000 --- a/aiopenapi3/expression/model.py +++ /dev/null @@ -1,137 +0,0 @@ -import json - -import httpx -from yarl import URL - -import aiopenapi3.general - -from ._model import ( - RuntimeExpressionModelBuilderSemantics as RuntimeExpressionModelBuilderSemanticsBase, - JSONPointer as JSONPointerBase, - Header as HeaderBase, - Query as QueryBase, - Path as PathBase, - Body as BodyBase, - RuntimeExpression as RuntimeExpressionBase, - Expression as ExpressionBase, -) - - -class RuntimeExpressionModelBuilderSemantics(RuntimeExpressionModelBuilderSemanticsBase): - def reference_token(self, ast, name=None): - return "".join(ast) - - def escaped(self, ast): - return "".join(ast) - - -# def token(self, ast, name=None): -# return "".join(ast) - - -class RuntimeExpression(RuntimeExpressionBase): - def __init__(self, ctx=None, ast=None, parseinfo=None, **kwargs): - super().__init__(ctx, None, parseinfo, **kwargs) - self.expression = ast - - def eval(self, req, resp): - return self.expression.eval(req, resp) - - -class Expression(ExpressionBase): - def __init__(self, ctx=None, ast=None, parseinfo=None, **kwargs): - super().__init__(ctx, None, parseinfo, **kwargs) - self.root = ast.root - self.next = ast.next - - def eval(self, req, resp): - data = None - item = self.root - if item[-1] == ".": - if item == "$request.": - data = req - if item == "$response.": - data = resp - - return self.next.eval(data) - else: - if item == "$url": - return req.url - elif item == "$method": - return req.method - elif item == "$statusCode": - return resp.status_code - - -class JSONPointer(JSONPointerBase): - def __init__(self, ctx=None, ast=None, parseinfo=None, **kwargs): - super().__init__(ctx, None, parseinfo, **kwargs) - self._tokens = ast.tokens - - @property - def tokens(self): - for i in self._tokens: - yield aiopenapi3.general.JSONPointer.decode(i) - - -class Header(HeaderBase): - def __init__(self, ctx=None, ast=None, parseinfo=None, **kwargs): - super().__init__(ctx, None, parseinfo, **kwargs) - self.key = ast.key - - def eval(self, data): - headers = None - if isinstance(data, httpx.Request): - headers = data.headers - elif isinstance(data, httpx.Response): - headers = data.headers - if headers is None: - return None - return headers.get(self.key, None) - - -class Query(QueryBase): - def __init__(self, ctx=None, ast=None, parseinfo=None, **kwargs): - super().__init__(ctx, None, parseinfo, **kwargs) - self.key = ast.key - - def eval(self, data): - if isinstance(data, httpx.Request): - url = URL(str(data.url)) - elif isinstance(data, httpx.Response): - url = None - return url.query.get(self.key, None) - - -class Path(PathBase): - def __init__(self, ctx=None, ast=None, parseinfo=None, **kwargs): - super().__init__(ctx, None, parseinfo, **kwargs) - self.key = ast.key - - def eval(self, data): - return data.path.get(self.key, None) - - -class Body(BodyBase): - def __init__(self, ctx=None, ast=None, parseinfo=None, **kwargs): - super().__init__(ctx, None, parseinfo, **kwargs) - self.fragment = ast.fragment - - def eval(self, data): - try: - if isinstance(data, httpx.Request): - body = json.loads(data.content) - elif isinstance(data, httpx.Response): - body = data.json() - except Exception: - return None - - data = body - try: - for i in self.fragment.tokens: - if isinstance(data, list): - i = int(i) - 1 - data = data[i] - return data - except KeyError: - return None From a0c9433eb4fd42240479f23800842bd361dc9604 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 3 Jan 2022 14:09:21 +0100 Subject: [PATCH 060/125] tests - pre-commit --- tests/api/main.py | 10 ++--- tests/api/v1/main.py | 68 +++++++++++++----------------- tests/api/v1/schema.py | 4 +- tests/api/v2/main.py | 75 ++++++++++++++++------------------ tests/api/v2/schema.py | 32 +++++++++------ tests/conftest.py | 16 +++++--- tests/fastapi_test.py | 19 +++++---- tests/linode_test.py | 6 ++- tests/loader_test.py | 28 +++++++------ tests/model_test.py | 51 ++++++++++++++--------- tests/parse_data_test.py | 23 +++++++---- tests/parsing_test.py | 33 +++++++++------ tests/path_test.py | 55 +++++++++++++------------ tests/ref_test.py | 29 ++++++------- tests/test_runtimexpression.py | 47 +++++++++++---------- 15 files changed, 264 insertions(+), 232 deletions(-) diff --git a/tests/api/main.py b/tests/api/main.py index 1c7a6da..5caa91d 100644 --- a/tests/api/main.py +++ b/tests/api/main.py @@ -6,14 +6,12 @@ from api.v1.main import router as v1 from api.v2.main import router as v2 -app = FastAPI(version="1.0.0", - title="Dorthu's Petstore", - servers=[{"url": "/", "description": "Default, relative server"}]) +app = FastAPI( + version="1.0.0", title="Dorthu's Petstore", servers=[{"url": "/", "description": "Default, relative server"}] +) app.include_router(v1) app.include_router(v2) -app = VersionedFastAPI(app, - version_format='{major}', - prefix_format='/v{major}') +app = VersionedFastAPI(app, version_format="{major}", prefix_format="/v{major}") diff --git a/tests/api/v1/main.py b/tests/api/v1/main.py index 3d44030..a0aa89b 100644 --- a/tests/api/v1/main.py +++ b/tests/api/v1/main.py @@ -15,72 +15,60 @@ router = APIRouter(route_class=versioned_api_route(1)) - ZOO = dict() + def _idx(l): for i in range(l): yield i + idx = _idx(100) -@router.post('/pet', - operation_id="createPet", - response_model=Pet, - responses={201: {"model": Pet}, - 409: {"model": Error}} - ) -def createPet(response: Response, - pet: PetCreate = Body(..., embed=True), - ) -> None: +@router.post( + "/pet", operation_id="createPet", response_model=Pet, responses={201: {"model": Pet}, 409: {"model": Error}} +) +def createPet( + response: Response, + pet: PetCreate = Body(..., embed=True), +) -> None: if pet.name in ZOO: - return JSONResponse(status_code=starlette.status.HTTP_409_CONFLICT, - content=Error(code=errno.EEXIST, - message=f"{pet.name} already exists" - ).dict() - ) + return JSONResponse( + status_code=starlette.status.HTTP_409_CONFLICT, + content=Error(code=errno.EEXIST, message=f"{pet.name} already exists").dict(), + ) ZOO[pet.name] = r = Pet(id=next(idx), **pet.dict()) response.status_code = starlette.status.HTTP_201_CREATED return r -@router.get('/pet', - operation_id="listPet", - response_model=Pets) +@router.get("/pet", operation_id="listPet", response_model=Pets) def listPet(limit: Optional[int] = None) -> Pets: return list(ZOO.values()) -@router.get('/pets/{pet_id}', - operation_id="getPet", - response_model=Pet, - responses={ - 404: {"model": Error} - } - ) -def getPet(pet_id: int = Query(..., alias='petId')) -> Pets: +@router.get("/pets/{pet_id}", operation_id="getPet", response_model=Pet, responses={404: {"model": Error}}) +def getPet(pet_id: int = Query(..., alias="petId")) -> Pets: for k, v in ZOO.items(): if pet_id == v.id: return v else: - return JSONResponse(status_code=starlette.status.HTTP_404_NOT_FOUND, - content=Error(code=errno.ENOENT, message=f"{pet_id} not found").dict()) - - -@router.delete('/pets/{pet_id}', - operation_id="deletePet", - responses={ - 204: {"model": None}, - 404: {"model": Error} - }) -def deletePet(response: Response, - pet_id: int = Query(..., alias='petId')) -> Pets: + return JSONResponse( + status_code=starlette.status.HTTP_404_NOT_FOUND, + content=Error(code=errno.ENOENT, message=f"{pet_id} not found").dict(), + ) + + +@router.delete("/pets/{pet_id}", operation_id="deletePet", responses={204: {"model": None}, 404: {"model": Error}}) +def deletePet(response: Response, pet_id: int = Query(..., alias="petId")) -> Pets: for k, v in ZOO.items(): if pet_id == v.id: del ZOO[k] response.status_code = starlette.status.HTTP_204_NO_CONTENT return response else: - return JSONResponse(status_code=starlette.status.HTTP_404_NOT_FOUND, - content=Error(code=errno.ENOENT, message=f"{pet_id} not found").dict()) + return JSONResponse( + status_code=starlette.status.HTTP_404_NOT_FOUND, + content=Error(code=errno.ENOENT, message=f"{pet_id} not found").dict(), + ) diff --git a/tests/api/v1/schema.py b/tests/api/v1/schema.py index 34ffb89..a1f7d25 100644 --- a/tests/api/v1/schema.py +++ b/tests/api/v1/schema.py @@ -17,9 +17,9 @@ class Pet(PetBase): class Pets(BaseModel): - __root__: List[Pet] = Field(..., description='list of pet') + __root__: List[Pet] = Field(..., description="list of pet") class Error(BaseModel): code: int - message: str \ No newline at end of file + message: str diff --git a/tests/api/v2/main.py b/tests/api/v2/main.py index 5100105..a1d683c 100644 --- a/tests/api/v2/main.py +++ b/tests/api/v2/main.py @@ -15,75 +15,70 @@ ZOO = dict() + def _idx(l): for i in range(l): yield i -idx = _idx(100) +idx = _idx(100) -@router.post('/pet', - operation_id="createPet", - response_model=schema.Pet, - responses={201: {"model": schema.Pet}, - 409: {"model": schema.Error}} - ) -def createPet(response: Response, - pet: schema.Pet = Body(..., embed=True), - ) -> None: +@router.post( + "/pet", + operation_id="createPet", + response_model=schema.Pet, + responses={201: {"model": schema.Pet}, 409: {"model": schema.Error}}, +) +def createPet( + response: Response, + pet: schema.Pet = Body(..., embed=True), +) -> None: # if isinstance(pet, Cat): # pet = pet.__root__ # elif isinstance(pet, Dog): # pass if pet.name in ZOO: - return JSONResponse(status_code=starlette.status.HTTP_409_CONFLICT, - content=schema.Error(code=errno.EEXIST, - message=f"{pet.name} already exists" - ).dict() - ) + return JSONResponse( + status_code=starlette.status.HTTP_409_CONFLICT, + content=schema.Error(code=errno.EEXIST, message=f"{pet.name} already exists").dict(), + ) pet.identifier = str(uuid.uuid4()) ZOO[pet.name] = r = pet response.status_code = starlette.status.HTTP_201_CREATED return r -@router.get('/pet', - operation_id="listPet", - response_model=schema.Pets) +@router.get("/pet", operation_id="listPet", response_model=schema.Pets) def listPet(limit: Optional[int] = None) -> schema.Pets: return list(ZOO.values()) -@router.get('/pets/{pet_id}', - operation_id="getPet", - response_model=schema.Pet, - responses={ - 404: {"model": schema.Error} - } - ) -def getPet(pet_id: str = Query(..., alias='petId')) -> schema.Pets: +@router.get( + "/pets/{pet_id}", operation_id="getPet", response_model=schema.Pet, responses={404: {"model": schema.Error}} +) +def getPet(pet_id: str = Query(..., alias="petId")) -> schema.Pets: for k, v in ZOO.items(): if pet_id == v.identifier: return v else: - return JSONResponse(status_code=starlette.status.HTTP_404_NOT_FOUND, - content=schema.Error(code=errno.ENOENT, message=f"{pet_id} not found").dict()) - - -@router.delete('/pets/{pet_id}', - operation_id="deletePet", - responses={ - 204: {"model": None}, - 404: {"model": schema.Error} - }) -def deletePet(response: Response, - pet_id: int = Query(..., alias='petId')) -> schema.Pets: + return JSONResponse( + status_code=starlette.status.HTTP_404_NOT_FOUND, + content=schema.Error(code=errno.ENOENT, message=f"{pet_id} not found").dict(), + ) + + +@router.delete( + "/pets/{pet_id}", operation_id="deletePet", responses={204: {"model": None}, 404: {"model": schema.Error}} +) +def deletePet(response: Response, pet_id: int = Query(..., alias="petId")) -> schema.Pets: for k, v in ZOO.items(): if pet_id == v.identifier: del ZOO[k] response.status_code = starlette.status.HTTP_204_NO_CONTENT return response else: - return JSONResponse(status_code=starlette.status.HTTP_404_NOT_FOUND, - content=schema.Error(code=errno.ENOENT, message=f"{pet_id} not found").dict()) + return JSONResponse( + status_code=starlette.status.HTTP_404_NOT_FOUND, + content=schema.Error(code=errno.ENOENT, message=f"{pet_id} not found").dict(), + ) diff --git a/tests/api/v2/schema.py b/tests/api/v2/schema.py index 8097e13..d5e6e28 100644 --- a/tests/api/v2/schema.py +++ b/tests/api/v2/schema.py @@ -5,56 +5,62 @@ from pydantic import BaseModel, Field from pydantic.fields import Undefined + class PetBase(BaseModel): identifier: Optional[str] = Field(default_factory=lambda: str(uuid.uuid4())) name: str - tags: Optional[List[str]] #= Field(default_factory=list) + tags: Optional[List[str]] # = Field(default_factory=list) class BlackCat(PetBase): - pet_type: Literal['cat'] = "cat" - color: Literal['black'] = "black" + pet_type: Literal["cat"] = "cat" + color: Literal["black"] = "black" black_name: str class WhiteCat(PetBase): - pet_type: Literal['cat'] = "cat" - color: Literal['white'] = "white" + pet_type: Literal["cat"] = "cat" + color: Literal["white"] = "white" white_name: str # Can also be written with a custom root type # class Cat(BaseModel): - __root__: Annotated[Union[BlackCat, WhiteCat], Field(discriminator='color')] + __root__: Annotated[Union[BlackCat, WhiteCat], Field(discriminator="color")] def __getattr__(self, item): return getattr(self.__root__, item) + def __setattr__(self, item, value): return setattr(self.__root__, item, value) -#Cat = Annotated[Union[BlackCat, WhiteCat], Field(default=Undefined, discriminator='color')] + +# Cat = Annotated[Union[BlackCat, WhiteCat], Field(default=Undefined, discriminator='color')] class Dog(PetBase): - pet_type: Literal['dog'] = "dog" + pet_type: Literal["dog"] = "dog" name: str -#Pet = Annotated[Union[Cat, Dog], Field(default=Undefined, discriminator='pet_type')] +# Pet = Annotated[Union[Cat, Dog], Field(default=Undefined, discriminator='pet_type')] + class Pet(BaseModel): - __root__: Annotated[Union[Cat, Dog], Field(discriminator='pet_type')] + __root__: Annotated[Union[Cat, Dog], Field(discriminator="pet_type")] + def __getattr__(self, item): return getattr(self.__root__, item) + def __setattr__(self, item, value): return setattr(self.__root__, item, value) -class Pets(BaseModel): - __root__: List[Pet] = Field(..., description='list of pet') +class Pets(BaseModel): + __root__: List[Pet] = Field(..., description="list of pet") class Error(BaseModel): code: int - message: str \ No newline at end of file + message: str diff --git a/tests/conftest.py b/tests/conftest.py index ca89960..94d7152 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ LOADED_FILES = {} URLBASE = "/" + def _get_parsed_yaml(filename): """ Returns a python dict that is a parsed yaml file from the tests/fixtures @@ -15,8 +16,8 @@ def _get_parsed_yaml(filename): include extension. :type filename: str """ - if filename not in LOADED_FILES: - with open("tests/fixtures/"+filename) as f: + if filename not in LOADED_FILES: + with open("tests/fixtures/" + filename) as f: raw = f.read() parsed = safe_load(raw) @@ -33,14 +34,14 @@ def _get_parsed_spec(filename): include extension. :type filename: str """ - if "spec:"+filename not in LOADED_FILES: + if "spec:" + filename not in LOADED_FILES: parsed = _get_parsed_yaml(filename) spec = OpenAPI(URLBASE, parsed) - LOADED_FILES["spec:"+filename] = spec + LOADED_FILES["spec:" + filename] = spec - return LOADED_FILES["spec:"+filename] + return LOADED_FILES["spec:" + filename] @pytest.fixture @@ -105,7 +106,7 @@ def obj_example_expanded(): """ yield _get_parsed_yaml("obj-example.yaml") - + @pytest.fixture def float_validation_expanded(): """ @@ -137,6 +138,7 @@ def with_broken_links(): """ yield _get_parsed_yaml("with-broken-links.yaml") + @pytest.fixture def with_securityparameters(): """ @@ -144,6 +146,7 @@ def with_securityparameters(): """ yield _get_parsed_yaml("with-securityparameters.yaml") + @pytest.fixture def with_parameters(): """ @@ -151,6 +154,7 @@ def with_parameters(): """ yield _get_parsed_yaml("with-parameters.yaml") + @pytest.fixture def with_callback(): """ diff --git a/tests/fastapi_test.py b/tests/fastapi_test.py index 32fc9d0..fdb4ccc 100644 --- a/tests/fastapi_test.py +++ b/tests/fastapi_test.py @@ -11,12 +11,14 @@ from api.main import app + @pytest.fixture(scope="session") def config(unused_tcp_port_factory): c = Config() c.bind = [f"localhost:{unused_tcp_port_factory()}"] return c + @pytest.fixture(scope="session") def event_loop(request): loop = asyncio.get_event_loop_policy().new_event_loop() @@ -35,13 +37,15 @@ async def server(event_loop, config): sd.set() await task + @pytest.fixture(scope="session") async def client(event_loop, server): api = await asyncio.to_thread(aiopenapi3.OpenAPI.load_sync, f"http://{server.bind[0]}/v1/openapi.json") return api + def randomPet(name=None): - return {"data":{"pet":{"name":str(name or uuid.uuid4()), "pet_type":"dog"}}} + return {"data": {"pet": {"name": str(name or uuid.uuid4()), "pet_type": "dog"}}} @pytest.mark.asyncio @@ -49,7 +53,7 @@ async def test_createPet(event_loop, server, client): r = await asyncio.to_thread(client._.createPet, **randomPet()) assert type(r).schema() == client.components.schemas["Pet"].get_type().schema() - r = await asyncio.to_thread(client._.createPet, data={"pet":{"name":r.name}}) + r = await asyncio.to_thread(client._.createPet, data={"pet": {"name": r.name}}) assert type(r).schema() == client.components.schemas["Error"].get_type().schema() @@ -59,23 +63,24 @@ async def test_listPet(event_loop, server, client): l = await asyncio.to_thread(client._.listPet) assert len(l) > 0 + @pytest.mark.asyncio async def test_getPet(event_loop, server, client): pet = await asyncio.to_thread(client._.createPet, **randomPet(uuid.uuid4())) - r = await asyncio.to_thread(client._.getPet, parameters={"pet_id":pet.id}) + r = await asyncio.to_thread(client._.getPet, parameters={"pet_id": pet.id}) assert type(r).schema() == type(pet).schema() assert r.id == pet.id - r = await asyncio.to_thread(client._.getPet, parameters={"pet_id":-1}) + r = await asyncio.to_thread(client._.getPet, parameters={"pet_id": -1}) assert type(r).schema() == client.components.schemas["Error"].get_type().schema() + @pytest.mark.asyncio async def test_deletePet(event_loop, server, client): - r = await asyncio.to_thread(client._.deletePet, parameters={"pet_id":-1}) + r = await asyncio.to_thread(client._.deletePet, parameters={"pet_id": -1}) assert type(r).schema() == client.components.schemas["Error"].get_type().schema() await asyncio.to_thread(client._.createPet, **randomPet(uuid.uuid4())) zoo = await asyncio.to_thread(client._.listPet) for pet in zoo: - await asyncio.to_thread(client._.deletePet, parameters={"pet_id":pet.id}) - + await asyncio.to_thread(client._.deletePet, parameters={"pet_id": pet.id}) diff --git a/tests/linode_test.py b/tests/linode_test.py index 3868bee..46fe13e 100644 --- a/tests/linode_test.py +++ b/tests/linode_test.py @@ -3,21 +3,25 @@ from aiopenapi3 import OpenAPI import pytest + @pytest.fixture(scope="session") def event_loop(request): loop = asyncio.get_event_loop_policy().new_event_loop() yield loop loop.close() + @pytest.fixture(scope="session") async def api(): return await OpenAPI.load_async("https://www.linode.com/docs/api/openapi.yaml") + @pytest.mark.asyncio async def test_linode_components_schemas(api): - for name,schema in api.components.schemas.items(): + for name, schema in api.components.schemas.items(): schema.get_type().construct() + @pytest.mark.asyncio async def test_linode_return_values(api): for i in api._: diff --git a/tests/loader_test.py b/tests/loader_test.py index 0cf5436..263fe01 100644 --- a/tests/loader_test.py +++ b/tests/loader_test.py @@ -10,9 +10,9 @@ info: title: spec01 version: 1.0.0 - description: | + description: | {description} - + paths: /load: get: @@ -26,20 +26,23 @@ Object: type: object properties: - name: + name: type: string - value: - type: boolean + value: + type: boolean """ -data = [("petstore-expanded.yaml#/components/schemas/Pet", None), - ("no-such.file.yaml#/components/schemas/Pet", FileNotFoundError), - ("petstore-expanded.yaml#/components/schemas/NoSuchPet", ReferenceResolutionError),] +data = [ + ("petstore-expanded.yaml#/components/schemas/Pet", None), + ("no-such.file.yaml#/components/schemas/Pet", FileNotFoundError), + ("petstore-expanded.yaml#/components/schemas/NoSuchPet", ReferenceResolutionError), +] + @pytest.mark.parametrize("jsonref, exception", data) def test_loader_jsonref(jsonref, exception): loader = FileSystemLoader(Path("tests/fixtures")) - values = {"jsonref":jsonref, "description":""} + values = {"jsonref": jsonref, "description": ""} if exception is None: api = OpenAPI.loads("loader.yaml", SPECTPL.format(**values), session_factory=None, loader=loader) else: @@ -49,13 +52,14 @@ def test_loader_jsonref(jsonref, exception): def test_loader_decode(): with pytest.raises(ValueError, match="encoding"): - Loader.decode(b'rvice.\r\n \xa9 2020, 3GPP Organ', codec="utf-8") + Loader.decode(b"rvice.\r\n \xa9 2020, 3GPP Organ", codec="utf-8") + def test_loader_format(): - values = {"jsonref":"'#/components/schemas/Example'", "description":""} + values = {"jsonref": "'#/components/schemas/Example'", "description": ""} spec = SPECTPL.format(**values) api = OpenAPI.loads("loader.yaml", spec) spec = Loader.dict(Path("loader.yaml"), spec) spec = json.dumps(spec) - api = OpenAPI.loads("loader.json", spec) \ No newline at end of file + api = OpenAPI.loads("loader.json", spec) diff --git a/tests/model_test.py b/tests/model_test.py index f5f376c..71b3fc7 100644 --- a/tests/model_test.py +++ b/tests/model_test.py @@ -21,12 +21,14 @@ from tests.api.main import app + @pytest.fixture(scope="session") def config(unused_tcp_port_factory): c = Config() c.bind = [f"localhost:{unused_tcp_port_factory()}"] return c + @pytest.fixture(scope="session") def event_loop(request): loop = asyncio.get_event_loop_policy().new_event_loop() @@ -45,6 +47,7 @@ async def server(event_loop, config): sd.set() await task + @pytest.fixture(scope="session", params=[2]) def version(request): return f"v{request.param}" @@ -78,13 +81,17 @@ async def test_model(event_loop, server, client): assert orig == crea orig = client.components.schemas["Cat"].dict(exclude_unset=True, by_alias=True) - crea = client.components.schemas["Cat"].get_type().schema(ref_template="#/components/schemas/{model}", by_alias=True) + crea = ( + client.components.schemas["Cat"].get_type().schema(ref_template="#/components/schemas/{model}", by_alias=True) + ) if "definitions" in crea: del crea["definitions"] assert crea == orig orig = client.components.schemas["Pet"].dict(exclude_unset=True, by_alias=True) - crea = client.components.schemas["Pet"].get_type().schema(ref_template="#/components/schemas/{model}", by_alias=True) + crea = ( + client.components.schemas["Pet"].get_type().schema(ref_template="#/components/schemas/{model}", by_alias=True) + ) if "definitions" in crea: del crea["definitions"] assert crea == orig @@ -92,20 +99,23 @@ async def test_model(event_loop, server, client): def randomPet(client, name=None): if name: - return {"pet": client.components.schemas["Dog"].model({"name":name}).dict()} + return {"pet": client.components.schemas["Dog"].model({"name": name}).dict()} else: - return {"pet": client.components.schemas["WhiteCat"].model({"name":str(uuid.uuid4()), "white_name":str(uuid.uuid4())}).dict()} + return { + "pet": client.components.schemas["WhiteCat"] + .model({"name": str(uuid.uuid4()), "white_name": str(uuid.uuid4())}) + .dict() + } + @pytest.mark.asyncio async def test_createPet(event_loop, server, client): data = { - "pet": client.components.schemas["WhiteCat"].model( - { - "name":str(uuid.uuid4()), - "white_name":str(uuid.uuid4()) - }).dict() + "pet": client.components.schemas["WhiteCat"] + .model({"name": str(uuid.uuid4()), "white_name": str(uuid.uuid4())}) + .dict() } -# r = await client._.createPet( data=data) + # r = await client._.createPet( data=data) r = await client._.createPet(data=data) assert type(r.__root__.__root__).schema() == client.components.schemas["WhiteCat"].get_type().schema() @@ -114,34 +124,35 @@ async def test_createPet(event_loop, server, client): with pytest.raises(pydantic.ValidationError): args = client._.createPet.args() - cls = args['data'].get_type() + cls = args["data"].get_type() cls() @pytest.mark.asyncio async def test_listPet(event_loop, server, client): - r = await client._.createPet( data=randomPet(client, str(uuid.uuid4()))) + r = await client._.createPet(data=randomPet(client, str(uuid.uuid4()))) l = await client._.listPet() assert len(l) > 0 + @pytest.mark.asyncio async def test_getPet(event_loop, server, client): - pet = await client._.createPet( data=randomPet(client, str(uuid.uuid4()))) - r = await client._.getPet( parameters={"pet_id":pet.__root__.identifier}) + pet = await client._.createPet(data=randomPet(client, str(uuid.uuid4()))) + r = await client._.getPet(parameters={"pet_id": pet.__root__.identifier}) assert type(r.__root__).schema() == type(pet.__root__).schema() - r = await client._.getPet(parameters={"pet_id":"-1"}) + r = await client._.getPet(parameters={"pet_id": "-1"}) assert type(r).schema() == client.components.schemas["Error"].get_type().schema() + @pytest.mark.asyncio async def test_deletePet(event_loop, server, client): - r = await client._.deletePet( parameters={"pet_id":-1}) + r = await client._.deletePet(parameters={"pet_id": -1}) assert type(r).schema() == client.components.schemas["Error"].get_type().schema() - await client._.createPet( data=randomPet(client, str(uuid.uuid4()))) + await client._.createPet(data=randomPet(client, str(uuid.uuid4()))) zoo = await client._.listPet() for pet in zoo: - while hasattr(pet, '__root__'): + while hasattr(pet, "__root__"): pet = pet.__root__ - await client._.deletePet(parameters={"pet_id":pet.identifier}) - + await client._.deletePet(parameters={"pet_id": pet.identifier}) diff --git a/tests/parse_data_test.py b/tests/parse_data_test.py index 762ece4..0f1c1f8 100644 --- a/tests/parse_data_test.py +++ b/tests/parse_data_test.py @@ -1,23 +1,30 @@ import pytest -from aiopenapi3 import FileSystemLoader,OpenAPI +from aiopenapi3 import FileSystemLoader, OpenAPI import pathlib URLBASE = "http://127.1.1.1/open5gs" + def pytest_generate_tests(metafunc): argnames, dir, filterfn = metafunc.cls.params[metafunc.function.__name__] dir = pathlib.Path(dir).expanduser() metafunc.parametrize( - argnames, [[dir, i.name] for i in sorted(filter(filterfn, dir.iterdir() if dir.exists() else []), key=lambda x: x.name)] + argnames, + [[dir, i.name] for i in sorted(filter(filterfn, dir.iterdir() if dir.exists() else []), key=lambda x: x.name)], ) class TestParseData: # a map specifying multiple argument sets for a test method params = { - "test_data": [("dir","file"),"tests/data", lambda x: x.is_file() and x.suffix in (".json",".yaml")], - "test_data_open5gs": [("dir","file"), "tests/data/open5gs/", - lambda x: x.is_file() and x.suffix in (".json",".yaml") and x.name.split("_")[0] not in ("TS29520","TS29509","TS29544","TS29517")], + "test_data": [("dir", "file"), "tests/data", lambda x: x.is_file() and x.suffix in (".json", ".yaml")], + "test_data_open5gs": [ + ("dir", "file"), + "tests/data/open5gs/", + lambda x: x.is_file() + and x.suffix in (".json", ".yaml") + and x.name.split("_")[0] not in ("TS29520", "TS29509", "TS29544", "TS29517"), + ], } def test_data(self, dir, file): @@ -27,8 +34,6 @@ def test_data(self, dir, file): def test_data_open5gs(self, dir, file): loader = FileSystemLoader(pathlib.Path(dir)) - data = loader.load(pathlib.Path(file).name) -# if "servers" in "data": + data = loader.load(pathlib.Path(file).name, codec="utf-8") + # if "servers" in "data": spec = OpenAPI(URLBASE, data, loader=loader) - - diff --git a/tests/parsing_test.py b/tests/parsing_test.py index fbfb3df..579d24b 100644 --- a/tests/parsing_test.py +++ b/tests/parsing_test.py @@ -8,6 +8,7 @@ URLBASE = "/" + def test_parse_from_yaml(petstore_expanded): """ Tests that we can parse a valid yaml file @@ -60,25 +61,25 @@ def test_object_example(obj_example_expanded): Tests that `example` exists. """ spec = OpenAPI(URLBASE, obj_example_expanded) - schema = spec.paths['/check-dict'].get.responses['200'].content['application/json'].schema_ + schema = spec.paths["/check-dict"].get.responses["200"].content["application/json"].schema_ assert isinstance(schema.example, dict) - assert isinstance(schema.example['real'], float) + assert isinstance(schema.example["real"], float) - schema = spec.paths['/check-str'].get.responses['200'].content['text/plain'] + schema = spec.paths["/check-str"].get.responses["200"].content["text/plain"] assert isinstance(schema.example, str) - + def test_parsing_float_validation(float_validation_expanded): """ Tests that `minimum` and similar validators work with floats. """ spec = OpenAPI(URLBASE, float_validation_expanded) - properties = spec.paths['/foo'].get.responses['200'].content['application/json'].schema_.properties + properties = spec.paths["/foo"].get.responses["200"].content["application/json"].schema_.properties - assert isinstance(properties['integer'].minimum, int) - assert isinstance(properties['integer'].maximum, int) - assert isinstance(properties['real'].minimum, float) - assert isinstance(properties['real'].maximum, float) + assert isinstance(properties["integer"].minimum, int) + assert isinstance(properties["integer"].maximum, int) + assert isinstance(properties["real"].minimum, float) + assert isinstance(properties["real"].maximum, float) def test_parsing_with_links(with_links): @@ -106,14 +107,20 @@ def test_parsing_broken_links(with_broken_links): with pytest.raises(ValidationError) as e: spec = OpenAPI(URLBASE, with_broken_links) - assert all([i in str(e.value) for i in [ - "operationId and operationRef are mutually exclusive, only one of them is allowed", - "operationId and operationRef are mutually exclusive, one of them must be specified", - ]]) + assert all( + [ + i in str(e.value) + for i in [ + "operationId and operationRef are mutually exclusive, only one of them is allowed", + "operationId and operationRef are mutually exclusive, one of them must be specified", + ] + ] + ) def test_securityparameters(with_securityparameters): spec = OpenAPI(URLBASE, with_securityparameters) + def test_callback(with_callback): spec = OpenAPI(URLBASE, with_callback) diff --git a/tests/path_test.py b/tests/path_test.py index a82796c..0eb3075 100644 --- a/tests/path_test.py +++ b/tests/path_test.py @@ -15,25 +15,27 @@ URLBASE = "/" + def test_paths_exist(petstore_expanded_spec): """ Tests that paths are parsed correctly """ - assert '/pets' in petstore_expanded_spec.paths - assert '/pets/{id}' in petstore_expanded_spec.paths + assert "/pets" in petstore_expanded_spec.paths + assert "/pets/{id}" in petstore_expanded_spec.paths assert len(petstore_expanded_spec.paths) == 2 + def test_operations_exist(petstore_expanded_spec): """ Tests that methods are populated as expected in paths """ - pets_path = petstore_expanded_spec.paths['/pets'] + pets_path = petstore_expanded_spec.paths["/pets"] assert pets_path.get is not None assert pets_path.post is not None assert pets_path.put is None assert pets_path.delete is None - pets_id_path = petstore_expanded_spec.paths['/pets/{id}'] + pets_id_path = petstore_expanded_spec.paths["/pets/{id}"] assert pets_id_path.get is not None assert pets_id_path.post is None assert pets_id_path.put is None @@ -44,7 +46,7 @@ def test_operation_populated(petstore_expanded_spec): """ Tests that operations are populated as expected """ - op = petstore_expanded_spec.paths['/pets'].get + op = petstore_expanded_spec.paths["/pets"].get # check description and metadata populated correctly assert op.operationId == "findPets" @@ -74,25 +76,25 @@ def test_operation_populated(petstore_expanded_spec): assert param2.schema_.format == "int32" # check that responses populated correctly - assert '200' in op.responses - assert 'default' in op.responses + assert "200" in op.responses + assert "default" in op.responses assert len(op.responses) == 2 - resp1 = op.responses['200'] + resp1 = op.responses["200"] assert resp1.description == "pet response" assert len(resp1.content) == 1 - assert 'application/json' in resp1.content - con1 = resp1.content['application/json'] + assert "application/json" in resp1.content + con1 = resp1.content["application/json"] assert con1.schema_ is not None assert con1.schema_.type == "array" # we're not going to test that the ref resolved correctly here - that's a separate test assert type(con1.schema_.items._target) == Schema - resp2 = op.responses['default'] + resp2 = op.responses["default"] assert resp2.description == "unexpected error" assert len(resp2.content) == 1 - assert 'application/json' in resp2.content - con2 = resp2.content['application/json'] + assert "application/json" in resp2.content + con2 = resp2.content["application/json"] assert con2.schema_ is not None # again, test ref resolution elsewhere assert type(con2.schema_._target) == Schema @@ -100,9 +102,9 @@ def test_operation_populated(petstore_expanded_spec): def test_securityparameters(httpx_mock, with_securityparameters): api = OpenAPI(URLBASE, with_securityparameters, session_factory=httpx.Client) - httpx_mock.add_response(headers={"Content-Type":"application/json"}, content=b"[]") + httpx_mock.add_response(headers={"Content-Type": "application/json"}, content=b"[]") - auth=str(uuid.uuid4()) + auth = str(uuid.uuid4()) for i in api.paths.values(): if not i.post or not i.post.security: @@ -115,42 +117,41 @@ def test_securityparameters(httpx_mock, with_securityparameters): assert False with pytest.raises(ValueError, match="does not accept security scheme xAuth"): - api.authenticate('xAuth', auth) + api.authenticate("xAuth", auth) api._.api_v1_auth_login_info(data={}, parameters={}) - # global security - api.authenticate('cookieAuth', auth) + api.authenticate("cookieAuth", auth) api._.api_v1_auth_login_info(data={}, parameters={}) request = httpx_mock.get_requests()[-1] # path - api.authenticate('tokenAuth', auth) + api.authenticate("tokenAuth", auth) api._.api_v1_auth_login_create(data={}, parameters={}) request = httpx_mock.get_requests()[-1] - assert request.headers['Authorization'] == auth + assert request.headers["Authorization"] == auth - api.authenticate('paramAuth', auth) + api.authenticate("paramAuth", auth) api._.api_v1_auth_login_create(data={}, parameters={}) request = httpx_mock.get_requests()[-1] assert yarl.URL(str(request.url)).query["auth"] == auth - api.authenticate('cookieAuth', auth) + api.authenticate("cookieAuth", auth) api._.api_v1_auth_login_create(data={}, parameters={}) request = httpx_mock.get_requests()[-1] assert request.headers["Cookie"] == "Session=%s" % (auth,) - api.authenticate('basicAuth', (auth, auth)) + api.authenticate("basicAuth", (auth, auth)) api._.api_v1_auth_login_create(data={}, parameters={}) request = httpx_mock.get_requests()[-1] - assert request.headers["Authorization"].split(" ")[1] == base64.b64encode((auth + ':' + auth).encode()).decode() + assert request.headers["Authorization"].split(" ")[1] == base64.b64encode((auth + ":" + auth).encode()).decode() - api.authenticate('digestAuth', (auth,auth)) + api.authenticate("digestAuth", (auth, auth)) api._.api_v1_auth_login_create(data={}, parameters={}) request = httpx_mock.get_requests()[-1] # can't test? - api.authenticate('bearerAuth', auth) + api.authenticate("bearerAuth", auth) api._.api_v1_auth_login_create(data={}, parameters={}) request = httpx_mock.get_requests()[-1] assert request.headers["Authorization"] == "Bearer %s" % (auth,) @@ -168,7 +169,7 @@ def test_parameters(httpx_mock, with_parameters): api._.getTest(data={}, parameters={}) Header = str([i ** i for i in range(3)]) - api._.getTest(data={}, parameters={"Cookie":"Cookie", "Path":"Path", "Header":Header, "Query":"Query"}) + api._.getTest(data={}, parameters={"Cookie": "Cookie", "Path": "Path", "Header": Header, "Query": "Query"}) request = httpx_mock.get_requests()[-1] assert request.headers["Header"] == Header diff --git a/tests/ref_test.py b/tests/ref_test.py index 281dd1e..b960e36 100644 --- a/tests/ref_test.py +++ b/tests/ref_test.py @@ -12,42 +12,43 @@ from pydantic.main import ModelMetaclass + def test_ref_resolution(petstore_expanded_spec): """ Tests that $refs are resolved as we expect them to be """ - ref = petstore_expanded_spec.paths['/pets'].get.responses['default'].content['application/json'].schema_ + ref = petstore_expanded_spec.paths["/pets"].get.responses["default"].content["application/json"].schema_ assert type(ref._target) == Schema assert ref.type == "object" assert len(ref.properties) == 2 - assert 'code' in ref.properties - assert 'message' in ref.properties - assert ref.required == ['code','message'] + assert "code" in ref.properties + assert "message" in ref.properties + assert ref.required == ["code", "message"] - code = ref.properties['code'] - assert code.type == 'integer' - assert code.format == 'int32' + code = ref.properties["code"] + assert code.type == "integer" + assert code.format == "int32" - message = ref.properties['message'] - assert message.type == 'string' + message = ref.properties["message"] + assert message.type == "string" def test_allOf_resolution(petstore_expanded_spec): """ Tests that allOfs are resolved correctly """ - ref = petstore_expanded_spec.paths['/pets'].get.responses['200'].content['application/json'].schema_.get_type() + ref = petstore_expanded_spec.paths["/pets"].get.responses["200"].content["application/json"].schema_.get_type() assert type(ref) == ModelMetaclass assert typing.get_origin(ref.__fields__["__root__"].outer_type_) == list items = typing.get_args(ref.__fields__["__root__"].outer_type_)[0].__fields__ - assert sorted(map(lambda x: x.name, filter(lambda y: y.required==True, items.values()))) == sorted(["id","name"]) + assert sorted(map(lambda x: x.name, filter(lambda y: y.required == True, items.values()))) == sorted(["id", "name"]) - assert sorted(map(lambda x: x.name, items.values())) == ["id","name","tag"] + assert sorted(map(lambda x: x.name, items.values())) == ["id", "name", "tag"] - assert items['id'].outer_type_ == int - assert items['name'].outer_type_ == str + assert items["id"].outer_type_ == int + assert items["name"].outer_type_ == str assert items["tag"].outer_type_ == str diff --git a/tests/test_runtimexpression.py b/tests/test_runtimexpression.py index 9d6a1ef..afacee2 100644 --- a/tests/test_runtimexpression.py +++ b/tests/test_runtimexpression.py @@ -1,4 +1,3 @@ -import json import sys import httpx @@ -7,29 +6,30 @@ import tatsu from aiopenapi3.expression.grammar import loads -not310 = pytest.mark.skipif( - sys.version_info >= (3, 10, 0), reason="tatsu 5.7 requires 3.10, we rely on 5.6" -) +not310 = pytest.mark.skipif(sys.version_info >= (3, 10, 0), reason="tatsu 5.7 requires 3.10, we rely on 5.6") parse_testdata = [ "$url", "$method", "$statusCode", -# "$request.", + # "$request.", "$request.body#/url", ] + @not310 @pytest.mark.parametrize("data", parse_testdata) def test_parse(data): m = loads(data) assert m is not None + @not310 def test_parse_fail(): with pytest.raises(tatsu.exceptions.FailedParse): loads("$request.body#/~test") + @not310 def test_parse_escape(): loads("$request.body#/~0test") @@ -40,21 +40,20 @@ def test_parse_escape(): loads("$request.body#/test/~") - - get_testdata = { - "$url":"http://example.org/subscribe/myevent?queryUrl=http://clientdomain.com/stillrunning", - "$method":"POST", - "$request.path.eventType":"myevent", - "$request.query.queryUrl":"http://clientdomain.com/stillrunning", - "$request.header.content-Type":"application/json", - "$request.body#/failedUrl":"http://clientdomain.com/failed", - "$request.body#/successUrls/2":"http://clientdomain.com/medium", - "$response.header.Location":"http://example.org/subscription/1" , - "$request.body#/escaped~1content/2/~0/~1/y":"yes", - "$request.body#/escaped~0content/2/~1/~0/x":"no", + "$url": "http://example.org/subscribe/myevent?queryUrl=http://clientdomain.com/stillrunning", + "$method": "POST", + "$request.path.eventType": "myevent", + "$request.query.queryUrl": "http://clientdomain.com/stillrunning", + "$request.header.content-Type": "application/json", + "$request.body#/failedUrl": "http://clientdomain.com/failed", + "$request.body#/successUrls/2": "http://clientdomain.com/medium", + "$response.header.Location": "http://example.org/subscription/1", + "$request.body#/escaped~1content/2/~0/~1/y": "yes", + "$request.body#/escaped~0content/2/~1/~0/x": "no", } + @not310 @pytest.mark.parametrize("param, result", get_testdata.items()) def test_get(httpx_mock, param, result): @@ -64,18 +63,22 @@ def test_get(httpx_mock, param, result): "successUrls": [ "http://clientdomain.com/fast", "http://clientdomain.com/medium", - "http://clientdomain.com/slow" + "http://clientdomain.com/slow", ], "escaped/content": [0, {"~": {"/": {"y": "yes"}}}], - "escaped~content": [0, {"/": {"~": {"x": "no" }}}] + "escaped~content": [0, {"/": {"~": {"x": "no"}}}], } - httpx_mock.add_response(headers={"Location":"http://example.org/subscription/1"},) + httpx_mock.add_response( + headers={"Location": "http://example.org/subscription/1"}, + ) client = httpx.Client() - resp = client.post(url, json=data, headers={"Location":"http://example.org/subscription/1", "Content-Type":"application/json"}) + resp = client.post( + url, json=data, headers={"Location": "http://example.org/subscription/1", "Content-Type": "application/json"} + ) req = httpx_mock.get_requests()[-1] - req.path = {"eventType":"myevent"} + req.path = {"eventType": "myevent"} m = loads(param) r = m.eval(req, resp) From 1784965e12ff6534b992ec096d2991ba45bee269 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 3 Jan 2022 14:11:51 +0100 Subject: [PATCH 061/125] expression - remove --- .github/workflows/codecov.yml | 2 +- README.md | 6 +- pyproject.toml | 2 +- requirements.txt | 9 +-- setup.cfg | 36 +++++---- tests/fixtures/petstore-expanded.yaml | 4 +- tests/fixtures/with-parameters.yaml | 4 - tests/fixtures/with-securityparameters.yaml | 3 +- tests/test_runtimexpression.py | 85 --------------------- 9 files changed, 27 insertions(+), 124 deletions(-) delete mode 100644 tests/test_runtimexpression.py diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 6289ab0..e6566d6 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -32,4 +32,4 @@ jobs: flags: unittests name: codecov-aiopenapi3 path_to_write_report: ./coverage/codecov_report.txt - verbose: true \ No newline at end of file + verbose: true diff --git a/README.md b/README.md index 706063b..503b308 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ print(json.dumps(body.dict(exclude_unset=True), indent=2)) } ->>> +>>> new_linode = api._.createLinodeInstance(data=body) ``` @@ -178,7 +178,3 @@ of this project. ```shell PYTHONPATH=. pytest --cov=./ --cov-report=xml . ``` - - - - diff --git a/pyproject.toml b/pyproject.toml index 7fd26b9..fed528d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,3 @@ [build-system] requires = ["setuptools"] -build-backend = "setuptools.build_meta" \ No newline at end of file +build-backend = "setuptools.build_meta" diff --git a/requirements.txt b/requirements.txt index f875d08..af55b5c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,9 @@ -pydantic~=1.9.0a1 -starlette~=0.16.0 fastapi~=0.70.1 +httpx~=0.21.1 +hypercorn~=0.13.0 +pydantic~=1.9.0a1 pytest~=6.2.5 PyYAML~=6.0 -httpx~=0.21.1 +starlette~=0.16.0 uvloop~=0.16.0 -hypercorn~=0.13.0 yarl~=1.7.2 - diff --git a/setup.cfg b/setup.cfg index cfa70de..595ac60 100644 --- a/setup.cfg +++ b/setup.cfg @@ -8,31 +8,30 @@ long_description_content_type = text/markdown keywords = openapi openapi3 license = classifiers = - Development Status :: 3 - Alpha - Environment :: Web Environment - Framework :: AsyncIO - Intended Audience :: Developers - Intended Audience :: Information Technology - Intended Audience :: System Administrators + Development Status :: 3 - Alpha + Environment :: Web Environment + Framework :: AsyncIO + Intended Audience :: Developers + Intended Audience :: Information Technology + Intended Audience :: System Administrators License :: OSI Approved :: BSD License - Operating System :: OS Independent - Topic :: Internet - Topic :: Internet :: WWW/HTTP - Topic :: Software Development - Topic :: Software Development :: Libraries - Topic :: Software Development :: Libraries :: Application Frameworks - Topic :: Software Development :: Libraries :: Python Modules + Operating System :: OS Independent + Topic :: Internet + Topic :: Internet :: WWW/HTTP + Topic :: Software Development + Topic :: Software Development :: Libraries + Topic :: Software Development :: Libraries :: Application Frameworks + Topic :: Software Development :: Libraries :: Python Modules Typing :: Typed - Programming Language :: Python :: 3 - Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 [options] packages = aiopenapi3 - aiopenapi3.expression install_requires = PyYaml @@ -52,4 +51,3 @@ tests = tatsu expression = tatsu - diff --git a/tests/fixtures/petstore-expanded.yaml b/tests/fixtures/petstore-expanded.yaml index acd46d9..9037f74 100644 --- a/tests/fixtures/petstore-expanded.yaml +++ b/tests/fixtures/petstore-expanded.yaml @@ -138,12 +138,12 @@ components: NewPet: type: object required: - - name + - name properties: name: type: string tag: - type: string + type: string Error: type: object diff --git a/tests/fixtures/with-parameters.yaml b/tests/fixtures/with-parameters.yaml index 61bef22..346af47 100644 --- a/tests/fixtures/with-parameters.yaml +++ b/tests/fixtures/with-parameters.yaml @@ -56,7 +56,3 @@ components: required: True schema: type: string - - - - diff --git a/tests/fixtures/with-securityparameters.yaml b/tests/fixtures/with-securityparameters.yaml index a77befa..c969e68 100644 --- a/tests/fixtures/with-securityparameters.yaml +++ b/tests/fixtures/with-securityparameters.yaml @@ -20,7 +20,7 @@ paths: schema: $ref: '#/components/schemas/Login' description: '' - + /api/v1/auth/login/: post: operationId: api_v1_auth_login_create @@ -85,4 +85,3 @@ components: bearerAuth: type: http scheme: bearer - diff --git a/tests/test_runtimexpression.py b/tests/test_runtimexpression.py deleted file mode 100644 index afacee2..0000000 --- a/tests/test_runtimexpression.py +++ /dev/null @@ -1,85 +0,0 @@ -import sys - -import httpx -import pytest - -import tatsu -from aiopenapi3.expression.grammar import loads - -not310 = pytest.mark.skipif(sys.version_info >= (3, 10, 0), reason="tatsu 5.7 requires 3.10, we rely on 5.6") - -parse_testdata = [ - "$url", - "$method", - "$statusCode", - # "$request.", - "$request.body#/url", -] - - -@not310 -@pytest.mark.parametrize("data", parse_testdata) -def test_parse(data): - m = loads(data) - assert m is not None - - -@not310 -def test_parse_fail(): - with pytest.raises(tatsu.exceptions.FailedParse): - loads("$request.body#/~test") - - -@not310 -def test_parse_escape(): - loads("$request.body#/~0test") - loads("$request.body#/~1test") - with pytest.raises(tatsu.exceptions.FailedParse): - loads("$request.body#/~2test") - with pytest.raises(tatsu.exceptions.FailedParse): - loads("$request.body#/test/~") - - -get_testdata = { - "$url": "http://example.org/subscribe/myevent?queryUrl=http://clientdomain.com/stillrunning", - "$method": "POST", - "$request.path.eventType": "myevent", - "$request.query.queryUrl": "http://clientdomain.com/stillrunning", - "$request.header.content-Type": "application/json", - "$request.body#/failedUrl": "http://clientdomain.com/failed", - "$request.body#/successUrls/2": "http://clientdomain.com/medium", - "$response.header.Location": "http://example.org/subscription/1", - "$request.body#/escaped~1content/2/~0/~1/y": "yes", - "$request.body#/escaped~0content/2/~1/~0/x": "no", -} - - -@not310 -@pytest.mark.parametrize("param, result", get_testdata.items()) -def test_get(httpx_mock, param, result): - url = "http://example.org/subscribe/myevent?queryUrl=http://clientdomain.com/stillrunning" - data = { - "failedUrl": "http://clientdomain.com/failed", - "successUrls": [ - "http://clientdomain.com/fast", - "http://clientdomain.com/medium", - "http://clientdomain.com/slow", - ], - "escaped/content": [0, {"~": {"/": {"y": "yes"}}}], - "escaped~content": [0, {"/": {"~": {"x": "no"}}}], - } - - httpx_mock.add_response( - headers={"Location": "http://example.org/subscription/1"}, - ) - client = httpx.Client() - resp = client.post( - url, json=data, headers={"Location": "http://example.org/subscription/1", "Content-Type": "application/json"} - ) - - req = httpx_mock.get_requests()[-1] - req.path = {"eventType": "myevent"} - - m = loads(param) - r = m.eval(req, resp) - assert r == result From 6d60db748c52f51ed1b5f6f5b55c35d6d0f16847 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 3 Jan 2022 14:31:07 +0100 Subject: [PATCH 062/125] tests/linode - skip on github, the downloaded file is broken --- tests/linode_test.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/linode_test.py b/tests/linode_test.py index 46fe13e..39a86ea 100644 --- a/tests/linode_test.py +++ b/tests/linode_test.py @@ -1,9 +1,13 @@ +import os import asyncio from aiopenapi3 import OpenAPI import pytest +noci = pytest.mark.skipif(os.environ.get("GITHUB_ACTIONS", None) is not None, reason="fails on github") + + @pytest.fixture(scope="session") def event_loop(request): loop = asyncio.get_event_loop_policy().new_event_loop() @@ -17,12 +21,14 @@ async def api(): @pytest.mark.asyncio +@noci async def test_linode_components_schemas(api): for name, schema in api.components.schemas.items(): schema.get_type().construct() @pytest.mark.asyncio +@noci async def test_linode_return_values(api): for i in api._: call = getattr(api._, i) From 3fdf3692cae1f50621bc5ca06c0872dbb7d8d230 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 3 Jan 2022 14:39:39 +0100 Subject: [PATCH 063/125] setup.cfg - add url --- aiopenapi3/__init__.py | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aiopenapi3/__init__.py b/aiopenapi3/__init__.py index 4f09c43..4808601 100644 --- a/aiopenapi3/__init__.py +++ b/aiopenapi3/__init__.py @@ -3,5 +3,5 @@ from .errors import SpecError, ReferenceResolutionError -__version__ = "0.1.0" +__version__ = "0.1.1" __all__ = ["__version__", "OpenAPI", "FileSystemLoader", "SpecError", "ReferenceResolutionError"] diff --git a/setup.cfg b/setup.cfg index 595ac60..4075894 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = aiopenapi3 version = attr: aiopenapi3.__version__ - +url = https://github.com/commonism/aiopenapi3 description = OpenAPI3 3.0.3 client / validator based on pydantic & httpx long_description = file: README.md long_description_content_type = text/markdown From f4180bb583d9db73c529a9e020cc9db2f3e3e5e0 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 5 Jan 2022 13:49:02 +0100 Subject: [PATCH 064/125] request - provide .data for easy access --- aiopenapi3/request.py | 15 +++++++++++---- tests/model_test.py | 3 +-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/aiopenapi3/request.py b/aiopenapi3/request.py index 3c9b8b0..c1068a8 100644 --- a/aiopenapi3/request.py +++ b/aiopenapi3/request.py @@ -5,6 +5,8 @@ import yarl from .paths import SecurityRequirement +from .schemas import Schema +from .parameter import Parameter class RequestParameter: @@ -43,16 +45,21 @@ def __call__(self, *args, **kwargs): def security(self): return self.api._security + @property + def data(self) -> Schema: + return self.operation.requestBody.content["application/json"].schema_ + + @property + def parameters(self) -> Dict[str, Parameter]: + return self.operation.parameters + self.spec.paths[self.path].parameters + def args(self, content_type: str = "application/json"): op = self.operation parameters = op.parameters + self.spec.paths[self.path].parameters - schema = op.requestBody.content[content_type].schema_ - # if isinstance(schema, Reference): - # schema = schema._target return {"parameters": parameters, "data": schema} - def return_value(self, http_status=200, content_type="application/json"): + def return_value(self, http_status: int = 200, content_type: str = "application/json") -> Schema: return self.operation.responses[str(http_status)].content[content_type].schema_ def _prepare_security(self): diff --git a/tests/model_test.py b/tests/model_test.py index 71b3fc7..59dee5b 100644 --- a/tests/model_test.py +++ b/tests/model_test.py @@ -123,8 +123,7 @@ async def test_createPet(event_loop, server, client): assert type(r).schema() == client.components.schemas["Error"].get_type().schema() with pytest.raises(pydantic.ValidationError): - args = client._.createPet.args() - cls = args["data"].get_type() + cls = client._.createPet.data.get_type() cls() From 7d7356de778e9808bb92bef23d80fc871bca741e Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 5 Jan 2022 14:50:19 +0100 Subject: [PATCH 065/125] plugin - dealing with real world implementations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit modify - the document description text/parsed - send - receive data/text/object/… similar to suds plugins --- aiopenapi3/plugin.py | 133 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 aiopenapi3/plugin.py diff --git a/aiopenapi3/plugin.py b/aiopenapi3/plugin.py new file mode 100644 index 0000000..7fabc67 --- /dev/null +++ b/aiopenapi3/plugin.py @@ -0,0 +1,133 @@ +import dataclasses +from typing import List, Any, Dict +from pydantic import BaseModel + +""" +the plugin interface replicates the suds way of dealing with broken data/schema information +""" + + +class Plugin: + pass + + +class Init(Plugin): + @dataclasses.dataclass + class Context: + initialized: "OpenAPISpec" + + def initialized(self, ctx: "Init.Context") -> "Init.Context": + return ctx + + +class Document(Plugin): + @dataclasses.dataclass + class Context: + url: str + document: Dict[str, Any] + + """ + loaded(text) -> parsed(dict) + """ + + def loaded(self, ctx: "Document.Context") -> "Document.Context": + """modify the text before parsing""" + return ctx + + def parsed(self, ctx: "Document.Context") -> "Document.Context": + """modify the parsed dict before …""" + return ctx + + +class Message(Plugin): + @dataclasses.dataclass + class Context: + operationId: str + marshalled: Dict[str, Any] = None + sending: str = None + received: str = None + parsed: Dict[str, Any] = None + unmarshalled: BaseModel = None + + """ + sending: marshalled(dict)-> sending(str) + + receiving: received -> parsed -> unmarshalled + """ + + def marshalled(self, ctx: "Message.Context") -> "Message.Context": + """ + modify the dict before sending + """ + return ctx + + def sending(self, ctx: "Message.Context") -> "Message.Context": + """ + modify the text before sending + """ + return ctx + + def received(self, ctx: "Message.Context") -> "Message.Context": + """ + modify the received text + """ + return ctx + + def parsed(self, ctx: "Message.Context") -> "Message.Context": + """ + modify the parsed dict structure + """ + return ctx + + def unmarshalled(self, ctx: "Message.Context") -> "Message.Context": + """ + modify the object + """ + return ctx + + +class Domain: + def __init__(self, ctx, plugins: List[Plugin]): + self.Context = ctx + self.plugins = plugins + + def __getattr__(self, name: str) -> "Method": + return Method(name, self) + + +class Method: + def __init__(self, name: str, domain: Domain): + self.name = name + self.domain = domain + + def __call__(self, **kwargs): + r = self.domain.Context(**kwargs) + for plugin in self.domain.plugins: + if (method := getattr(plugin, self.name, None)) is None: + continue + method(r) + return r + + +class Plugins: + _domains: Dict[str, Plugin] = {"init": Init, "document": Document, "message": Message} + + def __init__(self, plugins: List[Plugin]): + for i in self._domains.keys(): + setattr(self, f"_{i}", self._get_domain(i, plugins)) + + def _get_domain(self, name, plugins) -> "Domain": + plugins = [p for p in filter(lambda x: isinstance(x, self._domains.get(name)), plugins)] + return Domain(self._domains.get(name).Context, plugins) + + @property + def init(self) -> Domain: + return self._init + + @property + def document(self) -> Domain: + return self._document + + @property + def message(self) -> "Domain": + return self._message From 1a7beb4a857a1c42991497c7f5ccb871e6cfd754 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 5 Jan 2022 15:15:48 +0100 Subject: [PATCH 066/125] plugins - wire into loader & request --- aiopenapi3/loader.py | 22 ++++++-- aiopenapi3/openapi.py | 53 ++++++++++++++++--- aiopenapi3/request.py | 45 +++++++++++----- tests/loader_test.py | 4 +- tests/parse_data_test.py | 13 ++--- tests/plugin_test.py | 110 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 213 insertions(+), 34 deletions(-) create mode 100644 tests/plugin_test.py diff --git a/aiopenapi3/loader.py b/aiopenapi3/loader.py index 6a36642..97f9991 100644 --- a/aiopenapi3/loader.py +++ b/aiopenapi3/loader.py @@ -2,12 +2,14 @@ import json from pathlib import Path +from .plugin import Plugins + import yaml class Loader(abc.ABC): @abc.abstractmethod - def load(self, name: str): + def load(self, plugins, file: Path, codec=None): raise NotImplementedError("load") @classmethod @@ -27,7 +29,7 @@ def decode(cls, data, codec): return data @classmethod - def dict(cls, file, data): + def parse(cls, plugins, file, data): if file.suffix == ".yaml": data = yaml.safe_load(data) elif file.suffix == ".json": @@ -36,16 +38,28 @@ def dict(cls, file, data): raise ValueError(file.name) return data + def get(self, plugins, file): + data = self.load(plugins, file) + return self.parse(plugins, file, data) + class FileSystemLoader(Loader): def __init__(self, base: Path): assert isinstance(base, Path) self.base = base - def load(self, file: str, codec=None): + def load(self, plugins: Plugins, file: Path, codec=None): + assert plugins + assert isinstance(file, Path) path = self.base / file assert path.is_relative_to(self.base) data = path.open("rb").read() data = self.decode(data, codec) - data = self.dict(path, data) + data = plugins.document.loaded(url=str(file), document=data).document + return data + + @classmethod + def parse(cls, plugins, file, data): + data = Loader.parse(plugins, file, data) + data = plugins.document.parsed(url=str(file), document=data).document return data diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index 3e81c90..b08027d 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -18,6 +18,7 @@ from .tag import Tag from .request import Request, AsyncRequest from .loader import Loader +from .plugin import Plugin, Plugins HTTP_METHODS = frozenset(["get", "delete", "head", "post", "put", "patch", "trace"]) @@ -44,20 +45,48 @@ def servers(self): return self._spec.servers @classmethod - def load_sync(cls, url, session_factory: Callable[[], httpx.Client] = httpx.Client, loader=None): + def load_sync( + cls, url, session_factory: Callable[[], httpx.Client] = httpx.Client, loader=None, plugins: List[Plugin] = None + ): resp = session_factory().get(url) - return cls.loads(url, resp.text, session_factory, loader) + return cls.loads(url, resp.text, session_factory, loader, plugins) @classmethod - async def load_async(cls, url, session_factory: Callable[[], httpx.AsyncClient] = httpx.AsyncClient, loader=None): + async def load_async( + cls, + url, + session_factory: Callable[[], httpx.AsyncClient] = httpx.AsyncClient, + loader=None, + plugins: List[Plugin] = None, + ): async with session_factory() as client: resp = await client.get(url) - return cls.loads(url, resp.text, session_factory, loader) + return cls.loads(url, resp.text, session_factory, loader, plugins) + + @classmethod + def load_file( + cls, + url, + path, + session_factory: Callable[[], httpx.AsyncClient] = httpx.AsyncClient, + loader=None, + plugins: List[Plugin] = None, + ): + assert loader + data = loader.load(Plugins(plugins or []), path) + return cls.loads(url, data, session_factory, loader, plugins) @classmethod - def loads(cls, url, data, session_factory: Callable[[], httpx.AsyncClient] = httpx.AsyncClient, loader=None): - data = Loader.dict(pathlib.Path(url), data) - return cls(url, data, session_factory, loader) + def loads( + cls, + url, + data, + session_factory: Callable[[], httpx.AsyncClient] = httpx.AsyncClient, + loader=None, + plugins: List[Plugin] = None, + ): + data = Loader.parse(Plugins(plugins or []), pathlib.Path(url), data) + return cls(url, data, session_factory, loader, plugins) def __init__( self, @@ -65,6 +94,7 @@ def __init__( raw_document, session_factory: Callable[[], Union[httpx.Client, httpx.AsyncClient]] = httpx.AsyncClient, loader=None, + plugins: List[Plugin] = None, ): """ Creates a new OpenAPI document from a loaded spec file. This is @@ -83,9 +113,14 @@ def __init__( self._security: List[str] = None self._cached: Dict[str, "OpenAPISpec"] = dict() + self.plugins = Plugins(plugins or []) + raw_document = self.plugins.document.parsed(url=url, document=raw_document).document self._spec = OpenAPISpec.parse_obj(raw_document) + self._spec._resolve_references(self) + for i in list(self._cached.values()): + i._resolve_references(self) for name, schema in self.components.schemas.items(): schema._identity = name @@ -114,6 +149,8 @@ def test_operation(operation_id): if isinstance(content.schema_, Schema): content.schema_._identity = f"{path}.{m}.{r}.{c}" + self.plugins.init.initialized(initialized=self._spec) + @property def url(self): return self._base_url.join(yarl.URL(self._spec.servers[0].url)) @@ -137,7 +174,7 @@ def authenticate(self, security_scheme, value): self._security = {security_scheme: value} def _load(self, i): - data = self.loader.load(i) + data = self.loader.get(self.plugins, i) return OpenAPISpec.parse_obj(data) @property diff --git a/aiopenapi3/request.py b/aiopenapi3/request.py index c1068a8..9df3f8e 100644 --- a/aiopenapi3/request.py +++ b/aiopenapi3/request.py @@ -1,7 +1,8 @@ import json -from typing import List +from typing import List, Dict import httpx +import pydantic import yarl from .paths import SecurityRequirement @@ -148,10 +149,19 @@ def _prepare_body(self, data): raise ValueError("Request Body is required but none was provided.") if "application/json" in self.operation.requestBody.content: - if not isinstance(data, (dict, list)): + if isinstance(data, (dict, list)): + pass + elif isinstance(data, pydantic.BaseModel): + data = data.dict() + else: raise TypeError(data) - body = json.dumps(data) - self.req.content = body.encode() + data = self.api.plugins.message.marshalled( + operationId=self.operation.operationId, marshalled=data + ).marshalled + data = json.dumps(data) + data = data.encode() + data = self.api.plugins.message.sending(operationId=self.operation.operationId, sending=data).sending + self.req.content = data self.req.headers["Content-Type"] = "application/json" else: raise NotImplementedError() @@ -161,7 +171,8 @@ def _prepare(self, data, parameters): self._prepare_parameters(parameters) self._prepare_body(data) - req = httpx.Request( + def _build_req(self, session): + req = session.build_request( self.method, str(self.api.url / self.req.url[1:]), headers=self.req.headers, @@ -213,7 +224,15 @@ def _process(self, result): ) if content_type.lower() == "application/json": - return expected_media.schema_.model(result.json()) + data = result.text + data = self.api.plugins.message.received(operationId=self.operation.operationId, received=data).received + data = json.loads(data) + data = self.api.plugins.message.parsed(operationId=self.operation.operationId, parsed=data).parsed + data = expected_media.schema_.model(data) + data = self.api.plugins.message.unmarshalled( + operationId=self.operation.operationId, unmarshalled=data + ).unmarshalled + return data else: raise NotImplementedError() @@ -227,9 +246,10 @@ def request(self, data=None, parameters=None): :type parameters: dict{str: str} """ - req = self._prepare(data, parameters) - - result = self.api._session_factory(auth=self.req.auth).send(req) + self._prepare(data, parameters) + session = self.api._session_factory(auth=self.req.auth) + req = self._build_req(session) + result = session.send(req) return self._process(result) @@ -238,9 +258,10 @@ async def __call__(self, *args, **kwargs): return await self.request(*args, **kwargs) async def request(self, data=None, parameters=None): - req = self._prepare(data, parameters) - async with self.api._session_factory(auth=self.req.auth) as client: - result = await client.send(req) + self._prepare(data, parameters) + async with self.api._session_factory(auth=self.req.auth) as session: + req = self._build_req(session) + result = await session.send(req) return self._process(result) diff --git a/tests/loader_test.py b/tests/loader_test.py index 263fe01..f6deeb9 100644 --- a/tests/loader_test.py +++ b/tests/loader_test.py @@ -3,7 +3,7 @@ import pytest from aiopenapi3 import OpenAPI, FileSystemLoader, ReferenceResolutionError -from aiopenapi3.loader import Loader +from aiopenapi3.loader import Loader, Plugins SPECTPL = """ openapi: "3.0.0" @@ -60,6 +60,6 @@ def test_loader_format(): spec = SPECTPL.format(**values) api = OpenAPI.loads("loader.yaml", spec) - spec = Loader.dict(Path("loader.yaml"), spec) + spec = Loader.parse(Plugins([]), Path("loader.yaml"), spec) spec = json.dumps(spec) api = OpenAPI.loads("loader.json", spec) diff --git a/tests/parse_data_test.py b/tests/parse_data_test.py index 0f1c1f8..063513f 100644 --- a/tests/parse_data_test.py +++ b/tests/parse_data_test.py @@ -2,7 +2,9 @@ from aiopenapi3 import FileSystemLoader, OpenAPI import pathlib -URLBASE = "http://127.1.1.1/open5gs" +import yarl + +URLBASE = yarl.URL("http://127.1.1.1/open5gs/") def pytest_generate_tests(metafunc): @@ -28,12 +30,7 @@ class TestParseData: } def test_data(self, dir, file): - loader = FileSystemLoader(pathlib.Path(dir)) - data = loader.load(pathlib.Path(file).name) - spec = OpenAPI(URLBASE, data, loader=loader) + OpenAPI.load_file(str(URLBASE / file), pathlib.Path(file), loader=FileSystemLoader(pathlib.Path(dir))) def test_data_open5gs(self, dir, file): - loader = FileSystemLoader(pathlib.Path(dir)) - data = loader.load(pathlib.Path(file).name, codec="utf-8") - # if "servers" in "data": - spec = OpenAPI(URLBASE, data, loader=loader) + OpenAPI.load_file(str(URLBASE / file), pathlib.Path(file), loader=FileSystemLoader(pathlib.Path(dir))) diff --git a/tests/plugin_test.py b/tests/plugin_test.py new file mode 100644 index 0000000..b580f29 --- /dev/null +++ b/tests/plugin_test.py @@ -0,0 +1,110 @@ +import httpx +from pathlib import Path + +import yarl + +from aiopenapi3 import FileSystemLoader, OpenAPI +from aiopenapi3.plugin import Init, Message, Document + + +class OnInit(Init): + def initialized(self, ctx): + ctx.initialized.paths["/pets"].get.operationId = "listPets" + return ctx + + +class OnDocument(Document): + def loaded(self, ctx): + return ctx + + def parsed(self, ctx): + if ctx.url == "test.yaml": + ctx.document["components"] = { + "schemas": {"Pet": {"$ref": "petstore-expanded.yaml#/components/schemas/Pet"}} + } + ctx.document["servers"] = [{"url": "/"}] + elif ctx.url == "petstore-expanded.yaml": + + ctx.document["components"]["schemas"]["Pet"]["allOf"].append( + { + "type": "object", + "required": ["color"], + "properties": { + "color": {"type": "string", "default": "blue"}, + "weight": {"type": "integer", "default": 10}, + }, + } + ) + else: + raise ValueError(ctx.url) + + return ctx + + +class OnMessage(Message): + def marshalled(self, ctx): + return ctx + + def sending(self, ctx): + return ctx + + def received(self, ctx): + ctx.received = """[{"id":1,"name":"theanimal"}]""" + return ctx + + def parsed(self, ctx): + if ctx.operationId == "listPets": + if ctx.parsed[0].get("color", None) is None: + ctx.parsed[0]["color"] = "red" + + if ctx.parsed[0]["id"] == 1: + ctx.parsed[0]["id"] = 2 + return ctx + + def unmarshalled(self, ctx): + if ctx.operationId == "listPets": + if ctx.unmarshalled[0].id == 2: + ctx.unmarshalled[0].id = 3 + return ctx + + +SPEC = """ +openapi: 3.0.3 +info: + title: '' + version: 0.0.0 +paths: + /pets: + get: + description: '' + operationId: xPets + responses: + '200': + description: pet response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' +""" + + +def test_Plugins(httpx_mock): + httpx_mock.add_response(headers={"Content-Type": "application/json"}, content=b"[]") + plugins = [OnInit(), OnDocument(), OnMessage()] + api = OpenAPI.loads( + "test.yaml", + SPEC, + plugins=plugins, + loader=FileSystemLoader(Path().cwd() / "tests/fixtures"), + session_factory=httpx.Client, + ) + api._base_url = yarl.URL("http://127.0.0.1:80") + r = api._.listPets() + assert r + + item = r[0] + assert item.id == 3 + assert item.weight == None # default does not apply as it it unsed + assert item.color == "red" # default does not apply From 5a172d87cda66ab2f91e35842cb1a8054890d401 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 5 Jan 2022 16:54:04 +0100 Subject: [PATCH 067/125] Request/Plugin - coverage --- aiopenapi3/plugin.py | 32 ++++++++++++++++---------------- tests/model_test.py | 6 ++++++ 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/aiopenapi3/plugin.py b/aiopenapi3/plugin.py index 7fabc67..afcf140 100644 --- a/aiopenapi3/plugin.py +++ b/aiopenapi3/plugin.py @@ -16,8 +16,8 @@ class Init(Plugin): class Context: initialized: "OpenAPISpec" - def initialized(self, ctx: "Init.Context") -> "Init.Context": - return ctx + def initialized(self, ctx: "Init.Context") -> "Init.Context": # pragma: no cover + pass class Document(Plugin): @@ -30,13 +30,13 @@ class Context: loaded(text) -> parsed(dict) """ - def loaded(self, ctx: "Document.Context") -> "Document.Context": + def loaded(self, ctx: "Document.Context") -> "Document.Context": # pragma: no cover """modify the text before parsing""" - return ctx + pass - def parsed(self, ctx: "Document.Context") -> "Document.Context": + def parsed(self, ctx: "Document.Context") -> "Document.Context": # pragma: no cover """modify the parsed dict before …""" - return ctx + pass class Message(Plugin): @@ -55,35 +55,35 @@ class Context: receiving: received -> parsed -> unmarshalled """ - def marshalled(self, ctx: "Message.Context") -> "Message.Context": + def marshalled(self, ctx: "Message.Context") -> "Message.Context": # pragma: no cover """ modify the dict before sending """ - return ctx + pass - def sending(self, ctx: "Message.Context") -> "Message.Context": + def sending(self, ctx: "Message.Context") -> "Message.Context": # pragma: no cover """ modify the text before sending """ - return ctx + pass - def received(self, ctx: "Message.Context") -> "Message.Context": + def received(self, ctx: "Message.Context") -> "Message.Context": # pragma: no cover """ modify the received text """ - return ctx + pass - def parsed(self, ctx: "Message.Context") -> "Message.Context": + def parsed(self, ctx: "Message.Context") -> "Message.Context": # pragma: no cover """ modify the parsed dict structure """ - return ctx + pass - def unmarshalled(self, ctx: "Message.Context") -> "Message.Context": + def unmarshalled(self, ctx: "Message.Context") -> "Message.Context": # pragma: no cover """ modify the object """ - return ctx + pass class Domain: diff --git a/tests/model_test.py b/tests/model_test.py index 59dee5b..60f3115 100644 --- a/tests/model_test.py +++ b/tests/model_test.py @@ -110,6 +110,12 @@ def randomPet(client, name=None): @pytest.mark.asyncio async def test_createPet(event_loop, server, client): + + client._.createPet.data + client._.createPet.parameters + client._.createPet.args() + client._.createPet.return_value() + data = { "pet": client.components.schemas["WhiteCat"] .model({"name": str(uuid.uuid4()), "white_name": str(uuid.uuid4())}) From 36c16ab8c3897c6d5582c6a0f0ec1dae92cb0cb2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Jan 2022 19:47:20 +0000 Subject: [PATCH 068/125] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/hadialqattan/pycln: v0.0.4 → v1.1.0](https://github.com/hadialqattan/pycln/compare/v0.0.4...v1.1.0) - [github.com/psf/black: 21.6b0 → 21.12b0](https://github.com/psf/black/compare/21.6b0...21.12b0) - [github.com/pre-commit/pre-commit-hooks: v4.0.1 → v4.1.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.0.1...v4.1.0) --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b2d9000..7543182 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,16 +1,16 @@ repos: - repo: https://github.com/hadialqattan/pycln - rev: v0.0.4 # Possible releases: https://github.com/hadialqattan/pycln/releases + rev: v1.1.0 # Possible releases: https://github.com/hadialqattan/pycln/releases hooks: - id: pycln - repo: 'https://github.com/psf/black' - rev: 21.6b0 + rev: 21.12b0 hooks: - id: black args: - "--line-length=120" - repo: 'https://github.com/pre-commit/pre-commit-hooks' - rev: v4.0.1 + rev: v4.1.0 hooks: - id: end-of-file-fixer exclude: '^docs/[^/]*\.svg$' From 2d7785a1f46dde7e080eea62ead16ff5e6724b68 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Thu, 6 Jan 2022 16:37:30 +0100 Subject: [PATCH 069/125] validator - use load_file --- tests/model_test.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/model_test.py b/tests/model_test.py index 60f3115..e1d5f8c 100644 --- a/tests/model_test.py +++ b/tests/model_test.py @@ -109,13 +109,15 @@ def randomPet(client, name=None): @pytest.mark.asyncio -async def test_createPet(event_loop, server, client): - +async def test_Request(event_loop, server, client): client._.createPet.data client._.createPet.parameters client._.createPet.args() client._.createPet.return_value() + +@pytest.mark.asyncio +async def test_createPet(event_loop, server, client): data = { "pet": client.components.schemas["WhiteCat"] .model({"name": str(uuid.uuid4()), "white_name": str(uuid.uuid4())}) From 659c3e359e6bf5125b15354d7ef0c12721c0d4bd Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Thu, 6 Jan 2022 16:52:38 +0100 Subject: [PATCH 070/125] request - set user-agent --- aiopenapi3/__init__.py | 2 +- aiopenapi3/request.py | 8 ++++++-- aiopenapi3/version.py | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 aiopenapi3/version.py diff --git a/aiopenapi3/__init__.py b/aiopenapi3/__init__.py index 4808601..c0776cf 100644 --- a/aiopenapi3/__init__.py +++ b/aiopenapi3/__init__.py @@ -1,7 +1,7 @@ from .openapi import OpenAPI from .loader import FileSystemLoader from .errors import SpecError, ReferenceResolutionError +from .version import __version__ -__version__ = "0.1.1" __all__ = ["__version__", "OpenAPI", "FileSystemLoader", "SpecError", "ReferenceResolutionError"] diff --git a/aiopenapi3/request.py b/aiopenapi3/request.py index 9df3f8e..3e95f8d 100644 --- a/aiopenapi3/request.py +++ b/aiopenapi3/request.py @@ -8,6 +8,7 @@ from .paths import SecurityRequirement from .schemas import Schema from .parameter import Parameter +from .version import __version__ class RequestParameter: @@ -63,6 +64,9 @@ def args(self, content_type: str = "application/json"): def return_value(self, http_status: int = 200, content_type: str = "application/json") -> Schema: return self.operation.responses[str(http_status)].content[content_type].schema_ + def _factory_args(self): + return {"auth": self.req.auth, "headers": {"user-agent": f"aiopenapi3/{__version__}"}} + def _prepare_security(self): if self.security and self.operation.security: for scheme, value in self.security.items(): @@ -247,7 +251,7 @@ def request(self, data=None, parameters=None): """ self._prepare(data, parameters) - session = self.api._session_factory(auth=self.req.auth) + session = self.api._session_factory(**self._factory_args()) req = self._build_req(session) result = session.send(req) return self._process(result) @@ -260,7 +264,7 @@ async def __call__(self, *args, **kwargs): async def request(self, data=None, parameters=None): self._prepare(data, parameters) - async with self.api._session_factory(auth=self.req.auth) as session: + async with self.api._session_factory(**self._factory_args()) as session: req = self._build_req(session) result = await session.send(req) diff --git a/aiopenapi3/version.py b/aiopenapi3/version.py new file mode 100644 index 0000000..485f44a --- /dev/null +++ b/aiopenapi3/version.py @@ -0,0 +1 @@ +__version__ = "0.1.1" From b6b0009308d3857166b96d8be922e3eca1371acc Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Thu, 6 Jan 2022 16:53:10 +0100 Subject: [PATCH 071/125] validator - use load_file --- aiopenapi3/__main__.py | 6 ++---- aiopenapi3/loader.py | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/aiopenapi3/__main__.py b/aiopenapi3/__main__.py index ba2ff71..1595ca4 100644 --- a/aiopenapi3/__main__.py +++ b/aiopenapi3/__main__.py @@ -1,5 +1,4 @@ import sys -import yaml from pathlib import Path from .openapi import OpenAPI @@ -9,10 +8,9 @@ def main(): name = sys.argv[1] - loader = FileSystemLoader(Path().cwd()) - spec = loader.load(name) + try: - OpenAPI(name, spec, loader=loader) + OpenAPI.load_file(name, Path(name), loader=FileSystemLoader(Path().cwd())) except ValueError as e: print(e) else: diff --git a/aiopenapi3/loader.py b/aiopenapi3/loader.py index 97f9991..a47c24c 100644 --- a/aiopenapi3/loader.py +++ b/aiopenapi3/loader.py @@ -35,7 +35,7 @@ def parse(cls, plugins, file, data): elif file.suffix == ".json": data = json.loads(data) else: - raise ValueError(file.name) + raise ValueError(f"{file.name} is not yaml/json") return data def get(self, plugins, file): From e94b15cda44e49ec4a463e08645fede41cb7b9e0 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Fri, 7 Jan 2022 13:06:26 +0100 Subject: [PATCH 072/125] v3.1 - adding support --- aiopenapi3/__init__.py | 2 +- aiopenapi3/base.py | 168 ++++++++++++++++++ aiopenapi3/json.py | 29 ++++ aiopenapi3/model.py | 124 +++++++++++++ aiopenapi3/object_base.py | 39 ----- aiopenapi3/openapi.py | 268 ++++++++--------------------- aiopenapi3/request.py | 73 ++++++-- aiopenapi3/schemas.py | 243 -------------------------- aiopenapi3/v30/__init__.py | 4 + aiopenapi3/{ => v30}/components.py | 7 +- aiopenapi3/{ => v30}/example.py | 3 +- aiopenapi3/{ => v30}/general.py | 24 +-- aiopenapi3/{ => v30}/info.py | 2 +- aiopenapi3/{ => v30}/media.py | 3 +- aiopenapi3/{ => v30}/parameter.py | 10 +- aiopenapi3/{ => v30}/paths.py | 57 +----- aiopenapi3/v30/root.py | 36 ++++ aiopenapi3/v30/schemas.py | 98 +++++++++++ aiopenapi3/{ => v30}/security.py | 35 +++- aiopenapi3/{ => v30}/servers.py | 2 +- aiopenapi3/{ => v30}/tag.py | 2 +- aiopenapi3/{ => v30}/xml.py | 0 aiopenapi3/v31/__init__.py | 5 + aiopenapi3/v31/components.py | 35 ++++ aiopenapi3/v31/example.py | 18 ++ aiopenapi3/v31/general.py | 8 + aiopenapi3/v31/info.py | 36 ++++ aiopenapi3/v31/media.py | 42 +++++ aiopenapi3/v31/parameter.py | 51 ++++++ aiopenapi3/v31/paths.py | 72 ++++++++ aiopenapi3/v31/root.py | 45 +++++ aiopenapi3/v31/schemas.py | 149 ++++++++++++++++ aiopenapi3/v31/security.py | 1 + aiopenapi3/v31/servers.py | 39 +++++ aiopenapi3/v31/tag.py | 1 + aiopenapi3/v31/xml.py | 1 + aiopenapi3/version.py | 2 +- setup.cfg | 5 +- tests/model_test.py | 9 +- tests/path_test.py | 2 +- tests/ref_test.py | 6 +- 41 files changed, 1160 insertions(+), 596 deletions(-) create mode 100644 aiopenapi3/base.py create mode 100644 aiopenapi3/json.py create mode 100644 aiopenapi3/model.py delete mode 100644 aiopenapi3/object_base.py delete mode 100644 aiopenapi3/schemas.py create mode 100644 aiopenapi3/v30/__init__.py rename aiopenapi3/{ => v30}/components.py (89%) rename aiopenapi3/{ => v30}/example.py (93%) rename aiopenapi3/{ => v30}/general.py (71%) rename aiopenapi3/{ => v30}/info.py (96%) rename aiopenapi3/{ => v30}/media.py (97%) rename aiopenapi3/{ => v30}/parameter.py (94%) rename aiopenapi3/{ => v30}/paths.py (74%) create mode 100644 aiopenapi3/v30/root.py create mode 100644 aiopenapi3/v30/schemas.py rename aiopenapi3/{ => v30}/security.py (69%) rename aiopenapi3/{ => v30}/servers.py (95%) rename aiopenapi3/{ => v30}/tag.py (92%) rename aiopenapi3/{ => v30}/xml.py (100%) create mode 100644 aiopenapi3/v31/__init__.py create mode 100644 aiopenapi3/v31/components.py create mode 100644 aiopenapi3/v31/example.py create mode 100644 aiopenapi3/v31/general.py create mode 100644 aiopenapi3/v31/info.py create mode 100644 aiopenapi3/v31/media.py create mode 100644 aiopenapi3/v31/parameter.py create mode 100644 aiopenapi3/v31/paths.py create mode 100644 aiopenapi3/v31/root.py create mode 100644 aiopenapi3/v31/schemas.py create mode 100644 aiopenapi3/v31/security.py create mode 100644 aiopenapi3/v31/servers.py create mode 100644 aiopenapi3/v31/tag.py create mode 100644 aiopenapi3/v31/xml.py diff --git a/aiopenapi3/__init__.py b/aiopenapi3/__init__.py index c0776cf..5cb2cbd 100644 --- a/aiopenapi3/__init__.py +++ b/aiopenapi3/__init__.py @@ -1,7 +1,7 @@ +from .version import __version__ from .openapi import OpenAPI from .loader import FileSystemLoader from .errors import SpecError, ReferenceResolutionError -from .version import __version__ __all__ = ["__version__", "OpenAPI", "FileSystemLoader", "SpecError", "ReferenceResolutionError"] diff --git a/aiopenapi3/base.py b/aiopenapi3/base.py new file mode 100644 index 0000000..dab199c --- /dev/null +++ b/aiopenapi3/base.py @@ -0,0 +1,168 @@ +from typing import Optional + +from pydantic import BaseModel, Field, root_validator, Extra + +HTTP_METHODS = frozenset(["get", "delete", "head", "post", "put", "patch", "trace"]) + + +class ObjectBase(BaseModel): + """ + The base class for all schema objects. Includes helpers for common schema- + related functions. + """ + + class Config: + underscore_attrs_are_private = True + arbitrary_types_allowed = False + extra = Extra.forbid + + +class ObjectExtended(ObjectBase): + extensions: Optional[object] = Field(default=None) + + @root_validator(pre=True) + def validate_ObjectExtended_extensions(cls, values): + """FIXME + https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#specification-extensions + :param values: + :return: values + """ + e = dict() + for k, v in values.items(): + if k.startswith("x-"): + e[k[2:]] = v + if len(e): + for i in e.keys(): + del values[f"x-{i}"] + if "extensions" in values.keys(): + raise ValueError("extensions") + values["extensions"] = e + + return values + + +from .json import JSONPointer +from .errors import ReferenceResolutionError +import datetime + + +class RootBase: + @staticmethod + def resolve(api, root, obj, _PathItem, _Reference): + if isinstance(obj, ObjectBase): + for slot in filter(lambda x: not x.startswith("_"), obj.__fields_set__): + value = getattr(obj, slot) + if value is None: + continue + + if isinstance(obj, _PathItem) and slot == "ref": + ref = _Reference.construct(ref=value) + ref._target = api.resolve_jr(root, obj, ref) + setattr(obj, slot, ref) + + value = getattr(obj, slot) + if isinstance(value, _Reference): + value._target = api.resolve_jr(root, obj, value) + # setattr(obj, slot, resolved_value) + elif issubclass(type(value), ObjectBase): + # otherwise, continue resolving down the tree + RootBase.resolve(api, root, value, _PathItem, _Reference) + elif isinstance(value, dict): # pydantic does not use Map + RootBase.resolve(api, root, value, _PathItem, _Reference) + elif isinstance(value, list): + # if it's a list, resolve its item's references + for item in value: + if isinstance(item, _Reference): + item._target = api.resolve_jr(root, obj, item) + elif isinstance(item, (ObjectBase, dict, list)): + RootBase.resolve(api, root, item, _PathItem, _Reference) + elif isinstance(value, (str, int, float, datetime.datetime)): + continue + else: + raise TypeError(type(value)) + elif isinstance(obj, dict): + for k, v in obj.items(): + if isinstance(v, _Reference): + if v.ref: + v._target = api.resolve_jr(root, obj, v) + elif isinstance(v, (ObjectBase, dict, list)): + RootBase.resolve(api, root, v, _PathItem, _Reference) + + def _resolve_references(self, api): + """ + Resolves all reference objects below this object and notes their original + value was a reference. + """ + # don't circular import + + root = self + + RootBase.resolve(api, self, self, None, None) + raise NotImplementedError("specific") + + def resolve_jp(self, jp): + """ + Given a $ref path, follows the document tree and returns the given attribute. + + :param jp: The path down the spec tree to follow + :type jp: str #/foo/bar + + :returns: The node requested + :rtype: ObjectBase + :raises ValueError: if the given path is not valid + """ + path = jp.split("/")[1:] + node = self + + for part in path: + part = JSONPointer.decode(part) + if isinstance(node, dict): + if part not in node: # pylint: disable=unsupported-membership-test + raise ReferenceResolutionError(f"Invalid path {path} in Reference") + node = node.get(part) + else: + if not hasattr(node, part): + raise ReferenceResolutionError(f"Invalid path {path} in Reference") + node = getattr(node, part) + + return node + + +from typing import List, Dict +from .model import Model + + +class DiscriminatorBase: + pass + + +class SchemaBase: + # @lru_cache + def get_type(self, names: List[str] = None, discriminators: List[DiscriminatorBase] = None): + return Model.from_schema(self, names, discriminators) + + def model(self, data: Dict): + """ + Generates a model representing this schema from the given data. + + :param data: The data to create the model from. Should match this schema. + :type data: dict + + :returns: A new :any:`Model` created in this Schema's type from the data. + :rtype: self.get_type() + """ + if self.type in ("string", "number"): + assert len(self.properties) == 0 + # more simple types + # if this schema represents a simple type, simply return the data + # TODO - perhaps assert that the type of data matches the type we + # expected + return data + elif self.type == "array": + return [self.items.get_type().parse_obj(i) for i in data] + else: + return self.get_type().parse_obj(data) + + +class ParameterBase: + pass diff --git a/aiopenapi3/json.py b/aiopenapi3/json.py new file mode 100644 index 0000000..e45e91d --- /dev/null +++ b/aiopenapi3/json.py @@ -0,0 +1,29 @@ +import urllib.parse + +from yarl import URL + + +class JSONPointer: + """ + JavaScript Object Notation (JSON) Pointer + + https://datatracker.ietf.org/doc/html/rfc6901 + """ + + @staticmethod + def decode(part): + """ + + https://swagger.io/docs/specification/using-ref/ + :param part: + """ + part = urllib.parse.unquote(part) + part = part.replace("~1", "/") + return part.replace("~0", "~") + + +class JSONReference: + @staticmethod + def split(url): + u = URL(url) + return str(u.with_fragment("")), u.raw_fragment diff --git a/aiopenapi3/model.py b/aiopenapi3/model.py new file mode 100644 index 0000000..f480be7 --- /dev/null +++ b/aiopenapi3/model.py @@ -0,0 +1,124 @@ +import types +import uuid +from typing import List, Literal, Optional, Annotated, Union + +from pydantic import BaseModel, Extra, Field + + +class Model(BaseModel): + class Config: + extra: Extra.forbid + + @classmethod + def from_schema( + cls, shma: "SchemaBase", shmanm: List[str] = None, discriminators: List["DiscriminatorBase"] = None + ): + + if shmanm is None: + shmanm = [] + + if discriminators is None: + discriminators = [] + + def typeof(schema: "SchemaBase"): + r = None + if schema.type == "integer": + r = int + elif schema.type == "number": + r = float + elif schema.type == "string": + r = str + elif schema.type == "boolean": + r = bool + elif schema.type == "array": + r = List[schema.items.get_type()] + elif schema.type == "object": + return schema.get_type() + elif schema.type is None: # discriminated root + return None + else: + raise TypeError(schema.type) + + return r + + def annotationsof(schema: "SchemaBase"): + annos = dict() + if schema.type == "array": + annos["__root__"] = typeof(schema) + else: + + for name, f in schema.properties.items(): + r = None + for discriminator in discriminators: + if name != discriminator.propertyName: + continue + for disc, v in discriminator.mapping.items(): + if v in shmanm: + r = Literal[disc] + break + else: + raise ValueError(schema) + break + else: + r = typeof(f) + if name not in schema.required: + annos[name] = Optional[r] + else: + annos[name] = r + return annos + + def fieldof(schema: "SchemaBase"): + r = dict() + if schema.type == "array": + return r + else: + for name, f in schema.properties.items(): + args = dict() + for i in ["enum", "default"]: + v = getattr(f, i, None) + if v: + args[i] = v + r[name] = Field(**args) + return r + + # do not create models for primitive types + if shma.type in ("string", "integer"): + return typeof(shma) + + type_name = shma.title or shma._identity if hasattr(shma, "_identity") else str(uuid.uuid4()) + namespace = dict() + annos = dict() + if shma.allOf: + for i in shma.allOf: + annos.update(annotationsof(i)) + elif shma.anyOf: + t = tuple( + [ + i.get_type(names=shmanm + [i.ref], discriminators=discriminators + [shma.discriminator]) + for i in shma.anyOf + ] + ) + if shma.discriminator and shma.discriminator.mapping: + annos["__root__"] = Annotated[Union[t], Field(discriminator=shma.discriminator.propertyName)] + else: + annos["__root__"] = Union[t] + elif shma.oneOf: + t = tuple( + [ + i.get_type(names=shmanm + [i.ref], discriminators=discriminators + [shma.discriminator]) + for i in shma.oneOf + ] + ) + if shma.discriminator and shma.discriminator.mapping: + annos["__root__"] = Annotated[Union[t], Field(discriminator=shma.discriminator.propertyName)] + else: + annos["__root__"] = Union[t] + else: + annos = annotationsof(shma) + namespace.update(fieldof(shma)) + + namespace["__annotations__"] = annos + + m = types.new_class(type_name, (BaseModel,), {}, lambda ns: ns.update(namespace)) + m.update_forward_refs() + return m diff --git a/aiopenapi3/object_base.py b/aiopenapi3/object_base.py deleted file mode 100644 index 455965f..0000000 --- a/aiopenapi3/object_base.py +++ /dev/null @@ -1,39 +0,0 @@ -from typing import Optional - -from pydantic import BaseModel, Field, root_validator, Extra - - -class ObjectBase(BaseModel): - """ - The base class for all schema objects. Includes helpers for common schema- - related functions. - """ - - class Config: - underscore_attrs_are_private = True - arbitrary_types_allowed = False - extra = Extra.forbid - - -class ObjectExtended(ObjectBase): - extensions: Optional[object] = Field(default=None) - - @root_validator(pre=True) - def validate_ObjectExtended_extensions(cls, values): - """FIXME - https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#specification-extensions - :param values: - :return: values - """ - e = dict() - for k, v in values.items(): - if k.startswith("x-"): - e[k[2:]] = v - if len(e): - for i in e.keys(): - del values[f"x-{i}"] - if "extensions" in values.keys(): - raise ValueError("extensions") - values["extensions"] = e - - return values diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index b08027d..16f4652 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -1,48 +1,43 @@ -import datetime import pathlib -from typing import Any, List, Optional, Dict, Union, Callable +import re +from typing import List, Dict, Union, Callable -import yaml -from pydantic import Field import httpx import yarl -from .components import Components +from aiopenapi3.v30.general import Reference +from .json import JSONReference +from . import v30 +from . import v31 +from .request import OperationIndex, HTTP_METHODS from .errors import ReferenceResolutionError, SpecError -from .general import Reference, JSONPointer, JSONReference -from .info import Info -from .object_base import ObjectExtended, ObjectBase -from .paths import PathItem, SecurityRequirement, _validate_parameters, Operation -from .servers import Server -from .schemas import Schema, Discriminator -from .tag import Tag -from .request import Request, AsyncRequest from .loader import Loader from .plugin import Plugin, Plugins - -HTTP_METHODS = frozenset(["get", "delete", "head", "post", "put", "patch", "trace"]) +from .base import RootBase +from .v30.paths import Operation +from .base import SchemaBase class OpenAPI: @property def paths(self): - return self._spec.paths + return self._root.paths @property def components(self): - return self._spec.components + return self._root.components @property def info(self): - return self._spec.info + return self._root.info @property def openapi(self): - return self._spec.openapi + return self._root.openapi @property def servers(self): - return self._spec.servers + return self._root.servers @classmethod def load_sync( @@ -88,6 +83,19 @@ def loads( data = Loader.parse(Plugins(plugins or []), pathlib.Path(url), data) return cls(url, data, session_factory, loader, plugins) + def _parse_obj(self, raw_document): + if not (v := raw_document.get("openapi", None)): + raise ValueError("missing openapi field") + v = list(map(int, v.split("."))) + if v[0] != 3: + raise ValueError(f"openapi major version {v[0]} not supported") + if v[1] == 0: + return v30.Root.parse_obj(raw_document) + elif v[1] == 1: + return v31.Root.parse_obj(raw_document) + else: + raise ValueError(f"openapi major version {v[0]} not supported") + def __init__( self, url, @@ -112,48 +120,51 @@ def __init__( self._session_factory = session_factory self._security: List[str] = None - self._cached: Dict[str, "OpenAPISpec"] = dict() + self._cached: Dict[str, RootBase] = dict() self.plugins = Plugins(plugins or []) raw_document = self.plugins.document.parsed(url=url, document=raw_document).document - self._spec = OpenAPISpec.parse_obj(raw_document) - self._spec._resolve_references(self) + self._root = self._parse_obj(raw_document) + + self._root._resolve_references(self) for i in list(self._cached.values()): i._resolve_references(self) - for name, schema in self.components.schemas.items(): - schema._identity = name - - operation_map = set() - - def test_operation(operation_id): - if operation_id in operation_map: - raise SpecError(f"Duplicate operationId {operation_id}", element=None) - operation_map.add(operation_id) - - for path, obj in self.paths.items(): - for m in obj.__fields_set__ & HTTP_METHODS: - op = getattr(obj, m) - _validate_parameters(op, path) - if op.operationId is None: - continue - formatted_operation_id = op.operationId.replace(" ", "_") - test_operation(formatted_operation_id) - for r, response in op.responses.items(): - if isinstance(response, Reference): + if self.components: + for name, schema in filter(lambda v: isinstance(v[1], SchemaBase), self.components.schemas.items()): + schema._identity = name + + if self.paths: + operation_map = set() + + def test_operation(operation_id): + if operation_id in operation_map: + raise SpecError(f"Duplicate operationId {operation_id}", element=None) + operation_map.add(operation_id) + + for path, obj in self.paths.items(): + for m in obj.__fields_set__ & HTTP_METHODS: + op = getattr(obj, m) + _validate_parameters(op, path) + if op.operationId is None: continue - for c, content in response.content.items(): - if content.schema_ is None: + formatted_operation_id = op.operationId.replace(" ", "_") + test_operation(formatted_operation_id) + for r, response in op.responses.items(): + if isinstance(response, Reference): continue - if isinstance(content.schema_, Schema): - content.schema_._identity = f"{path}.{m}.{r}.{c}" + for c, content in response.content.items(): + if content.schema_ is None: + continue + if isinstance(content.schema_, (v30.Schema,)): + content.schema_._identity = f"{path}.{m}.{r}.{c}" - self.plugins.init.initialized(initialized=self._spec) + self.plugins.init.initialized(initialized=self._root) @property def url(self): - return self._base_url.join(yarl.URL(self._spec.servers[0].url)) + return self._base_url.join(yarl.URL(self._root.servers[0].url)) # public methods def authenticate(self, security_scheme, value): @@ -168,20 +179,20 @@ def authenticate(self, security_scheme, value): self._security = None return - if security_scheme not in self._spec.components.securitySchemes: + if security_scheme not in self._root.components.securitySchemes: raise ValueError("{} does not accept security scheme {}".format(self.info.title, security_scheme)) self._security = {security_scheme: value} def _load(self, i): data = self.loader.get(self.plugins, i) - return OpenAPISpec.parse_obj(data) + return self._parse_obj(data) @property def _(self): return OperationIndex(self) - def resolve_jr(self, root: "OpenAPISpec", obj, value: Reference): + def resolve_jr(self, root: "Rootv30", obj, value: Reference): url, jp = JSONReference.split(value.ref) if url != "": url = pathlib.Path(url) @@ -197,151 +208,14 @@ def resolve_jr(self, root: "OpenAPISpec", obj, value: Reference): raise -class OpenAPISpec(ObjectExtended): +def _validate_parameters(op: "Operation", path): """ - This class represents the root of the OpenAPI schema document, as defined - in `the spec`_ - - .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#openapi-object + Ensures that all parameters for this path are valid """ + assert isinstance(path, str) + allowed_path_parameters = re.findall(r"{([a-zA-Z0-9\-\._~]+)}", path) - openapi: str = Field(...) - info: Info = Field(...) - servers: Optional[List[Server]] = Field(default=None) - paths: Dict[str, PathItem] = Field(required=True, default_factory=dict) - components: Optional[Components] = Field(default_factory=Components) - security: Optional[List[SecurityRequirement]] = Field(default=None) - tags: Optional[List[Tag]] = Field(default=None) - externalDocs: Optional[Dict[Any, Any]] = Field(default_factory=dict) - - def _resolve_references(self, api): - """ - Resolves all reference objects below this object and notes their original - value was a reference. - """ - # don't circular import - - root = self - - def resolve(obj): - if isinstance(obj, ObjectBase): - for slot in filter(lambda x: not x.startswith("_"), obj.__fields_set__): - value = getattr(obj, slot) - if value is None: - continue - - if isinstance(obj, PathItem) and slot == "ref": - ref = Reference.construct(ref=value) - ref._target = api.resolve_jr(root, obj, ref) - setattr(obj, slot, ref) - - # if isinstance(obj, Discriminator) and slot == "mapping": - # mapping = dict() - # for k,v in value.items(): - # mapping[k] = Reference.construct(ref=v) - # setattr(obj, slot, mapping) - - value = getattr(obj, slot) - if isinstance(value, Reference): - value._target = api.resolve_jr(root, obj, value) - # setattr(obj, slot, resolved_value) - elif issubclass(type(value), ObjectBase): - # otherwise, continue resolving down the tree - resolve(value) - elif isinstance(value, dict): # pydantic does not use Map - resolve(value) - elif isinstance(value, list): - # if it's a list, resolve its item's references - for item in value: - if isinstance(item, Reference): - item._target = api.resolve_jr(root, obj, item) - elif isinstance(item, (ObjectBase, dict, list)): - resolve(item) - elif isinstance(value, (str, int, float, datetime.datetime)): - continue - else: - raise TypeError(type(value)) - elif isinstance(obj, dict): - for k, v in obj.items(): - if isinstance(v, Reference): - if v.ref: - v._target = api.resolve_jr(root, obj, v) - elif isinstance(v, (ObjectBase, dict, list)): - resolve(v) - - resolve(self) - - def resolve_jp(self, jp): - """ - Given a $ref path, follows the document tree and returns the given attribute. - - :param jp: The path down the spec tree to follow - :type jp: str #/foo/bar - - :returns: The node requested - :rtype: ObjectBase - :raises ValueError: if the given path is not valid - """ - path = jp.split("/")[1:] - node = self - - for part in path: - part = JSONPointer.decode(part) - if isinstance(node, dict): - if part not in node: # pylint: disable=unsupported-membership-test - raise ReferenceResolutionError(f"Invalid path {path} in Reference") - node = node.get(part) - else: - if not hasattr(node, part): - raise ReferenceResolutionError(f"Invalid path {path} in Reference") - node = getattr(node, part) - - return node - - -class OperationIndex: - class Iter: - def __init__(self, spec): - self.operations = [] - self.r = 0 - pi: PathItem - for path, pi in spec.paths.items(): - op: Operation - for method in pi.__fields_set__ & HTTP_METHODS: - op = getattr(pi, method) - if op.operationId is None: - continue - self.operations.append(op.operationId) - self.r = iter(range(len(self.operations))) - - def __iter__(self): - return self - - def __next__(self): - return self.operations[next(self.r)] - - def __init__(self, api): - self._api = api - self._spec = api._spec - - def __getattr__(self, item): - pi: PathItem - for path, pi in self._spec.paths.items(): - op: Operation - for method in pi.__fields_set__ & HTTP_METHODS: - op = getattr(pi, method) - if op.operationId != item: - continue - - if issubclass(self._api._session_factory, httpx.Client): - return Request(self._api, method, path, op) - if issubclass(self._api._session_factory, httpx.AsyncClient): - return AsyncRequest(self._api, method, path, op) - - raise ValueError(item) - - def __iter__(self): - return self.Iter(self._spec) - - -OpenAPISpec.update_forward_refs() + for c in op.parameters: + if c.in_ == "path": + if c.name not in allowed_path_parameters: + raise SpecError("Parameter name not found in path: {}".format(c.name)) diff --git a/aiopenapi3/request.py b/aiopenapi3/request.py index 3e95f8d..347f121 100644 --- a/aiopenapi3/request.py +++ b/aiopenapi3/request.py @@ -5,9 +5,8 @@ import pydantic import yarl -from .paths import SecurityRequirement -from .schemas import Schema -from .parameter import Parameter +from . import v30 +from .base import SchemaBase, ParameterBase, HTTP_METHODS from .version import __version__ @@ -33,7 +32,7 @@ class Request: def __init__(self, api: "OpenAPI", method: str, path: str, operation: "Operation.request"): self.api = api - self.spec = api._spec + self.spec = api._root self.method = method self.path = path self.operation = operation @@ -48,11 +47,11 @@ def security(self): return self.api._security @property - def data(self) -> Schema: + def data(self) -> SchemaBase: return self.operation.requestBody.content["application/json"].schema_ @property - def parameters(self) -> Dict[str, Parameter]: + def parameters(self) -> Dict[str, ParameterBase]: return self.operation.parameters + self.spec.paths[self.path].parameters def args(self, content_type: str = "application/json"): @@ -61,7 +60,7 @@ def args(self, content_type: str = "application/json"): schema = op.requestBody.content[content_type].schema_ return {"parameters": parameters, "data": schema} - def return_value(self, http_status: int = 200, content_type: str = "application/json") -> Schema: + def return_value(self, http_status: int = 200, content_type: str = "application/json") -> SchemaBase: return self.operation.responses[str(http_status)].content[content_type].schema_ def _factory_args(self): @@ -81,7 +80,7 @@ def _prepare_security(self): f"No security requirement satisfied (accepts {', '.join(self.operation.security.keys())})" ) - def _prepare_secschemes(self, security_requirement: SecurityRequirement, value: List[str]): + def _prepare_secschemes(self, security_requirement: v30.SecurityRequirement, value: List[str]): ss = self.spec.components.securitySchemes[security_requirement.name] if ss.type == "http" and ss.scheme_ == "basic": @@ -199,11 +198,11 @@ def _process(self, result): if expected_response is None: # TODO - custom exception class that has the response object in it + options = ",".join(self.operation.responses.keys()) raise ValueError( - f"""Unexpected response {result.status_code} from {self.operation.operationId} (expected one of { ",".join(self.operation.responses.keys()) }), no default is defined""" + f"""Unexpected response {result.status_code} from {self.operation.operationId} (expected one of {options}), no default is defined""" ) - # defined as "no content" if len(expected_response.content) == 0: return None @@ -222,9 +221,10 @@ def _process(self, result): expected_media = None if expected_media is None: - raise RuntimeError( + options = ",".join(expected_response.content.keys()) + raise ValueError( f"Unexpected Content-Type {content_type} returned for operation {self.operation.operationId} \ - (expected one of {','.join(expected_response.content.keys())})" + (expected one of {options})" ) if content_type.lower() == "application/json": @@ -269,3 +269,52 @@ async def request(self, data=None, parameters=None): result = await session.send(req) return self._process(result) + + +class OperationIndex: + class Iter: + def __init__(self, spec): + self.operations = [] + self.r = 0 + # pi: PathItem + for path, pi in spec.paths.items(): + # op: Operation + for method in pi.__fields_set__ & HTTP_METHODS: + op = getattr(pi, method) + if op.operationId is None: + continue + self.operations.append(op.operationId) + self.r = iter(range(len(self.operations))) + + def __iter__(self): + return self + + def __next__(self): + return self.operations[next(self.r)] + + def __init__(self, api): + self._api = api + self._spec = api._root + + def __getattr__(self, item): + # pi: PathItem + for path, pi in self._spec.paths.items(): + # op: Operation + for method in pi.__fields_set__ & HTTP_METHODS: + op = getattr(pi, method) + if op.operationId != item: + continue + sf = self._api._session_factory + if issubclass( + getattr(sf, "__annotations__", {}).get("return", None.__class__), httpx.Client + ) or issubclass(sf, httpx.Client): + return Request(self._api, method, path, op) + if issubclass( + getattr(sf, "__annotations__", {}).get("return", None.__class__), httpx.AsyncClient + ) or issubclass(sf, httpx.AsyncClient): + return AsyncRequest(self._api, method, path, op) + + raise ValueError(item) + + def __iter__(self): + return self.Iter(self._spec) diff --git a/aiopenapi3/schemas.py b/aiopenapi3/schemas.py deleted file mode 100644 index 79ebbb3..0000000 --- a/aiopenapi3/schemas.py +++ /dev/null @@ -1,243 +0,0 @@ -import types -import uuid -from typing import Union, List, Any, Optional, Dict, Literal, Annotated - -from pydantic import Field, root_validator, Extra, BaseModel - -from .general import Reference # need this for Model below -from .object_base import ObjectExtended -from .xml import XML - -TYPE_LOOKUP = { - "array": list, - "integer": int, - "object": dict, - "string": str, - "boolean": bool, -} - - -class Discriminator(ObjectExtended): - """ - - .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#discriminator-object - """ - - propertyName: str = Field(...) - mapping: Optional[Dict[str, str]] = Field(default_factory=dict) - - -class Schema(ObjectExtended): - """ - The `Schema Object`_ allows the definition of input and output data types. - - .. _Schema Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#schema-object - """ - - title: Optional[str] = Field(default=None) - multipleOf: Optional[int] = Field(default=None) - maximum: Optional[float] = Field(default=None) # FIXME Field(discriminator='type') would be better - exclusiveMaximum: Optional[bool] = Field(default=None) - minimum: Optional[float] = Field(default=None) - exclusiveMinimum: Optional[bool] = Field(default=None) - maxLength: Optional[int] = Field(default=None) - minLength: Optional[int] = Field(default=None) - pattern: Optional[str] = Field(default=None) - maxItems: Optional[int] = Field(default=None) - minItems: Optional[int] = Field(default=None) - uniqueItems: Optional[bool] = Field(default=None) - maxProperties: Optional[int] = Field(default=None) - minProperties: Optional[int] = Field(default=None) - required: Optional[List[str]] = Field(default_factory=list) - enum: Optional[list] = Field(default=None) - - type: Optional[str] = Field(default=None) - allOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) - oneOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) - anyOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) - not_: Optional[Union["Schema", Reference]] = Field(default=None, alias="not") - items: Optional[Union["Schema", Reference]] = Field(default=None) - properties: Optional[Dict[str, Union["Schema", Reference]]] = Field(default_factory=dict) - additionalProperties: Optional[Union[bool, "Schema", Reference]] = Field(default=None) - description: Optional[str] = Field(default=None) - format: Optional[str] = Field(default=None) - default: Optional[str] = Field(default=None) # TODO - str as a default? - nullable: Optional[bool] = Field(default=None) - discriminator: Optional[Discriminator] = Field(default=None) # 'Discriminator' - readOnly: Optional[bool] = Field(default=None) - writeOnly: Optional[bool] = Field(default=None) - xml: Optional[XML] = Field(default=None) # 'XML' - externalDocs: Optional[dict] = Field(default=None) # 'ExternalDocs' - example: Optional[Any] = Field(default=None) - deprecated: Optional[bool] = Field(default=None) - # contentEncoding: Optional[str] = Field(default=None) - # contentMediaType: Optional[str] = Field(default=None) - # contentSchema: Optional[str] = Field(default=None) - - _model_type: object - _request_model_type: object - - """ - The _identity attribute is set during OpenAPI.__init__ and used at get_type() - """ - _identity: str - - class Config: - # keep_untouched = (lru_cache,) - extra = Extra.forbid - - @root_validator - def validate_Schema_number_type(cls, values: Dict[str, object]): - conv = ["minimum", "maximum"] - if values.get("type", None) == "integer": - for i in conv: - v = values.get(i, None) - if v is not None: - values[i] = int(v) - return values - - # @lru_cache - def get_type(self, names: List[str] = None, discriminators: List[Discriminator] = None): - return Model.from_schema(self, names, discriminators) - - def model(self, data: Dict): - """ - Generates a model representing this schema from the given data. - - :param data: The data to create the model from. Should match this schema. - :type data: dict - - :returns: A new :any:`Model` created in this Schema's type from the data. - :rtype: self.get_type() - """ - if self.type in ("string", "number"): - assert len(self.properties) == 0 - # more simple types - # if this schema represents a simple type, simply return the data - # TODO - perhaps assert that the type of data matches the type we - # expected - return data - elif self.type == "array": - return [self.items.get_type().parse_obj(i) for i in data] - else: - return self.get_type().parse_obj(data) - - -class Model(BaseModel): - class Config: - extra: Extra.forbid - - @classmethod - def from_schema(cls, shma: Schema, shmanm: List[str] = None, discriminators: List[Discriminator] = None): - - if shmanm is None: - shmanm = [] - - if discriminators is None: - discriminators = [] - - def typeof(schema: Schema): - r = None - if schema.type == "integer": - r = int - elif schema.type == "number": - r = float - elif schema.type == "string": - r = str - elif schema.type == "boolean": - r = bool - elif schema.type == "array": - r = List[schema.items.get_type()] - elif schema.type == "object": - return schema.get_type() - elif schema.type is None: # discriminated root - return None - else: - raise TypeError(schema.type) - - return r - - def annotationsof(schema: Schema): - annos = dict() - if schema.type == "array": - annos["__root__"] = typeof(schema) - else: - - for name, f in schema.properties.items(): - r = None - for discriminator in discriminators: - if name != discriminator.propertyName: - continue - for disc, v in discriminator.mapping.items(): - if v in shmanm: - r = Literal[disc] - break - else: - raise ValueError(schema) - break - else: - r = typeof(f) - if name not in schema.required: - annos[name] = Optional[r] - else: - annos[name] = r - return annos - - def fieldof(schema: Schema): - r = dict() - if schema.type == "array": - return r - else: - for name, f in schema.properties.items(): - args = dict() - for i in ["enum", "default"]: - v = getattr(f, i, None) - if v: - args[i] = v - r[name] = Field(**args) - return r - - # do not create models for primitive types - if shma.type in ("string", "integer"): - return typeof(shma) - - type_name = shma.title or shma._identity if hasattr(shma, "_identity") else str(uuid.uuid4()) - namespace = dict() - annos = dict() - if shma.allOf: - for i in shma.allOf: - annos.update(annotationsof(i)) - elif shma.anyOf: - t = tuple( - [ - i.get_type(names=shmanm + [i.ref], discriminators=discriminators + [shma.discriminator]) - for i in shma.anyOf - ] - ) - if shma.discriminator and shma.discriminator.mapping: - annos["__root__"] = Annotated[Union[t], Field(discriminator=shma.discriminator.propertyName)] - else: - annos["__root__"] = Union[t] - elif shma.oneOf: - t = tuple( - [ - i.get_type(names=shmanm + [i.ref], discriminators=discriminators + [shma.discriminator]) - for i in shma.oneOf - ] - ) - if shma.discriminator and shma.discriminator.mapping: - annos["__root__"] = Annotated[Union[t], Field(discriminator=shma.discriminator.propertyName)] - else: - annos["__root__"] = Union[t] - else: - annos = annotationsof(shma) - namespace.update(fieldof(shma)) - - namespace["__annotations__"] = annos - - m = types.new_class(type_name, (BaseModel,), {}, lambda ns: ns.update(namespace)) - m.update_forward_refs() - return m - - -Schema.update_forward_refs() diff --git a/aiopenapi3/v30/__init__.py b/aiopenapi3/v30/__init__.py new file mode 100644 index 0000000..6e804ce --- /dev/null +++ b/aiopenapi3/v30/__init__.py @@ -0,0 +1,4 @@ +from .schemas import Schema +from .root import Root +from .paths import PathItem, Operation, SecurityRequirement +from .parameter import Parameter diff --git a/aiopenapi3/components.py b/aiopenapi3/v30/components.py similarity index 89% rename from aiopenapi3/components.py rename to aiopenapi3/v30/components.py index 8dae4f0..c40b9f1 100644 --- a/aiopenapi3/components.py +++ b/aiopenapi3/v30/components.py @@ -2,9 +2,12 @@ from pydantic import Field +from ..base import ObjectExtended + from .example import Example -from .object_base import ObjectExtended -from .paths import Reference, RequestBody, Link, Parameter, Response, Callback, Header, PathItem +from .paths import RequestBody, Link, Response, Callback +from .general import Reference +from .parameter import Header, Parameter from .schemas import Schema from .security import SecurityScheme diff --git a/aiopenapi3/example.py b/aiopenapi3/v30/example.py similarity index 93% rename from aiopenapi3/example.py rename to aiopenapi3/v30/example.py index b2b95b6..0024ee1 100644 --- a/aiopenapi3/example.py +++ b/aiopenapi3/v30/example.py @@ -2,8 +2,9 @@ from pydantic import Field +from ..base import ObjectExtended + from .general import Reference -from .object_base import ObjectExtended class Example(ObjectExtended): diff --git a/aiopenapi3/general.py b/aiopenapi3/v30/general.py similarity index 71% rename from aiopenapi3/general.py rename to aiopenapi3/v30/general.py index e4268b1..76a6b99 100644 --- a/aiopenapi3/general.py +++ b/aiopenapi3/v30/general.py @@ -1,10 +1,8 @@ -import urllib.parse from typing import Optional from pydantic import Field, Extra -from yarl import URL -from .object_base import ObjectExtended, ObjectBase +from ..base import ObjectExtended, ObjectBase class ExternalDocumentation(ObjectExtended): @@ -19,26 +17,6 @@ class ExternalDocumentation(ObjectExtended): description: Optional[str] = Field(default=None) -class JSONPointer: - @staticmethod - def decode(part): - """ - - https://swagger.io/docs/specification/using-ref/ - :param part: - """ - part = urllib.parse.unquote(part) - part = part.replace("~1", "/") - return part.replace("~0", "~") - - -class JSONReference: - @staticmethod - def split(url): - u = URL(url) - return str(u.with_fragment("")), u.raw_fragment - - class Reference(ObjectBase): """ A `Reference Object`_ designates a reference to another node in the specification. diff --git a/aiopenapi3/info.py b/aiopenapi3/v30/info.py similarity index 96% rename from aiopenapi3/info.py rename to aiopenapi3/v30/info.py index ff72eb7..0a104c4 100644 --- a/aiopenapi3/info.py +++ b/aiopenapi3/v30/info.py @@ -2,7 +2,7 @@ from pydantic import Field -from .object_base import ObjectExtended +from ..base import ObjectExtended class Contact(ObjectExtended): diff --git a/aiopenapi3/media.py b/aiopenapi3/v30/media.py similarity index 97% rename from aiopenapi3/media.py rename to aiopenapi3/v30/media.py index 41a26d9..35b1c64 100644 --- a/aiopenapi3/media.py +++ b/aiopenapi3/v30/media.py @@ -2,9 +2,10 @@ from pydantic import Field +from ..base import ObjectExtended + from .example import Example from .general import Reference -from .object_base import ObjectExtended from .schemas import Schema diff --git a/aiopenapi3/parameter.py b/aiopenapi3/v30/parameter.py similarity index 94% rename from aiopenapi3/parameter.py rename to aiopenapi3/v30/parameter.py index d6691d9..b90cf70 100644 --- a/aiopenapi3/parameter.py +++ b/aiopenapi3/v30/parameter.py @@ -2,11 +2,11 @@ from pydantic import Field, root_validator +from ..base import ObjectExtended + from .example import Example from .general import Reference -from .object_base import ObjectExtended from .schemas import Schema -from .media import MediaType class ParameterBase(ObjectExtended): @@ -49,3 +49,9 @@ class Header(ParameterBase): .. _HeaderObject: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#header-object """ + + +from .media import MediaType + +Parameter.update_forward_refs() +Header.update_forward_refs() diff --git a/aiopenapi3/paths.py b/aiopenapi3/v30/paths.py similarity index 74% rename from aiopenapi3/paths.py rename to aiopenapi3/v30/paths.py index 8ebe14c..30bbbb7 100644 --- a/aiopenapi3/paths.py +++ b/aiopenapi3/v30/paths.py @@ -1,64 +1,22 @@ -import re from typing import Union, List, Optional, Dict from pydantic import Field, BaseModel, root_validator -from .errors import SpecError +from ..base import ObjectBase, ObjectExtended +from ..errors import SpecError from .general import ExternalDocumentation from .general import Reference from .media import MediaType -from .object_base import ObjectBase, ObjectExtended from .parameter import Header, Parameter from .servers import Server - - -def _validate_parameters(op: "Operation", path): - """ - Ensures that all parameters for this path are valid - """ - assert isinstance(path, str) - allowed_path_parameters = re.findall(r"{([a-zA-Z0-9\-\._~]+)}", path) - - for c in op.parameters: - if c.in_ == "path": - if c.name not in allowed_path_parameters: - raise SpecError("Parameter name not found in path: {}".format(c.name)) - - -class SecurityRequirement(BaseModel): - """ - A `SecurityRequirement`_ object describes security schemes for API access. - - .. _SecurityRequirement: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#security-requirement-object - """ - - __root__: Dict[str, List[str]] - - @root_validator - def validate_SecurityRequirement(cls, values): - root = values.get("__root__", {}) - if not (len(root.keys()) == 1 and isinstance([c for c in root.values()][0], list) or len(root.keys()) == 0): - raise ValueError(root) - return values - - @property - def name(self): - if len(self.__root__.keys()): - return list(self.__root__.keys())[0] - return None - - @property - def types(self): - if self.name: - return self.__root__[self.name] - return None +from .security import SecurityRequirement class RequestBody(ObjectExtended): """ A `RequestBody`_ object describes a single request body. - .. _RequestBody: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#request-body-object + .. _RequestBody: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#requestBodyObject """ description: Optional[str] = Field(default=None) @@ -117,17 +75,14 @@ class Operation(ObjectExtended): description: Optional[str] = Field(default=None) externalDocs: Optional[ExternalDocumentation] = Field(default=None) operationId: Optional[str] = Field(default=None) - parameters: List[Union[Parameter, Reference]] = Field(default_factory=list) + parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) requestBody: Optional[Union[RequestBody, Reference]] = Field(default=None) - responses: Dict[str, Union[Response, Reference]] = Field(required=True) + responses: Dict[str, Union[Response, Reference]] = Field(...) callbacks: Optional[Dict[str, Union["Callback", Reference]]] = Field(default_factory=dict) deprecated: Optional[bool] = Field(default=None) security: Optional[List[SecurityRequirement]] = Field(default_factory=list) servers: Optional[List[Server]] = Field(default=None) - class Config: - underscore_attrs_are_private = True - class PathItem(ObjectExtended): """ diff --git a/aiopenapi3/v30/root.py b/aiopenapi3/v30/root.py new file mode 100644 index 0000000..6dd51c1 --- /dev/null +++ b/aiopenapi3/v30/root.py @@ -0,0 +1,36 @@ +from typing import Any, List, Optional, Dict + +from pydantic import Field + +from ..base import ObjectExtended, RootBase + +from .components import Components +from .general import Reference +from .info import Info +from .paths import PathItem, SecurityRequirement +from .servers import Server +from .tag import Tag + + +class Root(ObjectExtended, RootBase): + """ + This class represents the root of the OpenAPI schema document, as defined + in `the spec`_ + + .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#openapi-object + """ + + openapi: str = Field(...) + info: Info = Field(...) + servers: Optional[List[Server]] = Field(default_factory=list) + paths: Dict[str, PathItem] = Field(required=True, default_factory=dict) + components: Optional[Components] = Field(default_factory=Components) + security: Optional[List[SecurityRequirement]] = Field(default=None) + tags: Optional[List[Tag]] = Field(default=None) + externalDocs: Optional[Dict[Any, Any]] = Field(default_factory=dict) + + def _resolve_references(self, api): + RootBase.resolve(api, self, self, PathItem, Reference) + + +Root.update_forward_refs() diff --git a/aiopenapi3/v30/schemas.py b/aiopenapi3/v30/schemas.py new file mode 100644 index 0000000..f0dd20c --- /dev/null +++ b/aiopenapi3/v30/schemas.py @@ -0,0 +1,98 @@ +from typing import Union, List, Any, Optional, Dict + +from pydantic import Field, root_validator, Extra + +from ..base import ObjectExtended, SchemaBase, DiscriminatorBase +from .general import Reference +from .xml import XML + +TYPE_LOOKUP = { + "array": list, + "integer": int, + "object": dict, + "string": str, + "boolean": bool, +} + + +class Discriminator(ObjectExtended, DiscriminatorBase): + """ + + .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#discriminator-object + """ + + propertyName: str = Field(...) + mapping: Optional[Dict[str, str]] = Field(default_factory=dict) + + +class Schema(ObjectExtended, SchemaBase): + """ + The `Schema Object`_ allows the definition of input and output data types. + + .. _Schema Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#schema-object + """ + + title: Optional[str] = Field(default=None) + multipleOf: Optional[int] = Field(default=None) + maximum: Optional[float] = Field(default=None) # FIXME Field(discriminator='type') would be better + exclusiveMaximum: Optional[bool] = Field(default=None) + minimum: Optional[float] = Field(default=None) + exclusiveMinimum: Optional[bool] = Field(default=None) + maxLength: Optional[int] = Field(default=None) + minLength: Optional[int] = Field(default=None) + pattern: Optional[str] = Field(default=None) + maxItems: Optional[int] = Field(default=None) + minItems: Optional[int] = Field(default=None) + uniqueItems: Optional[bool] = Field(default=None) + maxProperties: Optional[int] = Field(default=None) + minProperties: Optional[int] = Field(default=None) + required: Optional[List[str]] = Field(default_factory=list) + enum: Optional[list] = Field(default=None) + + type: Optional[str] = Field(default=None) + allOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) + oneOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) + anyOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) + not_: Optional[Union["Schema", Reference]] = Field(default=None, alias="not") + items: Optional[Union["Schema", Reference]] = Field(default=None) + properties: Optional[Dict[str, Union["Schema", Reference]]] = Field(default_factory=dict) + additionalProperties: Optional[Union[bool, "Schema", Reference]] = Field(default=None) + description: Optional[str] = Field(default=None) + format: Optional[str] = Field(default=None) + default: Optional[str] = Field(default=None) # TODO - str as a default? + nullable: Optional[bool] = Field(default=None) + discriminator: Optional[Discriminator] = Field(default=None) # 'Discriminator' + readOnly: Optional[bool] = Field(default=None) + writeOnly: Optional[bool] = Field(default=None) + xml: Optional[XML] = Field(default=None) # 'XML' + externalDocs: Optional[dict] = Field(default=None) # 'ExternalDocs' + example: Optional[Any] = Field(default=None) + deprecated: Optional[bool] = Field(default=None) + # contentEncoding: Optional[str] = Field(default=None) + # contentMediaType: Optional[str] = Field(default=None) + # contentSchema: Optional[str] = Field(default=None) + + _model_type: object + _request_model_type: object + + """ + The _identity attribute is set during OpenAPI.__init__ and used at get_type() + """ + _identity: str + + class Config: + # keep_untouched = (lru_cache,) + extra = Extra.forbid + + @root_validator + def validate_Schema_number_type(cls, values: Dict[str, object]): + conv = ["minimum", "maximum"] + if values.get("type", None) == "integer": + for i in conv: + v = values.get(i, None) + if v is not None: + values[i] = int(v) + return values + + +Schema.update_forward_refs() diff --git a/aiopenapi3/security.py b/aiopenapi3/v30/security.py similarity index 69% rename from aiopenapi3/security.py rename to aiopenapi3/v30/security.py index 9b29cb3..49d19c5 100644 --- a/aiopenapi3/security.py +++ b/aiopenapi3/v30/security.py @@ -1,8 +1,8 @@ -from typing import Optional, Dict +from typing import Optional, Dict, List -from pydantic import Field, root_validator +from pydantic import Field, root_validator, BaseModel -from .object_base import ObjectExtended +from ..base import ObjectExtended class OAuthFlow(ObjectExtended): @@ -61,3 +61,32 @@ def validate_SecurityScheme(cls, values): if t == "openIdConnect": assert keys - frozenset(["openIdConnectUrl"]) == set([]) return values + + +class SecurityRequirement(BaseModel): + """ + A `SecurityRequirement`_ object describes security schemes for API access. + + .. _SecurityRequirement: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#securityRequirementObject + """ + + __root__: Dict[str, List[str]] + + @root_validator + def validate_SecurityRequirement(cls, values): + root = values.get("__root__", {}) + if not (len(root.keys()) == 1 and isinstance([c for c in root.values()][0], list) or len(root.keys()) == 0): + raise ValueError(root) + return values + + @property + def name(self): + if len(self.__root__.keys()): + return list(self.__root__.keys())[0] + return None + + @property + def types(self): + if self.name: + return self.__root__[self.name] + return None diff --git a/aiopenapi3/servers.py b/aiopenapi3/v30/servers.py similarity index 95% rename from aiopenapi3/servers.py rename to aiopenapi3/v30/servers.py index 0fd2cb4..c887e7d 100644 --- a/aiopenapi3/servers.py +++ b/aiopenapi3/v30/servers.py @@ -2,7 +2,7 @@ from pydantic import Field -from .object_base import ObjectExtended +from ..base import ObjectExtended class ServerVariable(ObjectExtended): diff --git a/aiopenapi3/tag.py b/aiopenapi3/v30/tag.py similarity index 92% rename from aiopenapi3/tag.py rename to aiopenapi3/v30/tag.py index 4724505..0dcbb2f 100644 --- a/aiopenapi3/tag.py +++ b/aiopenapi3/v30/tag.py @@ -2,7 +2,7 @@ from pydantic import Field -from .object_base import ObjectExtended +from ..base import ObjectExtended from .general import ExternalDocumentation diff --git a/aiopenapi3/xml.py b/aiopenapi3/v30/xml.py similarity index 100% rename from aiopenapi3/xml.py rename to aiopenapi3/v30/xml.py diff --git a/aiopenapi3/v31/__init__.py b/aiopenapi3/v31/__init__.py new file mode 100644 index 0000000..612cbdf --- /dev/null +++ b/aiopenapi3/v31/__init__.py @@ -0,0 +1,5 @@ +# from .schemas import Schema +from .root import Root + +# from .paths import PathItem, Operation, SecurityRequirement +# from .parameter import Parameter diff --git a/aiopenapi3/v31/components.py b/aiopenapi3/v31/components.py new file mode 100644 index 0000000..208cd79 --- /dev/null +++ b/aiopenapi3/v31/components.py @@ -0,0 +1,35 @@ +from typing import Union, Optional, Dict + +from pydantic import Field + +from ..base import ObjectExtended + +from .example import Example +from .paths import RequestBody, Link, Response, Callback, PathItem +from .general import Reference +from .parameter import Header, Parameter +from .schemas import Schema +from .security import SecurityScheme + + +class Components(ObjectExtended): + """ + A `Components Object`_ holds a reusable set of different aspects of the OAS + spec. + + .. _Components Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#components-object + """ + + schemas: Optional[Dict[str, Union[Schema, bool]]] = Field(default_factory=dict) + responses: Optional[Dict[str, Union[Response, Reference]]] = Field(default_factory=dict) + parameters: Optional[Dict[str, Union[Parameter, Reference]]] = Field(default_factory=dict) + examples: Optional[Dict[str, Union[Example, Reference]]] = Field(default_factory=dict) + requestBodies: Optional[Dict[str, Union[RequestBody, Reference]]] = Field(default_factory=dict) + headers: Optional[Dict[str, Union[Header, Reference]]] = Field(default_factory=dict) + securitySchemes: Optional[Dict[str, Union[SecurityScheme, Reference]]] = Field(default_factory=dict) + links: Optional[Dict[str, Union[Link, Reference]]] = Field(default_factory=dict) + callbacks: Optional[Dict[str, Union[Callback, Reference]]] = Field(default_factory=dict) + pathItems: Optional[Dict[str, Union[PathItem, Reference]]] = Field(default_factory=dict) # v3.1 + + +Components.update_forward_refs() diff --git a/aiopenapi3/v31/example.py b/aiopenapi3/v31/example.py new file mode 100644 index 0000000..1e24aa2 --- /dev/null +++ b/aiopenapi3/v31/example.py @@ -0,0 +1,18 @@ +from ..v30.example import Example as Example30 + +from typing import Union, Optional + +from pydantic import Field + +from .general import Reference + + +class Example(Example30): + """ + A `Example Object`_ holds a reusable set of different aspects of the OAS + spec. + + .. _Example Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#exampleObject + """ + + value: Optional[Union[Reference, dict, str]] = Field(default=None) # 'any' type diff --git a/aiopenapi3/v31/general.py b/aiopenapi3/v31/general.py new file mode 100644 index 0000000..0d4e681 --- /dev/null +++ b/aiopenapi3/v31/general.py @@ -0,0 +1,8 @@ +from typing import Optional +from pydantic import Field +from ..v30.general import Reference as Reference30, ExternalDocumentation + + +class Reference(Reference30): + summary: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) diff --git a/aiopenapi3/v31/info.py b/aiopenapi3/v31/info.py new file mode 100644 index 0000000..5de98b9 --- /dev/null +++ b/aiopenapi3/v31/info.py @@ -0,0 +1,36 @@ +from typing import Optional + +from pydantic import Field + +from aiopenapi3.base import ObjectExtended + + +from ..v30.info import Contact + + +class License(ObjectExtended): + """ + License object belonging to an Info object, as described `here`_ + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#license-object + """ + + name: str = Field(...) + identifier: Optional[str] = Field(default=None) + url: Optional[str] = Field(default=None) + + +class Info(ObjectExtended): + """ + An OpenAPI Info object, as defined in `the spec`_. + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#info-object + """ + + title: str = Field(...) + summary: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + termsOfService: Optional[str] = Field(default=None) + contact: Optional[Contact] = Field(default=None) + license: Optional[License] = Field(default=None) + version: str = Field(...) diff --git a/aiopenapi3/v31/media.py b/aiopenapi3/v31/media.py new file mode 100644 index 0000000..208620d --- /dev/null +++ b/aiopenapi3/v31/media.py @@ -0,0 +1,42 @@ +from typing import Union, Optional, Dict, Any + +from pydantic import Field + +from ..base import ObjectExtended + +from .example import Example +from .general import Reference +from .schemas import Schema + + +class Encoding(ObjectExtended): + """ + A single encoding definition applied to a single schema property. + + .. _Encoding: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#encodingObject + """ + + contentType: Optional[str] = Field(default=None) + headers: Optional[Dict[str, Union["Header", Reference]]] = Field(default_factory=dict) + style: Optional[str] = Field(default=None) + explode: Optional[bool] = Field(default=None) + allowReserved: Optional[bool] = Field(default=None) + + +class MediaType(ObjectExtended): + """ + A `MediaType`_ object provides schema and examples for the media type identified + by its key. These are used in a RequestBody object. + + .. _MediaType: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#media-type-object + """ + + schema_: Optional[Union[Schema, Reference]] = Field(required=True, alias="schema") + example: Optional[Any] = Field(default=None) # 'any' type + examples: Optional[Dict[str, Union[Example, Reference]]] = Field(default_factory=dict) + encoding: Optional[Dict[str, Encoding]] = Field(default_factory=dict) + + +from .parameter import Header + +Encoding.update_forward_refs() diff --git a/aiopenapi3/v31/parameter.py b/aiopenapi3/v31/parameter.py new file mode 100644 index 0000000..059ee51 --- /dev/null +++ b/aiopenapi3/v31/parameter.py @@ -0,0 +1,51 @@ +from typing import Union, Optional, Dict, Any + +from pydantic import Field + +from ..base import ObjectExtended + +from .example import Example +from .general import Reference +from .schemas import Schema + + +class ParameterBase(ObjectExtended): + """ + A `Parameter Object`_ defines a single operation parameter. + + .. _Parameter Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#parameterObject + """ + + description: Optional[str] = Field(default=None) + required: Optional[bool] = Field(default=None) + deprecated: Optional[bool] = Field(default=None) + allowEmptyValue: Optional[bool] = Field(default=None) + + style: Optional[str] = Field(default=None) + explode: Optional[bool] = Field(default=None) + allowReserved: Optional[bool] = Field(default=None) + schema_: Optional[Union[Schema, Reference]] = Field(default=None, alias="schema") + example: Optional[Any] = Field(default=None) + examples: Optional[Dict[str, Union["Example", Reference]]] = Field(default_factory=dict) + + content: Optional[Dict[str, "MediaType"]] + + +class Parameter(ParameterBase): + name: str = Field(required=True) + in_: str = Field(required=True, alias="in") # TODO must be one of ["query","header","path","cookie"] + + +class Header(ParameterBase): + """ + + .. _HeaderObject: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#headerObject + """ + + pass + + +from .media import MediaType + +Parameter.update_forward_refs() +Header.update_forward_refs() diff --git a/aiopenapi3/v31/paths.py b/aiopenapi3/v31/paths.py new file mode 100644 index 0000000..fce7e12 --- /dev/null +++ b/aiopenapi3/v31/paths.py @@ -0,0 +1,72 @@ +from typing import Optional, List, Union, Dict + +from pydantic import Field + +from ..base import ObjectExtended + +from .general import ExternalDocumentation, Reference +from .parameter import Parameter +from .servers import Server +from .media import MediaType +from .parameter import Header +from ..v30.paths import ( + Link, + Callback, + RuntimeExpression, + RequestBody as RequestBody30, + Operation as Operation30, + Response as Response30, +) + + +class RequestBody(RequestBody30): + """ + A `RequestBody`_ object describes a single request body. + + .. _RequestBody: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#requestBodyObject + """ + + content: Dict[str, MediaType] = Field(...) + + +class Response(Response30): + headers: Optional[Dict[str, Union[Header, Reference]]] = Field(default_factory=dict) + links: Optional[Dict[str, Union[Link, Reference]]] = Field(default_factory=dict) + + +class Operation(Operation30): + """ + An Operation object as defined `here`_ + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#operationObject + """ + + tags: Optional[List[str]] = Field(default=None) + + parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) + requestBody: Optional[Union[RequestBody, Reference]] = Field(default=None) + responses: Optional[Dict[str, Union[Response, Reference]]] = Field(default_factory=dict) + callbacks: Optional[Dict[str, Union["Callback", Reference]]] = Field(default_factory=dict) + + +class PathItem(ObjectExtended): + """ + A Path Item, as defined `here`_. + Describes the operations available on a single path. + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#pathItemObject + """ + + ref: Optional[str] = Field(default=None, alias="$ref") + summary: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + get: Optional[Operation] = Field(default=None) + put: Optional[Operation] = Field(default=None) + post: Optional[Operation] = Field(default=None) + delete: Optional[Operation] = Field(default=None) + options: Optional[Operation] = Field(default=None) + head: Optional[Operation] = Field(default=None) + patch: Optional[Operation] = Field(default=None) + trace: Optional[Operation] = Field(default=None) + servers: Optional[List[Server]] = Field(default=None) + parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) diff --git a/aiopenapi3/v31/root.py b/aiopenapi3/v31/root.py new file mode 100644 index 0000000..6b65026 --- /dev/null +++ b/aiopenapi3/v31/root.py @@ -0,0 +1,45 @@ +from typing import Any, List, Optional, Dict, Union + +from pydantic import Field, root_validator + +from ..base import ObjectExtended, RootBase + +from .info import Info +from .paths import PathItem +from .security import SecurityRequirement +from .servers import Server + +from .components import Components +from .general import Reference +from .tag import Tag + + +class Root(ObjectExtended, RootBase): + """ + This class represents the root of the OpenAPI schema document, as defined + in `the spec`_ + + .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#openapi-object + """ + + openapi: str = Field(...) + info: Info = Field(...) + jsonSchemaDialect: Optional[str] = Field(default=None) # FIXME should be URI + servers: Optional[List[Server]] = Field(default=None) + paths: Optional[Dict[str, PathItem]] = Field(required=False) + webhooks: Optional[Dict[str, Union[PathItem, Reference]]] = Field(required=False) + components: Optional[Components] = Field(default=None) + security: Optional[List[SecurityRequirement]] = Field(default=None) + tags: Optional[List[Tag]] = Field(default=None) + externalDocs: Optional[Dict[Any, Any]] = Field(default_factory=dict) + + @root_validator + def validate_Root(cls, values): + assert any([values.get(i) is not None for i in ["paths", "components", "webhooks"]]), values + return values + + def _resolve_references(self, api): + RootBase.resolve(api, self, self, PathItem, Reference) + + +Root.update_forward_refs() diff --git a/aiopenapi3/v31/schemas.py b/aiopenapi3/v31/schemas.py new file mode 100644 index 0000000..4257cc4 --- /dev/null +++ b/aiopenapi3/v31/schemas.py @@ -0,0 +1,149 @@ +from typing import Union, List, Any, Optional, Dict + +from pydantic import Field, root_validator, Extra + +from ..base import ObjectExtended, SchemaBase, DiscriminatorBase +from .general import Reference +from .xml import XML + +from ..v30.schemas import Discriminator + + +class Schema(ObjectExtended, SchemaBase): + """ + The `Schema Object`_ allows the definition of input and output data types. + + .. _Schema Object: https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-00#section-6 + """ + + """ + https://datatracker.ietf.org/doc/html/draft-handrews-json-schema-validation-02 + + 6.1. Validation Keywords for Any Instance Type + """ + + type: Optional[Union[str, List[str]]] = Field(default=None) + enum: Optional[List[str]] = Field(default=None) + const: Optional[str] = Field(default=None) + + """ + 6.2. Validation Keywords for Numeric Instances (number and integer) + """ + multipleOf: Optional[int] = Field(default=None) + maximum: Optional[float] = Field(default=None) # FIXME Field(discriminator='type') would be better + exclusiveMaximum: Optional[int] = Field(default=None) + minimum: Optional[float] = Field(default=None) + exclusiveMinimum: Optional[int] = Field(default=None) + + """ + 6.3. Validation Keywords for Strings + """ + maxLength: Optional[int] = Field(default=None) + minLength: Optional[int] = Field(default=None) + pattern: Optional[str] = Field(default=None) + + """ + 6.4. Validation Keywords for Arrays + """ + maxItems: Optional[int] = Field(default=None) + minItems: Optional[int] = Field(default=None) + uniqueItems: Optional[bool] = Field(default=None) + maxContains: Optional[int] = Field(default=None) + minContains: Optional[int] = Field(default=None) + + """ + 6.5. Validation Keywords for Objects + """ + maxProperties: Optional[int] = Field(default=None) + minProperties: Optional[int] = Field(default=None) + required: Optional[List[str]] = Field(default_factory=list) + dependentRequired: Dict[str, str] = Field(default_factory=dict) # FIXME + + """ + 7. A Vocabulary for Semantic Content With "format" + """ + format: Optional[str] = Field(default=None) + + """ + 8. A Vocabulary for the Contents of String-Encoded Data + """ + contentEncoding: Optional[str] = Field(default=None) + contentMediaType: Optional[str] = Field(default=None) + contentSchema: Optional[str] = Field(default=None) + + """ + 9. A Vocabulary for Basic Meta-Data Annotations + """ + title: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + default: Optional[str] = Field(default=None) # TODO - str as a default? + deprecated: Optional[bool] = Field(default=None) + readOnly: Optional[bool] = Field(default=None) + writeOnly: Optional[bool] = Field(default=None) + examples: Optional[Any] = Field(default=None) + + """ + https://datatracker.ietf.org/doc/html/draft-handrews-json-schema-02#section-9.2 + 9.2. Keywords for Applying Subschemas in Place + """ + allOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) + oneOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) + anyOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) + not_: Optional[Union["Schema", Reference]] = Field(default=None, alias="not") + + """ + 9.2.2. Keywords for Applying Subschemas Conditionally + """ + if_: Optional["Schema"] = Field(default=None, alias="if") + then_: Optional["Schema"] = Field(default=None, alias="then") + else_: Optional["Schema"] = Field(default=None, alias="else") + dependentSchemas: Optional[Dict[str, Union["Schema", Reference]]] = Field(default_factory=dict) + + """ + 9.3.1. Keywords for Applying Subschemas to Arrays + """ + items: Optional[Union[Union["Schema", Reference], List[Union["Schema", Reference]]]] = Field(default=None) + additionalItem: Optional[Union["Schema", Reference]] = Field(default=None) + unevaluatedItems: Optional[Union["Schema", Reference]] = Field(default=None) + contains: Optional[Union["Schema", Reference]] = Field(default=None) + + """ + 9.3.2. Keywords for Applying Subschemas to Objects + """ + properties: Optional[Dict[str, Union["Schema", Reference]]] = Field(default_factory=dict) + patternProperties: Optional[Dict[str, str]] = Field(default_factory=dict) + additionalProperties: Optional[Union["Schema", Reference]] = Field(default=None) + unevaluatedProperties: Optional[Union["Schema", Reference]] = Field(default=None) + propertyNames: Optional[Union["Schema", Reference]] = Field(default=None) + + """ + The OpenAPI Specification's base vocabulary is comprised of the following keywords: + """ + discriminator: Optional[Discriminator] = Field(default=None) # 'Discriminator' + xml: Optional[XML] = Field(default=None) # 'XML' + externalDocs: Optional[dict] = Field(default=None) # 'ExternalDocs' + example: Optional[Any] = Field(default=None) + + _model_type: object + _request_model_type: object + + """ + The _identity attribute is set during OpenAPI.__init__ and used at get_type() + """ + _identity: str + + class Config: + extra = Extra.allow + + @root_validator + def validate_Schema_number_type(cls, values: Dict[str, object]): + conv = ["minimum", "maximum"] + if values.get("type", None) == "integer": + for i in conv: + v = values.get(i, None) + if v is not None: + values[i] = int(v) + return values + + +Schema.update_forward_refs() diff --git a/aiopenapi3/v31/security.py b/aiopenapi3/v31/security.py new file mode 100644 index 0000000..cdab555 --- /dev/null +++ b/aiopenapi3/v31/security.py @@ -0,0 +1 @@ +from ..v30.security import SecurityScheme, SecurityRequirement diff --git a/aiopenapi3/v31/servers.py b/aiopenapi3/v31/servers.py new file mode 100644 index 0000000..cc9c69a --- /dev/null +++ b/aiopenapi3/v31/servers.py @@ -0,0 +1,39 @@ +from ..v30.servers import Server + +from typing import List, Optional, Dict + +from pydantic import Field, root_validator + +from ..base import ObjectExtended + + +class ServerVariable(ObjectExtended): + """ + A ServerVariable object as defined `here`_. + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#server-variable-object + """ + + enum: Optional[List[str]] = Field(default=None) + default: str = Field(...) + description: Optional[str] = Field(default=None) + + @root_validator + def validate_ServerVariable(cls, values): + assert isinstance(values.get("enum", None), (list, None.__class__)) + + # default value must be in enum + assert values.get("default", None) in values.get("enum", [None]) + return values + + +class Server(ObjectExtended): + """ + The Server object, as described `here`_ + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#server-object + """ + + url: str = Field(...) + description: Optional[str] = Field(default=None) + variables: Optional[Dict[str, ServerVariable]] = Field(default_factory=dict) diff --git a/aiopenapi3/v31/tag.py b/aiopenapi3/v31/tag.py new file mode 100644 index 0000000..d5c343c --- /dev/null +++ b/aiopenapi3/v31/tag.py @@ -0,0 +1 @@ +from ..v30.tag import Tag diff --git a/aiopenapi3/v31/xml.py b/aiopenapi3/v31/xml.py new file mode 100644 index 0000000..3c955ad --- /dev/null +++ b/aiopenapi3/v31/xml.py @@ -0,0 +1 @@ +from ..v30.xml import XML diff --git a/aiopenapi3/version.py b/aiopenapi3/version.py index 485f44a..b3f4756 100644 --- a/aiopenapi3/version.py +++ b/aiopenapi3/version.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.1.2" diff --git a/setup.cfg b/setup.cfg index 4075894..6f79ae5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = aiopenapi3 -version = attr: aiopenapi3.__version__ +version = attr: aiopenapi3.version.__version__ url = https://github.com/commonism/aiopenapi3 description = OpenAPI3 3.0.3 client / validator based on pydantic & httpx long_description = file: README.md @@ -48,6 +48,3 @@ tests = fastapi-versioning hypercorn uvloop - tatsu -expression = - tatsu diff --git a/tests/model_test.py b/tests/model_test.py index e1d5f8c..d162a51 100644 --- a/tests/model_test.py +++ b/tests/model_test.py @@ -1,23 +1,18 @@ import pydantic -import pytest - -from pydantic import Extra -from tests.api.v2.schema import Pet, Dog, Cat, WhiteCat, BlackCat +from tests.api.v2.schema import Dog import asyncio import uuid import pytest -import httpx - import uvloop from hypercorn.asyncio import serve from hypercorn.config import Config import aiopenapi3 -from aiopenapi3.schemas import Schema +from aiopenapi3.v30.schemas import Schema from tests.api.main import app diff --git a/tests/path_test.py b/tests/path_test.py index 0eb3075..b12489b 100644 --- a/tests/path_test.py +++ b/tests/path_test.py @@ -10,7 +10,7 @@ import yarl from aiopenapi3 import OpenAPI -from aiopenapi3.schemas import Schema +from aiopenapi3.v30.schemas import Schema URLBASE = "/" diff --git a/tests/ref_test.py b/tests/ref_test.py index b960e36..379583b 100644 --- a/tests/ref_test.py +++ b/tests/ref_test.py @@ -4,11 +4,7 @@ """ import typing -import pytest - - -from aiopenapi3 import OpenAPI -from aiopenapi3.schemas import Schema +from aiopenapi3.v30.schemas import Schema from pydantic.main import ModelMetaclass From b5d97a1ae509f7f82cc9d2aa0cfdadfd9aec9e9d Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 10 Jan 2022 08:42:50 +0100 Subject: [PATCH 073/125] v31 - do not to track v30->v31 changes to objects/relations --- aiopenapi3/v31/example.py | 9 ++- aiopenapi3/v31/general.py | 46 +++++++++++++++- aiopenapi3/v31/info.py | 24 +++++++- aiopenapi3/v31/media.py | 2 +- aiopenapi3/v31/parameter.py | 2 +- aiopenapi3/v31/paths.py | 107 ++++++++++++++++++++++++++++-------- aiopenapi3/v31/schemas.py | 60 ++++++++++++++------ aiopenapi3/v31/security.py | 93 ++++++++++++++++++++++++++++++- aiopenapi3/v31/servers.py | 4 +- setup.cfg | 1 + 10 files changed, 295 insertions(+), 53 deletions(-) diff --git a/aiopenapi3/v31/example.py b/aiopenapi3/v31/example.py index 1e24aa2..abcbbb4 100644 --- a/aiopenapi3/v31/example.py +++ b/aiopenapi3/v31/example.py @@ -1,13 +1,13 @@ -from ..v30.example import Example as Example30 - from typing import Union, Optional from pydantic import Field +from ..base import ObjectExtended + from .general import Reference -class Example(Example30): +class Example(ObjectExtended): """ A `Example Object`_ holds a reusable set of different aspects of the OAS spec. @@ -15,4 +15,7 @@ class Example(Example30): .. _Example Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#exampleObject """ + summary: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) value: Optional[Union[Reference, dict, str]] = Field(default=None) # 'any' type + externalValue: Optional[str] = Field(default=None) diff --git a/aiopenapi3/v31/general.py b/aiopenapi3/v31/general.py index 0d4e681..b2a51a4 100644 --- a/aiopenapi3/v31/general.py +++ b/aiopenapi3/v31/general.py @@ -1,8 +1,48 @@ from typing import Optional -from pydantic import Field -from ..v30.general import Reference as Reference30, ExternalDocumentation +from pydantic import Field, Extra, AnyUrl -class Reference(Reference30): +from ..base import ObjectExtended, ObjectBase + + +class ExternalDocumentation(ObjectExtended): + """ + An `External Documentation Object`_ references external resources for extended + documentation. + + .. _External Documentation Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#external-documentation-object + """ + + url: AnyUrl = Field(...) + description: Optional[str] = Field(default=None) + + +class Reference(ObjectBase): + """ + A `Reference Object`_ designates a reference to another node in the specification. + + .. _Reference Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#reference-object + """ + + ref: str = Field(alias="$ref") summary: Optional[str] = Field(default=None) description: Optional[str] = Field(default=None) + + _target: object = None + + class Config: + """This object cannot be extended with additional properties and any properties added SHALL be ignored.""" + + extra = Extra.ignore + + def __getattr__(self, item): + if item != "_target": + return getattr(self._target, item) + else: + return getattr(self, item) + + def __setattr__(self, item, value): + if item != "_target": + setattr(self._target, item, value) + else: + super().__setattr__(item, value) diff --git a/aiopenapi3/v31/info.py b/aiopenapi3/v31/info.py index 5de98b9..fa1c89d 100644 --- a/aiopenapi3/v31/info.py +++ b/aiopenapi3/v31/info.py @@ -1,11 +1,20 @@ from typing import Optional -from pydantic import Field +from pydantic import Field, AnyUrl, EmailStr, root_validator from aiopenapi3.base import ObjectExtended -from ..v30.info import Contact +class Contact(ObjectExtended): + """ + Contact object belonging to an Info object, as described `here`_ + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#contactObject + """ + + email: EmailStr = Field(default=None) + name: str = Field(default=None) + url: AnyUrl = Field(default=None) class License(ObjectExtended): @@ -17,7 +26,16 @@ class License(ObjectExtended): name: str = Field(...) identifier: Optional[str] = Field(default=None) - url: Optional[str] = Field(default=None) + url: Optional[AnyUrl] = Field(default=None) + + @root_validator + def validate_License(cls, values): + + """ + A URL to the license used for the API. This MUST be in the form of a URL. The url field is mutually exclusive of the identifier field. + """ + assert not all([values.get(i, None) is not None for i in ["identifier", "url"]]) + return values class Info(ObjectExtended): diff --git a/aiopenapi3/v31/media.py b/aiopenapi3/v31/media.py index 208620d..2dccf89 100644 --- a/aiopenapi3/v31/media.py +++ b/aiopenapi3/v31/media.py @@ -31,7 +31,7 @@ class MediaType(ObjectExtended): .. _MediaType: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#media-type-object """ - schema_: Optional[Union[Schema, Reference]] = Field(required=True, alias="schema") + schema_: Optional[Schema] = Field(required=True, alias="schema") example: Optional[Any] = Field(default=None) # 'any' type examples: Optional[Dict[str, Union[Example, Reference]]] = Field(default_factory=dict) encoding: Optional[Dict[str, Encoding]] = Field(default_factory=dict) diff --git a/aiopenapi3/v31/parameter.py b/aiopenapi3/v31/parameter.py index 059ee51..a86d49e 100644 --- a/aiopenapi3/v31/parameter.py +++ b/aiopenapi3/v31/parameter.py @@ -24,7 +24,7 @@ class ParameterBase(ObjectExtended): style: Optional[str] = Field(default=None) explode: Optional[bool] = Field(default=None) allowReserved: Optional[bool] = Field(default=None) - schema_: Optional[Union[Schema, Reference]] = Field(default=None, alias="schema") + schema_: Optional[Schema] = Field(default=None, alias="schema") example: Optional[Any] = Field(default=None) examples: Optional[Dict[str, Union["Example", Reference]]] = Field(default_factory=dict) diff --git a/aiopenapi3/v31/paths.py b/aiopenapi3/v31/paths.py index fce7e12..ccbc386 100644 --- a/aiopenapi3/v31/paths.py +++ b/aiopenapi3/v31/paths.py @@ -1,40 +1,69 @@ -from typing import Optional, List, Union, Dict +from typing import Union, List, Optional, Dict, Any -from pydantic import Field +from pydantic import Field, root_validator -from ..base import ObjectExtended - -from .general import ExternalDocumentation, Reference -from .parameter import Parameter -from .servers import Server +from ..base import ObjectBase, ObjectExtended +from ..errors import SpecError +from .general import ExternalDocumentation +from .general import Reference from .media import MediaType -from .parameter import Header -from ..v30.paths import ( - Link, - Callback, - RuntimeExpression, - RequestBody as RequestBody30, - Operation as Operation30, - Response as Response30, -) - - -class RequestBody(RequestBody30): +from .parameter import Header, Parameter +from .servers import Server +from .security import SecurityRequirement + + +class RequestBody(ObjectExtended): """ A `RequestBody`_ object describes a single request body. .. _RequestBody: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#requestBodyObject """ + description: Optional[str] = Field(default=None) content: Dict[str, MediaType] = Field(...) + required: Optional[bool] = Field(default=False) + + +class Link(ObjectExtended): + """ + A `Link Object`_ describes a single Link from an API Operation Response to an API Operation Request + + .. _Link Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#link-object + """ + + operationRef: Optional[str] = Field(default=None) + operationId: Optional[str] = Field(default=None) + parameters: Optional[Dict[str, Union[str, Any, "RuntimeExpression"]]] = Field(default=None) + requestBody: Optional[Union[Any, "RuntimeExpression"]] = Field(default=None) + description: Optional[str] = Field(default=None) + server: Optional[Server] = Field(default=None) + + @root_validator(pre=False) + def validate_Link_operation(cls, values): + if values["operationId"] != None and values["operationRef"] != None: + raise SpecError("operationId and operationRef are mutually exclusive, only one of them is allowed") + + if values["operationId"] == values["operationRef"] == None: + raise SpecError("operationId and operationRef are mutually exclusive, one of them must be specified") + return values + + +class Response(ObjectExtended): + """ + A `Response Object`_ describes a single response from an API Operation, + including design-time, static links to operations based on the response. + + .. _Response Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#responseObject + """ -class Response(Response30): + description: str = Field(...) headers: Optional[Dict[str, Union[Header, Reference]]] = Field(default_factory=dict) + content: Optional[Dict[str, MediaType]] = Field(default_factory=dict) links: Optional[Dict[str, Union[Link, Reference]]] = Field(default_factory=dict) -class Operation(Operation30): +class Operation(ObjectExtended): """ An Operation object as defined `here`_ @@ -42,11 +71,17 @@ class Operation(Operation30): """ tags: Optional[List[str]] = Field(default=None) - + summary: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + externalDocs: Optional[ExternalDocumentation] = Field(default=None) + operationId: Optional[str] = Field(default=None) parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) requestBody: Optional[Union[RequestBody, Reference]] = Field(default=None) - responses: Optional[Dict[str, Union[Response, Reference]]] = Field(default_factory=dict) + responses: Dict[str, Union[Response, Reference]] = Field(default_factory=dict) callbacks: Optional[Dict[str, Union["Callback", Reference]]] = Field(default_factory=dict) + deprecated: Optional[bool] = Field(default=None) + security: Optional[List[SecurityRequirement]] = Field(default_factory=list) + servers: Optional[List[Server]] = Field(default=None) class PathItem(ObjectExtended): @@ -70,3 +105,29 @@ class PathItem(ObjectExtended): trace: Optional[Operation] = Field(default=None) servers: Optional[List[Server]] = Field(default=None) parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) + + +class Callback(ObjectBase): + """ + A map of possible out-of band callbacks related to the parent operation. + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#callback-object + + This object MAY be extended with Specification Extensions. + """ + + __root__: Dict[str, PathItem] + + +class RuntimeExpression(ObjectBase): + """ + + + .. Runtime Expression: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#runtimeExpression + """ + + __root__: str = Field(...) + + +Operation.update_forward_refs() +Link.update_forward_refs() diff --git a/aiopenapi3/v31/schemas.py b/aiopenapi3/v31/schemas.py index 4257cc4..dec2b6c 100644 --- a/aiopenapi3/v31/schemas.py +++ b/aiopenapi3/v31/schemas.py @@ -6,7 +6,15 @@ from .general import Reference from .xml import XML -from ..v30.schemas import Discriminator + +class Discriminator(ObjectExtended, DiscriminatorBase): + """ + + .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#discriminator-object + """ + + propertyName: str = Field(...) + mapping: Optional[Dict[str, str]] = Field(default_factory=dict) class Schema(ObjectExtended, SchemaBase): @@ -17,8 +25,6 @@ class Schema(ObjectExtended, SchemaBase): """ """ - https://datatracker.ietf.org/doc/html/draft-handrews-json-schema-validation-02 - 6.1. Validation Keywords for Any Instance Type """ @@ -82,14 +88,36 @@ class Schema(ObjectExtended, SchemaBase): writeOnly: Optional[bool] = Field(default=None) examples: Optional[Any] = Field(default=None) + """ + https://datatracker.ietf.org/doc/html/draft-handrews-json-schema-validation-02 + """ + + """ + 8. The JSON Schema Core Vocabulary + """ + id: Optional[str] = Field(default=None, alias="$id") + schema_: Optional[str] = Field(default=None, alias="$schema") + anchor: Optional[str] = Field(default=None, alias="$anchor") + + """ + 8.2.4. Schema References + """ + ref: Optional[Reference] = Field(default=None, alias="$ref") + recursiveRef: Optional[Reference] = Field(default=None, alias="$recursiveRef") + recursiveAnchor: Optional[bool] = Field(default=None, alias="$recursiveAnchor") + + vocabulary: Optional[Dict[str, bool]] = Field(default=None, alias="$vocabulary") + comment: Optional[str] = Field(default=None, alias="$comment") + defs: Optional[Dict[str, Any]] = Field(default=None, alias="$defs") + """ https://datatracker.ietf.org/doc/html/draft-handrews-json-schema-02#section-9.2 9.2. Keywords for Applying Subschemas in Place """ - allOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) - oneOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) - anyOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) - not_: Optional[Union["Schema", Reference]] = Field(default=None, alias="not") + allOf: Optional[List["Schema"]] = Field(default_factory=list) + oneOf: Optional[List["Schema"]] = Field(default_factory=list) + anyOf: Optional[List["Schema"]] = Field(default_factory=list) + not_: Optional["Schema"] = Field(default=None, alias="not") """ 9.2.2. Keywords for Applying Subschemas Conditionally @@ -97,24 +125,24 @@ class Schema(ObjectExtended, SchemaBase): if_: Optional["Schema"] = Field(default=None, alias="if") then_: Optional["Schema"] = Field(default=None, alias="then") else_: Optional["Schema"] = Field(default=None, alias="else") - dependentSchemas: Optional[Dict[str, Union["Schema", Reference]]] = Field(default_factory=dict) + dependentSchemas: Optional[Dict[str, "Schema"]] = Field(default_factory=dict) """ 9.3.1. Keywords for Applying Subschemas to Arrays """ - items: Optional[Union[Union["Schema", Reference], List[Union["Schema", Reference]]]] = Field(default=None) - additionalItem: Optional[Union["Schema", Reference]] = Field(default=None) - unevaluatedItems: Optional[Union["Schema", Reference]] = Field(default=None) - contains: Optional[Union["Schema", Reference]] = Field(default=None) + items: Optional[Union["Schema", List["Schema"]]] = Field(default=None) + additionalItem: Optional["Schema"] = Field(default=None) + unevaluatedItems: Optional["Schema"] = Field(default=None) + contains: Optional["Schema"] = Field(default=None) """ 9.3.2. Keywords for Applying Subschemas to Objects """ - properties: Optional[Dict[str, Union["Schema", Reference]]] = Field(default_factory=dict) + properties: Optional[Dict[str, "Schema"]] = Field(default_factory=dict) patternProperties: Optional[Dict[str, str]] = Field(default_factory=dict) - additionalProperties: Optional[Union["Schema", Reference]] = Field(default=None) - unevaluatedProperties: Optional[Union["Schema", Reference]] = Field(default=None) - propertyNames: Optional[Union["Schema", Reference]] = Field(default=None) + additionalProperties: Optional["Schema"] = Field(default=None) + unevaluatedProperties: Optional["Schema"] = Field(default=None) + propertyNames: Optional["Schema"] = Field(default=None) """ The OpenAPI Specification's base vocabulary is comprised of the following keywords: diff --git a/aiopenapi3/v31/security.py b/aiopenapi3/v31/security.py index cdab555..8573dbe 100644 --- a/aiopenapi3/v31/security.py +++ b/aiopenapi3/v31/security.py @@ -1 +1,92 @@ -from ..v30.security import SecurityScheme, SecurityRequirement +from typing import Optional, Dict, List + +from pydantic import Field, root_validator, BaseModel + +from ..base import ObjectExtended + + +class OAuthFlow(ObjectExtended): + """ + Configuration details for a supported OAuth Flow + + .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#oauth-flow-object + """ + + authorizationUrl: Optional[str] = Field(default=None) + tokenUrl: Optional[str] = Field(default=None) + refreshUrl: Optional[str] = Field(default=None) + scopes: Dict[str, str] = Field(default_factory=dict) + + +class OAuthFlows(ObjectExtended): + """ + Allows configuration of the supported OAuth Flows. + + .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#oauth-flows-object + """ + + implicit: Optional[OAuthFlow] = Field(default=None) + password: Optional[OAuthFlow] = Field(default=None) + clientCredentials: Optional[OAuthFlow] = Field(default=None) + authorizationCode: Optional[OAuthFlow] = Field(default=None) + + +class SecurityScheme(ObjectExtended): + """ + A `Security Scheme`_ defines a security scheme that can be used by the operations. + + .. _Security Scheme: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#security-scheme-object + """ + + type: str = Field(...) + description: Optional[str] = Field(default=None) + name: Optional[str] = Field(default=None) + in_: Optional[str] = Field(default=None, alias="in") + scheme_: Optional[str] = Field(default=None, alias="scheme") + bearerFormat: Optional[str] = Field(default=None) + flows: Optional[OAuthFlows] = Field(default=None) + openIdConnectUrl: Optional[str] = Field(default=None) + + @root_validator + def validate_SecurityScheme(cls, values): + t = values.get("type", None) + keys = set(map(lambda x: x[0], filter(lambda x: x[1] is not None, values.items()))) + keys -= frozenset(["type", "description"]) + if t == "apikey": + assert keys == set(["in_", "name"]) + if t == "http": + assert keys - frozenset(["scheme_", "bearerFormat"]) == set([]) + if t == "oauth2": + assert keys == frozenset(["flows"]) + if t == "openIdConnect": + assert keys - frozenset(["openIdConnectUrl"]) == set([]) + return values + + +class SecurityRequirement(BaseModel): + """ + A `SecurityRequirement`_ object describes security schemes for API access. + + .. _SecurityRequirement: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#securityRequirementObject + """ + + __root__: Dict[str, List[str]] + + @root_validator + def validate_SecurityRequirement(cls, values): + root = values.get("__root__", {}) + if not (len(root.keys()) == 1 and isinstance([c for c in root.values()][0], list) or len(root.keys()) == 0): + raise ValueError(root) + return values + + @property + def name(self): + if len(self.__root__.keys()): + return list(self.__root__.keys())[0] + return None + + @property + def types(self): + if self.name: + return self.__root__[self.name] + return None diff --git a/aiopenapi3/v31/servers.py b/aiopenapi3/v31/servers.py index cc9c69a..c3faf84 100644 --- a/aiopenapi3/v31/servers.py +++ b/aiopenapi3/v31/servers.py @@ -11,7 +11,7 @@ class ServerVariable(ObjectExtended): """ A ServerVariable object as defined `here`_. - .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#server-variable-object + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#server-variable-object """ enum: Optional[List[str]] = Field(default=None) @@ -31,7 +31,7 @@ class Server(ObjectExtended): """ The Server object, as described `here`_ - .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#server-object + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#server-object """ url: str = Field(...) diff --git a/setup.cfg b/setup.cfg index 6f79ae5..25de12d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,6 +36,7 @@ packages = install_requires = PyYaml pydantic + pydantic[email] yarl httpx From 5e2a6e4ddb6490ab45d3aff69943abdc77097ec3 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 10 Jan 2022 12:53:50 +0100 Subject: [PATCH 074/125] v30 - fixes --- aiopenapi3/v30/paths.py | 10 +++++----- aiopenapi3/v30/schemas.py | 11 ----------- aiopenapi3/v30/security.py | 2 +- 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/aiopenapi3/v30/paths.py b/aiopenapi3/v30/paths.py index 30bbbb7..61f89a7 100644 --- a/aiopenapi3/v30/paths.py +++ b/aiopenapi3/v30/paths.py @@ -1,6 +1,6 @@ -from typing import Union, List, Optional, Dict +from typing import Union, List, Optional, Dict, Any -from pydantic import Field, BaseModel, root_validator +from pydantic import Field, root_validator from ..base import ObjectBase, ObjectExtended from ..errors import SpecError @@ -16,7 +16,7 @@ class RequestBody(ObjectExtended): """ A `RequestBody`_ object describes a single request body. - .. _RequestBody: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#requestBodyObject + .. _RequestBody: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#request-body-object """ description: Optional[str] = Field(default=None) @@ -33,8 +33,8 @@ class Link(ObjectExtended): operationRef: Optional[str] = Field(default=None) operationId: Optional[str] = Field(default=None) - parameters: Optional[Dict[str, Union["RuntimeExpression", str]]] = Field(default=None) - requestBody: Optional[dict] = Field(default=None) + parameters: Optional[Dict[str, Union[str, Any, "RuntimeExpression"]]] = Field(default=None) + requestBody: Optional[Union[str, "RuntimeExpression"]] = Field(default=None) description: Optional[str] = Field(default=None) server: Optional[Server] = Field(default=None) diff --git a/aiopenapi3/v30/schemas.py b/aiopenapi3/v30/schemas.py index f0dd20c..5d4eedf 100644 --- a/aiopenapi3/v30/schemas.py +++ b/aiopenapi3/v30/schemas.py @@ -6,14 +6,6 @@ from .general import Reference from .xml import XML -TYPE_LOOKUP = { - "array": list, - "integer": int, - "object": dict, - "string": str, - "boolean": bool, -} - class Discriminator(ObjectExtended, DiscriminatorBase): """ @@ -68,9 +60,6 @@ class Schema(ObjectExtended, SchemaBase): externalDocs: Optional[dict] = Field(default=None) # 'ExternalDocs' example: Optional[Any] = Field(default=None) deprecated: Optional[bool] = Field(default=None) - # contentEncoding: Optional[str] = Field(default=None) - # contentMediaType: Optional[str] = Field(default=None) - # contentSchema: Optional[str] = Field(default=None) _model_type: object _request_model_type: object diff --git a/aiopenapi3/v30/security.py b/aiopenapi3/v30/security.py index 49d19c5..466b318 100644 --- a/aiopenapi3/v30/security.py +++ b/aiopenapi3/v30/security.py @@ -67,7 +67,7 @@ class SecurityRequirement(BaseModel): """ A `SecurityRequirement`_ object describes security schemes for API access. - .. _SecurityRequirement: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#securityRequirementObject + .. _SecurityRequirement: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#security-requirement-object """ __root__: Dict[str, List[str]] From af41d8418ad267e5a663789fa440fcf32ea76b91 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 10 Jan 2022 13:01:01 +0100 Subject: [PATCH 075/125] v31/schema - ref --- aiopenapi3/base.py | 7 +++++ aiopenapi3/v31/schemas.py | 4 +-- tests/ref_test.py | 57 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/aiopenapi3/base.py b/aiopenapi3/base.py index dab199c..bebacd5 100644 --- a/aiopenapi3/base.py +++ b/aiopenapi3/base.py @@ -55,6 +55,13 @@ def resolve(api, root, obj, _PathItem, _Reference): if value is None: continue + # v3.1 - Schema $ref + if isinstance(value, SchemaBase): + r = getattr(value, "ref", None) + if r is not None: + value = _Reference.construct(ref=r) + setattr(obj, slot, value) + if isinstance(obj, _PathItem) and slot == "ref": ref = _Reference.construct(ref=value) ref._target = api.resolve_jr(root, obj, ref) diff --git a/aiopenapi3/v31/schemas.py b/aiopenapi3/v31/schemas.py index dec2b6c..52d2ada 100644 --- a/aiopenapi3/v31/schemas.py +++ b/aiopenapi3/v31/schemas.py @@ -102,8 +102,8 @@ class Schema(ObjectExtended, SchemaBase): """ 8.2.4. Schema References """ - ref: Optional[Reference] = Field(default=None, alias="$ref") - recursiveRef: Optional[Reference] = Field(default=None, alias="$recursiveRef") + ref: Optional[str] = Field(default=None, alias="$ref") + recursiveRef: Optional[str] = Field(default=None, alias="$recursiveRef") recursiveAnchor: Optional[bool] = Field(default=None, alias="$recursiveAnchor") vocabulary: Optional[Dict[str, bool]] = Field(default=None, alias="$vocabulary") diff --git a/tests/ref_test.py b/tests/ref_test.py index 379583b..3feabea 100644 --- a/tests/ref_test.py +++ b/tests/ref_test.py @@ -3,8 +3,9 @@ allOfs are populated as expected as well. """ import typing - -from aiopenapi3.v30.schemas import Schema +import dataclasses +import pytest +from aiopenapi3 import OpenAPI from pydantic.main import ModelMetaclass @@ -13,6 +14,8 @@ def test_ref_resolution(petstore_expanded_spec): """ Tests that $refs are resolved as we expect them to be """ + from aiopenapi3.v30.schemas import Schema + ref = petstore_expanded_spec.paths["/pets"].get.responses["default"].content["application/json"].schema_ assert type(ref._target) == Schema @@ -48,3 +51,53 @@ def test_allOf_resolution(petstore_expanded_spec): assert items["id"].outer_type_ == int assert items["name"].outer_type_ == str assert items["tag"].outer_type_ == str + + +@dataclasses.dataclass +class _Version: + major: int + minor: int + patch: int + + def __str__(self): + return f"{self.major}.{self.minor}.{self.patch}" + + +@pytest.fixture(scope="session", params=[_Version(3, 0, 3), _Version(3, 1, 0)]) +def openapi_version(request): + return request.param + + +def test_schemaref(openapi_version): + import aiopenapi3.v30.general + import aiopenapi3.v31.general + + expected = {0: aiopenapi3.v30.general.Reference, 1: aiopenapi3.v31.general.Reference}[openapi_version.minor] + + SPEC = f"""openapi: {openapi_version} +info: + title: API + version: 1.0.0 +paths: + /pets: + get: + description: yes + operationId: findPets + responses: + '200': + description: pet response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' +components: + schemas: + Pet: + type: str + """ + api = OpenAPI.loads("test.yaml", SPEC) + print(api) + + assert api.paths["/pets"].get.responses["200"].content["application/json"].schema_.items.__class__ == expected From d0b1f617cebfb0284e0223a6edcf407ab61b15be Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 10 Jan 2022 16:07:07 +0100 Subject: [PATCH 076/125] RootBase - resolving references --- aiopenapi3/base.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/aiopenapi3/base.py b/aiopenapi3/base.py index bebacd5..fd7e36f 100644 --- a/aiopenapi3/base.py +++ b/aiopenapi3/base.py @@ -62,29 +62,25 @@ def resolve(api, root, obj, _PathItem, _Reference): value = _Reference.construct(ref=r) setattr(obj, slot, value) + """ + ref fields embedded in objects -> replace the object with a Reference object + + PathItem Ref is ambigous + https://github.com/OAI/OpenAPI-Specification/issues/2635 + """ if isinstance(obj, _PathItem) and slot == "ref": ref = _Reference.construct(ref=value) ref._target = api.resolve_jr(root, obj, ref) setattr(obj, slot, ref) value = getattr(obj, slot) - if isinstance(value, _Reference): + if isinstance(value, (str, int, float, datetime.datetime)): + continue + elif isinstance(value, _Reference): value._target = api.resolve_jr(root, obj, value) - # setattr(obj, slot, resolved_value) - elif issubclass(type(value), ObjectBase): + elif issubclass(type(value), ObjectBase) or isinstance(value, (dict, list)): # otherwise, continue resolving down the tree RootBase.resolve(api, root, value, _PathItem, _Reference) - elif isinstance(value, dict): # pydantic does not use Map - RootBase.resolve(api, root, value, _PathItem, _Reference) - elif isinstance(value, list): - # if it's a list, resolve its item's references - for item in value: - if isinstance(item, _Reference): - item._target = api.resolve_jr(root, obj, item) - elif isinstance(item, (ObjectBase, dict, list)): - RootBase.resolve(api, root, item, _PathItem, _Reference) - elif isinstance(value, (str, int, float, datetime.datetime)): - continue else: raise TypeError(type(value)) elif isinstance(obj, dict): @@ -94,6 +90,13 @@ def resolve(api, root, obj, _PathItem, _Reference): v._target = api.resolve_jr(root, obj, v) elif isinstance(v, (ObjectBase, dict, list)): RootBase.resolve(api, root, v, _PathItem, _Reference) + elif isinstance(obj, list): + # if it's a list, resolve its item's references + for item in obj: + if isinstance(item, _Reference): + item._target = api.resolve_jr(root, obj, item) + elif isinstance(item, (ObjectBase, dict, list)): + RootBase.resolve(api, root, item, _PathItem, _Reference) def _resolve_references(self, api): """ From 948c99b3afff2f71ffd531ef2984aee4264ff521 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 11 Jan 2022 14:04:39 +0100 Subject: [PATCH 077/125] v20 - add support for Swagger/2.0 --- aiopenapi3/model.py | 4 +- aiopenapi3/openapi.py | 180 ++++++++++++++++++-------- aiopenapi3/request.py | 248 +++--------------------------------- aiopenapi3/v20/__init__.py | 4 + aiopenapi3/v20/general.py | 44 +++++++ aiopenapi3/v20/glue.py | 195 ++++++++++++++++++++++++++++ aiopenapi3/v20/info.py | 43 +++++++ aiopenapi3/v20/parameter.py | 91 +++++++++++++ aiopenapi3/v20/paths.py | 66 ++++++++++ aiopenapi3/v20/root.py | 44 +++++++ aiopenapi3/v20/schemas.py | 60 +++++++++ aiopenapi3/v20/security.py | 53 ++++++++ aiopenapi3/v20/tag.py | 19 +++ aiopenapi3/v20/xml.py | 17 +++ aiopenapi3/v30/__init__.py | 1 + aiopenapi3/v30/glue.py | 219 +++++++++++++++++++++++++++++++ 16 files changed, 1000 insertions(+), 288 deletions(-) create mode 100644 aiopenapi3/v20/__init__.py create mode 100644 aiopenapi3/v20/general.py create mode 100644 aiopenapi3/v20/glue.py create mode 100644 aiopenapi3/v20/info.py create mode 100644 aiopenapi3/v20/parameter.py create mode 100644 aiopenapi3/v20/paths.py create mode 100644 aiopenapi3/v20/root.py create mode 100644 aiopenapi3/v20/schemas.py create mode 100644 aiopenapi3/v20/security.py create mode 100644 aiopenapi3/v20/tag.py create mode 100644 aiopenapi3/v20/xml.py create mode 100644 aiopenapi3/v30/glue.py diff --git a/aiopenapi3/model.py b/aiopenapi3/model.py index f480be7..bee60a1 100644 --- a/aiopenapi3/model.py +++ b/aiopenapi3/model.py @@ -91,7 +91,7 @@ def fieldof(schema: "SchemaBase"): if shma.allOf: for i in shma.allOf: annos.update(annotationsof(i)) - elif shma.anyOf: + elif hasattr(shma, "anyOf") and shma.anyOf: t = tuple( [ i.get_type(names=shmanm + [i.ref], discriminators=discriminators + [shma.discriminator]) @@ -102,7 +102,7 @@ def fieldof(schema: "SchemaBase"): annos["__root__"] = Annotated[Union[t], Field(discriminator=shma.discriminator.propertyName)] else: annos["__root__"] = Union[t] - elif shma.oneOf: + elif hasattr(shma, "oneOf") and shma.oneOf: t = tuple( [ i.get_type(names=shmanm + [i.ref], discriminators=discriminators + [shma.discriminator]) diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index 16f4652..84c4369 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -1,12 +1,13 @@ import pathlib import re -from typing import List, Dict, Union, Callable +from typing import List, Dict, Union, Callable, Tuple import httpx import yarl from aiopenapi3.v30.general import Reference from .json import JSONReference +from . import v20 from . import v30 from . import v31 from .request import OperationIndex, HTTP_METHODS @@ -84,22 +85,30 @@ def loads( return cls(url, data, session_factory, loader, plugins) def _parse_obj(self, raw_document): - if not (v := raw_document.get("openapi", None)): - raise ValueError("missing openapi field") - v = list(map(int, v.split("."))) - if v[0] != 3: - raise ValueError(f"openapi major version {v[0]} not supported") - if v[1] == 0: - return v30.Root.parse_obj(raw_document) - elif v[1] == 1: - return v31.Root.parse_obj(raw_document) + if v := raw_document.get("openapi", None): + v = list(map(int, v.split("."))) + if v[0] == 3: + if v[1] == 0: + return v30.Root.parse_obj(raw_document) + elif v[1] == 1: + return v31.Root.parse_obj(raw_document) + else: + raise ValueError(f"openapi version 3.{v[1]} not supported") + else: + raise ValueError(f"openapi major version {v[0]} not supported") + elif v := raw_document.get("swagger", None): + v = list(map(int, v.split("."))) + if v[0] == 2 and v[1] == 0: + return v20.Root.parse_obj(raw_document) + else: + raise ValueError(f"swagger version {'.'.join(v)} not supported") else: - raise ValueError(f"openapi major version {v[0]} not supported") + raise ValueError("missing openapi/swagger field") def __init__( self, url, - raw_document, + document, session_factory: Callable[[], Union[httpx.Client, httpx.AsyncClient]] = httpx.AsyncClient, loader=None, plugins: List[Plugin] = None, @@ -109,62 +118,127 @@ def __init__( overridden here because we need to specify the path in the parent class' constructor. - :param raw_document: The raw OpenAPI file loaded into python - :type raw_document: dct + :param document: The raw OpenAPI file loaded into python + :type document: dct :param session_factory: default uses new session for each call, supply your own if required otherwise. :type session_factory: returns httpx.AsyncClient or http.Client """ self._base_url: yarl.URL = yarl.URL(url) + + self._session_factory: Callable[[], Union[httpx.Client, httpx.AsyncClient]] = session_factory + + """ + Loader - loading referenced documents + """ self.loader: Loader = loader - self._session_factory = session_factory - self._security: List[str] = None - self._cached: Dict[str, RootBase] = dict() - self.plugins = Plugins(plugins or []) + """ + creates the Async/Request for the protocol required + """ + self._createRequest: Callable[["OpenAPI", str, str, "Operation"], "RequestBase"] = None + + """ + authorization informations + e.g. {"BasicAuth": ("user","secret")} + """ + self._security: Dict[str, Tuple[str]] = None - raw_document = self.plugins.document.parsed(url=url, document=raw_document).document + """ + the related documents + """ + self._documents: Dict[str, RootBase] = dict() - self._root = self._parse_obj(raw_document) + """ + the plugin interface allows taking care of defects in description documents and implementations + """ + self.plugins: Plugins = Plugins(plugins or []) + + document = self.plugins.document.parsed(url=url, document=document).document + + self._root = self._parse_obj(document) + + if issubclass(getattr(session_factory, "__annotations__", {}).get("return", None.__class__), httpx.Client): + if isinstance(self._root, v20.Root): + self._createRequest = v20.Request + elif isinstance(self._root, (v30.Root, v31.Root)): + self._createRequest = v30.Request + else: + raise ValueError(self._root) + elif issubclass( + getattr(session_factory, "__annotations__", {}).get("return", None.__class__), httpx.AsyncClient + ): + if isinstance(self._root, v20.Root): + self._createRequest = v20.AsyncRequest + elif isinstance(self._root, (v30.Root, v31.Root)): + self._createRequest = v30.AsyncRequest + else: + raise ValueError(self._root) + else: + raise ValueError("invalid return value annotation for session_factory") self._root._resolve_references(self) - for i in list(self._cached.values()): + for i in list(self._documents.values()): i._resolve_references(self) - if self.components: - for name, schema in filter(lambda v: isinstance(v[1], SchemaBase), self.components.schemas.items()): - schema._identity = name - - if self.paths: - operation_map = set() - - def test_operation(operation_id): - if operation_id in operation_map: - raise SpecError(f"Duplicate operationId {operation_id}", element=None) - operation_map.add(operation_id) - - for path, obj in self.paths.items(): - for m in obj.__fields_set__ & HTTP_METHODS: - op = getattr(obj, m) - _validate_parameters(op, path) - if op.operationId is None: - continue - formatted_operation_id = op.operationId.replace(" ", "_") - test_operation(formatted_operation_id) - for r, response in op.responses.items(): - if isinstance(response, Reference): + operation_map = set() + + def test_operation(operation_id): + if operation_id in operation_map: + raise SpecError(f"Duplicate operationId {operation_id}", element=None) + operation_map.add(operation_id) + + if isinstance(self._root, v20.Root): + if self.paths: + for path, obj in self.paths.items(): + for m in obj.__fields_set__ & HTTP_METHODS: + op = getattr(obj, m) + _validate_parameters(op, path) + if op.operationId is None: continue - for c, content in response.content.items(): - if content.schema_ is None: + formatted_operation_id = op.operationId.replace(" ", "_") + test_operation(formatted_operation_id) + for r, response in op.responses.items(): + if isinstance(response, Reference): continue - if isinstance(content.schema_, (v30.Schema,)): - content.schema_._identity = f"{path}.{m}.{r}.{c}" - + if isinstance(response.schema_, (v20.Schema,)): + response.schema_._identity = f"{path}.{m}.{r}" + + elif isinstance(self._root, (v30.Root, v31.Root)): + if self.components: + for name, schema in filter(lambda v: isinstance(v[1], SchemaBase), self.components.schemas.items()): + schema._identity = name + + if self.paths: + for path, obj in self.paths.items(): + for m in obj.__fields_set__ & HTTP_METHODS: + op = getattr(obj, m) + _validate_parameters(op, path) + if op.operationId is None: + continue + formatted_operation_id = op.operationId.replace(" ", "_") + test_operation(formatted_operation_id) + for r, response in op.responses.items(): + if isinstance(response, Reference): + continue + for c, content in response.content.items(): + if content.schema_ is None: + continue + if isinstance(content.schema_, (v30.Schema,)): + content.schema_._identity = f"{path}.{m}.{r}.{c}" + else: + raise ValueError(self._root) self.plugins.init.initialized(initialized=self._root) @property def url(self): - return self._base_url.join(yarl.URL(self._root.servers[0].url)) + if isinstance(self._root, v20.Root): + r = self._base_url.with_path(self._root.basePath) + if self._root.host: + r = r.with_host(r) + return r + elif isinstance(self._root, (v30.Root, v31.Root)): + return self._base_url.join(yarl.URL(self._root.servers[0].url)) # public methods def authenticate(self, security_scheme, value): @@ -179,7 +253,7 @@ def authenticate(self, security_scheme, value): self._security = None return - if security_scheme not in self._root.components.securitySchemes: + if security_scheme not in self._root.securityDefinitions: raise ValueError("{} does not accept security scheme {}".format(self.info.title, security_scheme)) self._security = {security_scheme: value} @@ -196,9 +270,9 @@ def resolve_jr(self, root: "Rootv30", obj, value: Reference): url, jp = JSONReference.split(value.ref) if url != "": url = pathlib.Path(url) - if url not in self._cached: - self._cached[url] = self._load(url) - root = self._cached[url] + if url not in self._documents: + self._documents[url] = self._load(url) + root = self._documents[url] try: return root.resolve_jp(jp) diff --git a/aiopenapi3/request.py b/aiopenapi3/request.py index 347f121..bc2cea0 100644 --- a/aiopenapi3/request.py +++ b/aiopenapi3/request.py @@ -1,11 +1,7 @@ -import json -from typing import List, Dict - import httpx import pydantic import yarl -from . import v30 from .base import SchemaBase, ParameterBase, HTTP_METHODS from .version import __version__ @@ -21,225 +17,21 @@ def __init__(self, url: yarl.URL): self.headers = {} -class Request: - """ - This class is returned by instances of the OpenAPI class when members - formatted like call_operationId are accessed, and a valid Operation is - found, and allows calling the operation directly from the OpenAPI object - with the configured values included. This class is not intended to be used - directly. - """ - - def __init__(self, api: "OpenAPI", method: str, path: str, operation: "Operation.request"): +class RequestBase: + def __init__(self, api: "OpenAPI", method: str, path: str, operation: "Operation"): self.api = api - self.spec = api._root + self.root = api._root self.method = method self.path = path self.operation = operation - # self.session:Union[httpx.Client,httpx.AsyncClient] = self.req: RequestParameter = RequestParameter(self.path) def __call__(self, *args, **kwargs): return self.request(*args, **kwargs) - @property - def security(self): - return self.api._security - - @property - def data(self) -> SchemaBase: - return self.operation.requestBody.content["application/json"].schema_ - - @property - def parameters(self) -> Dict[str, ParameterBase]: - return self.operation.parameters + self.spec.paths[self.path].parameters - - def args(self, content_type: str = "application/json"): - op = self.operation - parameters = op.parameters + self.spec.paths[self.path].parameters - schema = op.requestBody.content[content_type].schema_ - return {"parameters": parameters, "data": schema} - - def return_value(self, http_status: int = 200, content_type: str = "application/json") -> SchemaBase: - return self.operation.responses[str(http_status)].content[content_type].schema_ - def _factory_args(self): return {"auth": self.req.auth, "headers": {"user-agent": f"aiopenapi3/{__version__}"}} - def _prepare_security(self): - if self.security and self.operation.security: - for scheme, value in self.security.items(): - for r in filter(lambda x: x.name == scheme, self.operation.security): - self._prepare_secschemes(r, value) - break - else: - continue - break - else: - raise ValueError( - f"No security requirement satisfied (accepts {', '.join(self.operation.security.keys())})" - ) - - def _prepare_secschemes(self, security_requirement: v30.SecurityRequirement, value: List[str]): - ss = self.spec.components.securitySchemes[security_requirement.name] - - if ss.type == "http" and ss.scheme_ == "basic": - self.req.auth = value - - if ss.type == "http" and ss.scheme_ == "digest": - self.req.auth = httpx.DigestAuth(*value) - - if ss.type == "http" and ss.scheme_ == "bearer": - header = ss.bearerFormat or "Bearer {}" - self.req.headers["Authorization"] = header.format(value) - - if ss.type == "mutualTLS": - # TLS Client certificates (mutualTLS) - self.req.cert = value - - if ss.type == "apiKey": - if ss.in_ == "query": - # apiKey in query parameter - self.req.params[ss.name] = value - - if ss.in_ == "header": - # apiKey in query header data - self.req.headers[ss.name] = value - - if ss.in_ == "cookie": - self.req.cookies = {ss.name: value} - - def _prepare_parameters(self, parameters): - # Parameters - path_parameters = {} - accepted_parameters = {} - p = self.operation.parameters + self.spec.paths[self.path].parameters - - for _ in list(p): - # TODO - make this work with $refs - can operations be $refs? - accepted_parameters.update({_.name: _}) - - for name, spec in accepted_parameters.items(): - if parameters is None or name not in parameters: - if spec.required: - raise ValueError(f"Required parameter {name} not provided") - continue - - value = parameters[name] - - if spec.in_ == "path": - # The string method `format` is incapable of partial updates, - # as such we need to collect all the path parameters before - # applying them to the format string. - path_parameters[name] = value - - if spec.in_ == "query": - self.req.params[name] = value - - if spec.in_ == "header": - self.req.headers[name] = value - - if spec.in_ == "cookie": - self.req.cookies[name] = value - - self.req.url = self.req.url.format(**path_parameters) - - def _prepare_body(self, data): - if not self.operation.requestBody: - return - - if data is None and self.operation.requestBody.required: - raise ValueError("Request Body is required but none was provided.") - - if "application/json" in self.operation.requestBody.content: - if isinstance(data, (dict, list)): - pass - elif isinstance(data, pydantic.BaseModel): - data = data.dict() - else: - raise TypeError(data) - data = self.api.plugins.message.marshalled( - operationId=self.operation.operationId, marshalled=data - ).marshalled - data = json.dumps(data) - data = data.encode() - data = self.api.plugins.message.sending(operationId=self.operation.operationId, sending=data).sending - self.req.content = data - self.req.headers["Content-Type"] = "application/json" - else: - raise NotImplementedError() - - def _prepare(self, data, parameters): - self._prepare_security() - self._prepare_parameters(parameters) - self._prepare_body(data) - - def _build_req(self, session): - req = session.build_request( - self.method, - str(self.api.url / self.req.url[1:]), - headers=self.req.headers, - cookies=self.req.cookies, - params=self.req.params, - content=self.req.content, - ) - return req - - def _process(self, result): - # spec enforces these are strings - status_code = str(result.status_code) - - # find the response model in spec we received - expected_response = None - if status_code in self.operation.responses: - expected_response = self.operation.responses[status_code] - elif "default" in self.operation.responses: - expected_response = self.operation.responses["default"] - - if expected_response is None: - # TODO - custom exception class that has the response object in it - options = ",".join(self.operation.responses.keys()) - raise ValueError( - f"""Unexpected response {result.status_code} from {self.operation.operationId} (expected one of {options}), no default is defined""" - ) - - if len(expected_response.content) == 0: - return None - - content_type = result.headers.get("Content-Type", None) - if content_type: - expected_media = expected_response.content.get(content_type, None) - if expected_media is None and "/" in content_type: - # accept media type ranges in the spec. the most specific matching - # type should always be chosen, but if we do not have a match here - # a generic range should be accepted if one if provided - # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#response-object - - generic_type = content_type.split("/")[0] + "/*" - expected_media = expected_response.content.get(generic_type, None) - else: - expected_media = None - - if expected_media is None: - options = ",".join(expected_response.content.keys()) - raise ValueError( - f"Unexpected Content-Type {content_type} returned for operation {self.operation.operationId} \ - (expected one of {options})" - ) - - if content_type.lower() == "application/json": - data = result.text - data = self.api.plugins.message.received(operationId=self.operation.operationId, received=data).received - data = json.loads(data) - data = self.api.plugins.message.parsed(operationId=self.operation.operationId, parsed=data).parsed - data = expected_media.schema_.model(data) - data = self.api.plugins.message.unmarshalled( - operationId=self.operation.operationId, unmarshalled=data - ).unmarshalled - return data - else: - raise NotImplementedError() - def request(self, data=None, parameters=None): """ Sends an HTTP request as described by this Path @@ -249,20 +41,18 @@ def request(self, data=None, parameters=None): :param parameters: The parameters used to create the path :type parameters: dict{str: str} """ - self._prepare(data, parameters) - session = self.api._session_factory(**self._factory_args()) - req = self._build_req(session) - result = session.send(req) + with self.api._session_factory(**self._factory_args()) as session: + req = self._build_req(session) + result = session.send(req) return self._process(result) -class AsyncRequest(Request): +class AsyncRequestBase(RequestBase): async def __call__(self, *args, **kwargs): return await self.request(*args, **kwargs) async def request(self, data=None, parameters=None): - self._prepare(data, parameters) async with self.api._session_factory(**self._factory_args()) as session: req = self._build_req(session) @@ -276,9 +66,9 @@ class Iter: def __init__(self, spec): self.operations = [] self.r = 0 - # pi: PathItem + pi: "PathItem" for path, pi in spec.paths.items(): - # op: Operation + op: "Operation" for method in pi.__fields_set__ & HTTP_METHODS: op = getattr(pi, method) if op.operationId is None: @@ -294,27 +84,19 @@ def __next__(self): def __init__(self, api): self._api = api - self._spec = api._root + self._root = api._root def __getattr__(self, item): - # pi: PathItem - for path, pi in self._spec.paths.items(): - # op: Operation + pi: "PathItem" + for path, pi in self._root.paths.items(): + op: "Operation" for method in pi.__fields_set__ & HTTP_METHODS: op = getattr(pi, method) if op.operationId != item: continue - sf = self._api._session_factory - if issubclass( - getattr(sf, "__annotations__", {}).get("return", None.__class__), httpx.Client - ) or issubclass(sf, httpx.Client): - return Request(self._api, method, path, op) - if issubclass( - getattr(sf, "__annotations__", {}).get("return", None.__class__), httpx.AsyncClient - ) or issubclass(sf, httpx.AsyncClient): - return AsyncRequest(self._api, method, path, op) + return self._api._createRequest(self._api, method, path, op) raise ValueError(item) def __iter__(self): - return self.Iter(self._spec) + return self.Iter(self._root) diff --git a/aiopenapi3/v20/__init__.py b/aiopenapi3/v20/__init__.py new file mode 100644 index 0000000..dd258ef --- /dev/null +++ b/aiopenapi3/v20/__init__.py @@ -0,0 +1,4 @@ +from .root import Root +from .security import SecurityRequirement +from .glue import Request, AsyncRequest +from .schemas import Schema diff --git a/aiopenapi3/v20/general.py b/aiopenapi3/v20/general.py new file mode 100644 index 0000000..0c0c31b --- /dev/null +++ b/aiopenapi3/v20/general.py @@ -0,0 +1,44 @@ +from typing import Optional + +from pydantic import Field, Extra + +from ..base import ObjectExtended, ObjectBase + + +class ExternalDocumentation(ObjectExtended): + """ + An `External Documentation Object`_ references external resources for extended + documentation. + + .. _External Documentation Object: https://swagger.io/specification/v2/#external-documentation-object + """ + + description: Optional[str] = Field(default=None) + url: str = Field(...) + + +class Reference(ObjectBase): + """ + A `Reference Object`_ designates a reference to another node in the specification. + + .. _Reference Object: https://swagger.io/specification/v2/#reference-object + """ + + ref: str = Field(alias="$ref") + + _target: object = None + + class Config: + extra = Extra.ignore + + def __getattr__(self, item): + if item != "_target": + return getattr(self._target, item) + else: + return getattr(self, item) + + def __setattr__(self, item, value): + if item != "_target": + setattr(self._target, item, value) + else: + super().__setattr__(item, value) diff --git a/aiopenapi3/v20/glue.py b/aiopenapi3/v20/glue.py new file mode 100644 index 0000000..dffb324 --- /dev/null +++ b/aiopenapi3/v20/glue.py @@ -0,0 +1,195 @@ +from typing import Dict, List +import json + +import httpx +import pydantic + +from ..base import SchemaBase, ParameterBase +from ..request import RequestBase, AsyncRequestBase + +from . import SecurityRequirement + + +class Request(RequestBase): + @property + def security(self): + return self.api._security + + @property + def data(self) -> SchemaBase: + for i in filter(lambda x: x.in_ == "body", self.operation.parameters): + return i.schema_ + raise ValueError("body") + + @property + def parameters(self) -> Dict[str, ParameterBase]: + return list( + filter(lambda x: x.in_ != "body", self.operation.parameters + self.root.paths[self.path].parameters) + ) + + def args(self, content_type: str = "application/json"): + op = self.operation + parameters = op.parameters + self.root.paths[self.path].parameters + schema = op.requestBody.content[content_type].schema_ + return {"parameters": parameters, "data": schema} + + def return_value(self, http_status: int = 200, content_type: str = "application/json") -> SchemaBase: + return self.operation.responses[str(http_status)].content[content_type].schema_ + + def _prepare_security(self): + if self.operation.security == []: + security = [] + else: + security = (self.operation.security or []) + self.root.security + + if self.security and security: + for scheme, value in self.security.items(): + for r in filter(lambda x: x.name == scheme, security): + self._prepare_secschemes(r, value) + break + else: + continue + break + else: + raise ValueError( + f"No security requirement satisfied (accepts {', '.join(self.operation.security.keys())})" + ) + + def _prepare_secschemes(self, security_requirement: SecurityRequirement, value: List[str]): + """ + https://swagger.io/specification/v2/#security-scheme-object + """ + ss = self.root.securityDefinitions[security_requirement.name] + + if ss.type == "basic": + self.req.auth = value + + if ss.type == "apiKey": + if ss.in_ == "query": + # apiKey in query parameter + self.req.params[ss.name] = value + + if ss.in_ == "header": + # apiKey in query header data + self.req.headers[ss.name] = value + + if ss.in_ == "cookie": + self.req.cookies = {ss.name: value} + + def _prepare_parameters(self, parameters): + # Parameters + path_parameters = {} + accepted_parameters = {} + p = filter(lambda x: x.in_ != "body", self.operation.parameters + self.root.paths[self.path].parameters) + + for _ in list(p): + # TODO - make this work with $refs - can operations be $refs? + accepted_parameters.update({_.name: _}) + + for name, spec in accepted_parameters.items(): + if parameters is None or name not in parameters: + if spec.required: + raise ValueError(f"Required parameter {name} not provided") + continue + + value = parameters[name] + + if spec.in_ == "path": + # The string method `format` is incapable of partial updates, + # as such we need to collect all the path parameters before + # applying them to the format string. + path_parameters[name] = value + + if spec.in_ == "query": + self.req.params[name] = value + + if spec.in_ == "header": + self.req.headers[name] = value + + if spec.in_ == "cookie": + self.req.cookies[name] = value + + self.req.url = self.req.url.format(**path_parameters) + + def _prepare_body(self, data): + try: + self.data + except ValueError: + return + + if data is None and self.data.required: + raise ValueError("Request Body is required but none was provided.") + + if "application/json" in self.operation.consumes: + if isinstance(data, (dict, list)): + pass + elif isinstance(data, pydantic.BaseModel): + data = data.dict() + else: + raise TypeError(data) + data = self.api.plugins.message.marshalled( + operationId=self.operation.operationId, marshalled=data + ).marshalled + data = json.dumps(data) + data = data.encode() + data = self.api.plugins.message.sending(operationId=self.operation.operationId, sending=data).sending + self.req.content = data + self.req.headers["Content-Type"] = "application/json" + else: + raise NotImplementedError() + + def _prepare(self, data, parameters): + self._prepare_security() + self._prepare_parameters(parameters) + self._prepare_body(data) + + def _build_req(self, session): + req = session.build_request( + self.method, + str(self.api.url / self.req.url[1:]), + headers=self.req.headers, + cookies=self.req.cookies, + params=self.req.params, + content=self.req.content, + ) + return req + + def _process(self, result): + # spec enforces these are strings + status_code = str(result.status_code) + + # find the response model in spec we received + expected_response = None + if status_code in self.operation.responses: + expected_response = self.operation.responses[status_code] + elif "default" in self.operation.responses: + expected_response = self.operation.responses["default"] + + if expected_response is None: + # TODO - custom exception class that has the response object in it + options = ",".join(self.operation.responses.keys()) + raise ValueError( + f"""Unexpected response {result.status_code} from {self.operation.operationId} (expected one of {options}), no default is defined""" + ) + + if status_code == "204": + return + + content_type = result.headers.get("Content-Type", None) + + if content_type.lower().partition(";")[0] == "application/json": + data = result.text + data = self.api.plugins.message.received(operationId=self.operation.operationId, received=data).received + data = json.loads(data) + data = self.api.plugins.message.parsed(operationId=self.operation.operationId, parsed=data).parsed + data = expected_response.schema_.model(data) + data = self.api.plugins.message.unmarshalled( + operationId=self.operation.operationId, unmarshalled=data + ).unmarshalled + return data + else: + raise NotImplementedError(content_type) + + +class AsyncRequest(Request, AsyncRequestBase): + pass diff --git a/aiopenapi3/v20/info.py b/aiopenapi3/v20/info.py new file mode 100644 index 0000000..b03a50d --- /dev/null +++ b/aiopenapi3/v20/info.py @@ -0,0 +1,43 @@ +from typing import Optional + +from pydantic import Field + +from ..base import ObjectExtended + + +class Contact(ObjectExtended): + """ + Contact object belonging to an Info object, as described `here`_ + + .. _here: https://swagger.io/specification/v2/#contact-object + """ + + email: str = Field(default=None) + name: str = Field(default=None) + url: str = Field(default=None) + + +class License(ObjectExtended): + """ + License object belonging to an Info object, as described `here`_ + + .. _here: https://swagger.io/specification/v2/#license-object + """ + + name: str = Field(...) + url: Optional[str] = Field(default=None) + + +class Info(ObjectExtended): + """ + An OpenAPI Info object, as defined in `the spec`_. + + .. _here: https://swagger.io/specification/v2/#info-object + """ + + title: str = Field(...) + description: Optional[str] = Field(default=None) + termsOfService: Optional[str] = Field(default=None) + license: Optional[License] = Field(default=None) + contact: Optional[Contact] = Field(default=None) + version: str = Field(...) diff --git a/aiopenapi3/v20/parameter.py b/aiopenapi3/v20/parameter.py new file mode 100644 index 0000000..7a69d99 --- /dev/null +++ b/aiopenapi3/v20/parameter.py @@ -0,0 +1,91 @@ +from typing import Union, Optional, Any + +from pydantic import Field + +from .general import Reference +from .schemas import Schema +from ..base import ObjectExtended + + +class Item(ObjectExtended): + """ + https://swagger.io/specification/v2/#items-object + """ + + type: str = Field(...) + format: Optional[str] = Field(default=None) + items: Optional["Item"] = Field(default=None) + collectionFormat: Optional[str] = Field(default=None) + default: Any = Field(default=None) + maximum: Optional[int] = Field(default=None) + exclusiveMaximum: Optional[bool] = Field(default=None) + minimum: Optional[int] = Field(default=None) + exclusiveMinimum: Optional[bool] = Field(default=None) + maxLength: Optional[int] = Field(default=None) + minLength: Optional[int] = Field(default=None) + pattern: Optional[str] = Field(default=None) + maxItems: Optional[int] = Field(default=None) + minItems: Optional[int] = Field(default=None) + enum: Optional[Any] = Field(default=None) + multipleOf: Optional[int] = Field(default=None) + + +class Parameter(ObjectExtended): + """ + Describes a single operation parameter. + + .. _Parameter Object: https://swagger.io/specification/v2/#parameter-object + """ + + name: str = Field(required=True) + in_: str = Field(required=True, alias="in") # TODO must be one of ["query","header","path","cookie"] + + description: Optional[str] = Field(default=None) + required: Optional[bool] = Field(default=None) + + schema_: Optional[Union[Schema, Reference]] = Field(default=None, alias="schema") + + type: Optional[str] = Field(default=None) + format: Optional[str] = Field(default=None) + items: Optional[Item] = Field(default=None) + collectionFormat: Optional[str] = Field(default=None) + default: Any = Field(default=None) + maximum: Optional[int] = Field(default=None) + exclusiveMaximum: Optional[bool] = Field(default=None) + minimum: Optional[int] = Field(default=None) + exclusiveMinimum: Optional[bool] = Field(default=None) + maxLength: Optional[int] = Field(default=None) + minLength: Optional[int] = Field(default=None) + pattern: Optional[str] = Field(default=None) + maxItems: Optional[int] = Field(default=None) + minItems: Optional[int] = Field(default=None) + enum: Optional[Any] = Field(default=None) + multipleOf: Optional[int] = Field(default=None) + + +class Header(ObjectExtended): + """ + https://swagger.io/specification/v2/#header-object + """ + + description: Optional[str] = Field(default=None) + + type: str = Field(...) + format: Optional[str] = Field(default=None) + items: Optional[Item] = Field(default=None) + collectionFormat: Optional[str] = Field(default=None) + default: Any = Field(default=None) + maximum: Optional[int] = Field(default=None) + exclusiveMaximum: Optional[bool] = Field(default=None) + minimum: Optional[int] = Field(default=None) + exclusiveMinimum: Optional[bool] = Field(default=None) + maxLength: Optional[int] = Field(default=None) + minLength: Optional[int] = Field(default=None) + pattern: Optional[str] = Field(default=None) + maxItems: Optional[int] = Field(default=None) + minItems: Optional[int] = Field(default=None) + enum: Optional[Any] = Field(default=None) + multipleOf: Optional[int] = Field(default=None) + + +Item.update_forward_refs() diff --git a/aiopenapi3/v20/paths.py b/aiopenapi3/v20/paths.py new file mode 100644 index 0000000..3f2a757 --- /dev/null +++ b/aiopenapi3/v20/paths.py @@ -0,0 +1,66 @@ +from typing import Union, List, Optional, Dict, Any + +from pydantic import Field + +from .general import ExternalDocumentation +from .general import Reference +from .parameter import Header, Parameter +from .schemas import Schema +from .security import SecurityRequirement +from ..base import ObjectExtended + + +class Response(ObjectExtended): + """ + Describes a single response from an API Operation. + + .. _Response Object: https://swagger.io/specification/v2/#response-object + """ + + description: str = Field(...) + schema_: Optional[Schema] = Field(default=None, alias="schema") + headers: Optional[Dict[str, Header]] = Field(default_factory=dict) + examples: Optional[Dict[str, Any]] = Field(default=None) + + +class Operation(ObjectExtended): + """ + An Operation object as defined `here`_ + + .. _here: https://swagger.io/specification/v2/#operation-object + """ + + tags: Optional[List[str]] = Field(default=None) + summary: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + externalDocs: Optional[ExternalDocumentation] = Field(default=None) + operationId: Optional[str] = Field(default=None) + consumes: Optional[List[str]] = Field(default_factory=list) + produces: Optional[List[str]] = Field(default_factory=list) + parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) + responses: Dict[str, Union[Reference, Response]] = Field(default_factory=dict) + schemes: Optional[List[str]] = Field(default_factory=list) + deprecated: Optional[bool] = Field(default=None) + security: Optional[List[SecurityRequirement]] = Field(default=None) + + +class PathItem(ObjectExtended): + """ + A Path Item, as defined `here`_. + Describes the operations available on a single path. + + .. _here: https://swagger.io/specification/v2/#path-item-object + """ + + ref: Optional[str] = Field(default=None, alias="$ref") + get: Optional[Operation] = Field(default=None) + put: Optional[Operation] = Field(default=None) + post: Optional[Operation] = Field(default=None) + delete: Optional[Operation] = Field(default=None) + options: Optional[Operation] = Field(default=None) + head: Optional[Operation] = Field(default=None) + patch: Optional[Operation] = Field(default=None) + parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) + + +Operation.update_forward_refs() diff --git a/aiopenapi3/v20/root.py b/aiopenapi3/v20/root.py new file mode 100644 index 0000000..f41e9e1 --- /dev/null +++ b/aiopenapi3/v20/root.py @@ -0,0 +1,44 @@ +from typing import List, Optional, Dict + +from pydantic import Field + +from ..base import ObjectExtended, RootBase + +from .general import Reference, ExternalDocumentation +from .info import Info +from .paths import PathItem +from .schemas import Schema +from .security import SecurityScheme, SecurityRequirement +from .paths import Response +from .paths import Parameter +from .tag import Tag + + +class Root(ObjectExtended, RootBase): + """ + This is the root document object for the API specification. + + https://swagger.io/specification/v2/#swagger-object + """ + + swagger: str = Field(...) + info: Info = Field(...) + host: Optional[str] = Field(default=None) + basePath: Optional[str] = Field(default=None) + schemes: Optional[List[str]] = Field(default_factory=list) + consumes: Optional[List[str]] = Field(default_factory=list) + produces: Optional[List[str]] = Field(default_factory=list) + paths: Dict[str, PathItem] = Field(default_factory=dict) + definitions: Optional[Dict[str, Schema]] = Field(default_factory=dict) + parameters: Optional[Dict[str, Parameter]] = Field(default_factory=dict) + responses: Optional[Dict[str, Response]] = Field(default_factory=dict) + securityDefinitions: Optional[Dict[str, SecurityScheme]] = Field(default_factory=dict) + security: Optional[List[SecurityRequirement]] = Field(default=None) + tags: Optional[List[Tag]] = Field(default_factory=list) + externalDocs: Optional[ExternalDocumentation] = Field(default=None) + + def _resolve_references(self, api): + RootBase.resolve(api, self, self, PathItem, Reference) + + +Root.update_forward_refs() diff --git a/aiopenapi3/v20/schemas.py b/aiopenapi3/v20/schemas.py new file mode 100644 index 0000000..7635301 --- /dev/null +++ b/aiopenapi3/v20/schemas.py @@ -0,0 +1,60 @@ +from typing import Union, List, Any, Optional, Dict + +from pydantic import Field + +from .general import Reference +from .xml import XML +from ..base import ObjectExtended, SchemaBase + + +class Schema(ObjectExtended, SchemaBase): + """ + The Schema Object allows the definition of input and output data types. + + https://swagger.io/specification/v2/#schema-object + """ + + ref: Optional[str] = Field(default=None, alias="$ref") + format: Optional[str] = Field(default=None) + title: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + default: Optional[str] = Field(default=None) # TODO - str as a default? + + multipleOf: Optional[int] = Field(default=None) + maximum: Optional[float] = Field(default=None) # FIXME Field(discriminator='type') would be better + exclusiveMaximum: Optional[bool] = Field(default=None) + minimum: Optional[float] = Field(default=None) + exclusiveMinimum: Optional[bool] = Field(default=None) + maxLength: Optional[int] = Field(default=None) + minLength: Optional[int] = Field(default=None) + pattern: Optional[str] = Field(default=None) + maxItems: Optional[int] = Field(default=None) + minItems: Optional[int] = Field(default=None) + uniqueItems: Optional[bool] = Field(default=None) + maxProperties: Optional[int] = Field(default=None) + minProperties: Optional[int] = Field(default=None) + required: Optional[List[str]] = Field(default_factory=list) + enum: Optional[list] = Field(default=None) + type: Optional[str] = Field(default=None) + + items: Optional[Union["Schema", Reference]] = Field(default=None) + allOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) + properties: Optional[Dict[str, Union["Schema", Reference]]] = Field(default_factory=dict) + additionalProperties: Optional[Union[bool, "Schema", Reference]] = Field(default=None) + + discriminator: Optional[str] = Field(default=None) # 'Discriminator' + readOnly: Optional[bool] = Field(default=None) + xml: Optional[XML] = Field(default=None) # 'XML' + externalDocs: Optional[dict] = Field(default=None) # 'ExternalDocs' + example: Optional[Any] = Field(default=None) + + _model_type: object + _request_model_type: object + + """ + The _identity attribute is set during OpenAPI.__init__ and used at get_type() + """ + _identity: str + + +Schema.update_forward_refs() diff --git a/aiopenapi3/v20/security.py b/aiopenapi3/v20/security.py new file mode 100644 index 0000000..a7ecdec --- /dev/null +++ b/aiopenapi3/v20/security.py @@ -0,0 +1,53 @@ +from typing import Optional, Dict, List + +from pydantic import Field, BaseModel, root_validator + +from ..base import ObjectExtended + + +class SecurityScheme(ObjectExtended): + """ + Allows the definition of a security scheme that can be used by the operations. + + https://swagger.io/specification/v2/#security-scheme-object + """ + + type: str = Field(...) + description: Optional[str] = Field(default=None) + name: Optional[str] = Field(default=None) + in_: Optional[str] = Field(default=None, alias="in") + + flow: Optional[str] = Field(default=None) + authorizationUrl: Optional[str] = Field(default=None) + tokenUrl: Optional[str] = Field(default=None) + refreshUrl: Optional[str] = Field(default=None) + scopes: Dict[str, str] = Field(default_factory=dict) + + +class SecurityRequirement(BaseModel): + """ + Lists the required security schemes to execute this operation. + + https://swagger.io/specification/v2/#security-requirement-object + """ + + __root__: Dict[str, List[str]] + + @root_validator + def validate_SecurityRequirement(cls, values): + root = values.get("__root__", {}) + if not (len(root.keys()) == 1 and isinstance([c for c in root.values()][0], list) or len(root.keys()) == 0): + raise ValueError(root) + return values + + @property + def name(self): + if len(self.__root__.keys()): + return list(self.__root__.keys())[0] + return None + + @property + def types(self): + if self.name: + return self.__root__[self.name] + return None diff --git a/aiopenapi3/v20/tag.py b/aiopenapi3/v20/tag.py new file mode 100644 index 0000000..0dcbb2f --- /dev/null +++ b/aiopenapi3/v20/tag.py @@ -0,0 +1,19 @@ +from typing import Optional + +from pydantic import Field + +from ..base import ObjectExtended +from .general import ExternalDocumentation + + +class Tag(ObjectExtended): + """ + A `Tag Object`_ holds a reusable set of different aspects of the OAS + spec. + + .. _Tag Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#tag-object + """ + + name: str = Field(...) + description: Optional[str] = Field(default=None) + externalDocs: Optional[ExternalDocumentation] = Field(default=None) diff --git a/aiopenapi3/v20/xml.py b/aiopenapi3/v20/xml.py new file mode 100644 index 0000000..db0cd41 --- /dev/null +++ b/aiopenapi3/v20/xml.py @@ -0,0 +1,17 @@ +from pydantic import Field + +from .general import ObjectExtended + + +class XML(ObjectExtended): + """ + A metadata object that allows for more fine-tuned XML model definitions. + + https://swagger.io/specification/v2/#xml-object + """ + + name: str = Field(default=None) + namespace: str = Field(default=None) + prefix: str = Field(default=None) + attribute: bool = Field(default=False) + wrapped: bool = Field(default=False) diff --git a/aiopenapi3/v30/__init__.py b/aiopenapi3/v30/__init__.py index 6e804ce..1e99716 100644 --- a/aiopenapi3/v30/__init__.py +++ b/aiopenapi3/v30/__init__.py @@ -2,3 +2,4 @@ from .root import Root from .paths import PathItem, Operation, SecurityRequirement from .parameter import Parameter +from .glue import Request diff --git a/aiopenapi3/v30/glue.py b/aiopenapi3/v30/glue.py new file mode 100644 index 0000000..aa4e9bd --- /dev/null +++ b/aiopenapi3/v30/glue.py @@ -0,0 +1,219 @@ +from typing import Dict, List +import json + +import httpx +import pydantic + +from ..base import SchemaBase, ParameterBase +from ..request import RequestBase, AsyncRequestBase + +from . import SecurityRequirement + + +class Request(RequestBase): + """ + This class is returned by instances of the OpenAPI class when members + formatted like call_operationId are accessed, and a valid Operation is + found, and allows calling the operation directly from the OpenAPI object + with the configured values included. This class is not intended to be used + directly. + """ + + @property + def security(self): + return self.api._security + + @property + def data(self) -> SchemaBase: + return self.operation.requestBody.content["application/json"].schema_ + + @property + def parameters(self) -> Dict[str, ParameterBase]: + return self.operation.parameters + self.root.paths[self.path].parameters + + def args(self, content_type: str = "application/json"): + op = self.operation + parameters = op.parameters + self.root.paths[self.path].parameters + schema = op.requestBody.content[content_type].schema_ + return {"parameters": parameters, "data": schema} + + def return_value(self, http_status: int = 200, content_type: str = "application/json") -> SchemaBase: + return self.operation.responses[str(http_status)].content[content_type].schema_ + + def _prepare_security(self): + if self.security and self.operation.security: + for scheme, value in self.security.items(): + for r in filter(lambda x: x.name == scheme, self.operation.security): + self._prepare_secschemes(r, value) + break + else: + continue + break + else: + raise ValueError( + f"No security requirement satisfied (accepts {', '.join(self.operation.security.keys())})" + ) + + def _prepare_secschemes(self, security_requirement: SecurityRequirement, value: List[str]): + ss = self.root.components.securitySchemes[security_requirement.name] + + if ss.type == "http" and ss.scheme_ == "basic": + self.req.auth = value + + if ss.type == "http" and ss.scheme_ == "digest": + self.req.auth = httpx.DigestAuth(*value) + + if ss.type == "http" and ss.scheme_ == "bearer": + header = ss.bearerFormat or "Bearer {}" + self.req.headers["Authorization"] = header.format(value) + + if ss.type == "mutualTLS": + # TLS Client certificates (mutualTLS) + self.req.cert = value + + if ss.type == "apiKey": + if ss.in_ == "query": + # apiKey in query parameter + self.req.params[ss.name] = value + + if ss.in_ == "header": + # apiKey in query header data + self.req.headers[ss.name] = value + + if ss.in_ == "cookie": + self.req.cookies = {ss.name: value} + + def _prepare_parameters(self, parameters): + # Parameters + path_parameters = {} + accepted_parameters = {} + p = self.operation.parameters + self.root.paths[self.path].parameters + + for _ in list(p): + # TODO - make this work with $refs - can operations be $refs? + accepted_parameters.update({_.name: _}) + + for name, spec in accepted_parameters.items(): + if parameters is None or name not in parameters: + if spec.required: + raise ValueError(f"Required parameter {name} not provided") + continue + + value = parameters[name] + + if spec.in_ == "path": + # The string method `format` is incapable of partial updates, + # as such we need to collect all the path parameters before + # applying them to the format string. + path_parameters[name] = value + + if spec.in_ == "query": + self.req.params[name] = value + + if spec.in_ == "header": + self.req.headers[name] = value + + if spec.in_ == "cookie": + self.req.cookies[name] = value + + self.req.url = self.req.url.format(**path_parameters) + + def _prepare_body(self, data): + if not self.operation.requestBody: + return + + if data is None and self.operation.requestBody.required: + raise ValueError("Request Body is required but none was provided.") + + if "application/json" in self.operation.requestBody.content: + if isinstance(data, (dict, list)): + pass + elif isinstance(data, pydantic.BaseModel): + data = data.dict() + else: + raise TypeError(data) + data = self.api.plugins.message.marshalled( + operationId=self.operation.operationId, marshalled=data + ).marshalled + data = json.dumps(data) + data = data.encode() + data = self.api.plugins.message.sending(operationId=self.operation.operationId, sending=data).sending + self.req.content = data + self.req.headers["Content-Type"] = "application/json" + else: + raise NotImplementedError() + + def _prepare(self, data, parameters): + self._prepare_security() + self._prepare_parameters(parameters) + self._prepare_body(data) + + def _build_req(self, session): + req = session.build_request( + self.method, + str(self.api.url / self.req.url[1:]), + headers=self.req.headers, + cookies=self.req.cookies, + params=self.req.params, + content=self.req.content, + ) + return req + + def _process(self, result): + # spec enforces these are strings + status_code = str(result.status_code) + + # find the response model in spec we received + expected_response = None + if status_code in self.operation.responses: + expected_response = self.operation.responses[status_code] + elif "default" in self.operation.responses: + expected_response = self.operation.responses["default"] + + if expected_response is None: + # TODO - custom exception class that has the response object in it + options = ",".join(self.operation.responses.keys()) + raise ValueError( + f"""Unexpected response {result.status_code} from {self.operation.operationId} (expected one of {options}), no default is defined""" + ) + + if len(expected_response.content) == 0: + return None + + content_type = result.headers.get("Content-Type", None) + if content_type: + expected_media = expected_response.content.get(content_type, None) + if expected_media is None and "/" in content_type: + # accept media type ranges in the spec. the most specific matching + # type should always be chosen, but if we do not have a match here + # a generic range should be accepted if one if provided + # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#response-object + + generic_type = content_type.split("/")[0] + "/*" + expected_media = expected_response.content.get(generic_type, None) + else: + expected_media = None + + if expected_media is None: + options = ",".join(expected_response.content.keys()) + raise ValueError( + f"Unexpected Content-Type {content_type} returned for operation {self.operation.operationId} \ + (expected one of {options})" + ) + + if content_type.lower() == "application/json": + data = result.text + data = self.api.plugins.message.received(operationId=self.operation.operationId, received=data).received + data = json.loads(data) + data = self.api.plugins.message.parsed(operationId=self.operation.operationId, parsed=data).parsed + data = expected_media.schema_.model(data) + data = self.api.plugins.message.unmarshalled( + operationId=self.operation.operationId, unmarshalled=data + ).unmarshalled + return data + else: + raise NotImplementedError() + + +class AsyncRequest(Request, AsyncRequestBase): + pass From 74afdb33e794efc0356f8a4c0e4ce0d8ccfde7ad Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 11 Jan 2022 16:49:18 +0100 Subject: [PATCH 078/125] loader - do not coerce date times when parsing yaml --- aiopenapi3/base.py | 3 +-- aiopenapi3/loader.py | 32 +++++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/aiopenapi3/base.py b/aiopenapi3/base.py index fd7e36f..34e95d6 100644 --- a/aiopenapi3/base.py +++ b/aiopenapi3/base.py @@ -43,7 +43,6 @@ def validate_ObjectExtended_extensions(cls, values): from .json import JSONPointer from .errors import ReferenceResolutionError -import datetime class RootBase: @@ -74,7 +73,7 @@ def resolve(api, root, obj, _PathItem, _Reference): setattr(obj, slot, ref) value = getattr(obj, slot) - if isinstance(value, (str, int, float, datetime.datetime)): + if isinstance(value, (str, int, float)): # , datetime.datetime, datetime.date)): continue elif isinstance(value, _Reference): value._target = api.resolve_jr(root, obj, value) diff --git a/aiopenapi3/loader.py b/aiopenapi3/loader.py index a47c24c..7e8d745 100644 --- a/aiopenapi3/loader.py +++ b/aiopenapi3/loader.py @@ -6,6 +6,36 @@ import yaml +from yaml import SafeLoader + +""" +https://stackoverflow.com/questions/34667108/ignore-dates-and-times-while-parsing-yaml +""" + + +class NoDatesSafeLoader(SafeLoader): + @classmethod + def remove_implicit_resolver(cls, tag_to_remove): + """ + Remove implicit resolvers for a particular tag + + Takes care not to modify resolvers in super classes. + + We want to load datetimes as strings, not dates, because we + go on to serialise as json which doesn't have the advanced types + of yaml, and leads to incompatibilities down the track. + """ + if not "yaml_implicit_resolvers" in cls.__dict__: + cls.yaml_implicit_resolvers = cls.yaml_implicit_resolvers.copy() + + for first_letter, mappings in cls.yaml_implicit_resolvers.items(): + cls.yaml_implicit_resolvers[first_letter] = [ + (tag, regexp) for tag, regexp in mappings if tag != tag_to_remove + ] + + +NoDatesSafeLoader.remove_implicit_resolver("tag:yaml.org,2002:timestamp") + class Loader(abc.ABC): @abc.abstractmethod @@ -31,7 +61,7 @@ def decode(cls, data, codec): @classmethod def parse(cls, plugins, file, data): if file.suffix == ".yaml": - data = yaml.safe_load(data) + data = yaml.load(data, Loader=NoDatesSafeLoader) elif file.suffix == ".json": data = json.loads(data) else: From d56b8ef7738cec61cd2928a1b73430a85c05dbf9 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 11 Jan 2022 16:50:15 +0100 Subject: [PATCH 079/125] base/resolve - lists & schema_ --- aiopenapi3/base.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/aiopenapi3/base.py b/aiopenapi3/base.py index 34e95d6..aacf720 100644 --- a/aiopenapi3/base.py +++ b/aiopenapi3/base.py @@ -125,10 +125,14 @@ def resolve_jp(self, jp): for part in path: part = JSONPointer.decode(part) + if part == "schema": + part = "schema_" if isinstance(node, dict): if part not in node: # pylint: disable=unsupported-membership-test raise ReferenceResolutionError(f"Invalid path {path} in Reference") node = node.get(part) + elif isinstance(node, list): + node = node[int(part)] else: if not hasattr(node, part): raise ReferenceResolutionError(f"Invalid path {path} in Reference") From 0c781cd0e7704a013c0e56b2505523e62d99b5a2 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 11 Jan 2022 16:51:02 +0100 Subject: [PATCH 080/125] =?UTF-8?q?v20=20-=20=E2=80=A6=20=20-=20Parameter.?= =?UTF-8?q?items=20can=20be=20empty=20=20-=20add=20Parameter.allowEmptyVal?= =?UTF-8?q?ue=20=20-=20{Item,Parameter,Header}=20have=20.uniqueitems?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aiopenapi3/v20/parameter.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/aiopenapi3/v20/parameter.py b/aiopenapi3/v20/parameter.py index 7a69d99..52ffe6a 100644 --- a/aiopenapi3/v20/parameter.py +++ b/aiopenapi3/v20/parameter.py @@ -4,7 +4,7 @@ from .general import Reference from .schemas import Schema -from ..base import ObjectExtended +from ..base import ObjectExtended, ObjectBase class Item(ObjectExtended): @@ -26,10 +26,15 @@ class Item(ObjectExtended): pattern: Optional[str] = Field(default=None) maxItems: Optional[int] = Field(default=None) minItems: Optional[int] = Field(default=None) + uniqueItems: Optional[bool] = Field(default=None) enum: Optional[Any] = Field(default=None) multipleOf: Optional[int] = Field(default=None) +class Empty(ObjectBase): + pass + + class Parameter(ObjectExtended): """ Describes a single operation parameter. @@ -47,7 +52,8 @@ class Parameter(ObjectExtended): type: Optional[str] = Field(default=None) format: Optional[str] = Field(default=None) - items: Optional[Item] = Field(default=None) + allowEmptyValue: Optional[bool] = Field(default=None) + items: Optional[Union[Item, Empty]] = Field(default=None) collectionFormat: Optional[str] = Field(default=None) default: Any = Field(default=None) maximum: Optional[int] = Field(default=None) @@ -59,6 +65,7 @@ class Parameter(ObjectExtended): pattern: Optional[str] = Field(default=None) maxItems: Optional[int] = Field(default=None) minItems: Optional[int] = Field(default=None) + uniqueItems: Optional[bool] = Field(default=None) enum: Optional[Any] = Field(default=None) multipleOf: Optional[int] = Field(default=None) @@ -84,6 +91,7 @@ class Header(ObjectExtended): pattern: Optional[str] = Field(default=None) maxItems: Optional[int] = Field(default=None) minItems: Optional[int] = Field(default=None) + uniqueItems: Optional[bool] = Field(default=None) enum: Optional[Any] = Field(default=None) multipleOf: Optional[int] = Field(default=None) From 6df4ae0ccdd1031744ce09081b7cdff1b77b009f Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 11 Jan 2022 16:53:06 +0100 Subject: [PATCH 081/125] schema - default is Any for v20/30/31 --- aiopenapi3/v20/schemas.py | 2 +- aiopenapi3/v30/schemas.py | 2 +- aiopenapi3/v31/schemas.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/aiopenapi3/v20/schemas.py b/aiopenapi3/v20/schemas.py index 7635301..e4754a2 100644 --- a/aiopenapi3/v20/schemas.py +++ b/aiopenapi3/v20/schemas.py @@ -18,7 +18,7 @@ class Schema(ObjectExtended, SchemaBase): format: Optional[str] = Field(default=None) title: Optional[str] = Field(default=None) description: Optional[str] = Field(default=None) - default: Optional[str] = Field(default=None) # TODO - str as a default? + default: Optional[Any] = Field(default=None) multipleOf: Optional[int] = Field(default=None) maximum: Optional[float] = Field(default=None) # FIXME Field(discriminator='type') would be better diff --git a/aiopenapi3/v30/schemas.py b/aiopenapi3/v30/schemas.py index 5d4eedf..2b89c10 100644 --- a/aiopenapi3/v30/schemas.py +++ b/aiopenapi3/v30/schemas.py @@ -51,7 +51,7 @@ class Schema(ObjectExtended, SchemaBase): additionalProperties: Optional[Union[bool, "Schema", Reference]] = Field(default=None) description: Optional[str] = Field(default=None) format: Optional[str] = Field(default=None) - default: Optional[str] = Field(default=None) # TODO - str as a default? + default: Optional[Any] = Field(default=None) nullable: Optional[bool] = Field(default=None) discriminator: Optional[Discriminator] = Field(default=None) # 'Discriminator' readOnly: Optional[bool] = Field(default=None) diff --git a/aiopenapi3/v31/schemas.py b/aiopenapi3/v31/schemas.py index 52d2ada..bf6bbe7 100644 --- a/aiopenapi3/v31/schemas.py +++ b/aiopenapi3/v31/schemas.py @@ -82,7 +82,7 @@ class Schema(ObjectExtended, SchemaBase): """ title: Optional[str] = Field(default=None) description: Optional[str] = Field(default=None) - default: Optional[str] = Field(default=None) # TODO - str as a default? + default: Optional[Any] = Field(default=None) deprecated: Optional[bool] = Field(default=None) readOnly: Optional[bool] = Field(default=None) writeOnly: Optional[bool] = Field(default=None) From 0e42c1fd14eab258684299169b0e02e3eb0048aa Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 11 Jan 2022 16:53:44 +0100 Subject: [PATCH 082/125] example - value is Any --- aiopenapi3/v30/example.py | 4 ++-- aiopenapi3/v31/example.py | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/aiopenapi3/v30/example.py b/aiopenapi3/v30/example.py index 0024ee1..44af95d 100644 --- a/aiopenapi3/v30/example.py +++ b/aiopenapi3/v30/example.py @@ -1,4 +1,4 @@ -from typing import Union, Optional +from typing import Optional, Any from pydantic import Field @@ -17,5 +17,5 @@ class Example(ObjectExtended): summary: Optional[str] = Field(default=None) description: Optional[str] = Field(default=None) - value: Optional[Union[Reference, dict, str]] = Field(default=None) # 'any' type + value: Optional[Any] = Field(default=None) externalValue: Optional[str] = Field(default=None) diff --git a/aiopenapi3/v31/example.py b/aiopenapi3/v31/example.py index abcbbb4..e910156 100644 --- a/aiopenapi3/v31/example.py +++ b/aiopenapi3/v31/example.py @@ -1,11 +1,9 @@ -from typing import Union, Optional +from typing import Optional, Any from pydantic import Field from ..base import ObjectExtended -from .general import Reference - class Example(ObjectExtended): """ @@ -17,5 +15,5 @@ class Example(ObjectExtended): summary: Optional[str] = Field(default=None) description: Optional[str] = Field(default=None) - value: Optional[Union[Reference, dict, str]] = Field(default=None) # 'any' type + value: Optional[Any] = Field(default=None) externalValue: Optional[str] = Field(default=None) From d4b55d24056d578ca648585f9441ff486f0c5162 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 11 Jan 2022 16:54:35 +0100 Subject: [PATCH 083/125] SecurityRequirement - the validator is wrong --- aiopenapi3/v30/security.py | 2 +- aiopenapi3/v31/security.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aiopenapi3/v30/security.py b/aiopenapi3/v30/security.py index 466b318..a5daa15 100644 --- a/aiopenapi3/v30/security.py +++ b/aiopenapi3/v30/security.py @@ -72,7 +72,7 @@ class SecurityRequirement(BaseModel): __root__: Dict[str, List[str]] - @root_validator + # @root_validator def validate_SecurityRequirement(cls, values): root = values.get("__root__", {}) if not (len(root.keys()) == 1 and isinstance([c for c in root.values()][0], list) or len(root.keys()) == 0): diff --git a/aiopenapi3/v31/security.py b/aiopenapi3/v31/security.py index 8573dbe..d2d08f4 100644 --- a/aiopenapi3/v31/security.py +++ b/aiopenapi3/v31/security.py @@ -72,7 +72,7 @@ class SecurityRequirement(BaseModel): __root__: Dict[str, List[str]] - @root_validator + # @root_validator def validate_SecurityRequirement(cls, values): root = values.get("__root__", {}) if not (len(root.keys()) == 1 and isinstance([c for c in root.values()][0], list) or len(root.keys()) == 0): From 6e5a0374af5d31079c0bc986e56ec9d2225492bc Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 11 Jan 2022 16:55:30 +0100 Subject: [PATCH 084/125] OpenAPI - allow the default session_factory --- aiopenapi3/openapi.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index 84c4369..95c232d 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -158,7 +158,9 @@ def __init__( self._root = self._parse_obj(document) - if issubclass(getattr(session_factory, "__annotations__", {}).get("return", None.__class__), httpx.Client): + if issubclass(getattr(session_factory, "__annotations__", {}).get("return", None.__class__), httpx.Client) or ( + type(session_factory) == type and issubclass(session_factory, httpx.Client) + ): if isinstance(self._root, v20.Root): self._createRequest = v20.Request elif isinstance(self._root, (v30.Root, v31.Root)): @@ -167,7 +169,7 @@ def __init__( raise ValueError(self._root) elif issubclass( getattr(session_factory, "__annotations__", {}).get("return", None.__class__), httpx.AsyncClient - ): + ) or (type(session_factory) == type and issubclass(session_factory, httpx.AsyncClient)): if isinstance(self._root, v20.Root): self._createRequest = v20.AsyncRequest elif isinstance(self._root, (v30.Root, v31.Root)): From f08a87ff380de2773b3dbdb11a370a92508a753c Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 11 Jan 2022 16:56:08 +0100 Subject: [PATCH 085/125] SecurityRequirement - wrong validator --- aiopenapi3/v20/security.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiopenapi3/v20/security.py b/aiopenapi3/v20/security.py index a7ecdec..e7ce7d4 100644 --- a/aiopenapi3/v20/security.py +++ b/aiopenapi3/v20/security.py @@ -33,7 +33,7 @@ class SecurityRequirement(BaseModel): __root__: Dict[str, List[str]] - @root_validator + # @root_validator def validate_SecurityRequirement(cls, values): root = values.get("__root__", {}) if not (len(root.keys()) == 1 and isinstance([c for c in root.values()][0], list) or len(root.keys()) == 0): From b7796386e00cf5f52efcba45468b3218a4ec2776 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 11 Jan 2022 16:56:36 +0100 Subject: [PATCH 086/125] v30 - export the glue --- aiopenapi3/v30/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiopenapi3/v30/__init__.py b/aiopenapi3/v30/__init__.py index 1e99716..fb30ad1 100644 --- a/aiopenapi3/v30/__init__.py +++ b/aiopenapi3/v30/__init__.py @@ -2,4 +2,4 @@ from .root import Root from .paths import PathItem, Operation, SecurityRequirement from .parameter import Parameter -from .glue import Request +from .glue import Request, AsyncRequest From d0e37eca07fbb2358c1b4c72391664e5a92823b6 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 12 Jan 2022 14:38:58 +0100 Subject: [PATCH 087/125] SecurityScheme - extensions are allowed --- aiopenapi3/v30/security.py | 2 +- aiopenapi3/v31/security.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aiopenapi3/v30/security.py b/aiopenapi3/v30/security.py index a5daa15..9521e9e 100644 --- a/aiopenapi3/v30/security.py +++ b/aiopenapi3/v30/security.py @@ -51,7 +51,7 @@ class SecurityScheme(ObjectExtended): def validate_SecurityScheme(cls, values): t = values.get("type", None) keys = set(map(lambda x: x[0], filter(lambda x: x[1] is not None, values.items()))) - keys -= frozenset(["type", "description"]) + keys -= frozenset(["type", "description", "extensions"]) if t == "apikey": assert keys == set(["in_", "name"]) if t == "http": diff --git a/aiopenapi3/v31/security.py b/aiopenapi3/v31/security.py index d2d08f4..3466eb0 100644 --- a/aiopenapi3/v31/security.py +++ b/aiopenapi3/v31/security.py @@ -51,7 +51,7 @@ class SecurityScheme(ObjectExtended): def validate_SecurityScheme(cls, values): t = values.get("type", None) keys = set(map(lambda x: x[0], filter(lambda x: x[1] is not None, values.items()))) - keys -= frozenset(["type", "description"]) + keys -= frozenset(["type", "description", "extensions"]) if t == "apikey": assert keys == set(["in_", "name"]) if t == "http": From e17b47bb02e9a930507f74496edf370b9cb29bb4 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 12 Jan 2022 15:05:33 +0100 Subject: [PATCH 088/125] v31 - Schema.additionalProperties can be bool/Schema --- aiopenapi3/v31/schemas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiopenapi3/v31/schemas.py b/aiopenapi3/v31/schemas.py index bf6bbe7..46b6f1d 100644 --- a/aiopenapi3/v31/schemas.py +++ b/aiopenapi3/v31/schemas.py @@ -140,7 +140,7 @@ class Schema(ObjectExtended, SchemaBase): """ properties: Optional[Dict[str, "Schema"]] = Field(default_factory=dict) patternProperties: Optional[Dict[str, str]] = Field(default_factory=dict) - additionalProperties: Optional["Schema"] = Field(default=None) + additionalProperties: Optional[Union[bool, "Schema"]] = Field(default=None) unevaluatedProperties: Optional["Schema"] = Field(default=None) propertyNames: Optional["Schema"] = Field(default=None) From 20112ee80e5582cd7c8b1bdce7681e359a4a6d5d Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 12 Jan 2022 15:06:19 +0100 Subject: [PATCH 089/125] v20 - Schema.items can be List --- aiopenapi3/v20/schemas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiopenapi3/v20/schemas.py b/aiopenapi3/v20/schemas.py index e4754a2..c6b4599 100644 --- a/aiopenapi3/v20/schemas.py +++ b/aiopenapi3/v20/schemas.py @@ -37,7 +37,7 @@ class Schema(ObjectExtended, SchemaBase): enum: Optional[list] = Field(default=None) type: Optional[str] = Field(default=None) - items: Optional[Union["Schema", Reference]] = Field(default=None) + items: Optional[Union[List[Union["Schema", Reference]], Union["Schema", Reference]]] = Field(default=None) allOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) properties: Optional[Dict[str, Union["Schema", Reference]]] = Field(default_factory=dict) additionalProperties: Optional[Union[bool, "Schema", Reference]] = Field(default=None) From 4d571fe895df31524f2edf73a700431a6b66fe71 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 12 Jan 2022 15:06:52 +0100 Subject: [PATCH 090/125] v20 - empty object can have extensions? --- aiopenapi3/v20/parameter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiopenapi3/v20/parameter.py b/aiopenapi3/v20/parameter.py index 52ffe6a..8b079bc 100644 --- a/aiopenapi3/v20/parameter.py +++ b/aiopenapi3/v20/parameter.py @@ -31,7 +31,7 @@ class Item(ObjectExtended): multipleOf: Optional[int] = Field(default=None) -class Empty(ObjectBase): +class Empty(ObjectExtended): pass From eb47989f254eb47162e21c2e8b49bd859c7fab66 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 12 Jan 2022 15:10:33 +0100 Subject: [PATCH 091/125] v20/30/31 - paths can have extensions --- aiopenapi3/v20/paths.py | 18 ++++++++++++++++-- aiopenapi3/v20/root.py | 12 +++++------- aiopenapi3/v30/paths.py | 14 ++++++++++++++ aiopenapi3/v30/root.py | 7 ++++--- aiopenapi3/v31/paths.py | 16 +++++++++++++++- aiopenapi3/v31/root.py | 7 +++---- tests/parsing_test.py | 30 ++++++++++++++++++++++++++++++ 7 files changed, 87 insertions(+), 17 deletions(-) diff --git a/aiopenapi3/v20/paths.py b/aiopenapi3/v20/paths.py index 3f2a757..bb1924a 100644 --- a/aiopenapi3/v20/paths.py +++ b/aiopenapi3/v20/paths.py @@ -1,13 +1,13 @@ from typing import Union, List, Optional, Dict, Any -from pydantic import Field +from pydantic import Field, root_validator from .general import ExternalDocumentation from .general import Reference from .parameter import Header, Parameter from .schemas import Schema from .security import SecurityRequirement -from ..base import ObjectExtended +from ..base import ObjectExtended, ObjectBase, PathsBase class Response(ObjectExtended): @@ -63,4 +63,18 @@ class PathItem(ObjectExtended): parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) +class Paths(PathsBase): + @root_validator(pre=True) + def validate_Paths(cls, values): + assert set(values.keys()) - frozenset(["__root__"]) == set([]) + p = {} + e = {} + for k, v in values.get("__root__", {}).items(): + if k[:2] == "x-": + e[k] = v + else: + p[k] = PathItem(**v) + return {"_paths": p, "_extensions": e} + + Operation.update_forward_refs() diff --git a/aiopenapi3/v20/root.py b/aiopenapi3/v20/root.py index f41e9e1..e8226a3 100644 --- a/aiopenapi3/v20/root.py +++ b/aiopenapi3/v20/root.py @@ -1,17 +1,15 @@ from typing import List, Optional, Dict -from pydantic import Field - -from ..base import ObjectExtended, RootBase +from pydantic import Field, validator from .general import Reference, ExternalDocumentation from .info import Info -from .paths import PathItem +from .parameter import Parameter +from .paths import Response, Paths, PathItem from .schemas import Schema from .security import SecurityScheme, SecurityRequirement -from .paths import Response -from .paths import Parameter from .tag import Tag +from ..base import ObjectExtended, RootBase class Root(ObjectExtended, RootBase): @@ -28,7 +26,7 @@ class Root(ObjectExtended, RootBase): schemes: Optional[List[str]] = Field(default_factory=list) consumes: Optional[List[str]] = Field(default_factory=list) produces: Optional[List[str]] = Field(default_factory=list) - paths: Dict[str, PathItem] = Field(default_factory=dict) + paths: Paths = Field(default=None) definitions: Optional[Dict[str, Schema]] = Field(default_factory=dict) parameters: Optional[Dict[str, Parameter]] = Field(default_factory=dict) responses: Optional[Dict[str, Response]] = Field(default_factory=dict) diff --git a/aiopenapi3/v30/paths.py b/aiopenapi3/v30/paths.py index 61f89a7..436f6a7 100644 --- a/aiopenapi3/v30/paths.py +++ b/aiopenapi3/v30/paths.py @@ -107,6 +107,20 @@ class PathItem(ObjectExtended): parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) +class Paths(PathsBase): + @root_validator(pre=True) + def validate_Paths(cls, values): + assert set(values.keys()) - frozenset(["__root__"]) == set([]) + p = {} + e = {} + for k, v in values.get("__root__", {}).items(): + if k[:2] == "x-": + e[k] = v + else: + p[k] = PathItem(**v) + return {"_paths": p, "_extensions": e} + + class Callback(ObjectBase): """ A map of possible out-of band callbacks related to the parent operation. diff --git a/aiopenapi3/v30/root.py b/aiopenapi3/v30/root.py index 6dd51c1..5f7d552 100644 --- a/aiopenapi3/v30/root.py +++ b/aiopenapi3/v30/root.py @@ -1,13 +1,14 @@ from typing import Any, List, Optional, Dict -from pydantic import Field +from pydantic import Field, validator from ..base import ObjectExtended, RootBase from .components import Components from .general import Reference from .info import Info -from .paths import PathItem, SecurityRequirement +from .paths import PathItem, Paths +from .security import SecurityRequirement from .servers import Server from .tag import Tag @@ -23,7 +24,7 @@ class Root(ObjectExtended, RootBase): openapi: str = Field(...) info: Info = Field(...) servers: Optional[List[Server]] = Field(default_factory=list) - paths: Dict[str, PathItem] = Field(required=True, default_factory=dict) + paths: Paths = Field(required=True, default=None) components: Optional[Components] = Field(default_factory=Components) security: Optional[List[SecurityRequirement]] = Field(default=None) tags: Optional[List[Tag]] = Field(default=None) diff --git a/aiopenapi3/v31/paths.py b/aiopenapi3/v31/paths.py index ccbc386..54bffa2 100644 --- a/aiopenapi3/v31/paths.py +++ b/aiopenapi3/v31/paths.py @@ -2,7 +2,7 @@ from pydantic import Field, root_validator -from ..base import ObjectBase, ObjectExtended +from ..base import ObjectBase, ObjectExtended, PathsBase from ..errors import SpecError from .general import ExternalDocumentation from .general import Reference @@ -107,6 +107,20 @@ class PathItem(ObjectExtended): parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) +class Paths(PathsBase): + @root_validator(pre=True) + def validate_Paths(cls, values): + assert set(values.keys()) - frozenset(["__root__"]) == set([]) + p = {} + e = {} + for k, v in values.get("__root__", {}).items(): + if k[:2] == "x-": + e[k] = v + else: + p[k] = PathItem(**v) + return {"_paths": p, "_extensions": e} + + class Callback(ObjectBase): """ A map of possible out-of band callbacks related to the parent operation. diff --git a/aiopenapi3/v31/root.py b/aiopenapi3/v31/root.py index 6b65026..5cc7c78 100644 --- a/aiopenapi3/v31/root.py +++ b/aiopenapi3/v31/root.py @@ -1,11 +1,11 @@ from typing import Any, List, Optional, Dict, Union -from pydantic import Field, root_validator +from pydantic import Field, root_validator, validator from ..base import ObjectExtended, RootBase from .info import Info -from .paths import PathItem +from .paths import Paths, PathItem from .security import SecurityRequirement from .servers import Server @@ -26,14 +26,13 @@ class Root(ObjectExtended, RootBase): info: Info = Field(...) jsonSchemaDialect: Optional[str] = Field(default=None) # FIXME should be URI servers: Optional[List[Server]] = Field(default=None) - paths: Optional[Dict[str, PathItem]] = Field(required=False) + paths: Paths = Field(default_factory=Paths) webhooks: Optional[Dict[str, Union[PathItem, Reference]]] = Field(required=False) components: Optional[Components] = Field(default=None) security: Optional[List[SecurityRequirement]] = Field(default=None) tags: Optional[List[Tag]] = Field(default=None) externalDocs: Optional[Dict[Any, Any]] = Field(default_factory=dict) - @root_validator def validate_Root(cls, values): assert any([values.get(i) is not None for i in ["paths", "components", "webhooks"]]), values return values diff --git a/tests/parsing_test.py b/tests/parsing_test.py index 579d24b..b20871a 100644 --- a/tests/parsing_test.py +++ b/tests/parsing_test.py @@ -124,3 +124,33 @@ def test_securityparameters(with_securityparameters): def test_callback(with_callback): spec = OpenAPI(URLBASE, with_callback) + + +@dataclasses.dataclass +class _Version: + major: int + minor: int + patch: int = 0 + + def __str__(self): + if self.major == 3: + return f'openapi: "{self.major}.{self.minor}.{self.patch}"' + else: + return f'swagger: "{self.major}.{self.minor}"' + + +@pytest.fixture(scope="session", params=[_Version(2, 0), _Version(3, 0, 3), _Version(3, 1, 0)]) +def openapi_version(request): + return request.param + + +def test_extended_paths(openapi_version): + DOC = f"""{openapi_version} +info: + title: '' + version: 0.0.0 +paths: + x-codegen-contextRoot: /apis/registry/v2 +""" + api = OpenAPI.loads("test.yaml", DOC) + print(api) From 0c3bb1422b771204f7b793b994f9417146f66eb3 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 12 Jan 2022 15:12:21 +0100 Subject: [PATCH 092/125] Link - validator behaviour changed --- aiopenapi3/v30/paths.py | 17 +++++++++-------- aiopenapi3/v31/paths.py | 17 +++++++++-------- tests/fixtures/with-broken-links.yaml | 16 ---------------- 3 files changed, 18 insertions(+), 32 deletions(-) diff --git a/aiopenapi3/v30/paths.py b/aiopenapi3/v30/paths.py index 436f6a7..afbc09b 100644 --- a/aiopenapi3/v30/paths.py +++ b/aiopenapi3/v30/paths.py @@ -2,7 +2,7 @@ from pydantic import Field, root_validator -from ..base import ObjectBase, ObjectExtended +from ..base import ObjectBase, ObjectExtended, PathsBase from ..errors import SpecError from .general import ExternalDocumentation from .general import Reference @@ -38,14 +38,15 @@ class Link(ObjectExtended): description: Optional[str] = Field(default=None) server: Optional[Server] = Field(default=None) - @root_validator(pre=False) + @root_validator def validate_Link_operation(cls, values): - if values["operationId"] != None and values["operationRef"] != None: - raise SpecError("operationId and operationRef are mutually exclusive, only one of them is allowed") - - if values["operationId"] == values["operationRef"] == None: - raise SpecError("operationId and operationRef are mutually exclusive, one of them must be specified") - + operationId, operationRef = (values.get(i, None) for i in ["operationId", "operationRef"]) + assert not ( + operationId != None and operationRef != None + ), "operationId and operationRef are mutually exclusive, only one of them is allowed" + assert not ( + operationId == operationRef == None + ), "operationId and operationRef are mutually exclusive, one of them must be specified" return values diff --git a/aiopenapi3/v31/paths.py b/aiopenapi3/v31/paths.py index 54bffa2..caacf5f 100644 --- a/aiopenapi3/v31/paths.py +++ b/aiopenapi3/v31/paths.py @@ -1,6 +1,6 @@ from typing import Union, List, Optional, Dict, Any -from pydantic import Field, root_validator +from pydantic import Field, root_validator, validator from ..base import ObjectBase, ObjectExtended, PathsBase from ..errors import SpecError @@ -38,14 +38,15 @@ class Link(ObjectExtended): description: Optional[str] = Field(default=None) server: Optional[Server] = Field(default=None) - @root_validator(pre=False) + @root_validator def validate_Link_operation(cls, values): - if values["operationId"] != None and values["operationRef"] != None: - raise SpecError("operationId and operationRef are mutually exclusive, only one of them is allowed") - - if values["operationId"] == values["operationRef"] == None: - raise SpecError("operationId and operationRef are mutually exclusive, one of them must be specified") - + operationId, operationRef = (values.get(i, None) for i in ["operationId", "operationRef"]) + assert not ( + operationId != None and operationRef != None + ), "operationId and operationRef are mutually exclusive, only one of them is allowed" + assert not ( + operationId == operationRef == None + ), "operationId and operationRef are mutually exclusive, one of them must be specified" return values diff --git a/tests/fixtures/with-broken-links.yaml b/tests/fixtures/with-broken-links.yaml index e393969..c3dcc5c 100644 --- a/tests/fixtures/with-broken-links.yaml +++ b/tests/fixtures/with-broken-links.yaml @@ -24,22 +24,6 @@ paths: operationRef: "/with-links" parameters: param: baz - /with-links-two: - get: - operationId: withLinksTwo - responses: - '200': - description: This has links too - content: - applicaton/json: - schema: - type: object - properties: - test: - type: string - description: A test response fields - example: foobar - links: exampleWithNeither: parameters: param: baz From 5c0ae85fb7842c66ea0fe2021caf77471f49679f Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 12 Jan 2022 15:16:12 +0100 Subject: [PATCH 093/125] loader - WebLoader, NullLoader --- aiopenapi3/loader.py | 47 ++++++++++++++++++++++++++++++++----------- aiopenapi3/openapi.py | 6 ++++-- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/aiopenapi3/loader.py b/aiopenapi3/loader.py index 7e8d745..fac38bb 100644 --- a/aiopenapi3/loader.py +++ b/aiopenapi3/loader.py @@ -2,18 +2,18 @@ import json from pathlib import Path -from .plugin import Plugins - import yaml +import httpx +import yarl -from yaml import SafeLoader +from .plugin import Plugins """ https://stackoverflow.com/questions/34667108/ignore-dates-and-times-while-parsing-yaml """ -class NoDatesSafeLoader(SafeLoader): +class YAMLCompatibilityLoader(yaml.SafeLoader): @classmethod def remove_implicit_resolver(cls, tag_to_remove): """ @@ -34,10 +34,13 @@ def remove_implicit_resolver(cls, tag_to_remove): ] -NoDatesSafeLoader.remove_implicit_resolver("tag:yaml.org,2002:timestamp") +YAMLCompatibilityLoader.remove_implicit_resolver("tag:yaml.org,2002:timestamp") class Loader(abc.ABC): + def __init__(self, yload: yaml.Loader = yaml.SafeLoader): + self.yload = yload + @abc.abstractmethod def load(self, plugins, file: Path, codec=None): raise NotImplementedError("load") @@ -58,10 +61,9 @@ def decode(cls, data, codec): raise ValueError("encoding") return data - @classmethod - def parse(cls, plugins, file, data): + def parse(self, plugins, file, data): if file.suffix == ".yaml": - data = yaml.load(data, Loader=NoDatesSafeLoader) + data = yaml.load(data, Loader=self.yload) elif file.suffix == ".json": data = json.loads(data) else: @@ -73,8 +75,30 @@ def get(self, plugins, file): return self.parse(plugins, file, data) +class NullLoader(Loader): + def load(self, plugins, file: Path, codec=None): + raise NotImplementedError("load") + + +class WebLoader(Loader): + def __init__(self, baseurl, session_factory=httpx.Client, yload=yaml.SafeLoader): + super().__init__(yload) + self.baseurl = baseurl + self.session_factory = session_factory + + def load(self, plugins, file: Path, codec=None): + url = self.baseurl.join(yarl.URL(str(file))) + with self.session_factory() as session: + data = session.get(str(url)) + data = data.content + data = self.decode(data, codec) + data = plugins.document.loaded(url=str(file), document=data).document + return data + + class FileSystemLoader(Loader): - def __init__(self, base: Path): + def __init__(self, base: Path, yload: yaml.Loader = yaml.SafeLoader): + super().__init__(yload) assert isinstance(base, Path) self.base = base @@ -88,8 +112,7 @@ def load(self, plugins: Plugins, file: Path, codec=None): data = plugins.document.loaded(url=str(file), document=data).document return data - @classmethod - def parse(cls, plugins, file, data): - data = Loader.parse(plugins, file, data) + def parse(self, plugins, file, data): + data = super().parse(plugins, file, data) data = plugins.document.parsed(url=str(file), document=data).document return data diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index 95c232d..9e46824 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -12,7 +12,7 @@ from . import v31 from .request import OperationIndex, HTTP_METHODS from .errors import ReferenceResolutionError, SpecError -from .loader import Loader +from .loader import Loader, NullLoader from .plugin import Plugin, Plugins from .base import RootBase from .v30.paths import Operation @@ -81,7 +81,9 @@ def loads( loader=None, plugins: List[Plugin] = None, ): - data = Loader.parse(Plugins(plugins or []), pathlib.Path(url), data) + if loader is None: + loader = NullLoader() + data = loader.parse(Plugins(plugins or []), pathlib.Path(url), data) return cls(url, data, session_factory, loader, plugins) def _parse_obj(self, raw_document): From dafb3cb59d70d559ef62ae8cec52736d5d293c2d Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 12 Jan 2022 15:16:58 +0100 Subject: [PATCH 094/125] loader - YAMLCompatibilityLoader remove tags for int/bool/dict --- aiopenapi3/loader.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/aiopenapi3/loader.py b/aiopenapi3/loader.py index fac38bb..33dfaff 100644 --- a/aiopenapi3/loader.py +++ b/aiopenapi3/loader.py @@ -36,6 +36,21 @@ def remove_implicit_resolver(cls, tag_to_remove): YAMLCompatibilityLoader.remove_implicit_resolver("tag:yaml.org,2002:timestamp") +""" +example: = +""" +YAMLCompatibilityLoader.remove_implicit_resolver("tag:yaml.org,2002:value") + +""" +18_24: test +""" +YAMLCompatibilityLoader.remove_implicit_resolver("tag:yaml.org,2002:int") + +""" +name: on +""" +YAMLCompatibilityLoader.remove_implicit_resolver("tag:yaml.org,2002:bool") + class Loader(abc.ABC): def __init__(self, yload: yaml.Loader = yaml.SafeLoader): From 5b6c43b4c141b0c10b0e61911b287752803bbf25 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 12 Jan 2022 15:18:31 +0100 Subject: [PATCH 095/125] v20/30/31 - the PathBase --- aiopenapi3/base.py | 38 +++++++++++++++++++++++++++++++++++--- tests/loader_test.py | 13 +++++++++++-- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/aiopenapi3/base.py b/aiopenapi3/base.py index aacf720..8aceefa 100644 --- a/aiopenapi3/base.py +++ b/aiopenapi3/base.py @@ -44,6 +44,27 @@ def validate_ObjectExtended_extensions(cls, values): from .json import JSONPointer from .errors import ReferenceResolutionError +from typing import Dict, Any + + +class PathsBase(ObjectBase): + __root__: Dict[str, Any] = Field(default_factory=dict) + + class Config: + from pydantic import Extra + + extra = Extra.allow + + @property + def extensions(self): + return self._extensions + + def __getitem__(self, item): + return self._paths[item] + + def items(self): + return self._paths.items() + class RootBase: @staticmethod @@ -73,6 +94,11 @@ def resolve(api, root, obj, _PathItem, _Reference): setattr(obj, slot, ref) value = getattr(obj, slot) + + if isinstance(value, PathsBase): + value.items() + value = value._paths + if isinstance(value, (str, int, float)): # , datetime.datetime, datetime.date)): continue elif isinstance(value, _Reference): @@ -125,18 +151,24 @@ def resolve_jp(self, jp): for part in path: part = JSONPointer.decode(part) - if part == "schema": - part = "schema_" + + if isinstance(node, PathsBase): # forward + node = node._paths # will be dict + if isinstance(node, dict): if part not in node: # pylint: disable=unsupported-membership-test raise ReferenceResolutionError(f"Invalid path {path} in Reference") node = node.get(part) elif isinstance(node, list): node = node[int(part)] - else: + elif isinstance(node, ObjectBase): + if part == "schema": + part = "schema_" if not hasattr(node, part): raise ReferenceResolutionError(f"Invalid path {path} in Reference") node = getattr(node, part) + else: + raise TypeError(node) return node diff --git a/tests/loader_test.py b/tests/loader_test.py index f6deeb9..5f09d74 100644 --- a/tests/loader_test.py +++ b/tests/loader_test.py @@ -44,10 +44,10 @@ def test_loader_jsonref(jsonref, exception): loader = FileSystemLoader(Path("tests/fixtures")) values = {"jsonref": jsonref, "description": ""} if exception is None: - api = OpenAPI.loads("loader.yaml", SPECTPL.format(**values), session_factory=None, loader=loader) + api = OpenAPI.loads("loader.yaml", SPECTPL.format(**values), loader=loader) else: with pytest.raises(exception): - api = OpenAPI.loads("loader.yaml", SPECTPL.format(**values), session_factory=None, loader=loader) + api = OpenAPI.loads("loader.yaml", SPECTPL.format(**values), loader=loader) def test_loader_decode(): @@ -63,3 +63,12 @@ def test_loader_format(): spec = Loader.parse(Plugins([]), Path("loader.yaml"), spec) spec = json.dumps(spec) api = OpenAPI.loads("loader.json", spec) + + +def test_webload(): + name = "https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/network/resource-manager/Microsoft.Network/stable/2018-10-01/serviceEndpointPolicy.json" + from aiopenapi3.loader import WebLoader + import yarl + + loader = WebLoader(yarl.URL(name)) + api = OpenAPI.load_sync(name, loader=loader) From eea8e95749664b84354196af86e0caf67d646958 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 12 Jan 2022 15:21:00 +0100 Subject: [PATCH 096/125] tests - fixes --- tests/parsing_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/parsing_test.py b/tests/parsing_test.py index b20871a..c760844 100644 --- a/tests/parsing_test.py +++ b/tests/parsing_test.py @@ -1,6 +1,7 @@ """ Tests parsing specs """ +import dataclasses import pytest from pydantic import ValidationError From 7626842f081f9017247cb7c63ff2fe19d6506d4f Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 12 Jan 2022 15:29:26 +0100 Subject: [PATCH 097/125] =?UTF-8?q?Paths=20-=20=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 37 ++++++++++++++++++++++++++++++++++++- aiopenapi3/base.py | 3 +++ aiopenapi3/openapi.py | 8 ++++++-- tests/path_test.py | 6 +++--- 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 503b308..99759cd 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,15 @@ A Python [OpenAPI 3 Specification](https://github.com/OAI/OpenAPI-Specification/ This project is based on [Dorthu/openapi3](github.com/Dorthu/openapi3/). ## Features - * implements OpenAPI 3.0.3 + * implements … + * Swagger 2.0 + * OpenAPI 3.0.3 + * OpenAPI 3.1.0 * object parsing via pydantic * request body model creation via [pydantic](https://github.com/samuelcolvin/pydantic) * blocking and nonblocking (asyncio) interface via [httpx](https://www.python-httpx.org/) + * tests with pytest + * providing access to methods and arguments via the sad smiley ._. interface ## Usage as a Client @@ -169,6 +174,36 @@ paths -> /with-links-two -> get -> responses -> 200 -> $ref field required (type=value_error.missing) ``` +## Real World issues +### YAML +The description document may no be valid yaml. +YAML type coercion can cause this. +```python +>>> yaml.safe_load(str(datetime.datetime.now().date())) +datetime.date(2022, 1, 12) + +>>> yaml.safe_load("name: on") +{'name': True} + +>>> yaml.safe_load('12_24: "test"') +{1224: 'test'} +``` +Those can be turned of using the yload yaml.Loader argument to the Loader. + +```python +import aiopenapi3.loader + +OpenAPI.load…(…, loader=FileSystemLoader(pathlib.Path(dir), yload = aiopenapi3.loader.YAMLCompatibilityLoader)) + +``` + +### description document mismatch +In case the description document does not match the protocol, it may be required to alter the description, objects or data sent/received. +The [Plugin interface](tests/plugin_test.py) can be used to alter any of those. +It can even be used to alter an invalid description document to be usable. + + + ## Running Tests This project includes a test suite, run via ``pytest``. To run the test suite, diff --git a/aiopenapi3/base.py b/aiopenapi3/base.py index 8aceefa..10c3817 100644 --- a/aiopenapi3/base.py +++ b/aiopenapi3/base.py @@ -65,6 +65,9 @@ def __getitem__(self, item): def items(self): return self._paths.items() + def values(self): + return self._paths.values() + class RootBase: @staticmethod diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index 9e46824..d091654 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -257,8 +257,12 @@ def authenticate(self, security_scheme, value): self._security = None return - if security_scheme not in self._root.securityDefinitions: - raise ValueError("{} does not accept security scheme {}".format(self.info.title, security_scheme)) + if isinstance(self._root, v20.Root): + if security_scheme not in self._root.securityDefinitions: + raise ValueError("{} does not accept security scheme {}".format(self.info.title, security_scheme)) + elif isinstance(self._root, (v30.Root, v31.Root)): + if security_scheme not in self._root.components.securitySchemes: + raise ValueError("{} does not accept security scheme {}".format(self.info.title, security_scheme)) self._security = {security_scheme: value} diff --git a/tests/path_test.py b/tests/path_test.py index b12489b..92de309 100644 --- a/tests/path_test.py +++ b/tests/path_test.py @@ -20,9 +20,9 @@ def test_paths_exist(petstore_expanded_spec): """ Tests that paths are parsed correctly """ - assert "/pets" in petstore_expanded_spec.paths - assert "/pets/{id}" in petstore_expanded_spec.paths - assert len(petstore_expanded_spec.paths) == 2 + assert "/pets" in petstore_expanded_spec.paths._paths + assert "/pets/{id}" in petstore_expanded_spec.paths._paths + assert len(petstore_expanded_spec.paths._paths) == 2 def test_operations_exist(petstore_expanded_spec): From 8c51026edf1c2903cc1f5c6ce88ed973e633aca0 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 12 Jan 2022 15:32:32 +0100 Subject: [PATCH 098/125] loader - fix tests --- tests/loader_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/loader_test.py b/tests/loader_test.py index 5f09d74..a51ad39 100644 --- a/tests/loader_test.py +++ b/tests/loader_test.py @@ -3,7 +3,7 @@ import pytest from aiopenapi3 import OpenAPI, FileSystemLoader, ReferenceResolutionError -from aiopenapi3.loader import Loader, Plugins +from aiopenapi3.loader import Loader, Plugins, NullLoader SPECTPL = """ openapi: "3.0.0" @@ -59,8 +59,8 @@ def test_loader_format(): values = {"jsonref": "'#/components/schemas/Example'", "description": ""} spec = SPECTPL.format(**values) api = OpenAPI.loads("loader.yaml", spec) - - spec = Loader.parse(Plugins([]), Path("loader.yaml"), spec) + loader = NullLoader() + spec = loader.parse(Plugins([]), Path("loader.yaml"), spec) spec = json.dumps(spec) api = OpenAPI.loads("loader.json", spec) From 432494e4cd65a6c22f518de0487931b70f0d4221 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 12 Jan 2022 17:09:22 +0100 Subject: [PATCH 099/125] compat - going to 3.7 --- .github/workflows/codecov.yml | 3 ++- aiopenapi3/loader.py | 9 ++++++++- aiopenapi3/model.py | 10 +++++++++- aiopenapi3/openapi.py | 8 ++++++-- aiopenapi3/plugin.py | 3 ++- setup.cfg | 6 ++++++ tests/api/v2/schema.py | 10 +++++++++- tests/fastapi_test.py | 5 +++++ tests/linode_test.py | 6 +++++- tests/model_test.py | 8 +++++--- tests/ref_test.py | 12 +++++++++++- 11 files changed, 68 insertions(+), 12 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index e6566d6..515aba2 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -6,7 +6,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - python: ["3.9","3.10"] + python: ["3.7","3.8","3.9","3.10"] env: OS: ${{ matrix.os }} PYTHON: ${{ matrix.python }} @@ -20,6 +20,7 @@ jobs: - name: Generate coverage report run: | pip install '.[tests]' + pip install '.[compat]' pytest --cov=./ --cov-report=xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v2 diff --git a/aiopenapi3/loader.py b/aiopenapi3/loader.py index 33dfaff..52bc1a2 100644 --- a/aiopenapi3/loader.py +++ b/aiopenapi3/loader.py @@ -1,11 +1,18 @@ import abc import json -from pathlib import Path + import yaml import httpx import yarl +import sys + +if sys.version_info >= (3, 9): + from pathlib import Path +else: + from pathlib3x import Path + from .plugin import Plugins """ diff --git a/aiopenapi3/model.py b/aiopenapi3/model.py index bee60a1..d9265be 100644 --- a/aiopenapi3/model.py +++ b/aiopenapi3/model.py @@ -1,6 +1,14 @@ +from __future__ import annotations import types import uuid -from typing import List, Literal, Optional, Annotated, Union + +import sys + +if sys.version_info >= (3, 9): + from typing import List, Optional, Literal, Union, Annotated +else: + from typing import List, Optional, Union + from typing_extensions import Annotated, Literal from pydantic import BaseModel, Extra, Field diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index d091654..1790158 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -87,7 +87,8 @@ def loads( return cls(url, data, session_factory, loader, plugins) def _parse_obj(self, raw_document): - if v := raw_document.get("openapi", None): + v = raw_document.get("openapi", None) + if v: v = list(map(int, v.split("."))) if v[0] == 3: if v[1] == 0: @@ -98,7 +99,10 @@ def _parse_obj(self, raw_document): raise ValueError(f"openapi version 3.{v[1]} not supported") else: raise ValueError(f"openapi major version {v[0]} not supported") - elif v := raw_document.get("swagger", None): + return + + v = raw_document.get("swagger", None) + if v: v = list(map(int, v.split("."))) if v[0] == 2 and v[1] == 0: return v20.Root.parse_obj(raw_document) diff --git a/aiopenapi3/plugin.py b/aiopenapi3/plugin.py index afcf140..31c80e6 100644 --- a/aiopenapi3/plugin.py +++ b/aiopenapi3/plugin.py @@ -103,7 +103,8 @@ def __init__(self, name: str, domain: Domain): def __call__(self, **kwargs): r = self.domain.Context(**kwargs) for plugin in self.domain.plugins: - if (method := getattr(plugin, self.name, None)) is None: + method = getattr(plugin, self.name, None) + if method is None: continue method(r) return r diff --git a/setup.cfg b/setup.cfg index 25de12d..2ffc376 100644 --- a/setup.cfg +++ b/setup.cfg @@ -25,6 +25,8 @@ classifiers = Typing :: Typed Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 @@ -49,3 +51,7 @@ tests = fastapi-versioning hypercorn uvloop +compat = + typing_extensions + pathlib3x + httpx_socks diff --git a/tests/api/v2/schema.py b/tests/api/v2/schema.py index d5e6e28..7eef836 100644 --- a/tests/api/v2/schema.py +++ b/tests/api/v2/schema.py @@ -1,5 +1,13 @@ import uuid -from typing import List, Optional, Literal, Union, Annotated + +import sys + +if sys.version_info >= (3, 9): + from typing import List, Optional, Literal, Union, Annotated +else: + from typing import List, Optional, Union + from typing_extensions import Annotated, Literal + import pydantic from pydantic import BaseModel, Field diff --git a/tests/fastapi_test.py b/tests/fastapi_test.py index fdb4ccc..4d31d2f 100644 --- a/tests/fastapi_test.py +++ b/tests/fastapi_test.py @@ -1,5 +1,6 @@ import asyncio import uuid +import sys import pytest @@ -49,6 +50,7 @@ def randomPet(name=None): @pytest.mark.asyncio +@pytest.mark.skipif(sys.version_info < (3, 9), reason="requires asyncio.to_thread") async def test_createPet(event_loop, server, client): r = await asyncio.to_thread(client._.createPet, **randomPet()) assert type(r).schema() == client.components.schemas["Pet"].get_type().schema() @@ -58,6 +60,7 @@ async def test_createPet(event_loop, server, client): @pytest.mark.asyncio +@pytest.mark.skipif(sys.version_info < (3, 9), reason="requires asyncio.to_thread") async def test_listPet(event_loop, server, client): r = await asyncio.to_thread(client._.createPet, **randomPet(uuid.uuid4())) l = await asyncio.to_thread(client._.listPet) @@ -65,6 +68,7 @@ async def test_listPet(event_loop, server, client): @pytest.mark.asyncio +@pytest.mark.skipif(sys.version_info < (3, 9), reason="requires asyncio.to_thread") async def test_getPet(event_loop, server, client): pet = await asyncio.to_thread(client._.createPet, **randomPet(uuid.uuid4())) r = await asyncio.to_thread(client._.getPet, parameters={"pet_id": pet.id}) @@ -76,6 +80,7 @@ async def test_getPet(event_loop, server, client): @pytest.mark.asyncio +@pytest.mark.skipif(sys.version_info < (3, 9), reason="requires asyncio.to_thread") async def test_deletePet(event_loop, server, client): r = await asyncio.to_thread(client._.deletePet, parameters={"pet_id": -1}) assert type(r).schema() == client.components.schemas["Error"].get_type().schema() diff --git a/tests/linode_test.py b/tests/linode_test.py index 39a86ea..7c5144f 100644 --- a/tests/linode_test.py +++ b/tests/linode_test.py @@ -17,7 +17,11 @@ def event_loop(request): @pytest.fixture(scope="session") async def api(): - return await OpenAPI.load_async("https://www.linode.com/docs/api/openapi.yaml") + from aiopenapi3.loader import NullLoader, YAMLCompatibilityLoader + + return await OpenAPI.load_async( + "https://www.linode.com/docs/api/openapi.yaml", loader=NullLoader(YAMLCompatibilityLoader) + ) @pytest.mark.asyncio diff --git a/tests/model_test.py b/tests/model_test.py index d162a51..4e0f446 100644 --- a/tests/model_test.py +++ b/tests/model_test.py @@ -1,10 +1,11 @@ +import sys +import asyncio +import uuid + import pydantic from tests.api.v2.schema import Dog -import asyncio -import uuid - import pytest import uvloop @@ -63,6 +64,7 @@ def test_Pet(): @pytest.mark.asyncio +@pytest.mark.skipif(sys.version_info < (3, 9), reason="requires asyncio.to_thread") async def test_sync(event_loop, server, version): url = f"http://{server.bind[0]}/{version}/openapi.json" api = await asyncio.to_thread(aiopenapi3.OpenAPI.load_sync, url) diff --git a/tests/ref_test.py b/tests/ref_test.py index 3feabea..96ce7cc 100644 --- a/tests/ref_test.py +++ b/tests/ref_test.py @@ -1,8 +1,18 @@ +from __future__ import annotations +import sys + """ This file tests that $ref resolution works as expected, and that allOfs are populated as expected as well. """ -import typing + +if sys.version_info >= (3, 8): + import typing +else: + # fot typing.get_origin + import typing_extensions as typing + + import dataclasses import pytest from aiopenapi3 import OpenAPI From 4d209edd946b84837e707a5d71d6f6b04f7358c1 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 12 Jan 2022 17:22:31 +0100 Subject: [PATCH 100/125] compat - use pathlib3x consistently --- aiopenapi3/__main__.py | 7 ++++++- aiopenapi3/openapi.py | 8 +++++++- tests/loader_test.py | 8 +++++++- tests/parse_data_test.py | 11 +++++++++-- tests/parsing_test.py | 7 +++++++ tests/plugin_test.py | 7 ++++++- 6 files changed, 42 insertions(+), 6 deletions(-) diff --git a/aiopenapi3/__main__.py b/aiopenapi3/__main__.py index 1595ca4..afc03d8 100644 --- a/aiopenapi3/__main__.py +++ b/aiopenapi3/__main__.py @@ -1,5 +1,10 @@ import sys -from pathlib import Path +import sys + +if sys.version_info >= (3, 9): + from pathlib import Path +else: + from pathlib3x import Path from .openapi import OpenAPI diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index 1790158..cec9d83 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -1,4 +1,10 @@ -import pathlib +import sys + +if sys.version_info >= (3, 9): + import pathlib +else: + import pathlib3x as pathlib + import re from typing import List, Dict, Union, Callable, Tuple diff --git a/tests/loader_test.py b/tests/loader_test.py index a51ad39..3678390 100644 --- a/tests/loader_test.py +++ b/tests/loader_test.py @@ -1,5 +1,11 @@ -from pathlib import Path import json +import sys + +if sys.version_info >= (3, 9): + from pathlib import Path +else: + from pathlib3x import Path + import pytest from aiopenapi3 import OpenAPI, FileSystemLoader, ReferenceResolutionError diff --git a/tests/parse_data_test.py b/tests/parse_data_test.py index 063513f..9251b34 100644 --- a/tests/parse_data_test.py +++ b/tests/parse_data_test.py @@ -1,9 +1,16 @@ import pytest -from aiopenapi3 import FileSystemLoader, OpenAPI -import pathlib +import sys + +if sys.version_info >= (3, 9): + import pathlib +else: + import pathlib3x as pathlib import yarl +import aiopenapi3.loader +from aiopenapi3 import FileSystemLoader, OpenAPI + URLBASE = yarl.URL("http://127.1.1.1/open5gs/") diff --git a/tests/parsing_test.py b/tests/parsing_test.py index c760844..9799e3a 100644 --- a/tests/parsing_test.py +++ b/tests/parsing_test.py @@ -2,6 +2,13 @@ Tests parsing specs """ import dataclasses +import sys + +if sys.version_info >= (3, 9): + pass +else: + import pathlib3x as pathlib + import pytest from pydantic import ValidationError diff --git a/tests/plugin_test.py b/tests/plugin_test.py index b580f29..5e81af0 100644 --- a/tests/plugin_test.py +++ b/tests/plugin_test.py @@ -1,5 +1,10 @@ import httpx -from pathlib import Path +import sys + +if sys.version_info >= (3, 9): + from pathlib import Path +else: + from pathlib3x import Path import yarl From 9ccbe59882157ae092c990aae6b70f72732612b6 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Thu, 13 Jan 2022 08:47:12 +0100 Subject: [PATCH 101/125] Docker - using containers for Python 3.7 --- Dockerfile | 10 ++++++++++ docker-compose.yml | 8 ++++++++ 2 files changed, 18 insertions(+) create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fbbe95d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.7-slim-buster + +WORKDIR /app +COPY setup.cfg pyproject.toml requirements.txt /app/ +COPY aiopenapi3/ /app/aiopenapi3 +COPY tests /app/tests +RUN ls -al /app +RUN pip install --upgrade pip +RUN pip install ".[compat]" +RUN pip install ".[tests]" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f6cb415 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +version: "3.3" +services: + aiopenapi3-container: + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/app From c9c399e652cbe43d76ba57e4e6b5b59a019def9f Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Thu, 13 Jan 2022 08:55:59 +0100 Subject: [PATCH 102/125] tests - invalid response --- aiopenapi3/request.py | 17 +++++++++-------- tests/schema_test.py | 18 ++++++++---------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/aiopenapi3/request.py b/aiopenapi3/request.py index bc2cea0..4ec6268 100644 --- a/aiopenapi3/request.py +++ b/aiopenapi3/request.py @@ -1,3 +1,4 @@ +from typing import Dict import httpx import pydantic import yarl @@ -83,20 +84,20 @@ def __next__(self): return self.operations[next(self.r)] def __init__(self, api): - self._api = api - self._root = api._root + self._api: "OpenAPI" = api + self._root: "RootBase" = api._root + + self._operations: Dict[str, "Operation"] = dict() - def __getattr__(self, item): - pi: "PathItem" for path, pi in self._root.paths.items(): op: "Operation" for method in pi.__fields_set__ & HTTP_METHODS: op = getattr(pi, method) - if op.operationId != item: - continue - return self._api._createRequest(self._api, method, path, op) + self._operations[op.operationId.replace(" ", "_")] = (method, path, op) - raise ValueError(item) + def __getattr__(self, item): + (method, path, op) = self._operations[item] + return self._api._createRequest(self._api, method, path, op) def __iter__(self): return self.Iter(self._root) diff --git a/tests/schema_test.py b/tests/schema_test.py index 5da2df7..f99f0a7 100644 --- a/tests/schema_test.py +++ b/tests/schema_test.py @@ -1,15 +1,13 @@ +import httpx import pytest +from pydantic import ValidationError -from openapi3 import OpenAPI -from openapi3.errors import ModelError +from aiopenapi3 import OpenAPI -from unittest.mock import patch, MagicMock +def test_invalid_response(httpx_mock, petstore_expanded): + httpx_mock.add_response(headers={"Content-Type": "application/json"}, json={"foo": 1}) + api = OpenAPI("test.yaml", petstore_expanded, session_factory=httpx.Client) -def test_invalid_response(petstore_expanded): - api = OpenAPI(petstore_expanded) - resp = MagicMock(status_code=200, headers={"Content-Type":"application/json"}, json=lambda: {'foo':1}) - with patch("requests.sessions.Session.send", return_value=resp) as s: - with pytest.raises(ModelError, match="Schema Pet got unexpected attribute keys {'foo'}") as r: - api.call_find_pet_by_id(data={}, parameters={"id":1}) - print(r) \ No newline at end of file + with pytest.raises(ValidationError, match="2 validation errors for Pet") as r: + p = api._.find_pet_by_id(data={}, parameters={"id": 1}) From 45121fe71e03e51eb595f73646c384075b203df3 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Thu, 13 Jan 2022 10:54:25 +0100 Subject: [PATCH 103/125] v20 - tests --- aiopenapi3/openapi.py | 20 +++++- aiopenapi3/v20/glue.py | 31 ++++----- aiopenapi3/v20/parameter.py | 2 +- aiopenapi3/v20/security.py | 7 ++ tests/conftest.py | 5 ++ tests/fixtures/swagger-example.yaml | 104 ++++++++++++++++++++++++++++ tests/swagger_test.py | 82 ++++++++++++++++++++++ 7 files changed, 232 insertions(+), 19 deletions(-) create mode 100644 tests/fixtures/swagger-example.yaml create mode 100644 tests/swagger_test.py diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index cec9d83..da6b4b3 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -247,9 +247,25 @@ def test_operation(operation_id): @property def url(self): if isinstance(self._root, v20.Root): - r = self._base_url.with_path(self._root.basePath) + base = yarl.URL(self._base_url) + scheme = host = path = None + if self._root.schemes: + for i in ["https", "http"]: + if i not in self._root.schemes: + continue + scheme = i + break + else: + scheme = base.scheme if self._root.host: - r = r.with_host(r) + host = self._root.host + else: + host = base.host + if self._root.basePath: + path = self._root.basePath + else: + path = base.path + r = yarl.URL.build(scheme=scheme, host=host, path=path) return r elif isinstance(self._root, (v30.Root, v31.Root)): return self._base_url.join(yarl.URL(self._root.servers[0].url)) diff --git a/aiopenapi3/v20/glue.py b/aiopenapi3/v20/glue.py index dffb324..9e5bffa 100644 --- a/aiopenapi3/v20/glue.py +++ b/aiopenapi3/v20/glue.py @@ -7,6 +7,8 @@ from ..base import SchemaBase, ParameterBase from ..request import RequestBase, AsyncRequestBase + +from .parameter import Parameter from . import SecurityRequirement @@ -16,11 +18,15 @@ def security(self): return self.api._security @property - def data(self) -> SchemaBase: + def _data_parameter(self) -> Parameter: for i in filter(lambda x: x.in_ == "body", self.operation.parameters): - return i.schema_ + return i raise ValueError("body") + @property + def data(self) -> SchemaBase: + return self._data_parameter.schema_ + @property def parameters(self) -> Dict[str, ParameterBase]: return list( @@ -34,7 +40,7 @@ def args(self, content_type: str = "application/json"): return {"parameters": parameters, "data": schema} def return_value(self, http_status: int = 200, content_type: str = "application/json") -> SchemaBase: - return self.operation.responses[str(http_status)].content[content_type].schema_ + return self.operation.responses[str(http_status)].schema_ def _prepare_security(self): if self.operation.security == []: @@ -51,9 +57,7 @@ def _prepare_security(self): continue break else: - raise ValueError( - f"No security requirement satisfied (accepts {', '.join(self.operation.security.keys())})" - ) + raise ValueError(f"No security requirement satisfied (accepts {', '.join([i.name for i in security])})") def _prepare_secschemes(self, security_requirement: SecurityRequirement, value: List[str]): """ @@ -73,9 +77,6 @@ def _prepare_secschemes(self, security_requirement: SecurityRequirement, value: # apiKey in query header data self.req.headers[ss.name] = value - if ss.in_ == "cookie": - self.req.cookies = {ss.name: value} - def _prepare_parameters(self, parameters): # Parameters path_parameters = {} @@ -106,21 +107,19 @@ def _prepare_parameters(self, parameters): if spec.in_ == "header": self.req.headers[name] = value - if spec.in_ == "cookie": - self.req.cookies[name] = value - self.req.url = self.req.url.format(**path_parameters) def _prepare_body(self, data): try: - self.data + required = self._data_parameter.required except ValueError: return - if data is None and self.data.required: + if data is None and required: raise ValueError("Request Body is required but none was provided.") - if "application/json" in self.operation.consumes: + consumes = frozenset(self.operation.consumes or self.root.consumes) + if "application/json" in consumes: if isinstance(data, (dict, list)): pass elif isinstance(data, pydantic.BaseModel): @@ -136,7 +135,7 @@ def _prepare_body(self, data): self.req.content = data self.req.headers["Content-Type"] = "application/json" else: - raise NotImplementedError() + raise NotImplementedError(f"unsupported mime types {consumes}") def _prepare(self, data, parameters): self._prepare_security() diff --git a/aiopenapi3/v20/parameter.py b/aiopenapi3/v20/parameter.py index 8b079bc..2259dd5 100644 --- a/aiopenapi3/v20/parameter.py +++ b/aiopenapi3/v20/parameter.py @@ -43,7 +43,7 @@ class Parameter(ObjectExtended): """ name: str = Field(required=True) - in_: str = Field(required=True, alias="in") # TODO must be one of ["query","header","path","cookie"] + in_: str = Field(required=True, alias="in") # "query", "header", "path", "formData" or "body" description: Optional[str] = Field(default=None) required: Optional[bool] = Field(default=None) diff --git a/aiopenapi3/v20/security.py b/aiopenapi3/v20/security.py index e7ce7d4..387c4de 100644 --- a/aiopenapi3/v20/security.py +++ b/aiopenapi3/v20/security.py @@ -23,6 +23,13 @@ class SecurityScheme(ObjectExtended): refreshUrl: Optional[str] = Field(default=None) scopes: Dict[str, str] = Field(default_factory=dict) + @root_validator + def validate_SecurityScheme(cls, values): + if values["type"] == "apiKey": + assert values["name"], "name is required for apiKey" + assert values["in_"] in frozenset(["query", "header"]), "in must be query or header" + return values + class SecurityRequirement(BaseModel): """ diff --git a/tests/conftest.py b/tests/conftest.py index 94d7152..661e166 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -161,3 +161,8 @@ def with_callback(): Provides a spec with callback """ yield _get_parsed_yaml("callback-example.yaml") + + +@pytest.fixture +def with_swagger(): + yield _get_parsed_yaml("swagger-example.yaml") diff --git a/tests/fixtures/swagger-example.yaml b/tests/fixtures/swagger-example.yaml new file mode 100644 index 0000000..caf6824 --- /dev/null +++ b/tests/fixtures/swagger-example.yaml @@ -0,0 +1,104 @@ +swagger: "2.0" +info: + title: Sample API + description: API description in Markdown. + version: 1.0.0 +host: api.example.com +basePath: /v1 +schemes: + - https + +consumes: + - application/json +produces: + - application/json + +securityDefinitions: + BasicAuth: + type: basic + HeaderAuth: + type: apiKey + in: header + name: Authorization + QueryAuth: + type: apiKey + in: query + name: auth + +security: + - BasicAuth: [] + +paths: + /users/{userId}: + get: + operationId: getUser + summary: Returns a user by ID. + parameters: + - in: path + name: userId + required: true + type: integer + responses: + 200: + description: OK + schema: + $ref: '#/definitions/User' + 400: + description: The specified user ID is invalid (e.g. not a number). + 404: + description: A user with the specified ID was not found. + default: + description: Unexpected error + /users: + get: + security: + - {} + operationId: listUsers + summary: Returns a list of users. + description: Optional extended description in Markdown. + parameters: + - in: header + name: inHeader + type: string + - in: query + name: inQuery + type: string + produces: + - application/json + responses: + 200: + description: OK + schema: + type: array + items: + $ref: '#/definitions/User' + + post: + security: + - QueryAuth: [] + - HeaderAuth: [] + operationId: createUser + summary: Creates a new user. + parameters: + - in: body + name: user + required: true + schema: + $ref: '#/definitions/User' + responses: + 200: + description: OK + schema: + $ref: '#/definitions/User' + +definitions: + User: + properties: + id: + type: integer + name: + type: string + # Both properties are required + required: + - id + - name diff --git a/tests/swagger_test.py b/tests/swagger_test.py new file mode 100644 index 0000000..ba99304 --- /dev/null +++ b/tests/swagger_test.py @@ -0,0 +1,82 @@ +import uuid + +import yarl +import httpx +import pytest + +from aiopenapi3 import OpenAPI + +URLBASE = "/" + + +def test_parse_swagger(with_swagger): + api = OpenAPI(URLBASE, with_swagger) + + +def test_swagger_url(with_swagger): + api = OpenAPI(URLBASE, with_swagger) + assert str(api.url) == "https://api.example.com/v1" + + +def test_securityparameters(httpx_mock, with_swagger): + api = OpenAPI(URLBASE, with_swagger, session_factory=httpx.Client) + user = api._.createUser.return_value().get_type().construct(name="test", id=1) + httpx_mock.add_response(headers={"Content-Type": "application/json"}, json=user.dict()) + + auth = str(uuid.uuid4()) + + with pytest.raises(ValueError, match="does not accept security scheme xAuth"): + api.authenticate("xAuth", auth) + api._.createUser(data=user, parameters={}) + + # global security + api.authenticate("BasicAuth", (auth, auth)) + api._.getUser(data={}, parameters={"userId": 1}) + request = httpx_mock.get_requests()[-1] + + # path + api.authenticate("QueryAuth", auth) + api._.createUser(data={}, parameters={}) + request = httpx_mock.get_requests()[-1] + assert request.url.params["auth"] == auth + + # header + api.authenticate("HeaderAuth", "Bearer %s" % (auth,)) + api._.createUser(data={}, parameters={}) + request = httpx_mock.get_requests()[-1] + assert request.headers["Authorization"] == "Bearer %s" % (auth,) + + # null session + httpx_mock.add_response(headers={"Content-Type": "application/json"}, json=[user.dict()]) + api.authenticate(None, None) + api._.listUsers(data={}, parameters={}) + + +def test_post_body(httpx_mock, with_swagger): + + auth = str(uuid.uuid4()) + api = OpenAPI(URLBASE, with_swagger, session_factory=httpx.Client) + user = api._.createUser.return_value().get_type().construct(name="test", id=1) + httpx_mock.add_response(headers={"Content-Type": "application/json"}, json=user.dict()) + + api.authenticate("HeaderAuth", "Bearer %s" % (auth,)) + with pytest.raises(ValueError, match="Request Body is required but none was provided."): + api._.createUser(data=None, parameters={}) + api._.createUser(data={}, parameters={}) + api._.createUser(data=user, parameters={}) + + +def test_parameters(httpx_mock, with_swagger): + api = OpenAPI(URLBASE, with_swagger, session_factory=httpx.Client) + user = api._.createUser.return_value().get_type().construct(name="test", id=1) + + with pytest.raises(ValueError, match="Required parameter \w+ not provided"): + api._.getUser(data={}, parameters={}) + + httpx_mock.add_response(headers={"Content-Type": "application/json"}, json=[user.dict()]) + api.authenticate(None, None) + api._.listUsers(data={}, parameters={"inQuery": "Q", "inHeader": "H"}) + + request = httpx_mock.get_requests()[-1] + assert request.headers["inHeader"] == "H" + assert yarl.URL(str(request.url)).query["inQuery"] == "Q" From c58d0e1239023ed0f8001319dbe16f7b4ad71293 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 17 Jan 2022 11:11:09 +0100 Subject: [PATCH 104/125] v20/30/31 - authentication - each SecurityRequirements ANDs it's SecuritySchemes combined authentication (e.g. user & token in header) is possible --- aiopenapi3/openapi.py | 31 ++++++++------- aiopenapi3/v20/glue.py | 44 +++++++++++++-------- aiopenapi3/v20/security.py | 19 --------- aiopenapi3/v30/glue.py | 40 ++++++++++++------- aiopenapi3/v30/security.py | 19 --------- aiopenapi3/v31/security.py | 19 --------- tests/fixtures/swagger-example.yaml | 22 ++++++++++- tests/fixtures/with-securityparameters.yaml | 32 +++++++++++++++ tests/path_test.py | 43 ++++++++++++++------ tests/swagger_test.py | 36 +++++++++++++---- 10 files changed, 182 insertions(+), 123 deletions(-) diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index da6b4b3..785b315 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -154,7 +154,7 @@ def __init__( authorization informations e.g. {"BasicAuth": ("user","secret")} """ - self._security: Dict[str, Tuple[str]] = None + self._security: Dict[str, Tuple[str]] = dict() """ the related documents @@ -271,26 +271,29 @@ def url(self): return self._base_url.join(yarl.URL(self._root.servers[0].url)) # public methods - def authenticate(self, security_scheme, value): + def authenticate(self, *args, **kwargs): """ - Authenticates all subsequent requests with the given arguments. - TODO - this should support more than just HTTP Auth + :param args: None to remove all credentials / reset the authorizations + :param kwargs: scheme=value """ + if len(args) == 1 and args[0] == None: + self._security = dict() - # authentication is optional and can be disabled - if security_scheme is None: - self._security = None - return - + schemes = frozenset(kwargs.keys()) if isinstance(self._root, v20.Root): - if security_scheme not in self._root.securityDefinitions: - raise ValueError("{} does not accept security scheme {}".format(self.info.title, security_scheme)) + v = schemes - frozenset(self._root.securityDefinitions) elif isinstance(self._root, (v30.Root, v31.Root)): - if security_scheme not in self._root.components.securitySchemes: - raise ValueError("{} does not accept security scheme {}".format(self.info.title, security_scheme)) + v = schemes - frozenset(self._root.components.securitySchemes) - self._security = {security_scheme: value} + if v: + raise ValueError("{} does not accept security schemes {}".format(self.info.title, sorted(v))) + + for security_scheme, value in kwargs.items(): + if value is None: + del self._security[security_scheme] + else: + self._security[security_scheme] = value def _load(self, i): data = self.loader.get(self.plugins, i) diff --git a/aiopenapi3/v20/glue.py b/aiopenapi3/v20/glue.py index 9e5bffa..e9ca006 100644 --- a/aiopenapi3/v20/glue.py +++ b/aiopenapi3/v20/glue.py @@ -9,7 +9,6 @@ from .parameter import Parameter -from . import SecurityRequirement class Request(RequestBase): @@ -43,27 +42,38 @@ def return_value(self, http_status: int = 200, content_type: str = "application/ return self.operation.responses[str(http_status)].schema_ def _prepare_security(self): - if self.operation.security == []: - security = [] - else: - security = (self.operation.security or []) + self.root.security - - if self.security and security: - for scheme, value in self.security.items(): - for r in filter(lambda x: x.name == scheme, security): - self._prepare_secschemes(r, value) - break - else: - continue - break + security = self.operation.security or self.api._root.security + + if not security: + return + + if not self.security: + if any([{} == i.__root__ for i in security]): + return else: - raise ValueError(f"No security requirement satisfied (accepts {', '.join([i.name for i in security])})") + options = " or ".join( + sorted(map(lambda x: f"{{{x}}}", [" and ".join(sorted(i.__root__.keys())) for i in security])) + ) + raise ValueError(f"No security requirement satisfied (accepts {options})") + + for s in security: + if frozenset(s.__root__.keys()) - frozenset(self.security.keys()): + continue + for scheme, _ in s.__root__.items(): + value = self.security[scheme] + self._prepare_secschemes(scheme, value) + break + else: + options = " or ".join( + sorted(map(lambda x: f"{{{x}}}", [" and ".join(sorted(i.__root__.keys())) for i in security])) + ) + raise ValueError(f"No security requirement satisfied (accepts {options})") - def _prepare_secschemes(self, security_requirement: SecurityRequirement, value: List[str]): + def _prepare_secschemes(self, scheme: str, value: List[str]): """ https://swagger.io/specification/v2/#security-scheme-object """ - ss = self.root.securityDefinitions[security_requirement.name] + ss = self.root.securityDefinitions[scheme] if ss.type == "basic": self.req.auth = value diff --git a/aiopenapi3/v20/security.py b/aiopenapi3/v20/security.py index 387c4de..aa27af0 100644 --- a/aiopenapi3/v20/security.py +++ b/aiopenapi3/v20/security.py @@ -39,22 +39,3 @@ class SecurityRequirement(BaseModel): """ __root__: Dict[str, List[str]] - - # @root_validator - def validate_SecurityRequirement(cls, values): - root = values.get("__root__", {}) - if not (len(root.keys()) == 1 and isinstance([c for c in root.values()][0], list) or len(root.keys()) == 0): - raise ValueError(root) - return values - - @property - def name(self): - if len(self.__root__.keys()): - return list(self.__root__.keys())[0] - return None - - @property - def types(self): - if self.name: - return self.__root__[self.name] - return None diff --git a/aiopenapi3/v30/glue.py b/aiopenapi3/v30/glue.py index aa4e9bd..1a9833f 100644 --- a/aiopenapi3/v30/glue.py +++ b/aiopenapi3/v30/glue.py @@ -7,8 +7,6 @@ from ..base import SchemaBase, ParameterBase from ..request import RequestBase, AsyncRequestBase -from . import SecurityRequirement - class Request(RequestBase): """ @@ -41,21 +39,35 @@ def return_value(self, http_status: int = 200, content_type: str = "application/ return self.operation.responses[str(http_status)].content[content_type].schema_ def _prepare_security(self): - if self.security and self.operation.security: - for scheme, value in self.security.items(): - for r in filter(lambda x: x.name == scheme, self.operation.security): - self._prepare_secschemes(r, value) - break - else: - continue - break + security = self.operation.security or self.api._root.security + + if not security: + return + + if not self.security: + if any([{} == i.__root__ for i in security]): + return else: - raise ValueError( - f"No security requirement satisfied (accepts {', '.join(self.operation.security.keys())})" + options = " or ".join( + sorted(map(lambda x: f"{{{x}}}", [" and ".join(sorted(i.__root__.keys())) for i in security])) ) + raise ValueError(f"No security requirement satisfied (accepts {options})") + + for s in security: + if frozenset(s.__root__.keys()) - frozenset(self.security.keys()): + continue + for scheme, _ in s.__root__.items(): + value = self.security[scheme] + self._prepare_secschemes(scheme, value) + break + else: + options = " or ".join( + sorted(map(lambda x: f"{{{x}}}", [" and ".join(sorted(i.__root__.keys())) for i in security])) + ) + raise ValueError(f"No security requirement satisfied (accepts {options})") - def _prepare_secschemes(self, security_requirement: SecurityRequirement, value: List[str]): - ss = self.root.components.securitySchemes[security_requirement.name] + def _prepare_secschemes(self, scheme: str, value: List[str]): + ss = self.root.components.securitySchemes[scheme] if ss.type == "http" and ss.scheme_ == "basic": self.req.auth = value diff --git a/aiopenapi3/v30/security.py b/aiopenapi3/v30/security.py index 9521e9e..153857b 100644 --- a/aiopenapi3/v30/security.py +++ b/aiopenapi3/v30/security.py @@ -71,22 +71,3 @@ class SecurityRequirement(BaseModel): """ __root__: Dict[str, List[str]] - - # @root_validator - def validate_SecurityRequirement(cls, values): - root = values.get("__root__", {}) - if not (len(root.keys()) == 1 and isinstance([c for c in root.values()][0], list) or len(root.keys()) == 0): - raise ValueError(root) - return values - - @property - def name(self): - if len(self.__root__.keys()): - return list(self.__root__.keys())[0] - return None - - @property - def types(self): - if self.name: - return self.__root__[self.name] - return None diff --git a/aiopenapi3/v31/security.py b/aiopenapi3/v31/security.py index 3466eb0..e2d3ffc 100644 --- a/aiopenapi3/v31/security.py +++ b/aiopenapi3/v31/security.py @@ -71,22 +71,3 @@ class SecurityRequirement(BaseModel): """ __root__: Dict[str, List[str]] - - # @root_validator - def validate_SecurityRequirement(cls, values): - root = values.get("__root__", {}) - if not (len(root.keys()) == 1 and isinstance([c for c in root.values()][0], list) or len(root.keys()) == 0): - raise ValueError(root) - return values - - @property - def name(self): - if len(self.__root__.keys()): - return list(self.__root__.keys())[0] - return None - - @property - def types(self): - if self.name: - return self.__root__[self.name] - return None diff --git a/tests/fixtures/swagger-example.yaml b/tests/fixtures/swagger-example.yaml index caf6824..df8740c 100644 --- a/tests/fixtures/swagger-example.yaml +++ b/tests/fixtures/swagger-example.yaml @@ -24,11 +24,32 @@ securityDefinitions: type: apiKey in: query name: auth + user: + type: apiKey + in: header + name: x-user + token: + type: apiKey + in: header + name: x-token + security: - BasicAuth: [] paths: + /combined: + get: + operationId: combinedSecurity + responses: + 200: + description: '' + schema: + type: str + security: + - user: [] + token: [] + /users/{userId}: get: operationId: getUser @@ -72,7 +93,6 @@ paths: type: array items: $ref: '#/definitions/User' - post: security: - QueryAuth: [] diff --git a/tests/fixtures/with-securityparameters.yaml b/tests/fixtures/with-securityparameters.yaml index c969e68..0831f3c 100644 --- a/tests/fixtures/with-securityparameters.yaml +++ b/tests/fixtures/with-securityparameters.yaml @@ -56,6 +56,30 @@ paths: - bearerAuth: [] - {} + /api/v1/auth/combined/: + post: + operationId: api_v1_auth_login_combined + description: '' + tags: + - api + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Login' + description: '' + security: + - user: [] + token: [] + + components: schemas: Login: @@ -85,3 +109,11 @@ components: bearerAuth: type: http scheme: bearer + user: + type: apiKey + in: header + name: x-user + token: + type: apiKey + in: header + name: x-token diff --git a/tests/path_test.py b/tests/path_test.py index 92de309..6adc91c 100644 --- a/tests/path_test.py +++ b/tests/path_test.py @@ -110,57 +110,76 @@ def test_securityparameters(httpx_mock, with_securityparameters): if not i.post or not i.post.security: continue s = i.post.security[0] - assert type(s.name) == str - assert type(s.types) == list + # assert type(s.name) == str + # assert type(s.types) == list break else: assert False - with pytest.raises(ValueError, match="does not accept security scheme xAuth"): - api.authenticate("xAuth", auth) + with pytest.raises(ValueError, match=r"does not accept security schemes \['xAuth'\]"): + api.authenticate(xAuth=auth) api._.api_v1_auth_login_info(data={}, parameters={}) # global security - api.authenticate("cookieAuth", auth) + api.authenticate(None, cookieAuth=auth) api._.api_v1_auth_login_info(data={}, parameters={}) request = httpx_mock.get_requests()[-1] # path - api.authenticate("tokenAuth", auth) + api.authenticate(None, tokenAuth=auth) api._.api_v1_auth_login_create(data={}, parameters={}) request = httpx_mock.get_requests()[-1] assert request.headers["Authorization"] == auth - api.authenticate("paramAuth", auth) + api.authenticate(None, paramAuth=auth) api._.api_v1_auth_login_create(data={}, parameters={}) request = httpx_mock.get_requests()[-1] assert yarl.URL(str(request.url)).query["auth"] == auth - api.authenticate("cookieAuth", auth) + api.authenticate(None, cookieAuth=auth) api._.api_v1_auth_login_create(data={}, parameters={}) request = httpx_mock.get_requests()[-1] assert request.headers["Cookie"] == "Session=%s" % (auth,) - api.authenticate("basicAuth", (auth, auth)) + api.authenticate(None, basicAuth=(auth, auth)) api._.api_v1_auth_login_create(data={}, parameters={}) request = httpx_mock.get_requests()[-1] assert request.headers["Authorization"].split(" ")[1] == base64.b64encode((auth + ":" + auth).encode()).decode() - api.authenticate("digestAuth", (auth, auth)) + api.authenticate(None, digestAuth=(auth, auth)) api._.api_v1_auth_login_create(data={}, parameters={}) request = httpx_mock.get_requests()[-1] # can't test? - api.authenticate("bearerAuth", auth) + api.authenticate(None, bearerAuth=auth) api._.api_v1_auth_login_create(data={}, parameters={}) request = httpx_mock.get_requests()[-1] assert request.headers["Authorization"] == "Bearer %s" % (auth,) # null session - api.authenticate(None, None) + api.authenticate(None) api._.api_v1_auth_login_info(data={}, parameters={}) +def test_combined_security(httpx_mock, with_securityparameters): + api = OpenAPI(URLBASE, with_securityparameters, session_factory=httpx.Client) + httpx_mock.add_response(headers={"Content-Type": "application/json"}, content=b"[]") + + auth = str(uuid.uuid4()) + + # combined + api.authenticate(user="test") + with pytest.raises(ValueError, match="No security requirement satisfied"): + r = api._.api_v1_auth_login_combined(data={}, parameters={}) + + api.authenticate(**{"user": "theuser", "token": "thetoken"}) + r = api._.api_v1_auth_login_combined(data={}, parameters={}) + + api.authenticate(None) + with pytest.raises(ValueError, match="No security requirement satisfied"): + r = api._.api_v1_auth_login_combined(data={}, parameters={}) + + def test_parameters(httpx_mock, with_parameters): httpx_mock.add_response(headers={"Content-Type": "application/json"}, content=b"[]") api = OpenAPI(URLBASE, with_parameters, session_factory=httpx.Client) diff --git a/tests/swagger_test.py b/tests/swagger_test.py index ba99304..033694e 100644 --- a/tests/swagger_test.py +++ b/tests/swagger_test.py @@ -25,33 +25,50 @@ def test_securityparameters(httpx_mock, with_swagger): auth = str(uuid.uuid4()) - with pytest.raises(ValueError, match="does not accept security scheme xAuth"): - api.authenticate("xAuth", auth) + with pytest.raises(ValueError, match="does not accept security schemes \['xAuth'\]"): + api.authenticate(xAuth=auth) api._.createUser(data=user, parameters={}) # global security - api.authenticate("BasicAuth", (auth, auth)) + api.authenticate(None, BasicAuth=(auth, auth)) api._.getUser(data={}, parameters={"userId": 1}) request = httpx_mock.get_requests()[-1] # path - api.authenticate("QueryAuth", auth) + api.authenticate(None, QueryAuth=auth) api._.createUser(data={}, parameters={}) request = httpx_mock.get_requests()[-1] assert request.url.params["auth"] == auth # header - api.authenticate("HeaderAuth", "Bearer %s" % (auth,)) + api.authenticate(None, HeaderAuth="Bearer %s" % (auth,)) api._.createUser(data={}, parameters={}) request = httpx_mock.get_requests()[-1] assert request.headers["Authorization"] == "Bearer %s" % (auth,) # null session httpx_mock.add_response(headers={"Content-Type": "application/json"}, json=[user.dict()]) - api.authenticate(None, None) + api.authenticate(None) api._.listUsers(data={}, parameters={}) +def test_combined_securityparameters(httpx_mock, with_swagger): + api = OpenAPI(URLBASE, with_swagger, session_factory=httpx.Client) + user = api._.createUser.return_value().get_type().construct(name="test", id=1) + httpx_mock.add_response(headers={"Content-Type": "application/json"}, json=user.dict()) + + api.authenticate(user="u") + with pytest.raises(ValueError, match="No security requirement satisfied"): + api._.combinedSecurity(data={}, parameters={}) + + api.authenticate(**{"user": "u", "token": "t"}) + api._.combinedSecurity(data={}, parameters={}) + + api.authenticate(None) + with pytest.raises(ValueError, match="No security requirement satisfied"): + api._.combinedSecurity(data={}, parameters={}) + + def test_post_body(httpx_mock, with_swagger): auth = str(uuid.uuid4()) @@ -59,7 +76,7 @@ def test_post_body(httpx_mock, with_swagger): user = api._.createUser.return_value().get_type().construct(name="test", id=1) httpx_mock.add_response(headers={"Content-Type": "application/json"}, json=user.dict()) - api.authenticate("HeaderAuth", "Bearer %s" % (auth,)) + api.authenticate(HeaderAuth="Bearer %s" % (auth,)) with pytest.raises(ValueError, match="Request Body is required but none was provided."): api._.createUser(data=None, parameters={}) api._.createUser(data={}, parameters={}) @@ -70,11 +87,14 @@ def test_parameters(httpx_mock, with_swagger): api = OpenAPI(URLBASE, with_swagger, session_factory=httpx.Client) user = api._.createUser.return_value().get_type().construct(name="test", id=1) + auth = str(uuid.uuid4()) + api.authenticate(BasicAuth=(auth, auth)) + with pytest.raises(ValueError, match="Required parameter \w+ not provided"): api._.getUser(data={}, parameters={}) httpx_mock.add_response(headers={"Content-Type": "application/json"}, json=[user.dict()]) - api.authenticate(None, None) + api.authenticate(None) api._.listUsers(data={}, parameters={"inQuery": "Q", "inHeader": "H"}) request = httpx_mock.get_requests()[-1] From f1272cad57395360cfd275f1de9f889735022b3a Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 17 Jan 2022 11:13:06 +0100 Subject: [PATCH 105/125] OperationIndex - operationId is optional --- aiopenapi3/request.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aiopenapi3/request.py b/aiopenapi3/request.py index 4ec6268..214ead4 100644 --- a/aiopenapi3/request.py +++ b/aiopenapi3/request.py @@ -93,6 +93,8 @@ def __init__(self, api): op: "Operation" for method in pi.__fields_set__ & HTTP_METHODS: op = getattr(pi, method) + if op.operationId is None: + continue self._operations[op.operationId.replace(" ", "_")] = (method, path, op) def __getattr__(self, item): From 6ae54d5d56d9625cbfd9efa81b9895a070f6621a Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 17 Jan 2022 13:44:46 +0100 Subject: [PATCH 106/125] README - authentication changes --- README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 99759cd..d1ce797 100644 --- a/README.md +++ b/README.md @@ -91,12 +91,12 @@ print(json.dumps((list(filter(lambda x: 'eu-west' in x.id, regions.data))[0]).di #### discriminators discriminators are supported as well, but the linode api can't be used to show how to use them. -look at [tests/model_test.py] test_model. +look at [aiopenapi3/tests/model_test.py](aiopenapi3/tests/model_test.py) test_model. ### authentication ```python my_token = "Gae6aikaegainoor" -api.authenticate('personalAccessToken', my_token) +api.authenticate(personalAccessToken=my_token) # call an operation that requires authentication linodes = api._.getLinodeInstances() @@ -106,7 +106,12 @@ HTTP basic authentication and HTTP digest authentication works like this: ```python # authenticate using a securityScheme defined in the spec's components.securitySchemes # Tuple with (username, password) as second argument -api.authenticate('basicAuth', ('username', 'password')) +api.authenticate(basicAuth=('username', 'password')) +``` + +Resetting authentication tokens: +```python +api.authenticate(None) ``` ### parameters From 44830af8aaaae3168e95b94c494e32bb71a8d425 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 18 Jan 2022 08:07:02 +0100 Subject: [PATCH 107/125] setup.cfg - add v20/30/31 packages to install --- setup.cfg | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/setup.cfg b/setup.cfg index 2ffc376..66da5b0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,6 +34,9 @@ classifiers = [options] packages = aiopenapi3 + aiopenapi3.v20 + aiopenapi3.v30 + aiopenapi3.v31 install_requires = PyYaml @@ -51,7 +54,10 @@ tests = fastapi-versioning hypercorn uvloop + compat = typing_extensions pathlib3x + +socks = httpx_socks From 28dfa4ca14f7bc607e896966bf5e513de5217fbb Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 19 Jan 2022 11:59:49 +0100 Subject: [PATCH 108/125] errors - raise {ContentType,HTTPStatus}Error for unexpected --- aiopenapi3/__init__.py | 13 +++++++++++-- aiopenapi3/errors.py | 25 +++++++++++++++++++++++++ aiopenapi3/v20/glue.py | 14 ++++++++++---- aiopenapi3/v30/glue.py | 12 ++++++++---- 4 files changed, 54 insertions(+), 10 deletions(-) diff --git a/aiopenapi3/__init__.py b/aiopenapi3/__init__.py index 5cb2cbd..ad8163b 100644 --- a/aiopenapi3/__init__.py +++ b/aiopenapi3/__init__.py @@ -1,7 +1,16 @@ from .version import __version__ from .openapi import OpenAPI from .loader import FileSystemLoader -from .errors import SpecError, ReferenceResolutionError +from .errors import SpecError, ReferenceResolutionError, HTTPError, HTTPStatusError, ContentTypeError -__all__ = ["__version__", "OpenAPI", "FileSystemLoader", "SpecError", "ReferenceResolutionError"] +__all__ = [ + "__version__", + "OpenAPI", + "FileSystemLoader", + "SpecError", + "ReferenceResolutionError", + "HTTPStatusError", + "ContentTypeError", + "HTTPError", +] diff --git a/aiopenapi3/errors.py b/aiopenapi3/errors.py index 6dce23d..f4f3c78 100644 --- a/aiopenapi3/errors.py +++ b/aiopenapi3/errors.py @@ -1,3 +1,6 @@ +import dataclasses + + class SpecError(ValueError): """ This error class is used when an invalid format is found while parsing an @@ -14,3 +17,25 @@ class ReferenceResolutionError(SpecError): This error class is used when resolving a reference fails, usually because of a malformed path in the reference. """ + + +class HTTPError(ValueError): + pass + + +@dataclasses.dataclass +class ContentTypeError(HTTPError): + """The content-type is unexpected""" + + content_type: str + message: str + response: object + + +@dataclasses.dataclass +class HTTPStatusError(HTTPError): + """The HTTP Status is unexpected""" + + http_status: int + message: str + response: object diff --git a/aiopenapi3/v20/glue.py b/aiopenapi3/v20/glue.py index e9ca006..e192af5 100644 --- a/aiopenapi3/v20/glue.py +++ b/aiopenapi3/v20/glue.py @@ -6,7 +6,7 @@ from ..base import SchemaBase, ParameterBase from ..request import RequestBase, AsyncRequestBase - +from ..errors import HTTPStatusError, ContentTypeError from .parameter import Parameter @@ -177,8 +177,10 @@ def _process(self, result): if expected_response is None: # TODO - custom exception class that has the response object in it options = ",".join(self.operation.responses.keys()) - raise ValueError( - f"""Unexpected response {result.status_code} from {self.operation.operationId} (expected one of {options}), no default is defined""" + raise HTTPStatusError( + result.status_code, + f"""Unexpected response {result.status_code} from {self.operation.operationId} (expected one of {options}), no default is defined""", + result, ) if status_code == "204": @@ -197,7 +199,11 @@ def _process(self, result): ).unmarshalled return data else: - raise NotImplementedError(content_type) + raise ContentTypeError( + content_type, + f"Unexpected Content-Type {content_type} returned for operation {self.operation.operationId} (expected application/json)", + result, + ) class AsyncRequest(Request, AsyncRequestBase): diff --git a/aiopenapi3/v30/glue.py b/aiopenapi3/v30/glue.py index 1a9833f..30b66cd 100644 --- a/aiopenapi3/v30/glue.py +++ b/aiopenapi3/v30/glue.py @@ -6,6 +6,7 @@ from ..base import SchemaBase, ParameterBase from ..request import RequestBase, AsyncRequestBase +from ..errors import HTTPStatusError, ContentTypeError class Request(RequestBase): @@ -185,8 +186,9 @@ def _process(self, result): if expected_response is None: # TODO - custom exception class that has the response object in it options = ",".join(self.operation.responses.keys()) - raise ValueError( - f"""Unexpected response {result.status_code} from {self.operation.operationId} (expected one of {options}), no default is defined""" + raise HTTPStatusError( + result.status_code, + f"""Unexpected response {result.status_code} from {self.operation.operationId} (expected one of {options}), no default is defined""", ) if len(expected_response.content) == 0: @@ -208,9 +210,11 @@ def _process(self, result): if expected_media is None: options = ",".join(expected_response.content.keys()) - raise ValueError( + raise ContentTypeError( + content_type, f"Unexpected Content-Type {content_type} returned for operation {self.operation.operationId} \ - (expected one of {options})" + (expected one of {options})", + result, ) if content_type.lower() == "application/json": From 572078415c50a4587a413d17d390053f5f938a8d Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 19 Jan 2022 12:00:37 +0100 Subject: [PATCH 109/125] v20/Model - required is Parameter attribute --- aiopenapi3/model.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/aiopenapi3/model.py b/aiopenapi3/model.py index d9265be..2cbe41c 100644 --- a/aiopenapi3/model.py +++ b/aiopenapi3/model.py @@ -69,10 +69,22 @@ def annotationsof(schema: "SchemaBase"): break else: r = typeof(f) - if name not in schema.required: - annos[name] = Optional[r] + + from . import v20, v30, v31 + + if isinstance(schema, v20.Schema): + if not f.required: + annos[name] = Optional[r] + else: + annos[name] = r + elif isinstance(schema, (v30.Schema, v31.Schema)): + if name not in schema.required: + annos[name] = Optional[r] + else: + annos[name] = r else: - annos[name] = r + raise TypeError(schema) + return annos def fieldof(schema: "SchemaBase"): From a160b863653311cec4c7c2bd0d79a3f6ed293b5c Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 19 Jan 2022 12:01:34 +0100 Subject: [PATCH 110/125] v20 - OpenAPI.url - include port --- aiopenapi3/openapi.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index 785b315..f223175 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -249,23 +249,28 @@ def url(self): if isinstance(self._root, v20.Root): base = yarl.URL(self._base_url) scheme = host = path = None - if self._root.schemes: - for i in ["https", "http"]: - if i not in self._root.schemes: - continue - scheme = i - break - else: - scheme = base.scheme + + for i in ["https", "http"]: + if not self._root.schemes or i not in self._root.schemes: + continue + scheme = i + break + else: + scheme = base.scheme + if self._root.host: host = self._root.host + if ":" in host: + host, _, port = host.partition(":") else: host = base.host + port = base.port + if self._root.basePath: path = self._root.basePath else: path = base.path - r = yarl.URL.build(scheme=scheme, host=host, path=path) + r = yarl.URL.build(scheme=scheme, host=host, port=port, path=path) return r elif isinstance(self._root, (v30.Root, v31.Root)): return self._base_url.join(yarl.URL(self._root.servers[0].url)) From 47a0f46d99a54acbe594bfa2e066dfc4512b7dda Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 19 Jan 2022 12:10:11 +0100 Subject: [PATCH 111/125] model - schema can be Reference --- aiopenapi3/model.py | 4 ++-- aiopenapi3/v20/__init__.py | 1 + aiopenapi3/v30/__init__.py | 1 + aiopenapi3/v31/__init__.py | 6 ++---- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/aiopenapi3/model.py b/aiopenapi3/model.py index 2cbe41c..235f4a1 100644 --- a/aiopenapi3/model.py +++ b/aiopenapi3/model.py @@ -72,12 +72,12 @@ def annotationsof(schema: "SchemaBase"): from . import v20, v30, v31 - if isinstance(schema, v20.Schema): + if isinstance(schema, (v20.Schema, v20.Reference)): if not f.required: annos[name] = Optional[r] else: annos[name] = r - elif isinstance(schema, (v30.Schema, v31.Schema)): + elif isinstance(schema, (v30.Schema, v31.Schema, v30.Reference, v31.Reference)): if name not in schema.required: annos[name] = Optional[r] else: diff --git a/aiopenapi3/v20/__init__.py b/aiopenapi3/v20/__init__.py index dd258ef..77d4436 100644 --- a/aiopenapi3/v20/__init__.py +++ b/aiopenapi3/v20/__init__.py @@ -2,3 +2,4 @@ from .security import SecurityRequirement from .glue import Request, AsyncRequest from .schemas import Schema +from .general import Reference diff --git a/aiopenapi3/v30/__init__.py b/aiopenapi3/v30/__init__.py index fb30ad1..e54baa4 100644 --- a/aiopenapi3/v30/__init__.py +++ b/aiopenapi3/v30/__init__.py @@ -3,3 +3,4 @@ from .paths import PathItem, Operation, SecurityRequirement from .parameter import Parameter from .glue import Request, AsyncRequest +from .general import Reference diff --git a/aiopenapi3/v31/__init__.py b/aiopenapi3/v31/__init__.py index 612cbdf..dd88c1f 100644 --- a/aiopenapi3/v31/__init__.py +++ b/aiopenapi3/v31/__init__.py @@ -1,5 +1,3 @@ -# from .schemas import Schema +from .schemas import Schema from .root import Root - -# from .paths import PathItem, Operation, SecurityRequirement -# from .parameter import Parameter +from .general import Reference From 2ceb13d40594015fb21645ca57becedef599be02 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Wed, 19 Jan 2022 12:10:30 +0100 Subject: [PATCH 112/125] OpenAPI.url - port --- aiopenapi3/openapi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index f223175..4fb5994 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -248,7 +248,7 @@ def test_operation(operation_id): def url(self): if isinstance(self._root, v20.Root): base = yarl.URL(self._base_url) - scheme = host = path = None + scheme = host = port = path = None for i in ["https", "http"]: if not self._root.schemes or i not in self._root.schemes: From d5178fcf784335dbedf4fc5a73f062b69718974a Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Thu, 20 Jan 2022 09:16:39 +0100 Subject: [PATCH 113/125] model - primitive types --- aiopenapi3/base.py | 2 +- aiopenapi3/model.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/aiopenapi3/base.py b/aiopenapi3/base.py index 10c3817..af6e457 100644 --- a/aiopenapi3/base.py +++ b/aiopenapi3/base.py @@ -199,7 +199,7 @@ def model(self, data: Dict): :returns: A new :any:`Model` created in this Schema's type from the data. :rtype: self.get_type() """ - if self.type in ("string", "number"): + if self.type in ("string", "number", "boolean", "integer"): assert len(self.properties) == 0 # more simple types # if this schema represents a simple type, simply return the data diff --git a/aiopenapi3/model.py b/aiopenapi3/model.py index 235f4a1..e99c64a 100644 --- a/aiopenapi3/model.py +++ b/aiopenapi3/model.py @@ -102,8 +102,8 @@ def fieldof(schema: "SchemaBase"): return r # do not create models for primitive types - if shma.type in ("string", "integer"): - return typeof(shma) + if shma.type in ("string", "integer", "number", "boolean"): + return Model.typeof(shma) type_name = shma.title or shma._identity if hasattr(shma, "_identity") else str(uuid.uuid4()) namespace = dict() From ad83b605508afc459ed13dd0cdc37c94054341d2 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Thu, 20 Jan 2022 09:17:02 +0100 Subject: [PATCH 114/125] tuple - unlist --- aiopenapi3/model.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/aiopenapi3/model.py b/aiopenapi3/model.py index e99c64a..e437548 100644 --- a/aiopenapi3/model.py +++ b/aiopenapi3/model.py @@ -113,10 +113,8 @@ def fieldof(schema: "SchemaBase"): annos.update(annotationsof(i)) elif hasattr(shma, "anyOf") and shma.anyOf: t = tuple( - [ - i.get_type(names=shmanm + [i.ref], discriminators=discriminators + [shma.discriminator]) - for i in shma.anyOf - ] + i.get_type(names=shmanm + [i.ref], discriminators=discriminators + [shma.discriminator]) + for i in shma.anyOf ) if shma.discriminator and shma.discriminator.mapping: annos["__root__"] = Annotated[Union[t], Field(discriminator=shma.discriminator.propertyName)] @@ -124,10 +122,8 @@ def fieldof(schema: "SchemaBase"): annos["__root__"] = Union[t] elif hasattr(shma, "oneOf") and shma.oneOf: t = tuple( - [ - i.get_type(names=shmanm + [i.ref], discriminators=discriminators + [shma.discriminator]) - for i in shma.oneOf - ] + i.get_type(names=shmanm + [i.ref], discriminators=discriminators + [shma.discriminator]) + for i in shma.oneOf ) if shma.discriminator and shma.discriminator.mapping: annos["__root__"] = Annotated[Union[t], Field(discriminator=shma.discriminator.propertyName)] From ddcd6f5f5301ace1f8dc7b8b666c5fdc175c3348 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Thu, 20 Jan 2022 09:18:22 +0100 Subject: [PATCH 115/125] model - fix type_name creation --- aiopenapi3/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiopenapi3/model.py b/aiopenapi3/model.py index e437548..b6fb886 100644 --- a/aiopenapi3/model.py +++ b/aiopenapi3/model.py @@ -105,7 +105,7 @@ def fieldof(schema: "SchemaBase"): if shma.type in ("string", "integer", "number", "boolean"): return Model.typeof(shma) - type_name = shma.title or shma._identity if hasattr(shma, "_identity") else str(uuid.uuid4()) + type_name = shma.title or getattr(shma, "_identity", None) or str(uuid.uuid4()) namespace = dict() annos = dict() if shma.allOf: From 17b3a1a2f0dd1f0eb38b78c941be357016f2a810 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Thu, 20 Jan 2022 09:20:11 +0100 Subject: [PATCH 116/125] model - extract functions --- aiopenapi3/model.py | 155 ++++++++++++++++++++++---------------------- 1 file changed, 79 insertions(+), 76 deletions(-) diff --git a/aiopenapi3/model.py b/aiopenapi3/model.py index b6fb886..054fee0 100644 --- a/aiopenapi3/model.py +++ b/aiopenapi3/model.py @@ -28,79 +28,6 @@ def from_schema( if discriminators is None: discriminators = [] - def typeof(schema: "SchemaBase"): - r = None - if schema.type == "integer": - r = int - elif schema.type == "number": - r = float - elif schema.type == "string": - r = str - elif schema.type == "boolean": - r = bool - elif schema.type == "array": - r = List[schema.items.get_type()] - elif schema.type == "object": - return schema.get_type() - elif schema.type is None: # discriminated root - return None - else: - raise TypeError(schema.type) - - return r - - def annotationsof(schema: "SchemaBase"): - annos = dict() - if schema.type == "array": - annos["__root__"] = typeof(schema) - else: - - for name, f in schema.properties.items(): - r = None - for discriminator in discriminators: - if name != discriminator.propertyName: - continue - for disc, v in discriminator.mapping.items(): - if v in shmanm: - r = Literal[disc] - break - else: - raise ValueError(schema) - break - else: - r = typeof(f) - - from . import v20, v30, v31 - - if isinstance(schema, (v20.Schema, v20.Reference)): - if not f.required: - annos[name] = Optional[r] - else: - annos[name] = r - elif isinstance(schema, (v30.Schema, v31.Schema, v30.Reference, v31.Reference)): - if name not in schema.required: - annos[name] = Optional[r] - else: - annos[name] = r - else: - raise TypeError(schema) - - return annos - - def fieldof(schema: "SchemaBase"): - r = dict() - if schema.type == "array": - return r - else: - for name, f in schema.properties.items(): - args = dict() - for i in ["enum", "default"]: - v = getattr(f, i, None) - if v: - args[i] = v - r[name] = Field(**args) - return r - # do not create models for primitive types if shma.type in ("string", "integer", "number", "boolean"): return Model.typeof(shma) @@ -110,7 +37,7 @@ def fieldof(schema: "SchemaBase"): annos = dict() if shma.allOf: for i in shma.allOf: - annos.update(annotationsof(i)) + annos.update(Model.annotationsof(i, discriminators, shmanm)) elif hasattr(shma, "anyOf") and shma.anyOf: t = tuple( i.get_type(names=shmanm + [i.ref], discriminators=discriminators + [shma.discriminator]) @@ -130,11 +57,87 @@ def fieldof(schema: "SchemaBase"): else: annos["__root__"] = Union[t] else: - annos = annotationsof(shma) - namespace.update(fieldof(shma)) + annos = Model.annotationsof(shma, discriminators, shmanm) + namespace.update(Model.fieldof(shma)) namespace["__annotations__"] = annos m = types.new_class(type_name, (BaseModel,), {}, lambda ns: ns.update(namespace)) m.update_forward_refs() return m + + @staticmethod + def typeof(schema: "SchemaBase"): + r = None + if schema.type == "integer": + r = int + elif schema.type == "number": + r = float + elif schema.type == "string": + r = str + elif schema.type == "boolean": + r = bool + elif schema.type == "array": + r = List[schema.items.get_type()] + elif schema.type == "object": + return schema.get_type() + elif schema.type is None: # discriminated root + return None + else: + raise TypeError(schema.type) + + return r + + @staticmethod + def annotationsof(schema: "SchemaBase", discriminators, shmanm): + annos = dict() + if schema.type == "array": + annos["__root__"] = Model.typeof(schema) + else: + + for name, f in schema.properties.items(): + r = None + for discriminator in discriminators: + if name != discriminator.propertyName: + continue + for disc, v in discriminator.mapping.items(): + if v in shmanm: + r = Literal[disc] + break + else: + raise ValueError(schema) + break + else: + r = Model.typeof(f) + + from . import v20, v30, v31 + + if isinstance(schema, (v20.Schema, v20.Reference)): + if not f.required: + annos[name] = Optional[r] + else: + annos[name] = r + elif isinstance(schema, (v30.Schema, v31.Schema, v30.Reference, v31.Reference)): + if name not in schema.required: + annos[name] = Optional[r] + else: + annos[name] = r + else: + raise TypeError(schema) + + return annos + + @staticmethod + def fieldof(schema: "SchemaBase"): + r = dict() + if schema.type == "array": + return r + else: + for name, f in schema.properties.items(): + args = dict() + for i in ["enum", "default"]: + v = getattr(f, i, None) + if v: + args[i] = v + r[name] = Field(**args) + return r From e185891e61eb8939224069499f0c0ecb269362eb Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Thu, 20 Jan 2022 09:27:07 +0100 Subject: [PATCH 117/125] model - cache type for discriminated objects create all types it is required to iterate the paths and pre-cache the types for the body & return values parameter types & v20 discriminated types are left --- aiopenapi3/base.py | 4 +++- aiopenapi3/model.py | 4 ++++ aiopenapi3/openapi.py | 8 +++++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/aiopenapi3/base.py b/aiopenapi3/base.py index af6e457..e41003d 100644 --- a/aiopenapi3/base.py +++ b/aiopenapi3/base.py @@ -187,7 +187,9 @@ class DiscriminatorBase: class SchemaBase: # @lru_cache def get_type(self, names: List[str] = None, discriminators: List[DiscriminatorBase] = None): - return Model.from_schema(self, names, discriminators) + if not hasattr(self, "_model_type"): + self._model_type = Model.from_schema(self, names, discriminators) + return self._model_type def model(self, data: Dict): """ diff --git a/aiopenapi3/model.py b/aiopenapi3/model.py index 054fee0..3f61474 100644 --- a/aiopenapi3/model.py +++ b/aiopenapi3/model.py @@ -82,6 +82,10 @@ def typeof(schema: "SchemaBase"): elif schema.type == "object": return schema.get_type() elif schema.type is None: # discriminated root + """ + recursively define related discriminated objects + """ + schema.get_type() return None else: raise TypeError(schema.type) diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index 4fb5994..786a537 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -232,14 +232,20 @@ def test_operation(operation_id): continue formatted_operation_id = op.operationId.replace(" ", "_") test_operation(formatted_operation_id) + if op.requestBody: + for r, media in op.requestBody.content.items(): + media.schema_.get_type() for r, response in op.responses.items(): + if isinstance(response, Reference): continue for c, content in response.content.items(): if content.schema_ is None: continue - if isinstance(content.schema_, (v30.Schema,)): + if isinstance(content.schema_, (v30.Schema, v31.Schema)): content.schema_._identity = f"{path}.{m}.{r}.{c}" + content.schema_.get_type() + else: raise ValueError(self._root) self.plugins.init.initialized(initialized=self._root) From 1d0eee1b3f9de7872d40d25a92532c063fdbbc6e Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Fri, 21 Jan 2022 13:59:10 +0100 Subject: [PATCH 118/125] =?UTF-8?q?Schema.get=5Ftype=20-=20initialize=20by?= =?UTF-8?q?=20iterating=20the=20gc's=20objects=20=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit taking care of discriminated Schemas. --- aiopenapi3/base.py | 16 +++++++++--- aiopenapi3/openapi.py | 54 ++++++++++++++++++++++++++++++--------- aiopenapi3/v20/general.py | 4 +-- aiopenapi3/v30/general.py | 4 +-- aiopenapi3/v31/general.py | 4 +-- aiopenapi3/v31/schemas.py | 1 - 6 files changed, 60 insertions(+), 23 deletions(-) diff --git a/aiopenapi3/base.py b/aiopenapi3/base.py index e41003d..97a5920 100644 --- a/aiopenapi3/base.py +++ b/aiopenapi3/base.py @@ -185,12 +185,16 @@ class DiscriminatorBase: class SchemaBase: - # @lru_cache - def get_type(self, names: List[str] = None, discriminators: List[DiscriminatorBase] = None): - if not hasattr(self, "_model_type"): - self._model_type = Model.from_schema(self, names, discriminators) + def set_type(self, names: List[str] = None, discriminators: List[DiscriminatorBase] = None): + self._model_type = Model.from_schema(self, names, discriminators) return self._model_type + def get_type(self, names: List[str] = None, discriminators: List[DiscriminatorBase] = None): + try: + return self._model_type + except AttributeError: + return self.set_type(names, discriminators) + def model(self, data: Dict): """ Generates a model representing this schema from the given data. @@ -214,5 +218,9 @@ def model(self, data: Dict): return self.get_type().parse_obj(data) +class ReferenceBase: + pass + + class ParameterBase: pass diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index 786a537..e0ed226 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -1,4 +1,6 @@ import sys +import gc + if sys.version_info >= (3, 9): import pathlib @@ -20,9 +22,8 @@ from .errors import ReferenceResolutionError, SpecError from .loader import Loader, NullLoader from .plugin import Plugin, Plugins -from .base import RootBase +from .base import RootBase, ReferenceBase, SchemaBase from .v30.paths import Operation -from .base import SchemaBase class OpenAPI: @@ -170,6 +171,14 @@ def __init__( self._root = self._parse_obj(document) + self._init_session_factory(session_factory) + self._init_references() + self._init_operationindex() + self._init_schema_types() + + self.plugins.init.initialized(initialized=self._root) + + def _init_session_factory(self, session_factory): if issubclass(getattr(session_factory, "__annotations__", {}).get("return", None.__class__), httpx.Client) or ( type(session_factory) == type and issubclass(session_factory, httpx.Client) ): @@ -191,10 +200,12 @@ def __init__( else: raise ValueError("invalid return value annotation for session_factory") + def _init_references(self): self._root._resolve_references(self) for i in list(self._documents.values()): i._resolve_references(self) + def _init_operationindex(self): operation_map = set() def test_operation(operation_id): @@ -232,9 +243,6 @@ def test_operation(operation_id): continue formatted_operation_id = op.operationId.replace(" ", "_") test_operation(formatted_operation_id) - if op.requestBody: - for r, media in op.requestBody.content.items(): - media.schema_.get_type() for r, response in op.responses.items(): if isinstance(response, Reference): @@ -244,11 +252,32 @@ def test_operation(operation_id): continue if isinstance(content.schema_, (v30.Schema, v31.Schema)): content.schema_._identity = f"{path}.{m}.{r}.{c}" - content.schema_.get_type() else: raise ValueError(self._root) - self.plugins.init.initialized(initialized=self._root) + + def _init_schema_types(self): + """ + create & cache all the types - + discriminated types are special, + they need to inherit properly and have to be created when creating the parent type + + :return: None + """ + # + gc.collect() + schemas = dict((id(i), i) for i in filter(lambda obj: isinstance(obj, SchemaBase), gc.get_objects())) + init = set(schemas.keys()) + for k, i in schemas.items(): + if not i.discriminator: + continue + init -= frozenset( + map(lambda x: id(x._target), filter(lambda x: isinstance(x, ReferenceBase), i.oneOf + i.anyOf)) + ) + + for i in init: + s = schemas[i] + s.set_type() @property def url(self): @@ -335,9 +364,10 @@ def _validate_parameters(op: "Operation", path): Ensures that all parameters for this path are valid """ assert isinstance(path, str) - allowed_path_parameters = re.findall(r"{([a-zA-Z0-9\-\._~]+)}", path) + allowed_path_parameters = frozenset(re.findall(r"{([a-zA-Z0-9\-\._~]+)}", path)) + + path_parameters = frozenset(map(lambda x: x.name, filter(lambda c: c.in_ == "path", op.parameters))) - for c in op.parameters: - if c.in_ == "path": - if c.name not in allowed_path_parameters: - raise SpecError("Parameter name not found in path: {}".format(c.name)) + r = path_parameters - allowed_path_parameters + if r: + raise SpecError(f"Parameter name(s) not found in path: {', '.join(r)}") diff --git a/aiopenapi3/v20/general.py b/aiopenapi3/v20/general.py index 0c0c31b..f3c8f89 100644 --- a/aiopenapi3/v20/general.py +++ b/aiopenapi3/v20/general.py @@ -2,7 +2,7 @@ from pydantic import Field, Extra -from ..base import ObjectExtended, ObjectBase +from ..base import ObjectExtended, ObjectBase, ReferenceBase class ExternalDocumentation(ObjectExtended): @@ -17,7 +17,7 @@ class ExternalDocumentation(ObjectExtended): url: str = Field(...) -class Reference(ObjectBase): +class Reference(ObjectBase, ReferenceBase): """ A `Reference Object`_ designates a reference to another node in the specification. diff --git a/aiopenapi3/v30/general.py b/aiopenapi3/v30/general.py index 76a6b99..a38881d 100644 --- a/aiopenapi3/v30/general.py +++ b/aiopenapi3/v30/general.py @@ -2,7 +2,7 @@ from pydantic import Field, Extra -from ..base import ObjectExtended, ObjectBase +from ..base import ObjectExtended, ObjectBase, ReferenceBase class ExternalDocumentation(ObjectExtended): @@ -17,7 +17,7 @@ class ExternalDocumentation(ObjectExtended): description: Optional[str] = Field(default=None) -class Reference(ObjectBase): +class Reference(ObjectBase, ReferenceBase): """ A `Reference Object`_ designates a reference to another node in the specification. diff --git a/aiopenapi3/v31/general.py b/aiopenapi3/v31/general.py index b2a51a4..9396a3a 100644 --- a/aiopenapi3/v31/general.py +++ b/aiopenapi3/v31/general.py @@ -2,7 +2,7 @@ from pydantic import Field, Extra, AnyUrl -from ..base import ObjectExtended, ObjectBase +from ..base import ObjectExtended, ObjectBase, ReferenceBase class ExternalDocumentation(ObjectExtended): @@ -17,7 +17,7 @@ class ExternalDocumentation(ObjectExtended): description: Optional[str] = Field(default=None) -class Reference(ObjectBase): +class Reference(ObjectBase, ReferenceBase): """ A `Reference Object`_ designates a reference to another node in the specification. diff --git a/aiopenapi3/v31/schemas.py b/aiopenapi3/v31/schemas.py index 46b6f1d..ec6c4b8 100644 --- a/aiopenapi3/v31/schemas.py +++ b/aiopenapi3/v31/schemas.py @@ -153,7 +153,6 @@ class Schema(ObjectExtended, SchemaBase): example: Optional[Any] = Field(default=None) _model_type: object - _request_model_type: object """ The _identity attribute is set during OpenAPI.__init__ and used at get_type() From 8b691d9e57500eede02866515fd7fd1f8dd713be Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Mon, 24 Jan 2022 08:35:25 +0100 Subject: [PATCH 119/125] SpecError - sorted names --- aiopenapi3/openapi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index e0ed226..100f6ff 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -370,4 +370,4 @@ def _validate_parameters(op: "Operation", path): r = path_parameters - allowed_path_parameters if r: - raise SpecError(f"Parameter name(s) not found in path: {', '.join(r)}") + raise SpecError(f"Parameter name{'s' if len(r) > 1 else ''} not found in path: {', '.join(sorted(r))}") From 59332e4e5ca7b939ad5d2ceb1d50839d6648cc88 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 25 Jan 2022 09:28:54 +0100 Subject: [PATCH 120/125] content-type in encodings ref: https://github.com/Dorthu/openapi3/pull/66 --- aiopenapi3/v20/glue.py | 2 +- aiopenapi3/v30/glue.py | 2 ++ tests/api/v1/main.py | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/aiopenapi3/v20/glue.py b/aiopenapi3/v20/glue.py index e192af5..74d9320 100644 --- a/aiopenapi3/v20/glue.py +++ b/aiopenapi3/v20/glue.py @@ -188,7 +188,7 @@ def _process(self, result): content_type = result.headers.get("Content-Type", None) - if content_type.lower().partition(";")[0] == "application/json": + if content_type and content_type.lower().partition(";")[0] == "application/json": data = result.text data = self.api.plugins.message.received(operationId=self.operation.operationId, received=data).received data = json.loads(data) diff --git a/aiopenapi3/v30/glue.py b/aiopenapi3/v30/glue.py index 30b66cd..cb92d3a 100644 --- a/aiopenapi3/v30/glue.py +++ b/aiopenapi3/v30/glue.py @@ -195,7 +195,9 @@ def _process(self, result): return None content_type = result.headers.get("Content-Type", None) + if content_type: + content_type, _, encoding = content_type.partition(";") expected_media = expected_response.content.get(content_type, None) if expected_media is None and "/" in content_type: # accept media type ranges in the spec. the most specific matching diff --git a/tests/api/v1/main.py b/tests/api/v1/main.py index a0aa89b..5718506 100644 --- a/tests/api/v1/main.py +++ b/tests/api/v1/main.py @@ -71,4 +71,5 @@ def deletePet(response: Response, pet_id: int = Query(..., alias="petId")) -> Pe return JSONResponse( status_code=starlette.status.HTTP_404_NOT_FOUND, content=Error(code=errno.ENOENT, message=f"{pet_id} not found").dict(), + media_type="application/json; utf-8", ) From efe58e3123eff93c4a8a59cbfe1982defcd44ee6 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 25 Jan 2022 10:59:26 +0100 Subject: [PATCH 121/125] =?UTF-8?q?tests/linode=20-=20the=20github=20CI=20?= =?UTF-8?q?=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/linode_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/linode_test.py b/tests/linode_test.py index 7c5144f..6c77815 100644 --- a/tests/linode_test.py +++ b/tests/linode_test.py @@ -4,7 +4,7 @@ from aiopenapi3 import OpenAPI import pytest - +# downloading the description document in the github CI fails due to the cloudflare captcha noci = pytest.mark.skipif(os.environ.get("GITHUB_ACTIONS", None) is not None, reason="fails on github") From a72fdbfdd652c1a285eb354e6b16161372f1b9ab Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 25 Jan 2022 11:24:43 +0100 Subject: [PATCH 122/125] tests - handle DeprecationWarnings --- pyproject.toml | 9 +++++++++ tests/fastapi_test.py | 5 +++-- tests/model_test.py | 5 +++-- tests/path_test.py | 5 ++++- tests/swagger_test.py | 4 ++-- 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fed528d..08576f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,12 @@ [build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" + +[tool.pytest.ini_options] +filterwarnings = [ + "error", + "ignore::UserWarning", + # note the use of single quote below to denote "raw" strings in TOML + 'ignore:function ham\(\) is deprecated:DeprecationWarning', +] +asyncio_mode = "strict" diff --git a/tests/fastapi_test.py b/tests/fastapi_test.py index 4d31d2f..7965095 100644 --- a/tests/fastapi_test.py +++ b/tests/fastapi_test.py @@ -3,6 +3,7 @@ import sys import pytest +import pytest_asyncio import uvloop from hypercorn.asyncio import serve @@ -27,7 +28,7 @@ def event_loop(request): loop.close() -@pytest.fixture(scope="session") +@pytest_asyncio.fixture(scope="session") async def server(event_loop, config): uvloop.install() try: @@ -39,7 +40,7 @@ async def server(event_loop, config): await task -@pytest.fixture(scope="session") +@pytest_asyncio.fixture(scope="session") async def client(event_loop, server): api = await asyncio.to_thread(aiopenapi3.OpenAPI.load_sync, f"http://{server.bind[0]}/v1/openapi.json") return api diff --git a/tests/model_test.py b/tests/model_test.py index 4e0f446..e37989a 100644 --- a/tests/model_test.py +++ b/tests/model_test.py @@ -7,6 +7,7 @@ from tests.api.v2.schema import Dog import pytest +import pytest_asyncio import uvloop from hypercorn.asyncio import serve @@ -32,7 +33,7 @@ def event_loop(request): loop.close() -@pytest.fixture(scope="session") +@pytest_asyncio.fixture(scope="session") async def server(event_loop, config): uvloop.install() try: @@ -49,7 +50,7 @@ def version(request): return f"v{request.param}" -@pytest.fixture(scope="session") +@pytest_asyncio.fixture(scope="session") async def client(event_loop, server, version): url = f"http://{server.bind[0]}/{version}/openapi.json" api = await aiopenapi3.OpenAPI.load_async(url) diff --git a/tests/path_test.py b/tests/path_test.py index 6adc91c..7e5c429 100644 --- a/tests/path_test.py +++ b/tests/path_test.py @@ -41,6 +41,9 @@ def test_operations_exist(petstore_expanded_spec): assert pets_id_path.put is None assert pets_id_path.delete is not None + for operation in petstore_expanded_spec._: + continue + def test_operation_populated(petstore_expanded_spec): """ @@ -184,7 +187,7 @@ def test_parameters(httpx_mock, with_parameters): httpx_mock.add_response(headers={"Content-Type": "application/json"}, content=b"[]") api = OpenAPI(URLBASE, with_parameters, session_factory=httpx.Client) - with pytest.raises(ValueError, match="Required parameter \w+ not provided"): + with pytest.raises(ValueError, match=r"Required parameter \w+ not provided"): api._.getTest(data={}, parameters={}) Header = str([i ** i for i in range(3)]) diff --git a/tests/swagger_test.py b/tests/swagger_test.py index 033694e..3f66f8b 100644 --- a/tests/swagger_test.py +++ b/tests/swagger_test.py @@ -25,7 +25,7 @@ def test_securityparameters(httpx_mock, with_swagger): auth = str(uuid.uuid4()) - with pytest.raises(ValueError, match="does not accept security schemes \['xAuth'\]"): + with pytest.raises(ValueError, match=r"does not accept security schemes \['xAuth'\]"): api.authenticate(xAuth=auth) api._.createUser(data=user, parameters={}) @@ -90,7 +90,7 @@ def test_parameters(httpx_mock, with_swagger): auth = str(uuid.uuid4()) api.authenticate(BasicAuth=(auth, auth)) - with pytest.raises(ValueError, match="Required parameter \w+ not provided"): + with pytest.raises(ValueError, match=r"Required parameter \w+ not provided"): api._.getUser(data={}, parameters={}) httpx_mock.add_response(headers={"Content-Type": "application/json"}, json=[user.dict()]) From c64f058f240d63ffb36758e9381b1313479769ef Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Tue, 25 Jan 2022 16:23:22 +0100 Subject: [PATCH 123/125] openapi.url - partition --- aiopenapi3/openapi.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py index 100f6ff..e4a591a 100644 --- a/aiopenapi3/openapi.py +++ b/aiopenapi3/openapi.py @@ -294,17 +294,12 @@ def url(self): scheme = base.scheme if self._root.host: - host = self._root.host - if ":" in host: - host, _, port = host.partition(":") + host, _, port = self._root.host.partition(":") else: - host = base.host - port = base.port + host, port = base.host, base.port + + path = self._root.basePath or base.path - if self._root.basePath: - path = self._root.basePath - else: - path = base.path r = yarl.URL.build(scheme=scheme, host=host, port=port, path=path) return r elif isinstance(self._root, (v30.Root, v31.Root)): From b5584d8f7b2ac7adc263b64e6ea08f4e804f9613 Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Fri, 28 Jan 2022 06:39:33 +0100 Subject: [PATCH 124/125] ObjectExtended - extensions better be Any than object - arbitrary_types_allowed = False --- aiopenapi3/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aiopenapi3/base.py b/aiopenapi3/base.py index 97a5920..cc70585 100644 --- a/aiopenapi3/base.py +++ b/aiopenapi3/base.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, Any from pydantic import BaseModel, Field, root_validator, Extra @@ -18,7 +18,7 @@ class Config: class ObjectExtended(ObjectBase): - extensions: Optional[object] = Field(default=None) + extensions: Optional[Any] = Field(default=None) @root_validator(pre=True) def validate_ObjectExtended_extensions(cls, values): From 8fb905928d5a172f651c2ee795f3b2ca0ddbbc2a Mon Sep 17 00:00:00 2001 From: Markus Koetter Date: Fri, 28 Jan 2022 07:37:59 +0100 Subject: [PATCH 125/125] model - support pydantic custom formats such as datetime.timedelta --- aiopenapi3/model.py | 31 +++++++++++++++++++++++++++++-- aiopenapi3/v20/glue.py | 4 ++-- aiopenapi3/v30/glue.py | 6 ++++-- tests/api/v2/schema.py | 2 ++ tests/model_test.py | 8 +++++++- 5 files changed, 44 insertions(+), 7 deletions(-) diff --git a/aiopenapi3/model.py b/aiopenapi3/model.py index 3f61474..38fd74c 100644 --- a/aiopenapi3/model.py +++ b/aiopenapi3/model.py @@ -1,4 +1,6 @@ from __future__ import annotations + +import collections import types import uuid @@ -11,6 +13,27 @@ from typing_extensions import Annotated, Literal from pydantic import BaseModel, Extra, Field +from pydantic.schema import field_class_to_schema + +type_format_to_class = collections.defaultdict(lambda: dict()) + + +def generate_type_format_to_class(): + """ + initialize type_format_to_class + :return: None + """ + global type_format_to_class + for cls, spec in field_class_to_schema: + if spec["type"] not in frozenset(["string", "number"]): + continue + type_format_to_class[spec["type"]][spec.get("format", None)] = cls + + +def class_from_schema(s): + a = type_format_to_class[s.type] + b = a.get(s.format, a[None]) + return b class Model(BaseModel): @@ -72,9 +95,9 @@ def typeof(schema: "SchemaBase"): if schema.type == "integer": r = int elif schema.type == "number": - r = float + r = class_from_schema(schema) elif schema.type == "string": - r = str + r = class_from_schema(schema) elif schema.type == "boolean": r = bool elif schema.type == "array": @@ -145,3 +168,7 @@ def fieldof(schema: "SchemaBase"): args[i] = v r[name] = Field(**args) return r + + +if len(type_format_to_class) == 0: + generate_type_format_to_class() diff --git a/aiopenapi3/v20/glue.py b/aiopenapi3/v20/glue.py index 74d9320..73d7036 100644 --- a/aiopenapi3/v20/glue.py +++ b/aiopenapi3/v20/glue.py @@ -133,13 +133,13 @@ def _prepare_body(self, data): if isinstance(data, (dict, list)): pass elif isinstance(data, pydantic.BaseModel): - data = data.dict() + data = dict(data._iter(to_dict=True)) else: raise TypeError(data) data = self.api.plugins.message.marshalled( operationId=self.operation.operationId, marshalled=data ).marshalled - data = json.dumps(data) + data = json.dumps(data, default=pydantic.json.pydantic_encoder) data = data.encode() data = self.api.plugins.message.sending(operationId=self.operation.operationId, sending=data).sending self.req.content = data diff --git a/aiopenapi3/v30/glue.py b/aiopenapi3/v30/glue.py index cb92d3a..b48c52b 100644 --- a/aiopenapi3/v30/glue.py +++ b/aiopenapi3/v30/glue.py @@ -3,6 +3,7 @@ import httpx import pydantic +import pydantic.json from ..base import SchemaBase, ParameterBase from ..request import RequestBase, AsyncRequestBase @@ -142,13 +143,13 @@ def _prepare_body(self, data): if isinstance(data, (dict, list)): pass elif isinstance(data, pydantic.BaseModel): - data = data.dict() + data = dict(data._iter(to_dict=True)) else: raise TypeError(data) data = self.api.plugins.message.marshalled( operationId=self.operation.operationId, marshalled=data ).marshalled - data = json.dumps(data) + data = json.dumps(data, default=pydantic.json.pydantic_encoder) data = data.encode() data = self.api.plugins.message.sending(operationId=self.operation.operationId, sending=data).sending self.req.content = data @@ -189,6 +190,7 @@ def _process(self, result): raise HTTPStatusError( result.status_code, f"""Unexpected response {result.status_code} from {self.operation.operationId} (expected one of {options}), no default is defined""", + result, ) if len(expected_response.content) == 0: diff --git a/tests/api/v2/schema.py b/tests/api/v2/schema.py index 7eef836..ffa0c4c 100644 --- a/tests/api/v2/schema.py +++ b/tests/api/v2/schema.py @@ -1,3 +1,4 @@ +from datetime import timedelta import uuid import sys @@ -50,6 +51,7 @@ def __setattr__(self, item, value): class Dog(PetBase): pet_type: Literal["dog"] = "dog" name: str + age: timedelta # Pet = Annotated[Union[Cat, Dog], Field(default=Undefined, discriminator='pet_type')] diff --git a/tests/model_test.py b/tests/model_test.py index e37989a..3839e46 100644 --- a/tests/model_test.py +++ b/tests/model_test.py @@ -1,3 +1,5 @@ +import datetime +import random import sys import asyncio import uuid @@ -97,7 +99,11 @@ async def test_model(event_loop, server, client): def randomPet(client, name=None): if name: - return {"pet": client.components.schemas["Dog"].model({"name": name}).dict()} + return client._.createPet.data.get_type().construct( + pet=client.components.schemas["Dog"] + .get_type() + .construct(name=name, age=datetime.timedelta(seconds=random.randint(1, 2 ** 32))) + ) else: return { "pet": client.components.schemas["WhiteCat"]