diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d6823a3..2b1c4bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,9 +2,14 @@ name: Release on: workflow_dispatch: +permissions: + contents: read jobs: release: runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-release-${{ github.ref_name }} + cancel-in-progress: false permissions: contents: write id-token: write @@ -12,15 +17,24 @@ jobs: - name: checkout uses: actions/checkout@v6 with: + ref: ${{ github.ref_name }} fetch-depth: 0 + - name: force release branch to workflow sha + run: git reset --hard ${{ github.sha }} - name: install uv uses: astral-sh/setup-uv@v6 with: + version: "0.12.3" python-version: "3.10" enable-cache: true - name: dependencies run: uv sync --locked --dev - - name: release - run: uv run semantic-release publish + - name: semantic release + id: release + run: uv run semantic-release -v version env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GIT_COMMIT_AUTHOR: "manchenkoff " + - name: publish to pypi + if: steps.release.outputs.released == 'true' + run: uv publish diff --git a/docs/recipes.md b/docs/recipes.md deleted file mode 100644 index de5fe2c..0000000 --- a/docs/recipes.md +++ /dev/null @@ -1,85 +0,0 @@ -## Convert OpenAPIv2 to OpenAPIv3 - -To convert OpenAPIv2 to OpenAPIv3, you can use the `prance` library. Install it using (after activate the virtual environment): - -```bash -pip install prance -``` - -Then, you can use the following code: - -```bash -prance convert openapi_v2.yaml openapi_v3.yaml -``` - -### Some Issues may exist in the generated OpenAPIv3 file - -- `description` field in some places may convert as multi-line string (bad format). use this code to fix it: - -```python -import yaml -import re - -def process_descriptions(data): - """ - Recursively process a dictionary/list to modify all 'description' fields to a single line. - """ - if isinstance(data, dict): - for key, value in data.items(): - if key == 'description' and isinstance(value, str): - # Replace newlines, tabs, and multiple spaces with a single space - cleaned_description = re.sub(r'\s+', ' ', value.strip()) - data[key] = cleaned_description - else: - process_descriptions(value) - elif isinstance(data, list): - for item in data: - process_descriptions(item) - -def modify_yaml_file(input_file, output_file): - try: - # Read the YAML file - with open(input_file, 'r', encoding='utf-8') as file: - yaml_data = yaml.safe_load(file) - - # Process all description fields - process_descriptions(yaml_data) - - # Write the modified data to a new YAML file - with open(output_file, 'w', encoding='utf-8') as file: - yaml.dump(yaml_data, file, allow_unicode=True, sort_keys=False, default_flow_style=False, width=1000) - - print(f"Modified YAML file saved as: {output_file}") - except yaml.YAMLError as ye: - print(f"YAML parsing error: {ye}") - except Exception as e: - print(f"Error processing YAML file: {e}") - -# Example usage -input_yaml = 'openapi_v3.yaml' # Replace with your input YAML file path -output_yaml = 'openapi_v3_output.yaml' # Replace with desired output YAML file path -modify_yaml_file(input_yaml, output_yaml) -``` - -- `successfull_response` in `responses` section. this is not a standard field in OpenAPIv3. You can remove it. remove it and put `example` right after `schema` field in `responses` section. - -```yaml -responses: - "200": - description: successful operation - content: - application/json: - schema: - $ref: "#/components/schemas/API_Entities_AccessRequester" - example: - id: 1 - username: ali - name: ali karami - state: active - created_at: 2012-10-22T14:13:35Z - access_level: 20 -``` - -- `type:file` in `parameters` or `schema` section. this is not a standard field in OpenAPIv3. You can replace it with `type: string` and add `format: binary` field to it. - -- `application/x-tar` in `content` section. this may be cause of error in some cases. you can remove it and replace it with `application/octet-stream` or other standard types for binary files. diff --git a/pyproject.toml b/pyproject.toml index e914460..b20d9b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,8 +22,9 @@ classifiers = [ "Topic :: Software Development :: Libraries", ] dependencies = [ - "openapi-spec-validator>=0.8.5", - "prance>=25.4.8.0", + "pydantic>=2.0", + "referencing>=0.35", + "PyYAML>=6.0", ] [project.urls] @@ -35,9 +36,10 @@ dev = [ "mypy>=2.1.0", "pytest>=9.0.3", "pytest-cov>=6.1.0", - "python-semantic-release>=10.5.3", + "python-semantic-release>=10.6.1,<11", "ruff>=0.15.13", "ty>=0.0.37", + "types-pyyaml>=6.0.12.20260518", ] [build-system] @@ -49,33 +51,17 @@ module-name = "openapi_parser" [tool.semantic_release] version_toml = ["pyproject.toml:project.version"] -branch = "main" build_command = """ uv lock --upgrade-package "$PACKAGE_NAME" git add uv.lock uv build """ -upload_to_pypi = true -upload_to_release = true -remove_dist = true - -[[tool.uv.index]] -name = "testpypi" -url = "https://test.pypi.org/simple/" -publish-url = "https://test.pypi.org/legacy/" -explicit = true [tool.mypy] python_version = "3.10" strict = true mypy_path = ["src"] - -[[tool.mypy.overrides]] -module = "tests.*" - -[[tool.mypy.overrides]] -module = "prance" -ignore_missing_imports = true +plugins = ["pydantic.mypy"] [tool.ruff.lint] extend-select = [ @@ -87,14 +73,34 @@ extend-select = [ "ARG", # unused arguments "RUF100", # unused noqa "TID252", # ban relative imports + "PLR0915", # too-many-statements ] +[tool.ruff.lint.pylint] +max-statements = 30 + [tool.ruff.lint.pydocstyle] convention = "google" [tool.ruff.lint.per-file-ignores] "tests/**" = ["D", "ARG"] +[tool.ty] +# Tests use Pydantic aliases (e.g. location= instead of in=, not_schema= +# instead of not=) which ty does not resolve through Field(alias=) + +# populate_by_name=True correctly. These are false positives from the +# Pydantic-to-ty impedance mismatch. +[[tool.ty.overrides]] +include = ["tests/test_parse/**"] +[tool.ty.overrides.rules] +"unknown-argument" = "ignore" +"missing-argument" = "ignore" +"invalid-argument-type" = "ignore" + +[tool.pytest.ini_options] +log_cli = true +log_cli_level = "WARNING" + [tool.coverage.run] source = ["openapi_parser"] omit = ["tests/*"] diff --git a/readme.md b/readme.md index 77698bb..ccde34c 100644 --- a/readme.md +++ b/readme.md @@ -5,15 +5,18 @@ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/openapi3-parser)](https://pypi.org/project/openapi3-parser/) [![PyPI - Format](https://img.shields.io/pypi/format/openapi3-parser)](https://pypi.org/project/openapi3-parser/) -Parse OpenAPI 3 documents into fully typed Python dataclass objects. +Parse OpenAPI and Swagger documents into fully typed Pydantic models. Navigate your API specification programmatically — servers, paths, operations, parameters, schemas, security schemes, and more. | Version | Status | | ------- | -------------- | -| 2.0 | Deprecated | -| 3.0 | **Supported** | -| 3.1 | In development | +| 2.0 | Supported* | +| 3.0 | Supported | +| 3.1 | Supported | +| 3.2 | Supported | + +\* Swagger 2.0 documents are normalized to OpenAPI 3.0. ## Installation @@ -49,6 +52,12 @@ info: version: "1.0.0" paths: {} """) + +# From raw string with external $refs (resolved relative to base_uri) +spec = parse( + spec_string=open("specs/openapi.yml").read(), + base_uri="file:///abs/path/specs/openapi.yml", +) ``` ### Navigate servers, paths, and operations @@ -60,35 +69,36 @@ specification = parse("swagger.yml") for server in specification.servers: print(f"{server.description} - {server.url}") -# List all paths and their HTTP methods -for path in specification.paths: - methods = ", ".join(op.method.value for op in path.operations) - print(f"{path.url}: [{methods}]") +# Iterate paths and their HTTP methods +for path, path_item in specification.paths.items(): + methods = ", ".join( + method for method in ("get", "put", "post", "delete", "patch") + if getattr(path_item, method) is not None + ) + print(f"{path}: [{methods}]") # Inspect operation details -for path in specification.paths: - for op in path.operations: - print(f"[{op.method.value}] {path.url}: {op.summary}") - if op.deprecated: - print(" (deprecated)") - if op.operation_id: - print(f" operationId: {op.operation_id}") +for path_item in specification.paths.values(): + get_op = path_item.get + if get_op is None: + continue + print(f"[GET] {path}: {get_op.summary}") + if get_op.deprecated: + print(" (deprecated)") + if get_op.operation_id: + print(f" operationId: {get_op.operation_id}") ``` -### Enum strictness +### Follow `$ref` references -By default, content types, string formats, and other enum fields are validated -against predefined enums. For specs that use custom values, pass -`strict_enum=False`: +`$ref` entries are resolved in place and annotated with a `ref_name` +pointing back to their canonical location: ```python -# Accepts non-standard content types like "application/vnd.api+json" -spec = parse("swagger.yml", strict_enum=False) +schema = specification.components.schemas["Pet"] +print(schema.ref_name) # "#/components/schemas/Pet" ``` -When strict mode is off, unrecognized values are wrapped in a `LooseEnum` -object instead of raising an error. - ### Error Handling ```python @@ -98,36 +108,39 @@ try: spec = parse("invalid.yml") except ParserError as e: print(f"Parsing failed: {e}") + for detail in e.errors(): + print(detail["loc"], detail["msg"]) ``` ## Data Model Parsed documents return a `Specification` object composed of fully typed -dataclasses: - -| Model | Description | -| --------------- | ----------- | -| `Specification` | Root document — version, info, servers, paths, schemas, security | -| `Info` | API metadata — title, version, description, contact, license | -| `Server` | Server definition — url, description, variables | -| `Path` | URL path — operations, parameters | -| `Operation` | HTTP method — responses, parameters, request body, security | -| `Parameter` | Path/query/header/cookie param — schema, style, required | -| `Response` | Status code, description, content, headers | -| `RequestBody` | Content, description, required | -| `Content` | Media type, schema, example | -| `Schema` | Base type — Integer, Number, String, Boolean, Array, Object, Null | -| `Property` | Object property — name, schema | -| `OneOf`/`AnyOf` | Composition schemas with discriminator support | -| `Security` | Security scheme — apiKey, http, oauth2, openIdConnect | -| `OAuthFlow` | OAuth flow — authorization, token, scopes | -| `Header` | Response header — name, schema, description | -| `Tag` | Tag with optional external docs | -| `ExternalDoc` | External documentation reference | -| `Discriminator` | Polymorphism discriminator — property name, mapping | - -See the [specification module](src/openapi_parser/specification.py) for -all available fields and types. +Pydantic models: + +| Model | Description | +| ---------------- | ----------- | +| `Specification` | Root document — openapi, info, servers, paths, components, security | +| `Info` | API metadata — title, version, description, contact, license | +| `Server` | Server definition — url, description, variables | +| `PathItem` | URL path — get/post/put/delete/patch, parameters, servers | +| `Operation` | HTTP method — responses, parameters, request body, security | +| `Parameter` | Path/query/header/cookie param — schema, style, required | +| `Response` | Status code, description, content, headers | +| `RequestBody` | Content, description, required | +| `MediaType` | Media type — schema, example, encoding | +| `Schema` | Data definition — type, properties, items, composition | +| `Components` | Reusable schemas, responses, parameters, examples, headers, ... | +| `SecurityScheme` | Security scheme — apiKey, http, oauth2, openIdConnect, mutualTLS | +| `OAuthFlow` | OAuth flow — authorization, token, scopes | +| `Header` | Response header — name, schema, description | +| `Link` | Link definition — operation, parameters, request body | +| `Example` | Example — value, summary, externalValue | +| `Tag` | Tag with optional external docs | +| `ExternalDoc` | External documentation reference | +| `Discriminator` | Polymorphism discriminator — property name, mapping | + +See the [models](src/openapi_parser/models/) package for all available +fields and types. ## Development @@ -136,13 +149,13 @@ all available fields and types. uv sync --dev # Lint -uv run ruff check . -uv run mypy . -uv run ty check . +uv run ruff check src/ tests/ +uv run mypy src/ tests/ +uv run ty check # Test uv run pytest # Format -uv run ruff format . +uv run ruff format src/ tests/ ``` diff --git a/src/openapi_parser/__init__.py b/src/openapi_parser/__init__.py index 151341e..d5e298b 100644 --- a/src/openapi_parser/__init__.py +++ b/src/openapi_parser/__init__.py @@ -1,5 +1,8 @@ """OpenAPI v3 specification parser.""" +from openapi_parser import enumeration +from openapi_parser.errors import ParserError +from openapi_parser.models import v3_0, v3_1 from openapi_parser.parser import parse -__all__ = ["parse"] +__all__ = ["parse", "ParserError", "enumeration", "v3_0", "v3_1"] diff --git a/src/openapi_parser/builders/__init__.py b/src/openapi_parser/builders/__init__.py deleted file mode 100644 index ebcd385..0000000 --- a/src/openapi_parser/builders/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Builders for converting raw OpenAPI dicts into typed specification objects.""" diff --git a/src/openapi_parser/builders/common.py b/src/openapi_parser/builders/common.py deleted file mode 100644 index 8fdaf5e..0000000 --- a/src/openapi_parser/builders/common.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Shared builder utilities and type helpers.""" - -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any - -from openapi_parser.errors import ParserError - - -@dataclass(frozen=True, slots=True) -class PropertyMeta: - """Property metadata for type-casting extraction.""" - - name: str - cast: Callable[..., Any] | None = None - - -def extract_typed_props( - data: dict[str, Any], - attrs_map: dict[str, PropertyMeta], -) -> dict[str, Any]: - """Extract properties from the dictionary with type-casting using passed mapping. - - Args: - data (dict): Original dictionary to process - attrs_map (Dict[str, PropertyMeta]): Type-casting mapping - - Returns: - Dict[str, Any]: Dictionary with type-casted values - """ - - def cast_value( - name: str, - value: Any, - type_cast_func: Callable[..., Any] | None, - ) -> Any: - try: - if type_cast_func is not None: - return type_cast_func(value) - - return value - except ValueError: - raise ParserError( - f"Invalid value for '{name}' property, got '{value}'", - ) from None - - custom_attrs = { - attr_name: cast_value( - attr_info.name, - data[attr_info.name], - attr_info.cast, - ) - for attr_name, attr_info in attrs_map.items() - if data.get(attr_info.name) is not None - } - - return custom_attrs - - -def merge_schema(original: dict[str, Any], other: dict[str, Any]) -> dict[str, Any]: - """Merge two schema dictionaries into single dict. - - Args: - original (dict): Source schema dictionary - other (dict): Schema dictionary to append to the source - - Returns: - dict: Dictionary value of new merged schema - """ - source = original.copy() - - for key, value in other.items(): - if key not in source: - source[key] = value - elif isinstance(value, list): - if isinstance(source[key], list): - source[key].extend(value) - else: - source[key] = value - elif isinstance(value, dict): - if isinstance(source[key], dict): - source[key] = merge_schema(source[key], value) - else: - source[key] = value - else: - source[key] = value - - return source - - -def extract_extension_attributes(schema: dict[str, Any]) -> dict[str, Any]: - """Extract custom 'x-*' attributes from schema dictionary. - - Args: - schema (dict): Schema dictionary - - Returns: - dict: Dictionary with parsed attributes w/o 'x-' prefix - """ - extension_key_format = "x-" - - extensions_dict: dict[str, Any] = { - key.replace(extension_key_format, "").replace("-", "_"): value - for key, value in schema.items() - if key.startswith(extension_key_format) - } - - return extensions_dict diff --git a/src/openapi_parser/builders/content.py b/src/openapi_parser/builders/content.py deleted file mode 100644 index 277c913..0000000 --- a/src/openapi_parser/builders/content.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Content builder for OpenAPI request/response bodies.""" - -import logging -from typing import Any - -from openapi_parser.builders.encoding import EncodingBuilder -from openapi_parser.builders.schema import SchemaFactory -from openapi_parser.enumeration import ContentType -from openapi_parser.logging import log_ctx -from openapi_parser.loose_types import LooseContentType -from openapi_parser.specification import Content - -logger = logging.getLogger(__name__) - -ContentTypeType = type[ContentType] | type[LooseContentType] - - -class ContentBuilder: - """Builds content objects for request/response bodies.""" - - _schema_factory: SchemaFactory - _encoding_builder: EncodingBuilder - _strict_enum: bool - - def __init__( - self, - schema_factory: SchemaFactory, - encoding_builder: EncodingBuilder, - strict_enum: bool = True, - ) -> None: - """Initialize content builder. - - Args: - schema_factory: Factory for creating schema objects - encoding_builder: Builder for encoding objects - strict_enum: Whether to validate enums strictly - """ - self._schema_factory = schema_factory - self._encoding_builder = encoding_builder - self._strict_enum = strict_enum - - def build_list( - self, - data: dict[str, Any], - ) -> list[Content]: - """Build a list of content objects from a dict of media types.""" - return [ - self._create_content(content_type, content_value) - for content_type, content_value in data.items() - ] - - def _create_content( - self, - content_type: str, - content_value: dict[str, Any], - ) -> Content: - with log_ctx("content", content_type): - logger.debug(f"Content building [type={content_type}]") - - ContentTypeCls: ContentTypeType = ( - ContentType if self._strict_enum else LooseContentType - ) - - encoding = ( - self._encoding_builder.build_dict(content_value["encoding"]) - if content_value.get("encoding") - else None - ) - - return Content( - type=ContentTypeCls(content_type), - schema=self._schema_factory.create(content_value.get("schema", {})), - example=content_value.get("example"), - examples=content_value.get("examples", {}), - encoding=encoding, - ) diff --git a/src/openapi_parser/builders/encoding.py b/src/openapi_parser/builders/encoding.py deleted file mode 100644 index 8b3cfc0..0000000 --- a/src/openapi_parser/builders/encoding.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Encoding builder for request body property encodings.""" - -import logging -from typing import Any - -from openapi_parser.builders.common import ( - PropertyMeta, - extract_extension_attributes, - extract_typed_props, -) -from openapi_parser.builders.header import HeaderBuilder -from openapi_parser.logging import log_ctx -from openapi_parser.specification import Encoding - -logger = logging.getLogger(__name__) - - -class EncodingBuilder: - """Builds encoding objects from raw specification data.""" - - _header_builder: HeaderBuilder - - def __init__(self, header_builder: HeaderBuilder) -> None: - """Initialize encoding builder. - - Args: - header_builder: Builder for header objects - """ - self._header_builder = header_builder - - def build_dict( - self, - data: dict[str, dict[str, Any]], - ) -> dict[str, Encoding]: - """Build a dict of encodings from a dict of raw encoding definitions.""" - result: dict[str, Encoding] = {} - - for property_name, encoding_data in data.items(): - with log_ctx("encoding", property_name): - result[property_name] = self._build(encoding_data) - - return result - - def _build(self, data: dict[str, Any]) -> Encoding: - logger.debug("Encoding building") - - attrs_map = { - "content_type": PropertyMeta(name="contentType", cast=str), - "headers": PropertyMeta( - name="headers", - cast=self._header_builder.build_list, - ), - "style": PropertyMeta(name="style", cast=str), - "explode": PropertyMeta(name="explode", cast=bool), - "allow_reserved": PropertyMeta(name="allowReserved", cast=bool), - } - - attrs = extract_typed_props(data, attrs_map) - attrs["extensions"] = extract_extension_attributes(data) - - if attrs["extensions"]: - logger.debug(f"Extracted custom properties [{attrs['extensions'].keys()}]") - - return Encoding(**attrs) diff --git a/src/openapi_parser/builders/external_doc.py b/src/openapi_parser/builders/external_doc.py deleted file mode 100644 index 70793ae..0000000 --- a/src/openapi_parser/builders/external_doc.py +++ /dev/null @@ -1,37 +0,0 @@ -"""External documentation builder.""" - -import logging -from typing import Any - -from openapi_parser.builders.common import extract_extension_attributes -from openapi_parser.errors import ParserError -from openapi_parser.specification import ExternalDoc - -logger = logging.getLogger(__name__) - - -class ExternalDocBuilder: - """Builds external documentation objects.""" - - @staticmethod - def build(data: dict[str, Any]) -> ExternalDoc: - """Build an ExternalDoc from a raw dict.""" - url = data.get("url") - - if url is None: - raise ParserError( - "External documentation is missing required 'url' property" - ) - - logger.debug(f"External doc parsing: {url}") - - attrs = { - "url": url, - "description": data.get("description"), - "extensions": extract_extension_attributes(data), - } - - if attrs["extensions"]: - logger.debug(f"Extracted custom properties [{attrs['extensions'].keys()}]") - - return ExternalDoc(**attrs) diff --git a/src/openapi_parser/builders/header.py b/src/openapi_parser/builders/header.py deleted file mode 100644 index f64ef26..0000000 --- a/src/openapi_parser/builders/header.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Header builder for response headers.""" - -import logging -from typing import Any - -from openapi_parser.builders.common import ( - PropertyMeta, - extract_extension_attributes, - extract_typed_props, -) -from openapi_parser.builders.schema import SchemaFactory -from openapi_parser.logging import log_ctx -from openapi_parser.specification import Header - -logger = logging.getLogger(__name__) - - -class HeaderBuilder: - """Builds header objects from raw specification data.""" - - _schema_factory: SchemaFactory - - def __init__(self, schema_factory: SchemaFactory) -> None: - """Initialize header builder. - - Args: - schema_factory: Factory for creating schema objects - """ - self._schema_factory = schema_factory - - def build_list( - self, - data: dict[str, Any], - ) -> list[Header]: - """Build a list of headers from a dict of header definitions.""" - return [ - self._build(header_name, header_value) - for header_name, header_value in data.items() - ] - - def _build( - self, - name: str, - data: dict[str, Any], - ) -> Header: - with log_ctx("headers", name): - logger.debug(f"Header parsing: {name}") - - attrs_map = { - "description": PropertyMeta(name="description", cast=str), - "deprecated": PropertyMeta(name="deprecated", cast=bool), - "required": PropertyMeta(name="required", cast=bool), - } - - attrs = extract_typed_props(data, attrs_map) - - if data.get("schema") is not None: - attrs["schema"] = self._schema_factory.create(data["schema"]) - - attrs["name"] = name - attrs["extensions"] = extract_extension_attributes(data) - - if attrs["extensions"]: - logger.debug( - f"Extracted custom properties [{attrs['extensions'].keys()}]" - ) - - return Header(**attrs) diff --git a/src/openapi_parser/builders/info.py b/src/openapi_parser/builders/info.py deleted file mode 100644 index 5c41d91..0000000 --- a/src/openapi_parser/builders/info.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Info section builder for OpenAPI metadata.""" - -import logging -from typing import Any - -from openapi_parser.builders.common import ( - PropertyMeta, - extract_extension_attributes, - extract_typed_props, -) -from openapi_parser.errors import ParserError -from openapi_parser.logging import log_ctx -from openapi_parser.specification import Contact, Info, License - -logger = logging.getLogger(__name__) - - -class InfoBuilder: - """Builds Info, Contact, and License objects.""" - - def build(self, data: dict[str, Any]) -> Info: - """Build an Info object from a raw dict.""" - with log_ctx("info"): - title = data.get("title") - - if title is None: - raise ParserError( - "Info section is missing required 'title' property", - ) - - logger.debug(f"Info section parsing [title={title}]") - - attrs_map = { - "title": PropertyMeta(name="title", cast=str), - "version": PropertyMeta(name="version", cast=str), - "description": PropertyMeta(name="description", cast=str), - "terms_of_service": PropertyMeta(name="termsOfService", cast=str), - "license": PropertyMeta(name="license", cast=self._create_license), - "contact": PropertyMeta(name="contact", cast=self._create_contact), - } - - attrs = extract_typed_props(data, attrs_map) - attrs["extensions"] = extract_extension_attributes(data) - - if attrs["extensions"]: - logger.debug( - f"Extracted custom properties [{attrs['extensions'].keys()}]" - ) - - return Info(**attrs) - - @staticmethod - def _create_license(data: dict[str, Any]) -> License: - name = data.get("name") - - if name is None: - raise ParserError("License section is missing required 'name' property") - - attrs = { - "name": name, - "url": data.get("url"), - } - - return License(**attrs) - - @staticmethod - def _create_contact(data: dict[str, Any]) -> Contact: - attrs = { - "name": data.get("name"), - "url": data.get("url"), - "email": data.get("email"), - } - - return Contact(**attrs) diff --git a/src/openapi_parser/builders/link.py b/src/openapi_parser/builders/link.py deleted file mode 100644 index ae7d7ee..0000000 --- a/src/openapi_parser/builders/link.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Link builder for response links.""" - -import logging -from typing import Any - -from openapi_parser.builders.common import ( - PropertyMeta, - extract_extension_attributes, - extract_typed_props, -) -from openapi_parser.logging import log_ctx -from openapi_parser.specification import Link, Server - -logger = logging.getLogger(__name__) - - -def build_server(value: dict[str, Any]) -> Server: - """Build a Server object from raw data.""" - return Server( - url=value["url"], - description=value.get("description"), - ) - - -class LinkBuilder: - """Builds link objects from raw specification data.""" - - def build_dict( - self, - data: dict[str, dict[str, Any]], - ) -> dict[str, Link]: - """Build a dict of links from a dict of raw link definitions.""" - result: dict[str, Link] = {} - - for link_name, link_data in data.items(): - with log_ctx("links", link_name): - result[link_name] = self._build(link_data) - - return result - - def _build(self, data: dict[str, Any]) -> Link: - logger.debug("Link building") - - attrs_map = { - "operation_ref": PropertyMeta(name="operationRef", cast=str), - "operation_id": PropertyMeta(name="operationId", cast=str), - "parameters": PropertyMeta(name="parameters", cast=dict), - "request_body": PropertyMeta(name="requestBody", cast=None), - "description": PropertyMeta(name="description", cast=str), - "server": PropertyMeta(name="server", cast=build_server), - } - - attrs = extract_typed_props(data, attrs_map) - attrs["extensions"] = extract_extension_attributes(data) - - if attrs["extensions"]: - logger.debug(f"Extracted custom properties [{attrs['extensions'].keys()}]") - - return Link(**attrs) diff --git a/src/openapi_parser/builders/oauth_flow.py b/src/openapi_parser/builders/oauth_flow.py deleted file mode 100644 index cc7dfdb..0000000 --- a/src/openapi_parser/builders/oauth_flow.py +++ /dev/null @@ -1,54 +0,0 @@ -"""OAuth flow builder for security schemes.""" - -import logging -from typing import Any, cast - -from openapi_parser.builders.common import ( - PropertyMeta, - extract_extension_attributes, - extract_typed_props, -) -from openapi_parser.enumeration import OAuthFlowType -from openapi_parser.specification import OAuthFlow - -logger = logging.getLogger(__name__) - - -class OAuthFlowBuilder: - """Builds OAuth flow collections from raw specification data.""" - - @staticmethod - def build_collection( - data: dict[str, Any], - ) -> dict[OAuthFlowType, OAuthFlow]: - """Build a dict of OAuthFlow objects from a raw dict.""" - logger.debug(f"Parsing OAuth items collection: {data.keys()}") - - attrs_map = { - "refresh_url": PropertyMeta(name="refreshUrl", cast=str), - "authorization_url": PropertyMeta(name="authorizationUrl", cast=str), - "token_url": PropertyMeta(name="tokenUrl", cast=str), - "scopes": PropertyMeta(name="scopes", cast=dict), - } - - result_oauth_dict = { - OAuthFlowType(oauth_type): OAuthFlow( - extensions=extract_extension_attributes(oauth_value), - **extract_typed_props(oauth_value, attrs_map), - ) - for oauth_type, oauth_value in data.items() - if not oauth_type.startswith("x-") - } - - extensions = extract_extension_attributes(data) - - if extensions: - logger.debug(f"Extracted custom properties [{extensions.keys()}]") - - for extension in extensions: - result_oauth_dict[cast(OAuthFlowType, extension)] = cast( - OAuthFlow, - extensions[extension], - ) - - return result_oauth_dict diff --git a/src/openapi_parser/builders/operation.py b/src/openapi_parser/builders/operation.py deleted file mode 100644 index 4ea4cff..0000000 --- a/src/openapi_parser/builders/operation.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Operation builder for API path operations.""" - -import logging -from typing import Any - -from openapi_parser.builders.common import ( - PropertyMeta, - extract_extension_attributes, - extract_typed_props, -) -from openapi_parser.builders.external_doc import ExternalDocBuilder -from openapi_parser.builders.parameter import ParameterBuilder -from openapi_parser.builders.request import RequestBuilder -from openapi_parser.builders.response import ResponseBuilder -from openapi_parser.enumeration import OperationMethod -from openapi_parser.logging import log_ctx -from openapi_parser.specification import Operation, Response - -logger = logging.getLogger(__name__) - - -class OperationBuilder: - """Builds operation objects from raw specification data.""" - - _response_builder: ResponseBuilder - _external_doc_builder: ExternalDocBuilder - _request_builder: RequestBuilder - _parameter_builder: ParameterBuilder - - def __init__( - self, - response_builder: ResponseBuilder, - external_doc_builder: ExternalDocBuilder, - request_builder: RequestBuilder, - parameter_builder: ParameterBuilder, - ): - """Initialize operation builder. - - Args: - response_builder: Builder for response objects - external_doc_builder: Builder for external docs - request_builder: Builder for request bodies - parameter_builder: Builder for parameters - """ - self._response_builder = response_builder - self._external_doc_builder = external_doc_builder - self._request_builder = request_builder - self._parameter_builder = parameter_builder - - def build( - self, - method: OperationMethod, - data: dict[str, Any], - ) -> Operation: - """Build an Operation from a method and raw data dict.""" - with log_ctx(method.value): - logger.info( - f"Operation item parsing [method={method.value}, id={data.get('operationId')}]", - ) - - attrs_map = { - "summary": PropertyMeta(name="summary", cast=str), - "description": PropertyMeta(name="description", cast=str), - "operation_id": PropertyMeta(name="operationId", cast=str), - "external_docs": PropertyMeta( - name="externalDocs", - cast=self._external_doc_builder.build, - ), - "request_body": PropertyMeta( - name="requestBody", - cast=self._request_builder.build, - ), - "deprecated": PropertyMeta(name="deprecated", cast=bool), - "parameters": PropertyMeta( - name="parameters", - cast=self._parameter_builder.build_list, - ), - "tags": PropertyMeta(name="tags", cast=list), - "security": PropertyMeta(name="security", cast=None), - "callbacks": PropertyMeta(name="callbacks", cast=None), - } - - attrs = extract_typed_props(data, attrs_map) - - if data.get("responses") is not None: - attrs["responses"] = self._get_response_list(data["responses"]) - - attrs["extensions"] = extract_extension_attributes(data) - - if attrs["extensions"]: - logger.debug( - f"Extracted custom properties [{attrs['extensions'].keys()}]" - ) - - attrs["method"] = method - - return Operation(**attrs) - - def _get_response_list( - self, - data: dict[str, dict[str, Any]], - ) -> list[Response]: - return [ - self._response_builder.build(http_code, response) - for http_code, response in data.items() - ] diff --git a/src/openapi_parser/builders/parameter.py b/src/openapi_parser/builders/parameter.py deleted file mode 100644 index cc599ca..0000000 --- a/src/openapi_parser/builders/parameter.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Parameter builder for path and operation parameters.""" - -import logging -from typing import Any - -from openapi_parser.builders.common import ( - PropertyMeta, - extract_extension_attributes, - extract_typed_props, -) -from openapi_parser.builders.content import ContentBuilder -from openapi_parser.builders.schema import SchemaFactory -from openapi_parser.enumeration import ( - CookieParameterStyle, - HeaderParameterStyle, - ParameterLocation, - PathParameterStyle, - QueryParameterStyle, -) -from openapi_parser.errors import ParserError -from openapi_parser.logging import log_ctx -from openapi_parser.specification import Parameter - -logger = logging.getLogger(__name__) - -style_to_enum_map = { - ParameterLocation.HEADER: HeaderParameterStyle, - ParameterLocation.PATH: PathParameterStyle, - ParameterLocation.QUERY: QueryParameterStyle, - ParameterLocation.COOKIE: CookieParameterStyle, -} - -default_styles_by_location = { - ParameterLocation.HEADER: HeaderParameterStyle.SIMPLE, - ParameterLocation.PATH: PathParameterStyle.SIMPLE, - ParameterLocation.QUERY: QueryParameterStyle.FORM, - ParameterLocation.COOKIE: CookieParameterStyle.FORM, -} - - -class ParameterBuilder: - """Builds parameter objects from raw specification data.""" - - _schema_factory: SchemaFactory - _content_builder: ContentBuilder - - def __init__( - self, - schema_factory: SchemaFactory, - content_builder: ContentBuilder, - ) -> None: - """Initialize parameter builder. - - Args: - schema_factory: Factory for creating schema objects - content_builder: Builder for content objects - """ - self._schema_factory = schema_factory - self._content_builder = content_builder - - def build_list( - self, - parameters: list[dict[str, Any]], - ) -> list[Parameter]: - """Build a list of parameters from a list of raw dicts.""" - return [self.build(parameter) for parameter in parameters] - - def build(self, data: dict[str, Any]) -> Parameter: - """Build a Parameter from a raw dict.""" - with log_ctx("parameters"): - parameter_name = data.get("name") - - if parameter_name is None: - raise ParserError( - "Parameter is missing required 'name' property", - ) - - with log_ctx(parameter_name): - logger.debug(f"Parameter parsing [name={parameter_name}]") - - attrs_map = { - "name": PropertyMeta(name="name", cast=str), - "location": PropertyMeta(name="in", cast=ParameterLocation), - "required": PropertyMeta(name="required", cast=bool), - "description": PropertyMeta(name="description", cast=str), - "example": PropertyMeta(name="example", cast=None), - "examples": PropertyMeta(name="examples", cast=dict), - "deprecated": PropertyMeta(name="deprecated", cast=bool), - "explode": PropertyMeta(name="explode", cast=bool), - "allow_reserved": PropertyMeta(name="allowReserved", cast=bool), - } - - attrs = extract_typed_props(data, attrs_map) - - if data.get("schema") is not None: - attrs["schema"] = self._schema_factory.create(data["schema"]) - - if data.get("content") is not None: - attrs["content"] = self._content_builder.build_list(data["content"]) - - if data.get("style"): - attrs["style"] = style_to_enum_map[attrs["location"]](data["style"]) - else: - attrs["style"] = default_styles_by_location[attrs["location"]] - - if not attrs.get("explode") and attrs["style"].value == "form": - attrs["explode"] = True - - attrs["extensions"] = extract_extension_attributes(data) - - if attrs["extensions"]: - logger.debug( - f"Extracted custom properties [{attrs['extensions'].keys()}]" - ) - - return Parameter(**attrs) diff --git a/src/openapi_parser/builders/path.py b/src/openapi_parser/builders/path.py deleted file mode 100644 index 3f128b2..0000000 --- a/src/openapi_parser/builders/path.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Path builder for API endpoint paths.""" - -import logging -from typing import Any - -from openapi_parser.builders.common import ( - PropertyMeta, - extract_extension_attributes, - extract_typed_props, -) -from openapi_parser.builders.operation import OperationBuilder -from openapi_parser.builders.parameter import ParameterBuilder -from openapi_parser.enumeration import OperationMethod -from openapi_parser.logging import log_ctx -from openapi_parser.specification import Path - -logger = logging.getLogger(__name__) - - -class PathBuilder: - """Builds path objects from raw specification data.""" - - _operation_builder: OperationBuilder - _parameter_builder: ParameterBuilder - - def __init__( - self, - operation_builder: OperationBuilder, - parameter_builder: ParameterBuilder, - ) -> None: - """Initialize path builder. - - Args: - operation_builder: Builder for operation objects - parameter_builder: Builder for parameter objects - """ - self._operation_builder = operation_builder - self._parameter_builder = parameter_builder - - def build_list( - self, - data: dict[str, dict[str, Any]], - ) -> list[Path]: - """Build a list of paths from a raw dict of path definitions.""" - return [self._build_path(url, path) for url, path in data.items()] - - def _build_path(self, url: str, data: dict[str, Any]) -> Path: - with log_ctx("paths", url): - logger.info(f"Path item parsing [url={url}]") - - attrs_map = { - "summary": PropertyMeta(name="summary", cast=str), - "description": PropertyMeta(name="description", cast=str), - "parameters": PropertyMeta( - name="parameters", - cast=self._parameter_builder.build_list, - ), - } - - attrs = extract_typed_props(data, attrs_map) - - attrs["url"] = url - - attrs["operations"] = [ - self._operation_builder.build( - method, - data[method.value], - ) - for method in OperationMethod - if method.value in data - ] - - if attrs.get("parameters"): - for operation in attrs["operations"]: - merged = operation.parameters + attrs["parameters"] - object.__setattr__(operation, "parameters", merged) - - attrs["extensions"] = extract_extension_attributes(data) - - if attrs["extensions"]: - logger.debug( - f"Extracted custom properties [{attrs['extensions'].keys()}]" - ) - - return Path(**attrs) diff --git a/src/openapi_parser/builders/request.py b/src/openapi_parser/builders/request.py deleted file mode 100644 index fe1ee7d..0000000 --- a/src/openapi_parser/builders/request.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Request body builder.""" - -import logging -from typing import Any - -from openapi_parser.builders.common import ( - PropertyMeta, - extract_typed_props, -) -from openapi_parser.builders.content import ContentBuilder -from openapi_parser.logging import log_ctx -from openapi_parser.specification import RequestBody - -logger = logging.getLogger(__name__) - - -class RequestBuilder: - """Builds request body objects.""" - - _content_builder: ContentBuilder - - def __init__(self, content_builder: ContentBuilder) -> None: - """Initialize request builder. - - Args: - content_builder: Builder for content objects - """ - self._content_builder = content_builder - - def build(self, data: dict[str, Any]) -> RequestBody: - """Build a RequestBody from a raw dict.""" - with log_ctx("requestBody"): - logger.debug("Request building") - - attrs_map = { - "content": PropertyMeta( - name="content", - cast=self._content_builder.build_list, - ), - "description": PropertyMeta(name="description", cast=str), - "required": PropertyMeta(name="required", cast=bool), - } - - attrs = extract_typed_props(data, attrs_map) - - return RequestBody(**attrs) diff --git a/src/openapi_parser/builders/response.py b/src/openapi_parser/builders/response.py deleted file mode 100644 index 7b3013f..0000000 --- a/src/openapi_parser/builders/response.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Response builder for API responses.""" - -import logging -from typing import Any - -from openapi_parser.builders.common import ( - PropertyMeta, - extract_typed_props, -) -from openapi_parser.builders.content import ContentBuilder -from openapi_parser.builders.header import HeaderBuilder -from openapi_parser.builders.link import LinkBuilder -from openapi_parser.logging import log_ctx -from openapi_parser.specification import Response - -logger = logging.getLogger(__name__) - - -class ResponseBuilder: - """Builds response objects from raw specification data.""" - - _content_builder: ContentBuilder - _header_builder: HeaderBuilder - _link_builder: LinkBuilder - - def __init__( - self, - content_builder: ContentBuilder, - header_builder: HeaderBuilder, - link_builder: LinkBuilder, - ) -> None: - """Initialize response builder. - - Args: - content_builder: Builder for content objects - header_builder: Builder for header objects - link_builder: Builder for link objects - """ - self._content_builder = content_builder - self._header_builder = header_builder - self._link_builder = link_builder - - def build( - self, - code: int | str, - data: dict[str, Any], - ) -> Response: - """Build a Response from a status code and raw data dict.""" - with log_ctx("responses", str(code)): - logger.debug(f"Response building [code={code}]") - - attrs_map = { - "description": PropertyMeta(name="description", cast=str), - "content": PropertyMeta( - name="content", - cast=self._content_builder.build_list, - ), - "headers": PropertyMeta( - name="headers", - cast=self._header_builder.build_list, - ), - "links": PropertyMeta( - name="links", - cast=self._link_builder.build_dict, - ), - } - - attrs = extract_typed_props(data, attrs_map) - - attrs["is_default"] = code == "default" - - try: - attrs["code"] = int(code) - except ValueError: - logger.debug(f"Response code is not an integer [code={code}]") - - return Response(**attrs) diff --git a/src/openapi_parser/builders/schema.py b/src/openapi_parser/builders/schema.py deleted file mode 100644 index b890d91..0000000 --- a/src/openapi_parser/builders/schema.py +++ /dev/null @@ -1,325 +0,0 @@ -"""Schema builder for type-casting and schema creation.""" - -import logging -from collections.abc import Callable -from typing import Any - -from openapi_parser.builders.common import ( - PropertyMeta, - extract_extension_attributes, - extract_typed_props, - merge_schema, -) -from openapi_parser.enumeration import ( - DataType, - IntegerFormat, - NumberFormat, - StringFormat, -) -from openapi_parser.errors import ParserError -from openapi_parser.loose_types import ( - LooseIntegerFormat, - LooseNumberFormat, - LooseStringFormat, -) -from openapi_parser.specification import ( - AnyOf, - Array, - Boolean, - Discriminator, - Integer, - Null, - Number, - Object, - OneOf, - Property, - Schema, - String, -) - -SchemaBuilderMethod = Callable[..., Schema] - -ALL_OF_SCHEMAS_KEY = "allOf" - -logger = logging.getLogger(__name__) - - -def extract_attrs( - data: dict[str, Any], - attrs_map: dict[str, PropertyMeta], -) -> dict[str, Any]: - """Extract attributes of schema description with specific type-casting mapping. - - Args: - data (dict): Source dictionary with schema data - attrs_map (Dict[str, PropertyMeta]): Type-casting mapping - - Returns: - dict: Extracted attributes dictionary - """ - base_attrs_map = { - "type": "type", - "title": "title", - "enum": "enum", - "example": "example", - "description": "description", - "default": "default", - "nullable": "nullable", - "read_only": "readOnly", - "write_only": "writeOnly", - "deprecated": "deprecated", - } - - attrs = { - key: data[name] - for key, name in base_attrs_map.items() - if data.get(name) is not None - } - - attrs["type"] = DataType(attrs["type"]) - - attrs.update(extract_typed_props(data, attrs_map)) - - attrs["extensions"] = extract_extension_attributes(data) - - if attrs["extensions"]: - logger.debug(f"Extracted custom properties [{attrs['extensions'].keys()}]") - - return attrs - - -def merge_all_of_schemas( - original_data: dict[str, Any], -) -> dict[str, Any]: - """Recursive merge schemas with 'allOf' type into single schema dictionary. - - Args: - original_data (dict): Dictionary with schema description - - Returns: - dict: Merged dictionary of schema item - """ - if ALL_OF_SCHEMAS_KEY not in original_data: - return original_data - - logger.debug("Merging 'allOf' schemas") - - schema_dict: dict[str, Any] = {} - - for nested_schema_dict in original_data[ALL_OF_SCHEMAS_KEY]: - merged_nested_schema = merge_all_of_schemas(nested_schema_dict) - schema_dict = merge_schema(schema_dict, merged_nested_schema) - - return schema_dict - - -def build_discriminator(value: dict[str, Any]) -> Discriminator: - """Build a Discriminator object from raw data. - - Args: - value: Raw discriminator data - - Returns: - Discriminator object - """ - return Discriminator( - property_name=value["propertyName"], - mapping=value.get("mapping", {}), - ) - - -class SchemaFactory: - """Factory for creating schema objects from raw dicts.""" - - _builders: dict[DataType, SchemaBuilderMethod] - _strict_enum: bool - - def __init__(self, strict_enum: bool = True) -> None: - """Initialize schema factory. - - Args: - strict_enum: Whether to validate enums strictly - """ - self._strict_enum = strict_enum - self._builders = { - DataType.NULL: self._null, - DataType.INTEGER: self._integer, - DataType.NUMBER: self._number, - DataType.STRING: self._string, - DataType.BOOLEAN: self._boolean, - DataType.ARRAY: self._array, - DataType.OBJECT: self._object, - DataType.ONE_OF: self._one_of, - DataType.ANY_OF: self._any_of, - } - - def create(self, data: dict[str, Any]) -> Schema: - """Create a schema object from a raw dict.""" - data = merge_all_of_schemas(data) - not_data = data.pop("not", None) - - if "oneOf" in data: - data["type"] = DataType.ONE_OF - - if "anyOf" in data: - data["type"] = DataType.ANY_OF - - try: - schema_type = data["type"] - except KeyError: - logger.warning( - msg="Implicit type assignment: schema does not contain 'type' property", - ) - schema_type = DataType.ANY_OF - - try: - data_type = DataType(schema_type) - except ValueError: - raise ParserError(f"Invalid schema type '{schema_type}'") from None - - try: - builder_func = self._builders[data_type] - except KeyError: - raise ParserError(f"Unsupported schema type: '{schema_type}'") from None - - logger.debug(f"Building schema [type={data_type}]") - - schema = builder_func(data) - - if not_data is not None: - object.__setattr__(schema, "not_schema", self.create(not_data)) - - return schema - - def _null(self, data: dict[str, Any]) -> Null: - return Null(**extract_attrs(data, {})) - - def _integer(self, data: dict[str, Any]) -> Integer: - format_cast = IntegerFormat if self._strict_enum else LooseIntegerFormat - - attrs_map = { - "multiple_of": PropertyMeta(name="multipleOf", cast=int), - "maximum": PropertyMeta(name="maximum", cast=int), - "exclusive_maximum": PropertyMeta(name="exclusiveMaximum", cast=int), - "minimum": PropertyMeta(name="minimum", cast=int), - "exclusive_minimum": PropertyMeta(name="exclusiveMinimum", cast=int), - "format": PropertyMeta(name="format", cast=format_cast), - } - - return Integer(**extract_attrs(data, attrs_map)) - - def _number(self, data: dict[str, Any]) -> Number: - format_cast = NumberFormat if self._strict_enum else LooseNumberFormat - - attrs_map = { - "multiple_of": PropertyMeta(name="multipleOf", cast=float), - "maximum": PropertyMeta(name="maximum", cast=float), - "exclusive_maximum": PropertyMeta(name="exclusiveMaximum", cast=float), - "minimum": PropertyMeta(name="minimum", cast=float), - "exclusive_minimum": PropertyMeta(name="exclusiveMinimum", cast=float), - "format": PropertyMeta(name="format", cast=format_cast), - } - - return Number(**extract_attrs(data, attrs_map)) - - def _string(self, data: dict[str, Any]) -> String: - format_cast = StringFormat if self._strict_enum else LooseStringFormat - - attrs_map = { - "max_length": PropertyMeta(name="maxLength", cast=int), - "min_length": PropertyMeta(name="minLength", cast=int), - "pattern": PropertyMeta(name="pattern", cast=str), - "format": PropertyMeta(name="format", cast=format_cast), - } - - return String(**extract_attrs(data, attrs_map)) - - @staticmethod - def _boolean(data: dict[str, Any]) -> Boolean: - return Boolean(**extract_attrs(data, {})) - - def _array(self, data: dict[str, Any]) -> Array: - def build_items(items_data: dict[str, Any]) -> Schema: - return self.create(items_data) - - attrs_map = { - "max_items": PropertyMeta(name="maxItems", cast=int), - "min_items": PropertyMeta(name="minItems", cast=int), - "unique_items": PropertyMeta(name="uniqueItems", cast=bool), - "items": PropertyMeta(name="items", cast=build_items), - } - - return Array(**extract_attrs(data, attrs_map)) - - def _object(self, data: dict[str, Any]) -> Object: - def build_properties(object_attrs: dict[str, Any]) -> list[Property]: - return [ - Property(name, self.create(schema)) - for name, schema in object_attrs.items() - ] - - def build_additional_properties( - value: bool | dict[str, Any], - ) -> bool | Schema: - if isinstance(value, bool): - return value - return self.create(value) - - attrs_map = { - "max_properties": PropertyMeta(name="maxProperties", cast=int), - "min_properties": PropertyMeta(name="minProperties", cast=int), - "required": PropertyMeta(name="required", cast=list), - "properties": PropertyMeta(name="properties", cast=build_properties), - "additional_properties": PropertyMeta( - name="additionalProperties", - cast=build_additional_properties, - ), - } - - return Object(**extract_attrs(data, attrs_map)) - - def _one_of(self, data: dict[str, Any]) -> OneOf: - def create_inner_schemas(schemas: list[dict[str, Any]]) -> list[Schema]: - return [self.create(x) for x in schemas] - - attrs_map = { - "schemas": PropertyMeta(name="oneOf", cast=create_inner_schemas), - "discriminator": PropertyMeta( - name="discriminator", - cast=build_discriminator, - ), - } - - return OneOf(**extract_attrs(data, attrs_map)) - - def _any_of(self, data: dict[str, Any]) -> AnyOf: - def create_inner_schemas(schemas: list[dict[str, Any]]) -> list[Schema]: - return [self.create(x) for x in schemas] - - attrs_map = { - "schemas": PropertyMeta(name="anyOf", cast=create_inner_schemas), - "discriminator": PropertyMeta( - name="discriminator", - cast=build_discriminator, - ), - } - - if "type" in data: - return AnyOf(**extract_attrs(data, attrs_map)) - - possible_implicit_types = ( - DataType.INTEGER, - DataType.NUMBER, - DataType.STRING, - DataType.BOOLEAN, - DataType.ARRAY, - DataType.OBJECT, - ) - - schemas = [ - builder_func({**data, **{"type": data_type}}) - for data_type, builder_func in self._builders.items() - if data_type in possible_implicit_types - ] - - return AnyOf(type=DataType.ANY_OF, schemas=schemas) diff --git a/src/openapi_parser/builders/schemas.py b/src/openapi_parser/builders/schemas.py deleted file mode 100644 index 5cb97d3..0000000 --- a/src/openapi_parser/builders/schemas.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Component schemas builder.""" - -import logging -from typing import Any - -from openapi_parser.builders.schema import SchemaFactory -from openapi_parser.logging import log_ctx -from openapi_parser.specification import Schema - -logger = logging.getLogger(__name__) - - -class SchemasBuilder: - """Builds a collection of named schemas from component definitions.""" - - _schema_factory: SchemaFactory - - def __init__(self, schema_factory: SchemaFactory) -> None: - """Initialize schemas builder. - - Args: - schema_factory: Factory for creating schema objects - """ - self._schema_factory = schema_factory - - def build_collection( - self, - schemas: dict[str, Any], - ) -> dict[str, Schema]: - """Build a dict of named Schema objects.""" - logger.debug(f"Schemas parsing: {schemas.keys()}") - - result: dict[str, Schema] = {} - - for key, value in schemas.items(): - with log_ctx(key): - result[key] = self._schema_factory.create(value) - - return result diff --git a/src/openapi_parser/builders/security.py b/src/openapi_parser/builders/security.py deleted file mode 100644 index f6f3d6f..0000000 --- a/src/openapi_parser/builders/security.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Security scheme builder.""" - -import logging -from typing import Any - -from openapi_parser.builders.common import ( - PropertyMeta, - extract_extension_attributes, - extract_typed_props, -) -from openapi_parser.builders.oauth_flow import OAuthFlowBuilder -from openapi_parser.enumeration import AuthenticationScheme, BaseLocation, SecurityType -from openapi_parser.logging import log_ctx -from openapi_parser.specification import Security - -logger = logging.getLogger(__name__) - - -class SecurityBuilder: - """Builds security scheme objects from raw specification data.""" - - _oauth_flow_builder: OAuthFlowBuilder - - def __init__(self, oauth_flow_builder: OAuthFlowBuilder) -> None: - """Initialize security builder. - - Args: - oauth_flow_builder: Builder for OAuth flow objects - """ - self._oauth_flow_builder = oauth_flow_builder - - def build(self, data: dict[str, Any]) -> Security: - """Build a Security object from a raw dict.""" - logger.debug("Security item parsing") - - attrs_map = { - "type": PropertyMeta(name="type", cast=SecurityType), - "location": PropertyMeta(name="in", cast=BaseLocation), - "name": PropertyMeta(name="name", cast=str), - "description": PropertyMeta(name="description", cast=str), - "scheme": PropertyMeta(name="scheme", cast=AuthenticationScheme), - "bearer_format": PropertyMeta(name="bearerFormat", cast=str), - "url": PropertyMeta(name="openIdConnectUrl", cast=str), - "flows": PropertyMeta( - name="flows", - cast=self._oauth_flow_builder.build_collection, - ), - } - - attrs = extract_typed_props(data, attrs_map) - attrs["extensions"] = extract_extension_attributes(data) - - if attrs["extensions"]: - logger.debug( - f"Extracted custom properties [{attrs['extensions'].keys()}]", - ) - - return Security(**attrs) - - def build_collection( - self, - data: dict[str, Any], - ) -> dict[str, Security]: - """Build a dict of named Security objects.""" - result: dict[str, Security] = {} - - for scheme_name, scheme_data in data.items(): - with log_ctx("securitySchemes", scheme_name): - result[scheme_name] = self.build(scheme_data) - - return result diff --git a/src/openapi_parser/builders/server.py b/src/openapi_parser/builders/server.py deleted file mode 100644 index 50454f0..0000000 --- a/src/openapi_parser/builders/server.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Server builder for API server definitions.""" - -import logging -from typing import Any - -from openapi_parser.builders.common import ( - PropertyMeta, - extract_extension_attributes, - extract_typed_props, -) -from openapi_parser.errors import ParserError -from openapi_parser.logging import log_ctx -from openapi_parser.specification import Server - -logger = logging.getLogger(__name__) - - -class ServerBuilder: - """Builds server objects from raw specification data.""" - - def build_list( - self, - data_list: list[dict[str, Any]], - ) -> list[Server]: - """Build a list of Server objects from a list of raw dicts.""" - return [self._build_server(item) for item in data_list] - - @staticmethod - def _build_server(data: dict[str, Any]) -> Server: - url = data.get("url") - - with log_ctx("servers", url): - if url is None: - raise ParserError( - "Server definition is missing required 'url' property" - ) - - logger.debug(f"Server item parsing [{url}]") - - attrs_map = { - "url": PropertyMeta(name="url", cast=str), - "description": PropertyMeta(name="description", cast=str), - "variables": PropertyMeta(name="variables", cast=dict), - } - - attrs = extract_typed_props(data, attrs_map) - attrs["extensions"] = extract_extension_attributes(data) - - if attrs["extensions"]: - logger.debug( - f"Extracted custom properties [{attrs['extensions'].keys()}]" - ) - - return Server(**attrs) diff --git a/src/openapi_parser/builders/tag.py b/src/openapi_parser/builders/tag.py deleted file mode 100644 index 6eda834..0000000 --- a/src/openapi_parser/builders/tag.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Tag builder for API tag definitions.""" - -import logging -from typing import Any - -from openapi_parser.builders.common import ( - PropertyMeta, - extract_typed_props, -) -from openapi_parser.builders.external_doc import ExternalDocBuilder -from openapi_parser.errors import ParserError -from openapi_parser.logging import log_ctx -from openapi_parser.specification import Tag - -logger = logging.getLogger(__name__) - - -class TagBuilder: - """Builds tag objects from raw specification data.""" - - _external_doc_builder: ExternalDocBuilder - - def __init__(self, external_doc_builder: ExternalDocBuilder) -> None: - """Initialize tag builder. - - Args: - external_doc_builder: Builder for external docs - """ - self._external_doc_builder = external_doc_builder - - def build_list( - self, - data_list: list[dict[str, Any]], - ) -> list[Tag]: - """Build a list of Tag objects from a list of raw dicts.""" - return [self._build_tag(item) for item in data_list] - - def _build_tag(self, data: dict[str, Any]) -> Tag: - with log_ctx("tags"): - name = data.get("name") - - if name is None: - raise ParserError("Tag is missing required 'name' property") - - with log_ctx(name): - logger.debug(f"Tag building [{name}]") - - attrs_map = { - "name": PropertyMeta(name="name", cast=str), - "description": PropertyMeta(name="description", cast=str), - "external_docs": PropertyMeta( - name="externalDocs", - cast=self._external_doc_builder.build, - ), - } - - attrs = extract_typed_props(data, attrs_map) - - return Tag(**attrs) diff --git a/src/openapi_parser/enumeration.py b/src/openapi_parser/enumeration.py index 34a3f12..c4fbb70 100644 --- a/src/openapi_parser/enumeration.py +++ b/src/openapi_parser/enumeration.py @@ -4,7 +4,7 @@ @unique -class DataType(Enum): +class DataType(str, Enum): """OpenAPI data types.""" NULL = "null" @@ -14,63 +14,11 @@ class DataType(Enum): BOOLEAN = "boolean" ARRAY = "array" OBJECT = "object" - ONE_OF = "oneOf" - ANY_OF = "anyOf" @unique -class IntegerFormat(Enum): - """Integer format variants.""" - - INT32 = "int32" - INT64 = "int64" - - -@unique -class NumberFormat(Enum): - """Number format variants.""" - - FLOAT = "float" - DOUBLE = "double" - - -@unique -class StringFormat(Enum): - """String format variants.""" - - BYTE = "byte" - BINARY = "binary" - DATE = "date" - DATETIME = "date-time" - PASSWORD = "password" - UUID = "uuid" - UUID4 = "uuid4" - EMAIL = "email" - URI = "uri" - HOSTNAME = "hostname" - IPV4 = "ipv4" - IPV6 = "ipv6" - URL = "url" - TIME = "time" - - -@unique -class OperationMethod(Enum): - """HTTP operation methods.""" - - GET = "get" - PUT = "put" - POST = "post" - DELETE = "delete" - OPTIONS = "options" - HEAD = "head" - PATCH = "patch" - TRACE = "trace" - - -@unique -class BaseLocation(Enum): - """Base security location types.""" +class ApiKeyLocation(str, Enum): + """API key location in the request.""" HEADER = "header" QUERY = "query" @@ -78,7 +26,7 @@ class BaseLocation(Enum): @unique -class ParameterLocation(Enum): +class ParameterLocation(str, Enum): """Parameter location variants.""" HEADER = "header" @@ -88,7 +36,7 @@ class ParameterLocation(Enum): @unique -class PathParameterStyle(Enum): +class PathParameterStyle(str, Enum): """Path parameter serialization styles.""" SIMPLE = "simple" @@ -97,7 +45,7 @@ class PathParameterStyle(Enum): @unique -class QueryParameterStyle(Enum): +class QueryParameterStyle(str, Enum): """Query parameter serialization styles.""" FORM = "form" @@ -107,72 +55,32 @@ class QueryParameterStyle(Enum): @unique -class HeaderParameterStyle(Enum): +class HeaderParameterStyle(str, Enum): """Header parameter serialization styles.""" SIMPLE = "simple" @unique -class CookieParameterStyle(Enum): +class CookieParameterStyle(str, Enum): """Cookie parameter serialization styles.""" FORM = "form" @unique -class ContentType(Enum): - """Media content type variants.""" - - JSON = "application/json" - JSON_TEXT = "text/json" - JSON_ANY = "application/*+json" - JSON_PROBLEM = "application/problem+json" - XML = "application/xml" - FORM = "application/x-www-form-urlencoded" - MULTIPART_FORM = "multipart/form-data" - PLAIN_TEXT = "text/plain" - HTML = "text/html" - PDF = "application/pdf" - PNG = "image/png" - JPEG = "image/jpeg" - GIF = "image/gif" - SVG = "image/svg+xml" - AVIF = "image/avif" - BMP = "image/bmp" - WEBP = "image/webp" - Image = "image/*" - BINARY = "application/octet-stream" - - -@unique -class SecurityType(Enum): +class SecurityType(str, Enum): """Security scheme types.""" API_KEY = "apiKey" HTTP = "http" OAUTH2 = "oauth2" OPEN_ID_CONNECT = "openIdConnect" + MUTUAL_TLS = "mutualTLS" @unique -class AuthenticationScheme(Enum): - """Authentication scheme variants.""" - - BASIC = "basic" - BEARER = "bearer" - DIGEST = "digest" - HOBA = "hoba" - MUTUAL = "mutual" - NEGOTIATE = "negotiate" - OAUTH = "oauth" - SCRAM_SHA1 = "scram-sha-1" - SCRAM_SHA256 = "scram-sha-256" - VAPID = "vapid" - - -@unique -class OAuthFlowType(Enum): +class OAuthFlowType(str, Enum): """OAuth flow type variants.""" IMPLICIT = "implicit" diff --git a/src/openapi_parser/errors.py b/src/openapi_parser/errors.py index 66cfadc..86719b4 100644 --- a/src/openapi_parser/errors.py +++ b/src/openapi_parser/errors.py @@ -1,31 +1,18 @@ """Custom exceptions for OpenAPI parsing errors.""" -from openapi_parser.logging import _log_ctx_var +from pydantic import ValidationError +from pydantic_core import ErrorDetails class ParserError(Exception): - """Base parser exception class. + """Wraps all parsing/validation errors with context.""" - Throws when any error occurs. - """ + def errors(self) -> list[ErrorDetails]: + """Return validation errors if available. - context: str | None - - def __init__(self, message: str, context: str | None = None) -> None: - """Initialize the error with an optional parse context path. - - Args: - message: Error description - context: Path within the spec where the error occurred (e.g. "paths./users.get") + Returns an empty list otherwise. """ - super().__init__(message) - self.context = context if context is not None else (_log_ctx_var.get()) - - def __str__(self) -> str: - """Format error message with optional context prefix.""" - msg = super().__str__() - - if self.context: - return f"[{self.context}] {msg}" + if isinstance(self.__cause__, ValidationError): + return self.__cause__.errors() - return msg + return [] diff --git a/src/openapi_parser/logging.py b/src/openapi_parser/logging.py deleted file mode 100644 index d9b4539..0000000 --- a/src/openapi_parser/logging.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Logging utilities for automatic context prefixing.""" - -from __future__ import annotations - -import contextvars -import logging - -_log_ctx_var: contextvars.ContextVar[str] = contextvars.ContextVar( - "openapi_log_ctx", - default="", -) - -_original_factory = logging.getLogRecordFactory() - - -def _context_record_factory( - name: str, - level: int, - pathname: str, - lineno: int, - msg: str, - args: tuple[object, ...], - exc_info: object, - func: str | None = None, - sinfo: str | None = None, - **kwargs: object, -) -> logging.LogRecord: - record = _original_factory( - name, - level, - pathname, - lineno, - msg, - args, - exc_info, - func, - sinfo, - **kwargs, - ) - - ctx = _log_ctx_var.get() - - if ctx: - record.msg = f"[{ctx}] {record.msg}" - - return record - - -logging.setLogRecordFactory(_context_record_factory) - - -class log_ctx: - """Context manager that appends segments to the current parse context. - - Usage: - with log_ctx("paths", url): - ... - with log_ctx("get"): - ... - """ - - def __init__(self, *segments: str | None) -> None: - """Initialize context manager with path segments to append.""" - self.segments = [s for s in segments if s is not None] - - def __enter__(self) -> log_ctx: - """Enter context block, appending segments to the current context.""" - current = _log_ctx_var.get() - - parts = [current] if current else [] - parts.extend(self.segments) - - self._token = _log_ctx_var.set(".".join(parts)) - - return self - - def __exit__(self, *_args: object) -> None: - """Exit context block, restoring the previous context.""" - _log_ctx_var.reset(self._token) diff --git a/src/openapi_parser/loose_types.py b/src/openapi_parser/loose_types.py deleted file mode 100644 index 54a729f..0000000 --- a/src/openapi_parser/loose_types.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Loose type aliases for non-strict enum matching.""" - -from dataclasses import dataclass - - -@dataclass(frozen=True, slots=True) -class LooseEnum: - """Enum that accepts any string value.""" - - value: str - - -LooseContentType = LooseEnum -LooseIntegerFormat = LooseEnum -LooseNumberFormat = LooseEnum -LooseStringFormat = LooseEnum diff --git a/src/openapi_parser/models/__init__.py b/src/openapi_parser/models/__init__.py new file mode 100644 index 0000000..ddb247b --- /dev/null +++ b/src/openapi_parser/models/__init__.py @@ -0,0 +1,5 @@ +"""OpenAPI parser specification models.""" + +from openapi_parser.models import base, mixins, v3_0, v3_1 + +__all__ = ["base", "mixins", "v3_0", "v3_1"] diff --git a/src/openapi_parser/models/base.py b/src/openapi_parser/models/base.py new file mode 100644 index 0000000..f0f675a --- /dev/null +++ b/src/openapi_parser/models/base.py @@ -0,0 +1,95 @@ +"""Shared OpenAPI base models.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from openapi_parser.models.mixins import ExtensionsMixin, RefCacheMixin + + +class _ModelBase(BaseModel): + """Base for frozen models.""" + + model_config = ConfigDict(populate_by_name=True, frozen=True) + + +class _MutableModelBase(BaseModel): + """Base for mutable models (e.g. Schema with circular ref placeholders).""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + +class Contact(ExtensionsMixin, _ModelBase): + """Contact information for the exposed API.""" + + name: str | None = None + url: str | None = None + email: str | None = None + + +class License(ExtensionsMixin, _ModelBase): + """License information for the exposed API.""" + + name: str + url: str | None = None + + +class Info(ExtensionsMixin, _ModelBase): + """Metadata about the API.""" + + title: str + version: str + description: str | None = None + terms_of_service: str | None = Field(default=None, alias="termsOfService") + contact: Contact | None = None + license: License | None = None + + +class ServerVariable(ExtensionsMixin, _ModelBase): + """An object representing a Server Variable for server URL template substitution.""" + + default: str + enum: list[str] | None = None + description: str | None = None + + +class Server(ExtensionsMixin, _ModelBase): + """An object representing a Server.""" + + url: str + description: str | None = None + variables: dict[str, ServerVariable] | None = None + + +class ExternalDoc(ExtensionsMixin, _ModelBase): + """Information about external documentation.""" + + url: str + description: str | None = None + + +class Discriminator(_ModelBase): + """Discriminator object for inheritance mapping.""" + + property_name: str = Field(alias="propertyName") + mapping: dict[str, str] | None = None + + +class OAuthFlow(ExtensionsMixin, _ModelBase): + """Configuration details for a supported OAuth Flow.""" + + authorization_url: str | None = Field(default=None, alias="authorizationUrl") + token_url: str | None = Field(default=None, alias="tokenUrl") + refresh_url: str | None = Field(default=None, alias="refreshUrl") + scopes: dict[str, str] = Field(default_factory=dict) + + +class Link(ExtensionsMixin, RefCacheMixin, _ModelBase): + """Link definition for response links.""" + + operation_ref: str | None = Field(default=None, alias="operationRef") + operation_id: str | None = Field(default=None, alias="operationId") + parameters: dict[str, Any] | None = None + request_body: Any | None = Field(default=None, alias="requestBody") + description: str | None = None + server: Server | None = None diff --git a/src/openapi_parser/models/mixins.py b/src/openapi_parser/models/mixins.py new file mode 100644 index 0000000..2726dad --- /dev/null +++ b/src/openapi_parser/models/mixins.py @@ -0,0 +1,116 @@ +"""Deduplication, cache, and extension mixins for OpenAPI models.""" + +import threading +from typing import Any + +from pydantic import BaseModel, Field, ValidationInfo, model_validator + +_CACHE_ATTRIBUTE = "_ref_cache" + +_thread_cache = threading.local() + + +class ExtensionsMixin(BaseModel): + """Mixin that provides an ``extensions`` dict with automatic ``x-*`` extraction.""" + + extensions: dict[str, Any] = Field(default_factory=dict) + + @model_validator(mode="before") + @classmethod + def _extract_extensions(cls, data: Any) -> Any: + """Move ``x-*`` keys into the extensions dict before model validation.""" + if not isinstance(data, dict): + return data + + raw_extensions = data.get("extensions") + extensions = dict(raw_extensions) if isinstance(raw_extensions, dict) else {} + + result = {} + + for key, value in data.items(): + if isinstance(key, str) and key.startswith("x-"): + extensions[key] = value + else: + result[key] = value + + if extensions: + result["extensions"] = extensions + + return result + + +class RefCacheMixin(BaseModel): + """Mixin for models that can be $ref targets. + + Stores a per-class cache keyed by ref_name. When model_validate + encounters the same ref_name twice, it returns the cached object, + ensuring single Python object identity for each $ref. + + The cache lives in a ``threading.local`` so concurrent ``parse()`` + calls in different threads never share state. + + Circular refs are already broken by the resolver before Pydantic + validation, so no cycle-handling logic is needed here. + """ + + ref_name: str | None = None + + @model_validator(mode="wrap") + @classmethod + def _deduplicate_refs(cls, value: Any, handler: Any, _info: ValidationInfo) -> Any: + """Return the cached object for a ref_name already seen, otherwise validate and cache. + + Called by Pydantic automatically on models with this mixin. + """ + if not cls._should_cache(value): + return handler(value) + + ref_name: str = value["ref_name"] + cache = cls._get_cache() + + if ref_name in cache: + return cache[ref_name] + + result = handler(value) + cache[ref_name] = result + + return result + + @classmethod + def _should_cache(cls, value: Any) -> bool: + """Check whether *value* has a ref_name that should be cached.""" + return ( + isinstance(value, dict) + and "ref_name" in value + and isinstance(value["ref_name"], str) + ) + + @classmethod + def _get_cache(cls) -> dict[str, Any]: + """Get or create the per-class ref cache for the current thread.""" + caches: dict[type, dict[str, Any]] | None = getattr( + _thread_cache, _CACHE_ATTRIBUTE, None + ) + + if caches is None: + caches = {} + setattr(_thread_cache, _CACHE_ATTRIBUTE, caches) + + cache = caches.get(cls) + + if cache is None: + cache = {} + caches[cls] = cache + + return cache + + @classmethod + def clear_ref_cache(cls) -> None: + """Clear the ref cache for the current thread.""" + caches: dict[type, dict[str, Any]] | None = getattr( + _thread_cache, _CACHE_ATTRIBUTE, None + ) + + if caches is not None: + for cache in caches.values(): + cache.clear() diff --git a/src/openapi_parser/models/v2_0.py b/src/openapi_parser/models/v2_0.py new file mode 100644 index 0000000..17f1389 --- /dev/null +++ b/src/openapi_parser/models/v2_0.py @@ -0,0 +1,243 @@ +"""Swagger 2.0 to OpenAPI 3.0 normalization helper.""" + +from collections.abc import Iterator +from typing import Any + + +def _iter_path_entries( + data: dict[str, Any], +) -> Iterator[tuple[str, str, dict[str, Any]]]: + """Yield ``(path, key, value)`` for every dict entry in every path item.""" + paths = data.get("paths", {}) + + if not isinstance(paths, dict): + return + + for path, path_item in paths.items(): + if not isinstance(path_item, dict): + continue + + for key, value in path_item.items(): + if isinstance(value, dict): + yield path, key, value + + +def normalize_swagger_v2(data: dict[str, Any]) -> dict[str, Any]: + """Transform Swagger 2.0 dict to OpenAPI 3.0-compatible shape.""" + data["openapi"] = "3.0.0" + + # host + basePath + schemes → servers + if "host" in data or "basePath" in data or "schemes" in data: + host = data.pop("host", "localhost") + base_path = data.pop("basePath", "") + schemes = data.pop("schemes", ["https"]) + data["servers"] = [ + {"url": f"{scheme}://{host}{base_path}"} for scheme in schemes + ] + + # definitions → components.schemas + if "definitions" in data: + data.setdefault("components", {})["schemas"] = data.pop("definitions") + + # securityDefinitions → components.securitySchemes + if "securityDefinitions" in data: + data.setdefault("components", {})["securitySchemes"] = data.pop( + "securityDefinitions" + ) + + # parameters -> components.parameters + if "parameters" in data: + data.setdefault("components", {})["parameters"] = data.pop("parameters") + + # responses -> components.responses + if "responses" in data: + data.setdefault("components", {})["responses"] = data.pop("responses") + + # Rewrite $ref strings + data = _rewrite_refs(data) + + # consumes/produces → per-operation defaults & inject + _inject_content_defaults(data) + + # in: formData parameters → requestBody + _convert_formdata_to_request_body(data) + + # in: body parameters → requestBody + _convert_body_parameters(data) + + # convert response schemas + _convert_responses(data) + + return data + + +def _rewrite_refs(node: Any) -> Any: + """Rewrite Swagger 2.0 $ref paths to OpenAPI 3.0 equivalents.""" + if isinstance(node, dict): + if "$ref" in node and isinstance(node["$ref"], str): + ref = node["$ref"] + + if ref.startswith("#/definitions/"): + node["$ref"] = ref.replace( + "#/definitions/", + "#/components/schemas/", + ) + elif ref.startswith("#/securityDefinitions/"): + node["$ref"] = ref.replace( + "#/securityDefinitions/", + "#/components/securitySchemes/", + ) + elif ref.startswith("#/parameters/"): + node["$ref"] = ref.replace( + "#/parameters/", + "#/components/parameters/", + ) + elif ref.startswith("#/responses/"): + node["$ref"] = ref.replace( + "#/responses/", + "#/components/responses/", + ) + + return {k: _rewrite_refs(v) for k, v in node.items()} + + if isinstance(node, list): + return [_rewrite_refs(v) for v in node] + + return node + + +def _inject_content_defaults(data: dict[str, Any]) -> None: + """Migrate global consumes/produces into each operation.""" + global_consumes = data.pop("consumes", ["application/json"]) + global_produces = data.pop("produces", ["application/json"]) + + for _, _, operation in _iter_path_entries(data): + if "consumes" in operation: + operation["consumes"] = list(operation["consumes"]) + else: + operation["consumes"] = list(global_consumes) + + if "produces" in operation: + operation["produces"] = list(operation["produces"]) + else: + operation["produces"] = list(global_produces) + + +def _build_formdata_schema(form_params: list[dict[str, Any]]) -> dict[str, Any]: + """Build an object schema from formData parameter definitions.""" + properties: dict[str, Any] = {} + required: list[str] = [] + + for param in form_params: + name = param.get("name") + + if not name: + continue + + prop_schema: dict[str, Any] = {} + + for field in ("type", "description", "default", "enum", "format", "items"): + if field in param: + prop_schema[field] = param[field] + + properties[name] = prop_schema + + if param.get("required"): + required.append(name) + + schema: dict[str, Any] = {"type": "object", "properties": properties} + + if required: + schema["required"] = required + + return schema + + +def _convert_formdata_to_request_body(data: dict[str, Any]) -> None: + """Merge formData parameters into a single requestBody per operation.""" + for _, _, operation in _iter_path_entries(data): + parameters = operation.get("parameters", []) + + if not isinstance(parameters, list): + continue + + form_params = [] + new_parameters = [] + + for param in parameters: + if isinstance(param, dict) and param.get("in") == "formData": + form_params.append(param) + else: + new_parameters.append(param) + + operation["parameters"] = new_parameters + + if form_params: + schema = _build_formdata_schema(form_params) + + consumes = operation.pop("consumes", ["application/x-www-form-urlencoded"]) + if not consumes: + consumes = ["application/x-www-form-urlencoded"] + + content = {} + for mime in consumes: + content[mime] = {"schema": schema} + + operation["requestBody"] = {"required": True, "content": content} + + +def _convert_body_parameters(data: dict[str, Any]) -> None: + """Convert body parameters into a requestBody object per operation.""" + for _, _, operation in _iter_path_entries(data): + parameters = operation.get("parameters", []) + + if not isinstance(parameters, list): + continue + + body_param = None + new_parameters = [] + + for param in parameters: + if isinstance(param, dict) and param.get("in") == "body": + body_param = param + else: + new_parameters.append(param) + + operation["parameters"] = new_parameters + + if body_param: + consumes = operation.pop("consumes", ["application/json"]) + content = {} + schema = body_param.get("schema", {}) + + for mime in consumes: + content[mime] = {"schema": schema} + + operation["requestBody"] = { + "required": body_param.get("required", False), + "description": body_param.get("description"), + "content": content, + } + + +def _convert_responses(data: dict[str, Any]) -> None: + """Wrap response schemas into content/media-type structure.""" + for _, _, operation in _iter_path_entries(data): + produces = operation.pop("produces", ["application/json"]) + responses = operation.get("responses", {}) + + if not isinstance(responses, dict): + continue + + for _code, response in responses.items(): + if not isinstance(response, dict): + continue + + if "schema" in response: + schema = response.pop("schema") + content = {} + + for mime in produces: + content[mime] = {"schema": schema} + + response["content"] = content diff --git a/src/openapi_parser/models/v3_0.py b/src/openapi_parser/models/v3_0.py new file mode 100644 index 0000000..32e9d0b --- /dev/null +++ b/src/openapi_parser/models/v3_0.py @@ -0,0 +1,315 @@ +"""OpenAPI 3.0 specification models.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import Field, model_serializer, model_validator + +from openapi_parser.enumeration import ( + ApiKeyLocation, + CookieParameterStyle, + DataType, + HeaderParameterStyle, + OAuthFlowType, + ParameterLocation, + PathParameterStyle, + QueryParameterStyle, + SecurityType, +) +from openapi_parser.models.base import ( + Discriminator, + ExternalDoc, + Info, + Link, + OAuthFlow, + Server, + _ModelBase, + _MutableModelBase, +) +from openapi_parser.models.mixins import ExtensionsMixin, RefCacheMixin + + +class Schema(ExtensionsMixin, RefCacheMixin, _MutableModelBase): + """Schema object for data definition. + + Not frozen to allow in-place update of placeholders during + circular $ref resolution. Object identity is preserved via + RefCacheMixin's dedup cache. + """ + + type: DataType | None = None + title: str | None = None + description: str | None = None + enum: list[Any] | None = None + example: Any | None = None + default: Any | None = None + nullable: bool | None = None + read_only: bool | None = Field(default=None, alias="readOnly") + write_only: bool | None = Field(default=None, alias="writeOnly") + deprecated: bool = False + + # Numeric + multiple_of: int | float | None = Field(default=None, alias="multipleOf") + maximum: int | float | None = None + exclusive_maximum: int | float | None = Field( + default=None, alias="exclusiveMaximum" + ) + minimum: int | float | None = None + exclusive_minimum: int | float | None = Field( + default=None, + alias="exclusiveMinimum", + ) + + # String + max_length: int | None = Field(default=None, alias="maxLength") + min_length: int | None = Field(default=None, alias="minLength") + pattern: str | None = None + + # Array + max_items: int | None = Field(default=None, alias="maxItems") + min_items: int | None = Field(default=None, alias="minItems") + unique_items: bool | None = Field(default=None, alias="uniqueItems") + items: Schema | None = None + + # Object + properties: dict[str, Schema] | None = None + additional_properties: bool | Schema | None = Field( + default=None, + alias="additionalProperties", + ) + required: list[str] = Field(default_factory=list) + max_properties: int | None = Field(default=None, alias="maxProperties") + min_properties: int | None = Field(default=None, alias="minProperties") + + # Composition + all_of: list[Schema] | None = Field(default=None, alias="allOf") + one_of: list[Schema] | None = Field(default=None, alias="oneOf") + any_of: list[Schema] | None = Field(default=None, alias="anyOf") + not_schema: Schema | None = Field(default=None, alias="not") + + # Meta + format: str | None = None + discriminator: Discriminator | None = None + xml: dict[str, Any] | None = None + external_docs: ExternalDoc | None = Field(default=None, alias="externalDocs") + + +class Header(ExtensionsMixin, RefCacheMixin, _ModelBase): + """Header object definition.""" + + schema_object: Schema | None = Field(default=None, alias="schema") + description: str | None = None + required: bool | None = None + deprecated: bool = False + + +class Encoding(ExtensionsMixin, _ModelBase): + """Encoding object definition.""" + + content_type: str | None = Field(default=None, alias="contentType") + headers: dict[str, Header] | None = None + style: ( + PathParameterStyle + | QueryParameterStyle + | HeaderParameterStyle + | CookieParameterStyle + | None + ) = None + explode: bool | None = None + allow_reserved: bool | None = Field(default=None, alias="allowReserved") + + +class Example(ExtensionsMixin, RefCacheMixin, _ModelBase): + """Example object definition.""" + + summary: str | None = None + description: str | None = None + value: Any | None = None + external_value: str | None = Field(default=None, alias="externalValue") + + +class MediaType(ExtensionsMixin, _ModelBase): + """Media Type object definition.""" + + schema_object: Schema | None = Field(default=None, alias="schema") + example: Any | None = None + examples: dict[str, Example] | None = None + encoding: dict[str, Encoding] | None = None + + +class Parameter(ExtensionsMixin, RefCacheMixin, _ModelBase): + """Parameter object definition.""" + + name: str + location: ParameterLocation = Field(alias="in") + description: str | None = None + required: bool | None = None + deprecated: bool = False + allow_empty_value: bool | None = Field(default=None, alias="allowEmptyValue") + style: ( + PathParameterStyle + | QueryParameterStyle + | HeaderParameterStyle + | CookieParameterStyle + | None + ) = None + explode: bool | None = None + allow_reserved: bool | None = Field(default=None, alias="allowReserved") + schema_object: Schema | None = Field(default=None, alias="schema") + example: Any | None = None + examples: dict[str, Example] | None = None + content: dict[str, MediaType] | None = None + + +class RequestBody(ExtensionsMixin, RefCacheMixin, _ModelBase): + """Request Body object definition.""" + + description: str | None = None + content: dict[str, MediaType] + required: bool | None = None + + +class Response(ExtensionsMixin, RefCacheMixin, _ModelBase): + """Response object definition.""" + + description: str + headers: dict[str, Header] | None = None + content: dict[str, MediaType] | None = None + links: dict[str, Link] | None = None + + +class Callback(ExtensionsMixin, _ModelBase): + """A map of expressions to PathItem objects.""" + + expressions: dict[str, PathItem] + + @model_validator(mode="before") + @classmethod + def _parse_callback(cls, data: Any) -> Any: + if isinstance(data, dict): + if "expressions" in data: + return data + + expressions = {} + rest: dict[str, Any] = {} + + for k, v in data.items(): + if k.startswith("x-"): + rest[k] = v + else: + expressions[k] = v + + return {"expressions": expressions, **rest} + + return data + + @model_serializer(mode="wrap") + def _dump_callback(self, handler: Any) -> Any: + """Flatten back to the spec-compliant map of expression to PathItem.""" + data = handler(self) + result: dict[str, Any] = dict(data.get("expressions") or {}) + result.update(data.get("extensions") or {}) + + return result + + +class Operation(ExtensionsMixin, _ModelBase): + """Operation object definition.""" + + summary: str | None = None + description: str | None = None + operation_id: str | None = Field(default=None, alias="operationId") + parameters: list[Parameter] | None = None + request_body: RequestBody | None = Field(default=None, alias="requestBody") + responses: dict[str, Response] + callbacks: dict[str, Callback] | None = None + deprecated: bool = False + security: list[dict[str, list[str]]] | None = None + servers: list[Server] | None = None + tags: list[str] | None = None + external_docs: ExternalDoc | None = Field(default=None, alias="externalDocs") + + @model_validator(mode="before") + @classmethod + def _coerce_response_keys(cls, data: Any) -> Any: + if ( + isinstance(data, dict) + and "responses" in data + and isinstance(data["responses"], dict) + ): + data["responses"] = {str(k): v for k, v in data["responses"].items()} + + return data + + +class PathItem(ExtensionsMixin, RefCacheMixin, _ModelBase): + """Path Item object definition.""" + + summary: str | None = None + description: str | None = None + get: Operation | None = None + put: Operation | None = None + post: Operation | None = None + delete: Operation | None = None + options: Operation | None = None + head: Operation | None = None + patch: Operation | None = None + trace: Operation | None = None + parameters: list[Parameter] | None = None + servers: list[Server] | None = None + + +class SecurityScheme(ExtensionsMixin, RefCacheMixin, _ModelBase): + """Security Scheme object definition.""" + + type: SecurityType + description: str | None = None + name: str | None = None + location: ApiKeyLocation | None = Field(default=None, alias="in") + scheme: str | None = None + bearer_format: str | None = Field(default=None, alias="bearerFormat") + flows: dict[OAuthFlowType, OAuthFlow] | None = None + open_id_connect_url: str | None = Field(default=None, alias="openIdConnectUrl") + + +class Tag(ExtensionsMixin, _ModelBase): + """Tag object definition.""" + + name: str + description: str | None = None + external_docs: ExternalDoc | None = Field(default=None, alias="externalDocs") + + +class Components(ExtensionsMixin, _ModelBase): + """Components object definition.""" + + schemas: dict[str, Schema] | None = None + responses: dict[str, Response] | None = None + parameters: dict[str, Parameter] | None = None + examples: dict[str, Example] | None = None + request_bodies: dict[str, RequestBody] | None = Field( + default=None, + alias="requestBodies", + ) + headers: dict[str, Header] | None = None + security_schemes: dict[str, SecurityScheme] | None = Field( + default=None, + alias="securitySchemes", + ) + links: dict[str, Link] | None = None + callbacks: dict[str, Callback] | None = None + path_items: dict[str, PathItem] | None = Field(default=None, alias="pathItems") + + +class Specification(ExtensionsMixin, _ModelBase): + """OpenAPI 3.0 specification root object.""" + + openapi: str = "3.0.0" + info: Info + servers: list[Server] = [] + paths: dict[str, PathItem] + components: Components | None = None + security: list[dict[str, list[str]]] | None = None + tags: list[Tag] | None = None + external_docs: ExternalDoc | None = Field(default=None, alias="externalDocs") diff --git a/src/openapi_parser/models/v3_1.py b/src/openapi_parser/models/v3_1.py new file mode 100644 index 0000000..8b00e20 --- /dev/null +++ b/src/openapi_parser/models/v3_1.py @@ -0,0 +1,135 @@ +"""OpenAPI 3.1/3.2 specification models.""" + +from __future__ import annotations + +from pydantic import Field + +from openapi_parser.enumeration import DataType +from openapi_parser.models import v3_0 +from openapi_parser.models.base import ExternalDoc, _ModelBase +from openapi_parser.models.mixins import ExtensionsMixin + + +class Schema(v3_0.Schema): + """Schema object for data definition in OpenAPI 3.1+.""" + + type: DataType | list[DataType] | None = None # type: ignore[assignment] # 3.1 allows type as array + items: Schema | None = None + properties: dict[str, Schema] | None = None # type: ignore[assignment] # narrowed to v3_1.Schema + additional_properties: bool | Schema | None = Field( + default=None, + alias="additionalProperties", + ) + all_of: list[Schema] | None = Field(default=None, alias="allOf") # type: ignore[assignment] # narrowed to v3_1.Schema + one_of: list[Schema] | None = Field(default=None, alias="oneOf") # type: ignore[assignment] # narrowed to v3_1.Schema + any_of: list[Schema] | None = Field(default=None, alias="anyOf") # type: ignore[assignment] # narrowed to v3_1.Schema + not_schema: Schema | None = Field(default=None, alias="not") + + +class Header(v3_0.Header): + """Header object definition in OpenAPI 3.1+.""" + + schema_object: Schema | None = Field(default=None, alias="schema") + + +class Encoding(v3_0.Encoding): + """Encoding object definition in OpenAPI 3.1+.""" + + headers: dict[str, Header] | None = None # type: ignore[assignment] # narrowed to v3_1.Header + + +class MediaType(v3_0.MediaType): + """Media Type object definition in OpenAPI 3.1+.""" + + schema_object: Schema | None = Field(default=None, alias="schema") + encoding: dict[str, Encoding] | None = None # type: ignore[assignment] # narrowed to v3_1.Encoding + + +class Parameter(v3_0.Parameter): + """Parameter object definition in OpenAPI 3.1+.""" + + schema_object: Schema | None = Field(default=None, alias="schema") + content: dict[str, MediaType] | None = None # type: ignore[assignment] # narrowed to v3_1.MediaType + + +class RequestBody(v3_0.RequestBody): + """Request Body object definition in OpenAPI 3.1+.""" + + content: dict[str, MediaType] # type: ignore[assignment] # narrowed to v3_1.MediaType + + +class Response(v3_0.Response): + """Response object definition in OpenAPI 3.1+.""" + + headers: dict[str, Header] | None = None # type: ignore[assignment] # narrowed to v3_1.Header + content: dict[str, MediaType] | None = None # type: ignore[assignment] # narrowed to v3_1.MediaType + + +class Callback(v3_0.Callback): + """A map of expressions to PathItem objects (v3.1+).""" + + expressions: dict[str, PathItem] # type: ignore[assignment] # narrowed to v3_1.PathItem + + +class Operation(v3_0.Operation): + """Operation object definition in OpenAPI 3.1+.""" + + parameters: list[Parameter] | None = None # type: ignore[assignment] # narrowed to v3_1.Parameter + request_body: RequestBody | None = Field(default=None, alias="requestBody") + responses: dict[str, Response] # type: ignore[assignment] # narrowed to v3_1.Response + callbacks: dict[str, Callback] | None = None # type: ignore[assignment] # narrowed to v3_1.Callback + + +class PathItem(v3_0.PathItem): + """Path Item object definition in OpenAPI 3.1+.""" + + get: Operation | None = None + put: Operation | None = None + post: Operation | None = None + delete: Operation | None = None + options: Operation | None = None + head: Operation | None = None + patch: Operation | None = None + trace: Operation | None = None + parameters: list[Parameter] | None = None # type: ignore[assignment] # narrowed to v3_1.Parameter + additional_operations: dict[str, Operation] | None = Field( + default=None, + alias="additionalOperations", + ) + + +class Tag(ExtensionsMixin, _ModelBase): + """Structured Tag object definition for OpenAPI 3.2.""" + + name: str + summary: str | None = None + description: str | None = None + parent: str | None = None + kind: str | None = None + external_docs: ExternalDoc | None = Field(default=None, alias="externalDocs") + + +class Components(v3_0.Components): + """Components object definition in OpenAPI 3.1+.""" + + schemas: dict[str, Schema] | None = None # type: ignore[assignment] # narrowed to v3_1.Schema + responses: dict[str, Response] | None = None # type: ignore[assignment] # narrowed to v3_1.Response + parameters: dict[str, Parameter] | None = None # type: ignore[assignment] # narrowed to v3_1.Parameter + request_bodies: dict[str, RequestBody] | None = Field( # type: ignore[assignment] # narrowed to v3_1.RequestBody + default=None, + alias="requestBodies", + ) + headers: dict[str, Header] | None = None # type: ignore[assignment] # narrowed to v3_1.Header + callbacks: dict[str, Callback] | None = None # type: ignore[assignment] # narrowed to v3_1.Callback + path_items: dict[str, PathItem] | None = Field(default=None, alias="pathItems") # type: ignore[assignment] # narrowed to v3_1.PathItem + + +class Specification(v3_0.Specification): + """OpenAPI 3.1+ specification root object.""" + + openapi: str = "3.1.0" + paths: dict[str, PathItem] # type: ignore[assignment] # narrowed to v3_1.PathItem + components: Components | None = None + tags: list[Tag] | None = None # type: ignore[assignment] # narrowed to v3_1.Tag + webhooks: dict[str, PathItem] | None = None + json_schema_dialect: str | None = Field(default=None, alias="jsonSchemaDialect") diff --git a/src/openapi_parser/parser.py b/src/openapi_parser/parser.py index 0c82df3..497dd54 100644 --- a/src/openapi_parser/parser.py +++ b/src/openapi_parser/parser.py @@ -1,206 +1,121 @@ -"""OpenAPI specification parser entry point.""" - -import logging -from typing import Any - -from openapi_parser.builders.common import PropertyMeta, extract_typed_props -from openapi_parser.builders.content import ContentBuilder -from openapi_parser.builders.encoding import EncodingBuilder -from openapi_parser.builders.external_doc import ExternalDocBuilder -from openapi_parser.builders.header import HeaderBuilder -from openapi_parser.builders.info import InfoBuilder -from openapi_parser.builders.link import LinkBuilder -from openapi_parser.builders.oauth_flow import OAuthFlowBuilder -from openapi_parser.builders.operation import OperationBuilder -from openapi_parser.builders.parameter import ParameterBuilder -from openapi_parser.builders.path import PathBuilder -from openapi_parser.builders.request import RequestBuilder -from openapi_parser.builders.response import ResponseBuilder -from openapi_parser.builders.schema import SchemaFactory -from openapi_parser.builders.schemas import SchemasBuilder -from openapi_parser.builders.security import SecurityBuilder -from openapi_parser.builders.server import ServerBuilder -from openapi_parser.builders.tag import TagBuilder +"""Main entry point for the OpenAPI parser.""" + +import os +import types +from typing import Any, TypeAlias, cast + +from pydantic import ValidationError +from yaml import YAMLError +from yaml import safe_load as safe_load_yaml + +from openapi_parser import models from openapi_parser.errors import ParserError -from openapi_parser.logging import log_ctx -from openapi_parser.resolver import OpenAPIResolver -from openapi_parser.specification import Specification - -logger = logging.getLogger(__name__) - - -class Parser: - """Builds Specification objects from parsed OpenAPI data.""" - - info_builder: InfoBuilder - server_builder: ServerBuilder - tag_builder: TagBuilder - external_doc_builder: ExternalDocBuilder - path_builder: PathBuilder - security_builder: SecurityBuilder - schemas_builder: SchemasBuilder - - def __init__( - self, - info_builder: InfoBuilder, - server_builder: ServerBuilder, - tag_builder: TagBuilder, - external_doc_builder: ExternalDocBuilder, - path_builder: PathBuilder, - security_builder: SecurityBuilder, - schemas_builder: SchemasBuilder, - ) -> None: - """Initialize parser with specialized builders. - - Args: - info_builder: Builder for info metadata - server_builder: Builder for server definitions - tag_builder: Builder for tag definitions - external_doc_builder: Builder for external docs - path_builder: Builder for path definitions - security_builder: Builder for security schemes - schemas_builder: Builder for component schemas - """ - self.info_builder = info_builder - self.server_builder = server_builder - self.tag_builder = tag_builder - self.external_doc_builder = external_doc_builder - self.path_builder = path_builder - self.security_builder = security_builder - self.schemas_builder = schemas_builder - - def load_specification(self, data: dict[str, Any]) -> Specification: - """Load OpenAPI Specification object from a file or a remote URI. - - Args: - data (dict): Parsed YAML/JSON dictionary of OpenAPI specification - - Returns: - Specification: Specification object - - Raises: - ParserError: If OpenAPI schema is invalid - """ - with log_ctx("spec"): - logger.debug("Building Specification objects") - - try: - version = data["openapi"] - except KeyError: - raise ParserError( - "Invalid OpenAPI version, check 'openapi' property in the document", - ) from None - - attrs_map = { - "servers": PropertyMeta( - name="servers", - cast=self.server_builder.build_list, - ), - "tags": PropertyMeta( - name="tags", - cast=self.tag_builder.build_list, - ), - "external_docs": PropertyMeta( - name="externalDocs", - cast=self.external_doc_builder.build, - ), - "paths": PropertyMeta( - name="paths", - cast=self.path_builder.build_list, - ), - "security": PropertyMeta(name="security", cast=None), - } - - attrs = extract_typed_props(data, attrs_map) - - attrs["version"] = version - - info_data = data.get("info") - - if info_data is None: - raise ParserError( - "OpenAPI document is missing required 'info' property" - ) - - attrs["info"] = self.info_builder.build(info_data) - - components = data.get("components") or {} - - if "securitySchemes" in components: - attrs["security_schemas"] = self.security_builder.build_collection( - components["securitySchemes"], - ) - - if "schemas" in components: - attrs["schemas"] = self.schemas_builder.build_collection( - components["schemas"], - ) - - logger.debug("Specification parsed successfully") - - return Specification(**attrs) - - -def _create_parser(strict_enum: bool = True) -> Parser: - logger.info("Initializing parser") - - info_builder = InfoBuilder() - server_builder = ServerBuilder() - external_doc_builder = ExternalDocBuilder() - tag_builder = TagBuilder(external_doc_builder) - schema_factory = SchemaFactory(strict_enum=strict_enum) - header_builder = HeaderBuilder(schema_factory) - encoding_builder = EncodingBuilder(header_builder) - content_builder = ContentBuilder( - schema_factory, - encoding_builder, - strict_enum=strict_enum, - ) - parameter_builder = ParameterBuilder(schema_factory, content_builder) - schemas_builder = SchemasBuilder(schema_factory) - link_builder = LinkBuilder() - response_builder = ResponseBuilder(content_builder, header_builder, link_builder) - request_builder = RequestBuilder(content_builder) - operation_builder = OperationBuilder( - response_builder, - external_doc_builder, - request_builder, - parameter_builder, - ) - path_builder = PathBuilder(operation_builder, parameter_builder) - oauth_flow_builder = OAuthFlowBuilder() - security_builder = SecurityBuilder(oauth_flow_builder) - - return Parser( - info_builder, - server_builder, - tag_builder, - external_doc_builder, - path_builder, - security_builder, - schemas_builder, - ) +from openapi_parser.models.mixins import RefCacheMixin +from openapi_parser.models.v2_0 import normalize_swagger_v2 +from openapi_parser.models.v3_0 import Specification as SpecificationV3_0 +from openapi_parser.models.v3_1 import Specification as SpecificationV3_1 +from openapi_parser.resolver import _read_uri, resolve + +Specification: TypeAlias = SpecificationV3_0 | SpecificationV3_1 + +_VERSION_SPEC_MAP = { + "2.0": models.v3_0, + "3.0": models.v3_0, + "3.1": models.v3_1, + "3.2": models.v3_1, +} + + +def _detect_version(raw: dict[str, Any]) -> str: + """Determine the OpenAPI/Swagger version from the raw spec dict.""" + if "swagger" in raw: + return "2.0" + + # extract major.minor version from the string + version_parts = raw.get("openapi", "").split(".")[:2] + + return ".".join(version_parts) + + +def _load_raw(uri: str | None, spec_string: str | None) -> dict[str, Any]: + """Load and parse YAML/JSON from *uri* or *spec_string*.""" + try: + if uri: + raw = safe_load_yaml(_read_uri(uri)) + elif spec_string: + raw = safe_load_yaml(spec_string) + else: + raise ParserError("Either uri or spec_string must be provided") + except (OSError, YAMLError) as e: + raise ParserError(f"Failed to load spec: {e}") from e + + if not isinstance(raw, dict): + raise ParserError("OpenAPI spec must be a dictionary") + + return raw + + +def _validate_model( + spec_module: types.ModuleType, + resolved: dict[str, Any], + version_key: str, +) -> Specification: + """Validate the resolved spec against a version-specific module.""" + try: + return cast(Specification, spec_module.Specification.model_validate(resolved)) + except ValidationError as e: + raise ParserError(f"Validation failed for OpenAPI {version_key}: {e}") from e def parse( - uri: str | None = None, + uri: str | os.PathLike[str] | None = None, spec_string: str | None = None, - strict_enum: bool = True, - recursion_limit: int = 1, + base_uri: str | os.PathLike[str] | None = None, ) -> Specification: - """Parse specification document by URL/filepath or as a string. - - Args: - uri (str): Path or URL to OpenAPI file - spec_string (str): OpenAPI specification as a string to parse - strict_enum (bool): Validate content types and string formats against the - enums defined in openapi-parser. Note that the OpenAPI specification allows - for custom values in these properties. - recursion_limit (int): Maximum recursion depth for resolving references + """Parse an OpenAPI/Swagger spec into fully typed Pydantic models. + + Parameters + ---------- + uri : str, optional + Location of the spec file. Accepts a local paths and URIs. + spec_string : str, optional + Raw spec YAML/JSON string (alternative to *uri*). + base_uri : str, optional + Location used to resolve external ``$ref`` targets when parsing + a *spec_string* (e.g. ``"file:///path/to/specs/main.yaml"``). + Ignored when *uri* is provided. + + Returns: + ------- + Specification + Version-specific typed specification model. + + Raises: + ------ + ParserError + On parse failures, wrapping the original exception. """ - resolver = OpenAPIResolver(uri, spec_string, recursion_limit=recursion_limit) - specification = resolver.resolve() + RefCacheMixin.clear_ref_cache() + + if uri is not None: + uri = os.fspath(uri) + + if base_uri is not None: + base_uri = os.fspath(base_uri) + + raw = _load_raw(uri, spec_string) + + version = _detect_version(raw) + if version == "2.0": + raw = normalize_swagger_v2(raw) + + spec_module = _VERSION_SPEC_MAP.get(version) + if spec_module is None: + raise ParserError(f"Unsupported OpenAPI version: {version}") - parser = _create_parser(strict_enum=strict_enum) + try: + resolved = resolve(raw, base_uri if uri is None else uri, version) + except Exception as e: + raise ParserError(f"Failed to resolve references: {e}") from e - return parser.load_specification(specification) + return _validate_model(spec_module, resolved, version) diff --git a/src/openapi_parser/resolver.py b/src/openapi_parser/resolver.py index eca980c..b42e0d0 100644 --- a/src/openapi_parser/resolver.py +++ b/src/openapi_parser/resolver.py @@ -1,76 +1,259 @@ -"""OpenAPI specification resolver using prance.""" +"""OpenAPI specification resolver using the referencing library.""" -import logging -from typing import Any, cast +from collections.abc import Callable +from os.path import abspath, dirname, isabs, join +from typing import Any, TypeVar, cast +from urllib.parse import urljoin, urlparse +from urllib.request import url2pathname, urlopen -import prance +from referencing import Registry, Resource, Specification +from referencing.jsonschema import DRAFT4, DRAFT202012 +from yaml import safe_load -from openapi_parser.errors import ParserError +_DRAFT_BY_VERSION = { + "2.0": DRAFT4, + "3.0": DRAFT4, + "3.1": DRAFT202012, + "3.2": DRAFT202012, +} -OPENAPI_SPEC_VALIDATOR = "openapi-spec-validator" -logger = logging.getLogger(__name__) +def _read_uri(uri: str) -> str: + """Read the full contents of a URI into a string.""" + parsed = urlparse(uri) + if parsed.scheme in ("http", "https"): + with urlopen(uri, timeout=10) as response: + body: str = response.read().decode("utf-8") + return body + + if parsed.scheme == "file": + with open(url2pathname(parsed.path)) as f: + return f.read() + + with open(uri) as f: + return f.read() + + +def _make_retriever( + base_uri: str, draft: Specification[Any] +) -> Callable[[str], Resource[Any]]: + """Build a retriever callable for the ``referencing`` library. + + Handles both local files and HTTP(S) external ``$ref`` targets + using the shared :func:`_read_uri` helper. + """ + parsed = urlparse(base_uri) + is_http = parsed.scheme in ("http", "https") + + if not is_http: + resolved = url2pathname(parsed.path) if parsed.scheme == "file" else base_uri + base_dir = dirname(abspath(resolved)) + + def _retrieve(u: str) -> Resource[Any]: + if is_http: + ref_url = urljoin(base_uri, u) + raw: Any = safe_load(_read_uri(ref_url)) + else: + path = join(base_dir, u) if not isabs(u) else u + raw = safe_load(_read_uri(path)) + + return Resource.from_contents(raw, default_specification=draft) + + return _retrieve + + +_COMPONENT_SECTIONS = frozenset( + { + "schemas", + "responses", + "parameters", + "examples", + "requestBodies", + "headers", + "securitySchemes", + "links", + "callbacks", + "pathItems", + } +) + + +def _traverse( + node: Any, + process_dict: Callable[[dict[str, Any]], Any] | None = None, + *, + _tracking: set[int], +) -> Any: + """Walk a JSON-like tree with ``id()``-based cycle tracking. + + For every dict encountered, *process_dict* is called first. + If it returns a value other than ``None``, that value replaces the + dict and recursion is skipped (used for ``$ref`` resolution and + cycle-breaking placeholders). + """ + if isinstance(node, dict): + if process_dict is not None: + replacement = process_dict(node) + + if replacement is not None: + return replacement + + nid = id(node) + + _tracking.add(nid) + + try: + for k, v in list(node.items()): + node[k] = _traverse(v, process_dict, _tracking=_tracking) + + return node + finally: + _tracking.discard(nid) + + if isinstance(node, list): + for i, v in enumerate(node): + node[i] = _traverse(v, process_dict, _tracking=_tracking) + + return node + + return node + + +def _annotate_component_refs(data: dict[str, Any]) -> None: + """Add ref_name to every component entry so RefCacheMixin can track them.""" + components = data.get("components") + + if not isinstance(components, dict): + return + + for section in _COMPONENT_SECTIONS: + entries = components.get(section) + + if not isinstance(entries, dict): + continue + + for name, entry in entries.items(): + if isinstance(entry, dict) and "ref_name" not in entry: + entry["ref_name"] = f"#/components/{section}/{name}" + + +T = TypeVar("T") + + +def _resolve_ref_node( + node: dict[str, Any], + resolver: Any, + resolved_cache: dict[str, dict[str, Any]], + _walking: set[int], +) -> Any: + """Resolve a single $ref node and return the referenced content.""" + ref = node["$ref"] + + if ref in resolved_cache: + cached: Any = resolved_cache[ref] + + if isinstance(cached, dict) and id(cached) in _walking: + return {"ref_name": ref} + + return cached + + result = resolver.lookup(ref) + contents = result.contents + evolved_resolver = result.resolver + + if isinstance(contents, dict): + resolved_cache[ref] = contents + contents_id = id(contents) + + if contents_id in _walking: + return {"ref_name": ref} + + if "$ref" in contents and isinstance(contents["$ref"], str): + resolved = _resolve_ref_node( + contents, evolved_resolver, resolved_cache, _walking + ) + resolved_cache[ref] = resolved + return resolved + + _walking.add(contents_id) -def _default_recursion_limit_handler( - limit: int, - parsed_url: Any, - _recursions: tuple[Any, ...] = (), -) -> dict[str, str]: - """Log warning and return minimal schema for circular reference.""" - logger.warning( - "Recursion limit of %d reached at %s. " - "Replacing circular reference with placeholder schema.", - limit, - str(parsed_url), - ) - return {"type": "object"} - - -class OpenAPIResolver: - """Resolves and validates OpenAPI specs using prance.""" - - _resolver: prance.ResolvingParser - - def __init__( - self, - uri: str | None, - spec_string: str | None = None, - recursion_limit: int = 1, - ) -> None: - """Initialize resolver. - - Args: - uri: Path or URL to the spec file - spec_string: Raw spec string as alternative to uri - recursion_limit: Maximum recursion depth for resolving references - """ - self._resolver = prance.ResolvingParser( - uri, - spec_string=spec_string, - backend=OPENAPI_SPEC_VALIDATOR, - strict=False, - lazy=True, - recursion_limit=recursion_limit, - recursion_limit_handler=_default_recursion_limit_handler, - ) - - def resolve(self) -> dict[str, Any]: - """Resolve OpenAPI specification with Prance parser. - - Returns: - dict: Normalized and parsed specification as a dictionary - - Raises: - ParserError: If some validation or parsing error occurred - """ try: - logger.debug("Resolving specification file") + for k, v in list(contents.items()): + contents[k] = _walk(v, evolved_resolver, resolved_cache, _walking) + finally: + _walking.discard(contents_id) + + contents["ref_name"] = ref + + return contents + + return _walk(contents, evolved_resolver, resolved_cache, _walking) + + +def _walk( + node: T, + resolver: Any, + resolved_cache: dict[str, dict[str, Any]] | None = None, + _walking: set[int] | None = None, +) -> T: + """Recursively walk and resolve all $ref nodes in the spec tree.""" + if resolved_cache is None: + resolved_cache = {} + + if _walking is None: + _walking = set() + + def _dict_fn(d: dict[str, Any]) -> Any: + if "$ref" in d and isinstance(d["$ref"], str): + return _resolve_ref_node(d, resolver, resolved_cache, _walking) + + return None + + return cast(T, _traverse(node, _dict_fn, _tracking=_walking)) + + +def _build_registry( + raw: dict[str, Any], + uri: str | None = None, + version: str | None = None, +) -> Registry[Any]: + """Build a ``referencing`` Registry with the root spec loaded.""" + draft = _DRAFT_BY_VERSION.get(version or "", DRAFT202012) + retrieval: Callable[[str], Resource[Any]] | None = ( + _make_retriever(uri, draft) if uri else None + ) + registry = ( + Registry(retrieve=retrieval) if retrieval else Registry() # type: ignore[call-arg] # referencing stubs missing ``retrieve`` + ) + + return cast( + "Registry[Any]", + registry.with_resource( + "urn:root", + Resource.from_contents(raw, default_specification=draft), + ), + ) + + +def resolve( + raw: dict[str, Any], + uri: str | None = None, + version: str | None = None, +) -> dict[str, Any]: + """Resolve all ``$ref`` entries in *raw*, annotating each with *ref_name*. + + Every ``$ref`` is replaced with the resolved content plus a + ``{"ref_name": ""}`` marker. Python object cycles + (self-referencing / bidirectional refs) are broken by replacing + the nested occurrence with ``{"ref_name": ""}``. - self._resolver.parse() + The JSON Schema *version* ("3.0" vs "3.1") selects the dialect used + for ``$ref`` resolution (Draft 4 vs Draft 2020-12). + """ + registry = _build_registry(raw, uri, version) + resolver_obj = registry.resolver(base_uri="urn:root") + result = _walk(raw, resolver_obj) + _annotate_component_refs(result) - return cast(dict[str, Any], self._resolver.specification) - except prance.ValidationError as error: - raise ParserError(f"OpenAPI validation error: {error}") from error - except Exception as error: - raise ParserError(f"OpenAPI file parsing error: {error}") from error + return result diff --git a/src/openapi_parser/specification.py b/src/openapi_parser/specification.py deleted file mode 100644 index deffc0f..0000000 --- a/src/openapi_parser/specification.py +++ /dev/null @@ -1,374 +0,0 @@ -"""OpenAPI specification data models.""" - -from dataclasses import dataclass, field -from typing import Any - -from openapi_parser.enumeration import ( - AuthenticationScheme, - BaseLocation, - ContentType, - CookieParameterStyle, - DataType, - HeaderParameterStyle, - IntegerFormat, - NumberFormat, - OAuthFlowType, - OperationMethod, - ParameterLocation, - PathParameterStyle, - QueryParameterStyle, - SecurityType, - StringFormat, -) -from openapi_parser.loose_types import ( - LooseContentType, - LooseIntegerFormat, - LooseNumberFormat, - LooseStringFormat, -) - - -@dataclass(frozen=True, slots=True) -class Contact: - """API contact information.""" - - name: str | None = None - url: str | None = None - email: str | None = None - - -@dataclass(frozen=True, slots=True) -class License: - """API license information.""" - - name: str - url: str | None = None - extensions: dict[str, Any] | None = field(default_factory=dict) - - -@dataclass(frozen=True, slots=True) -class Info: - """API metadata information.""" - - title: str - version: str - description: str | None = None - terms_of_service: str | None = None - contact: Contact | None = None - license: License | None = None - extensions: dict[str, Any] | None = field(default_factory=dict) - - -@dataclass(frozen=True, slots=True) -class Server: - """API server definition.""" - - url: str - description: str | None = None - variables: dict[str, Any] | None = field(default_factory=dict) - extensions: dict[str, Any] | None = field(default_factory=dict) - - -@dataclass(frozen=True, slots=True) -class ExternalDoc: - """External documentation reference.""" - - url: str - description: str | None = None - extensions: dict[str, Any] | None = field(default_factory=dict) - - -@dataclass(frozen=True, slots=True) -class Schema: - """Base schema model.""" - - type: DataType - title: str | None = None - enum: list[Any] | None = field(default_factory=list) - example: Any | None = None - description: str | None = None - default: Any | None = None - nullable: bool | None = field(default=False) - read_only: bool | None = field(default=False) - write_only: bool | None = field(default=False) - deprecated: bool | None = field(default=False) - not_schema: "Schema | None" = None - extensions: dict[str, Any] | None = field(default_factory=dict) - - -@dataclass(frozen=True, slots=True) -class Integer(Schema): - """Integer type schema.""" - - multiple_of: int | None = None - maximum: int | None = None - exclusive_maximum: int | None = None - minimum: int | None = None - exclusive_minimum: int | None = None - format: IntegerFormat | LooseIntegerFormat | None = None - - -@dataclass(frozen=True, slots=True) -class Number(Schema): - """Number type schema.""" - - multiple_of: float | None = None - maximum: float | None = None - exclusive_maximum: float | None = None - minimum: float | None = None - exclusive_minimum: float | None = None - format: NumberFormat | LooseNumberFormat | None = None - - -@dataclass(frozen=True, slots=True) -class String(Schema): - """String type schema.""" - - max_length: int | None = None - min_length: int | None = None - pattern: str | None = None - format: StringFormat | LooseStringFormat | None = None - - -@dataclass(frozen=True, slots=True) -class Null(Schema): - """Null type schema.""" - - pass - - -@dataclass(frozen=True, slots=True) -class Boolean(Schema): - """Boolean type schema.""" - - pass - - -@dataclass(frozen=True, slots=True) -class Array(Schema): - """Array type schema.""" - - max_items: int | None = None - min_items: int | None = None - unique_items: bool | None = None - items: Schema | None = None - - -@dataclass(frozen=True, slots=True) -class Discriminator: - """Polymorphism discriminator.""" - - property_name: str - mapping: dict[str, str] | None = field(default_factory=dict) - - -@dataclass(frozen=True, slots=True) -class OneOf(Schema): - """OneOf composition schema.""" - - schemas: list[Schema] = field(default_factory=list) - discriminator: Discriminator | None = None - - -@dataclass(frozen=True, slots=True) -class AnyOf(Schema): - """AnyOf composition schema.""" - - schemas: list[Schema] = field(default_factory=list) - discriminator: Discriminator | None = None - - -@dataclass(frozen=True, slots=True) -class Property: - """Schema property definition.""" - - name: str - schema: Schema - - -@dataclass(frozen=True, slots=True) -class Object(Schema): - """Object type schema.""" - - max_properties: int | None = None - min_properties: int | None = None - required: list[str] = field(default_factory=list) - properties: list[Property] = field(default_factory=list) - additional_properties: bool | Schema | None = None - - -@dataclass(frozen=True, slots=True) -class Parameter: - """API parameter definition.""" - - name: str - location: ParameterLocation - schema: Schema | None = None - content: "list[Content] | None" = None - required: bool | None = field(default=False) - description: str | None = None - example: Any | None = None - examples: dict[str, Any] = field(default_factory=dict) - allow_reserved: bool | None = None - deprecated: bool | None = field(default=False) - style: ( - str - | PathParameterStyle - | QueryParameterStyle - | HeaderParameterStyle - | CookieParameterStyle - | None - ) = None - explode: bool | None = field(default=False) - extensions: dict[str, Any] | None = field(default_factory=dict) - - -@dataclass(frozen=True, slots=True) -class Encoding: - """Encoding definition for request body properties.""" - - content_type: str | None = None - headers: "list[Header]" = field(default_factory=list) - style: str | None = None - explode: bool | None = None - allow_reserved: bool | None = None - extensions: dict[str, Any] | None = field(default_factory=dict) - - -@dataclass(frozen=True, slots=True) -class Content: - """Request/response content definition.""" - - type: ContentType | LooseContentType - schema: Schema - example: Any | None = None - examples: dict[str, Any] = field(default_factory=dict) - encoding: dict[str, Encoding] | None = None - - -@dataclass(frozen=True, slots=True) -class RequestBody: - """Request body definition.""" - - content: list[Content] - description: str | None = None - required: bool | None = field(default=False) - - -@dataclass(frozen=True, slots=True) -class Header: - """Response header definition.""" - - name: str - schema: Schema - description: str | None = None - required: bool | None = field(default=False) - deprecated: bool | None = field(default=False) - extensions: dict[str, Any] | None = field(default_factory=dict) - - -@dataclass(frozen=True, slots=True) -class Link: - """Link definition for response links.""" - - operation_ref: str | None = None - operation_id: str | None = None - parameters: dict[str, Any] = field(default_factory=dict) - request_body: Any | None = None - description: str | None = None - server: Server | None = None - extensions: dict[str, Any] | None = field(default_factory=dict) - - -@dataclass(frozen=True, slots=True) -class Response: - """API response definition.""" - - is_default: bool - description: str - code: int | None = None - content: list[Content] | None = None - headers: list[Header] = field(default_factory=list) - links: dict[str, Link] | None = None - - -@dataclass(frozen=True, slots=True) -class OAuthFlow: - """OAuth flow definition.""" - - refresh_url: str | None = None - authorization_url: str | None = None - token_url: str | None = None - scopes: dict[str, str] = field(default_factory=dict) - extensions: dict[str, Any] | None = field(default_factory=dict) - - -@dataclass(frozen=True, slots=True) -class Security: - """Security scheme definition.""" - - type: SecurityType - location: BaseLocation | None = None - description: str | None = None - name: str | None = None - scheme: AuthenticationScheme | None = None - bearer_format: str | None = None - flows: dict[OAuthFlowType, OAuthFlow] = field(default_factory=dict) - url: str | None = None - extensions: dict[str, Any] | None = field(default_factory=dict) - - -@dataclass(frozen=True, slots=True) -class Operation: - """API operation definition.""" - - method: OperationMethod - responses: list[Response] - summary: str | None = None - description: str | None = None - operation_id: str | None = None - external_docs: ExternalDoc | None = None - request_body: RequestBody | None = None - deprecated: bool | None = field(default=False) - parameters: list[Parameter] = field(default_factory=list) - tags: list[str] = field(default_factory=list) - security: list[dict[str, Any]] = field(default_factory=list) - extensions: dict[str, Any] | None = field(default_factory=dict) - callbacks: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True, slots=True) -class Path: - """API path definition.""" - - url: str - summary: str | None = None - description: str | None = None - operations: list[Operation] = field(default_factory=list) - parameters: list[Parameter] = field(default_factory=list) - extensions: dict[str, Any] | None = field(default_factory=dict) - - -@dataclass(frozen=True, slots=True) -class Tag: - """API tag definition.""" - - name: str - description: str | None = None - external_docs: ExternalDoc | None = None - - -@dataclass(frozen=True, slots=True) -class Specification: - """Root OpenAPI specification object.""" - - version: str - info: Info - servers: list[Server] = field(default_factory=list) - tags: list[Tag] = field(default_factory=list) - security_schemas: dict[str, Security] = field(default_factory=dict) - security: list[dict[str, Any]] = field(default_factory=list) - schemas: dict[str, Schema] = field(default_factory=dict) - external_docs: ExternalDoc | None = None - paths: list[Path] = field(default_factory=list) - extensions: dict[str, Any] | None = field(default_factory=dict) diff --git a/tests/builders/schema/test_anyof.py b/tests/builders/schema/test_anyof.py deleted file mode 100644 index f654727..0000000 --- a/tests/builders/schema/test_anyof.py +++ /dev/null @@ -1,178 +0,0 @@ -from typing import Any - -import pytest - -from openapi_parser.builders.schema import SchemaFactory -from openapi_parser.enumeration import DataType, IntegerFormat, StringFormat -from openapi_parser.specification import ( - AnyOf, - Array, - Boolean, - Discriminator, - Integer, - Number, - Object, - Property, - String, -) - -string_schema = String(type=DataType.STRING) -number_schema = Number(type=DataType.NUMBER) - -data_provider = ( - ( - { - "anyOf": [ - { - "type": "string", - "maxLength": 1, - "minLength": 0, - "pattern": "[0-9]", - "format": "uuid", - }, - { - "type": "integer", - "format": "int32", - }, - ], - }, - AnyOf( - type=DataType.ANY_OF, - schemas=[ - String( - type=DataType.STRING, - max_length=1, - min_length=0, - pattern="[0-9]", - format=StringFormat.UUID, - ), - Integer( - type=DataType.INTEGER, - format=IntegerFormat.INT32, - ), - ], - ), - ), - ( - { - "description": "Can be any type - string, number, integer, boolean, object and array" - }, - AnyOf( - type=DataType.ANY_OF, - schemas=[ - Integer( - type=DataType.INTEGER, - description="Can be any type - string, number, integer, boolean, object and array", - ), - Number( - type=DataType.NUMBER, - description="Can be any type - string, number, integer, boolean, object and array", - ), - String( - type=DataType.STRING, - description="Can be any type - string, number, integer, boolean, object and array", - ), - Boolean( - type=DataType.BOOLEAN, - description="Can be any type - string, number, integer, boolean, object and array", - ), - Array( - type=DataType.ARRAY, - description="Can be any type - string, number, integer, boolean, object and array", - ), - Object( - type=DataType.OBJECT, - properties=[], - description="Can be any type - string, number, integer, boolean, object and array", - ), - ], - ), - ), - ( - {"description": "Array with implicit type.", "items": {"type": "string"}}, - AnyOf( - type=DataType.ANY_OF, - schemas=[ - Integer(type=DataType.INTEGER, description="Array with implicit type."), - Number(type=DataType.NUMBER, description="Array with implicit type."), - String(type=DataType.STRING, description="Array with implicit type."), - Boolean(type=DataType.BOOLEAN, description="Array with implicit type."), - Array( - type=DataType.ARRAY, - items=string_schema, - description="Array with implicit type.", - ), - Object( - type=DataType.OBJECT, - properties=[], - description="Array with implicit type.", - ), - ], - ), - ), - ( - { - "description": "Object with implicit type.", - "properties": { - "property1": {"type": "string"}, - "property2": {"type": "number"}, - }, - }, - AnyOf( - type=DataType.ANY_OF, - schemas=[ - Integer( - type=DataType.INTEGER, - description="Object with implicit type.", - ), - Number(type=DataType.NUMBER, description="Object with implicit type."), - String(type=DataType.STRING, description="Object with implicit type."), - Boolean( - type=DataType.BOOLEAN, - description="Object with implicit type.", - ), - Array(type=DataType.ARRAY, description="Object with implicit type."), - Object( - type=DataType.OBJECT, - properties=[ - Property("property1", string_schema), - Property("property2", number_schema), - ], - description="Object with implicit type.", - ), - ], - ), - ), - ( - { - "anyOf": [ - {"type": "string"}, - {"type": "integer"}, - ], - "discriminator": { - "propertyName": "objectType", - "mapping": { - "str": "SomeTarget", - "int": "OtherTarget", - }, - }, - }, - AnyOf( - type=DataType.ANY_OF, - schemas=[ - String(type=DataType.STRING), - Integer(type=DataType.INTEGER), - ], - discriminator=Discriminator( - property_name="objectType", - mapping={"str": "SomeTarget", "int": "OtherTarget"}, - ), - ), - ), -) - - -@pytest.mark.parametrize(["data", "expected"], data_provider) -def test_anyof_builder(data: dict[str, Any], expected: AnyOf) -> None: - factory = SchemaFactory() - assert expected == factory.create(data) diff --git a/tests/builders/schema/test_array.py b/tests/builders/schema/test_array.py deleted file mode 100644 index aa106c6..0000000 --- a/tests/builders/schema/test_array.py +++ /dev/null @@ -1,45 +0,0 @@ -from typing import Any - -import pytest - -from openapi_parser.builders.schema import SchemaFactory -from openapi_parser.enumeration import DataType -from openapi_parser.specification import Array, String - -string_schema = String(type=DataType.STRING) - -data_provider = ( - ( - { - "type": "array", - "items": { - "type": "string", - }, - }, - Array(type=DataType.ARRAY, items=string_schema), - ), - ( - { - "type": "array", - "maxItems": "1", - "minItems": "0", - "uniqueItems": False, - "items": { - "type": "string", - }, - }, - Array( - type=DataType.ARRAY, - max_items=1, - min_items=0, - unique_items=False, - items=string_schema, - ), - ), -) - - -@pytest.mark.parametrize(["data", "expected"], data_provider) -def test_array_builder(data: dict[str, Any], expected: Array) -> None: - factory = SchemaFactory() - assert expected == factory.create(data) diff --git a/tests/builders/schema/test_boolean.py b/tests/builders/schema/test_boolean.py deleted file mode 100644 index 5da7a59..0000000 --- a/tests/builders/schema/test_boolean.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import Any - -import pytest - -from openapi_parser.builders.schema import SchemaFactory -from openapi_parser.enumeration import DataType -from openapi_parser.specification import Boolean - -data_provider = ( - ( - { - "type": "boolean", - }, - Boolean(type=DataType.BOOLEAN), - ), - ( - { - "type": "boolean", - "default": True, - "deprecated": False, - }, - Boolean(type=DataType.BOOLEAN, default=True, deprecated=False), - ), -) - - -@pytest.mark.parametrize(["data", "expected"], data_provider) -def test_boolean_builder(data: dict[str, Any], expected: Boolean) -> None: - factory = SchemaFactory() - assert expected == factory.create(data) diff --git a/tests/builders/schema/test_integer.py b/tests/builders/schema/test_integer.py deleted file mode 100644 index 2afa04d..0000000 --- a/tests/builders/schema/test_integer.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import Any - -import pytest - -from openapi_parser.builders.schema import SchemaFactory -from openapi_parser.enumeration import DataType, IntegerFormat -from openapi_parser.specification import Integer - -data_provider = ( - ( - { - "type": "integer", - }, - Integer(type=DataType.INTEGER), - ), - ( - { - "type": "integer", - "multipleOf": "0", - "maximum": "0", - "exclusiveMaximum": "0", - "minimum": "0", - "exclusiveMinimum": "0", - "format": "int32", - }, - Integer( - type=DataType.INTEGER, - multiple_of=0, - maximum=0, - exclusive_maximum=0, - minimum=0, - exclusive_minimum=0, - format=IntegerFormat.INT32, - ), - ), -) - - -@pytest.mark.parametrize(["data", "expected"], data_provider) -def test_integer_builder(data: dict[str, Any], expected: Integer) -> None: - factory = SchemaFactory() - assert expected == factory.create(data) diff --git a/tests/builders/schema/test_null.py b/tests/builders/schema/test_null.py deleted file mode 100644 index 25f238d..0000000 --- a/tests/builders/schema/test_null.py +++ /dev/null @@ -1,22 +0,0 @@ -from typing import Any - -import pytest - -from openapi_parser.builders.schema import SchemaFactory -from openapi_parser.enumeration import DataType -from openapi_parser.specification import Null - -data_provider = ( - ( - { - "type": "null", - }, - Null(type=DataType.NULL), - ), -) - - -@pytest.mark.parametrize(["data", "expected"], data_provider) -def test_null_builder(data: dict[str, Any], expected: Null) -> None: - factory = SchemaFactory() - assert expected == factory.create(data) diff --git a/tests/builders/schema/test_number.py b/tests/builders/schema/test_number.py deleted file mode 100644 index ffc5de7..0000000 --- a/tests/builders/schema/test_number.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import Any - -import pytest - -from openapi_parser.builders.schema import SchemaFactory -from openapi_parser.enumeration import DataType, NumberFormat -from openapi_parser.specification import Number - -data_provider = ( - ( - { - "type": "number", - }, - Number(type=DataType.NUMBER), - ), - ( - { - "type": "number", - "multipleOf": "0", - "maximum": "0", - "exclusiveMaximum": "0", - "minimum": "0", - "exclusiveMinimum": "0", - "format": "float", - }, - Number( - type=DataType.NUMBER, - multiple_of=0.0, - maximum=0.0, - exclusive_maximum=0.0, - minimum=0.0, - exclusive_minimum=0.0, - format=NumberFormat.FLOAT, - ), - ), -) - - -@pytest.mark.parametrize(["data", "expected"], data_provider) -def test_number_builder(data: dict[str, Any], expected: Number) -> None: - factory = SchemaFactory() - assert expected == factory.create(data) diff --git a/tests/builders/schema/test_object.py b/tests/builders/schema/test_object.py deleted file mode 100644 index 9906f68..0000000 --- a/tests/builders/schema/test_object.py +++ /dev/null @@ -1,70 +0,0 @@ -from typing import Any - -import pytest - -from openapi_parser.builders.schema import SchemaFactory -from openapi_parser.enumeration import DataType -from openapi_parser.specification import Object, Property, String - -string_schema = String(type=DataType.STRING) - -data_provider = ( - ( - { - "type": "object", - }, - Object(type=DataType.OBJECT), - ), - ( - { - "type": "object", - "required": ["name"], - "properties": { - "name": { - "type": "string", - }, - }, - }, - Object( - type=DataType.OBJECT, - required=["name"], - properties=[Property("name", string_schema)], - ), - ), - ( - { - "type": "object", - "additionalProperties": True, - }, - Object( - type=DataType.OBJECT, - additional_properties=True, - ), - ), - ( - { - "type": "object", - "additionalProperties": False, - }, - Object( - type=DataType.OBJECT, - additional_properties=False, - ), - ), - ( - { - "type": "object", - "additionalProperties": {"type": "string"}, - }, - Object( - type=DataType.OBJECT, - additional_properties=string_schema, - ), - ), -) - - -@pytest.mark.parametrize(["data", "expected"], data_provider) -def test_object_builder(data: dict[str, Any], expected: Object) -> None: - factory = SchemaFactory() - assert expected == factory.create(data) diff --git a/tests/builders/schema/test_oneof.py b/tests/builders/schema/test_oneof.py deleted file mode 100644 index 8306dfc..0000000 --- a/tests/builders/schema/test_oneof.py +++ /dev/null @@ -1,140 +0,0 @@ -from typing import Any - -import pytest - -from openapi_parser.builders.schema import SchemaFactory -from openapi_parser.enumeration import DataType, IntegerFormat, StringFormat -from openapi_parser.specification import ( - Discriminator, - Integer, - OneOf, - String, -) - -data_provider = ( - ( - { - "oneOf": [ - { - "type": "string", - "maxLength": 1, - "minLength": 0, - "pattern": "[0-9]", - "format": "uuid", - }, - { - "type": "integer", - "format": "int32", - }, - ], - }, - OneOf( - type=DataType.ONE_OF, - schemas=[ - String( - type=DataType.STRING, - max_length=1, - min_length=0, - pattern="[0-9]", - format=StringFormat.UUID, - ), - Integer( - type=DataType.INTEGER, - format=IntegerFormat.INT32, - ), - ], - ), - ), - ( - { - "oneOf": [ - { - "type": "string", - "maxLength": 1, - "minLength": 0, - "pattern": "[0-9]", - "format": "uuid", - }, - { - "type": "integer", - "format": "int32", - }, - ], - "discriminator": { - "propertyName": "objectType", - }, - }, - OneOf( - type=DataType.ONE_OF, - schemas=[ - String( - type=DataType.STRING, - max_length=1, - min_length=0, - pattern="[0-9]", - format=StringFormat.UUID, - ), - Integer( - type=DataType.INTEGER, - format=IntegerFormat.INT32, - ), - ], - discriminator=Discriminator( - property_name="objectType", - ), - ), - ), - ( - { - "oneOf": [ - { - "type": "string", - "maxLength": 1, - "minLength": 0, - "pattern": "[0-9]", - "format": "uuid", - }, - { - "type": "integer", - "format": "int32", - }, - ], - "discriminator": { - "propertyName": "objectType", - "mapping": { - "objectType1": "objectType1", - "objectType2": "objectType2", - }, - }, - }, - OneOf( - type=DataType.ONE_OF, - schemas=[ - String( - type=DataType.STRING, - max_length=1, - min_length=0, - pattern="[0-9]", - format=StringFormat.UUID, - ), - Integer( - type=DataType.INTEGER, - format=IntegerFormat.INT32, - ), - ], - discriminator=Discriminator( - property_name="objectType", - mapping={ - "objectType1": "objectType1", - "objectType2": "objectType2", - }, - ), - ), - ), -) - - -@pytest.mark.parametrize(["data", "expected"], data_provider) -def test_oneof_builder(data: dict[str, Any], expected: OneOf) -> None: - factory = SchemaFactory() - assert expected == factory.create(data) diff --git a/tests/builders/schema/test_schema_factory.py b/tests/builders/schema/test_schema_factory.py deleted file mode 100644 index 1b0522d..0000000 --- a/tests/builders/schema/test_schema_factory.py +++ /dev/null @@ -1,236 +0,0 @@ -from typing import Any, cast -from unittest.mock import MagicMock - -import pytest - -from openapi_parser.builders.common import extract_extension_attributes -from openapi_parser.builders.schema import ( - SchemaBuilderMethod, - SchemaFactory, - merge_all_of_schemas, -) -from openapi_parser.enumeration import DataType -from openapi_parser.errors import ParserError - - -@pytest.fixture() -def container() -> dict[DataType, MagicMock]: - return { - data_type: MagicMock() - for data_type in ( - DataType.INTEGER, - DataType.NUMBER, - DataType.STRING, - DataType.ARRAY, - DataType.OBJECT, - ) - } - - -schema_data_provider = ( - ({"type": "integer"}, DataType.INTEGER), - ({"type": "number"}, DataType.NUMBER), - ({"type": "string"}, DataType.STRING), - ({"type": "array"}, DataType.ARRAY), - ({"type": "object"}, DataType.OBJECT), -) - - -@pytest.mark.parametrize(["data", "expected_type"], schema_data_provider) -def test_create( - data: dict[str, Any], - expected_type: DataType, - container: dict[DataType, MagicMock], -) -> None: - factory = SchemaFactory() - factory._builders = cast(dict[DataType, SchemaBuilderMethod], container) - - factory.create(data) - - container[expected_type].assert_called_once() - - -def test_create_error() -> None: - data = {"type": "unsupported"} - factory = SchemaFactory() - - with pytest.raises(ParserError, match="Invalid schema type"): - factory.create(data) - - -def test_container_error() -> None: - data = {"type": "integer"} - factory = SchemaFactory() - factory._builders = {} - - with pytest.raises(ParserError, match="Unsupported schema type"): - factory.create(data) - - -merge_schemas_data_provider = ( - ( - { - "allOf": [ - { - "type": "object", - }, - { - "title": "UserDTO", - "description": "User Data Transfer Object", - }, - { - "title": "UserDTO", - "description": "Replaced Description", - }, - ], - }, - { - "type": "object", - "title": "UserDTO", - "description": "Replaced Description", - }, - ), - ( - { - "allOf": [ - { - "type": "object", - }, - { - "title": "UserDTO", - "description": "User Data Transfer Object", - }, - { - "description": "Replaced Description", - }, - { - "properties": { - "login": {"type": "string"}, - "email": {"type": "string"}, - }, - }, - { - "properties": { - "firstname": {"type": "string"}, - "lastname": {"type": "string"}, - }, - }, - ], - }, - { - "type": "object", - "title": "UserDTO", - "description": "Replaced Description", - "properties": { - "login": {"type": "string"}, - "email": {"type": "string"}, - "firstname": {"type": "string"}, - "lastname": {"type": "string"}, - }, - }, - ), - ( - { - "allOf": [ - { - "type": "object", - "title": "UserDTO", - "description": "User Data Transfer Object", - }, - { - "properties": { - "login": {"type": "string"}, - "email": {"type": "object"}, - "info": { - "type": "object", - "properties": { - "first_name": {"type": "string"}, - "last_name": {"type": "string"}, - "card": { - "type": "object", - "properties": { - "holder": {"type": "string", "required": True}, - "number": {"type": "integer", "required": True}, - }, - }, - }, - }, - }, - }, - { - "properties": { - "email": {"type": "string", "example": "john@doe.com"}, - "info": { - "properties": { - "last_name": {"type": "string", "required": True}, - "card": { - "properties": { - "cvc": {"type": "integer", "required": True}, - } - }, - }, - }, - }, - }, - ], - }, - { - "type": "object", - "title": "UserDTO", - "description": "User Data Transfer Object", - "properties": { - "login": {"type": "string"}, - "email": {"type": "string", "example": "john@doe.com"}, - "info": { - "type": "object", - "properties": { - "first_name": {"type": "string"}, - "last_name": {"type": "string", "required": True}, - "card": { - "type": "object", - "properties": { - "holder": {"type": "string", "required": True}, - "number": {"type": "integer", "required": True}, - "cvc": {"type": "integer", "required": True}, - }, - }, - }, - }, - }, - }, - ), -) - - -@pytest.mark.parametrize(["original_data", "expected"], merge_schemas_data_provider) -def test_merge_all_of_schemas( - original_data: dict[str, Any], - expected: dict[str, Any], -) -> None: - assert merge_all_of_schemas(original_data) == expected - - -extension_schema_provider = ( - ( - { - "type": "object", - "title": "Object with extension attributes", - "x-number-attribute": 123, - "x-boolean-attribute": False, - "x-object-attribute": {"key1": "value", "key2": "another value"}, - }, - { - "number_attribute": 123, - "boolean_attribute": False, - "object_attribute": {"key1": "value", "key2": "another value"}, - }, - ), -) - - -@pytest.mark.parametrize(["original_data", "expected"], extension_schema_provider) -def test_extension_attributes_extracting( - original_data: dict[str, Any], - expected: dict[str, Any], -) -> None: - assert extract_extension_attributes(original_data) == expected diff --git a/tests/builders/schema/test_string.py b/tests/builders/schema/test_string.py deleted file mode 100644 index 45b9042..0000000 --- a/tests/builders/schema/test_string.py +++ /dev/null @@ -1,45 +0,0 @@ -from typing import Any - -import pytest - -from openapi_parser.builders.schema import SchemaFactory -from openapi_parser.enumeration import DataType, StringFormat -from openapi_parser.specification import String - -data_provider = ( - ( - { - "type": "string", - }, - String(type=DataType.STRING), - ), - ( - { - "type": "string", - "maxLength": 1, - "minLength": 0, - "pattern": "[0-9]", - "format": "uuid", - }, - String( - type=DataType.STRING, - max_length=1, - min_length=0, - pattern="[0-9]", - format=StringFormat.UUID, - ), - ), - ( - { - "type": "string", - "x-custom-attr": "value", - }, - String(type=DataType.STRING, extensions={"custom_attr": "value"}), - ), -) - - -@pytest.mark.parametrize(["data", "expected"], data_provider) -def test_string_builder(data: dict[str, Any], expected: String) -> None: - factory = SchemaFactory() - assert expected == factory.create(data) diff --git a/tests/builders/test_common.py b/tests/builders/test_common.py deleted file mode 100644 index 503c3f3..0000000 --- a/tests/builders/test_common.py +++ /dev/null @@ -1,36 +0,0 @@ -import pytest - -from openapi_parser.builders.common import ( - PropertyMeta, - extract_typed_props, - merge_schema, -) -from openapi_parser.enumeration import SecurityType -from openapi_parser.errors import ParserError - - -def test_extract_typed_props_cast_failure() -> None: - attrs_map = { - "type": PropertyMeta(name="type", cast=SecurityType), - } - - with pytest.raises(ParserError, match="Invalid value for 'type' property"): - extract_typed_props({"type": "invalid_enum"}, attrs_map) - - -def test_merge_schema_list_conflict() -> None: - original = {"items": "not_a_list"} - other = {"items": [1, 2]} - - result = merge_schema(original, other) - - assert result == {"items": [1, 2]} - - -def test_merge_schema_dict_conflict() -> None: - original = {"schema": "not_a_dict"} - other = {"schema": {"type": "object"}} - - result = merge_schema(original, other) - - assert result == {"schema": {"type": "object"}} diff --git a/tests/builders/test_content_builder.py b/tests/builders/test_content_builder.py deleted file mode 100644 index 277767c..0000000 --- a/tests/builders/test_content_builder.py +++ /dev/null @@ -1,186 +0,0 @@ -from typing import Any -from unittest.mock import MagicMock - -import pytest - -from openapi_parser.builders.content import ContentBuilder -from openapi_parser.builders.encoding import EncodingBuilder -from openapi_parser.builders.schema import SchemaFactory -from openapi_parser.enumeration import ContentType, DataType -from openapi_parser.specification import Content, Encoding, Schema, String - - -def _get_schema_factory_mock(expected_value: Schema) -> SchemaFactory: - mock_object = MagicMock() - mock_object.create.return_value = expected_value - - return mock_object - - -def _get_encoding_builder_mock() -> EncodingBuilder: - mock_object = MagicMock() - mock_object.build_dict.return_value = None - - return mock_object - - -string_schema = String(type=DataType.STRING) - -collection_data_provider = ( - ( - {"application/json": {"schema": {"type": "string"}}}, - [Content(type=ContentType.JSON, schema=string_schema)], - _get_schema_factory_mock(string_schema), - ), - ( - {"text/json": {"schema": {"type": "string"}}}, - [Content(type=ContentType.JSON_TEXT, schema=string_schema)], - _get_schema_factory_mock(string_schema), - ), -) - - -@pytest.mark.parametrize( - ["data", "expected", "schema_factory"], - collection_data_provider, -) -def test_build( - data: dict[str, Any], - expected: list[Content], - schema_factory: SchemaFactory, -) -> None: - builder = ContentBuilder(schema_factory, _get_encoding_builder_mock()) - - assert expected == builder.build_list(data) - - -def test_build_empty_dict() -> None: - builder = ContentBuilder( - _get_schema_factory_mock(string_schema), - _get_encoding_builder_mock(), - ) - - assert builder.build_list({}) == [] - - -def test_build_with_example() -> None: - schema_factory = _get_schema_factory_mock(string_schema) - builder = ContentBuilder(schema_factory, _get_encoding_builder_mock()) - - result = builder.build_list( - { - "application/json": { - "schema": {"type": "string"}, - "example": "hello world", - } - } - ) - - assert len(result) == 1 - assert result[0].example == "hello world" - - -def test_build_with_examples() -> None: - schema_factory = _get_schema_factory_mock(string_schema) - builder = ContentBuilder(schema_factory, _get_encoding_builder_mock()) - examples = {"test": {"value": "hello"}} - - result = builder.build_list( - { - "application/json": { - "schema": {"type": "string"}, - "examples": examples, - } - } - ) - - assert len(result) == 1 - assert result[0].examples == examples - - -def test_build_missing_schema() -> None: - schema_factory_mock = MagicMock() - builder = ContentBuilder(schema_factory_mock, _get_encoding_builder_mock()) - - builder.build_list({"application/json": {}}) - - schema_factory_mock.create.assert_called_once_with({}) - - -def test_build_multiple_content_types() -> None: - schema_factory = MagicMock() - schema_factory.create.side_effect = [string_schema, string_schema] - builder = ContentBuilder(schema_factory, _get_encoding_builder_mock()) - - result = builder.build_list( - { - "application/json": {"schema": {"type": "string"}}, - "application/x-www-form-urlencoded": {"schema": {"type": "string"}}, - } - ) - - assert len(result) == 2 - assert result[0].type == ContentType.JSON - assert result[1].type == ContentType.FORM - - -def test_build_non_strict_enum() -> None: - schema_factory = _get_schema_factory_mock(string_schema) - builder = ContentBuilder( - schema_factory, - _get_encoding_builder_mock(), - strict_enum=False, - ) - - result = builder.build_list( - { - "application/vnd.api+json": {"schema": {"type": "string"}}, - } - ) - - assert len(result) == 1 - assert result[0].type.value == "application/vnd.api+json" - - -def test_build_with_encoding() -> None: - schema_factory = _get_schema_factory_mock(string_schema) - encoding_builder = MagicMock() - encoding_builder.build_dict.return_value = { - "name": Encoding(content_type="text/plain"), - } - builder = ContentBuilder(schema_factory, encoding_builder) - - result = builder.build_list( - { - "application/json": { - "schema": {"type": "string"}, - "encoding": { - "name": { - "contentType": "text/plain", - } - }, - } - } - ) - - assert len(result) == 1 - assert result[0].encoding == { - "name": Encoding(content_type="text/plain"), - } - encoding_builder.build_dict.assert_called_once_with( - {"name": {"contentType": "text/plain"}}, - ) - - -def test_build_without_encoding() -> None: - schema_factory = _get_schema_factory_mock(string_schema) - builder = ContentBuilder(schema_factory, _get_encoding_builder_mock()) - - result = builder.build_list( - { - "application/json": {"schema": {"type": "string"}}, - } - ) - - assert len(result) == 1 - assert result[0].encoding is None diff --git a/tests/builders/test_encoding_builder.py b/tests/builders/test_encoding_builder.py deleted file mode 100644 index 642cc46..0000000 --- a/tests/builders/test_encoding_builder.py +++ /dev/null @@ -1,109 +0,0 @@ -from typing import Any -from unittest.mock import MagicMock - -import pytest - -from openapi_parser.builders.encoding import EncodingBuilder -from openapi_parser.builders.header import HeaderBuilder -from openapi_parser.enumeration import DataType -from openapi_parser.specification import Encoding, Header, Integer - - -def _get_header_builder_mock(expected_value: list[Header]) -> HeaderBuilder: - mock_object = MagicMock() - mock_object.build_list.return_value = expected_value - - return mock_object - - -data_provider = ( - ( - { - "contentType": "text/plain", - }, - Encoding(content_type="text/plain"), - ), - ( - { - "contentType": "application/json", - "style": "form", - "explode": True, - "allowReserved": False, - }, - Encoding( - content_type="application/json", - style="form", - explode=True, - allow_reserved=False, - ), - ), -) - - -@pytest.mark.parametrize(["data", "expected"], data_provider) -def test_build(data: dict[str, Any], expected: Encoding) -> None: - builder = EncodingBuilder(_get_header_builder_mock([])) - - result = builder._build(data) - - assert expected == result - - -def test_build_with_headers() -> None: - headers = [ - Header( - name="X-Rate-Limit-Limit", - description="The number of allowed requests in the current period", - schema=Integer(type=DataType.INTEGER), - ) - ] - builder = EncodingBuilder(_get_header_builder_mock(headers)) - - result = builder._build( - { - "contentType": "application/json", - "headers": { - "X-Rate-Limit-Limit": { - "description": "The number of allowed requests in the current period", - "schema": {"type": "integer"}, - } - }, - } - ) - - assert result.content_type == "application/json" - assert result.headers == headers - - -def test_build_with_extensions() -> None: - builder = EncodingBuilder(_get_header_builder_mock([])) - - result = builder._build( - { - "contentType": "text/plain", - "x-custom-encoding": "value", - } - ) - - assert result.content_type == "text/plain" - assert result.extensions == {"custom_encoding": "value"} - - -def test_build_dict() -> None: - builder = EncodingBuilder(_get_header_builder_mock([])) - - result = builder.build_dict( - { - "name": { - "contentType": "text/plain", - }, - "email": { - "contentType": "application/json", - }, - } - ) - - assert result == { - "name": Encoding(content_type="text/plain"), - "email": Encoding(content_type="application/json"), - } diff --git a/tests/builders/test_external_doc_builder.py b/tests/builders/test_external_doc_builder.py deleted file mode 100644 index 16974c1..0000000 --- a/tests/builders/test_external_doc_builder.py +++ /dev/null @@ -1,46 +0,0 @@ -from typing import Any - -import pytest - -from openapi_parser.builders.external_doc import ExternalDocBuilder -from openapi_parser.errors import ParserError -from openapi_parser.specification import ExternalDoc - -data_provider = ( - ( - {"url": "https://example.com"}, - ExternalDoc(url="https://example.com"), - ), - ( - { - "description": "Find more info here", - "url": "https://example.com", - }, - ExternalDoc(url="https://example.com", description="Find more info here"), - ), - ( - { - "description": "Find more info here", - "url": "https://example.com", - "x-logo-url": "https://example.com/logo.png", - }, - ExternalDoc( - url="https://example.com", - description="Find more info here", - extensions={"logo_url": "https://example.com/logo.png"}, - ), - ), -) - - -@pytest.mark.parametrize(["data", "expected"], data_provider) -def test_build(data: dict[str, Any], expected: ExternalDoc) -> None: - builder = ExternalDocBuilder() - assert expected == builder.build(data) - - -def test_build_missing_url() -> None: - builder = ExternalDocBuilder() - - with pytest.raises(ParserError, match="missing required 'url' property"): - builder.build({"description": "test"}) diff --git a/tests/builders/test_header_builder.py b/tests/builders/test_header_builder.py deleted file mode 100644 index b8e076c..0000000 --- a/tests/builders/test_header_builder.py +++ /dev/null @@ -1,90 +0,0 @@ -from typing import Any -from unittest.mock import MagicMock - -import pytest - -from openapi_parser.builders.header import HeaderBuilder -from openapi_parser.builders.schema import SchemaFactory -from openapi_parser.enumeration import DataType -from openapi_parser.specification import Header, Integer, Schema, String - - -def _get_schema_factory_mock(expected_value: Schema) -> SchemaFactory: - mock_object = MagicMock() - mock_object.create.return_value = expected_value - - return mock_object - - -string_schema = String(type=DataType.STRING) -integer_schema = Integer(type=DataType.INTEGER) - -collection_data_provider = ( - ( - {"X-Header": {"schema": {"type": "string"}}}, - [ - Header(schema=string_schema, name="X-Header"), - ], - _get_schema_factory_mock(string_schema), - ), - ( - { - "X-Header": { - "description": "The number of allowed requests in the current period", - "required": True, - "deprecated": True, - "schema": { - "type": "integer", - }, - } - }, - [ - Header( - name="X-Header", - required=True, - description="The number of allowed requests in the current period", - deprecated=True, - schema=integer_schema, - ) - ], - _get_schema_factory_mock(integer_schema), - ), - ( - { - "X-Header": { - "description": "The number of allowed requests in the current period", - "required": True, - "deprecated": True, - "schema": { - "type": "integer", - }, - "x-custom-go-tag": 'json:"x-header"', - } - }, - [ - Header( - name="X-Header", - required=True, - description="The number of allowed requests in the current period", - deprecated=True, - schema=integer_schema, - extensions={"custom_go_tag": 'json:"x-header"'}, - ) - ], - _get_schema_factory_mock(integer_schema), - ), -) - - -@pytest.mark.parametrize( - ["data", "expected", "schema_factory"], - collection_data_provider, -) -def test_build_collection( - data: dict[str, Any], - expected: list[Header], - schema_factory: SchemaFactory, -) -> None: - builder = HeaderBuilder(schema_factory) - - assert expected == builder.build_list(data) diff --git a/tests/builders/test_info_builder.py b/tests/builders/test_info_builder.py deleted file mode 100644 index 7a88083..0000000 --- a/tests/builders/test_info_builder.py +++ /dev/null @@ -1,85 +0,0 @@ -from typing import Any - -import pytest - -from openapi_parser.builders.info import InfoBuilder -from openapi_parser.errors import ParserError -from openapi_parser.specification import Contact, Info, License - -data_provider = ( - ( - {"title": "Sample Pet Store App", "version": "1.0.1"}, - Info(title="Sample Pet Store App", version="1.0.1"), - ), - ( - { - "title": "Sample Pet Store App", - "version": "1.0.1", - "x-rnd-team": "super team", - }, - Info( - title="Sample Pet Store App", - version="1.0.1", - extensions={"rnd_team": "super team"}, - ), - ), - ( - { - "title": "Sample Pet Store App", - "description": "This is a sample server for a pet store.", - "termsOfService": "http://example.com/terms/", - "contact": { - "name": "API Support", - "url": "http://www.example.com/support", - "email": "support@example.com", - }, - "license": { - "name": "Apache 2.0", - "url": "https://www.apache.org/licenses/LICENSE-2.0.html", - }, - "version": "1.0.1", - }, - Info( - title="Sample Pet Store App", - version="1.0.1", - description="This is a sample server for a pet store.", - terms_of_service="http://example.com/terms/", - contact=Contact( - name="API Support", - url="http://www.example.com/support", - email="support@example.com", - ), - license=License( - name="Apache 2.0", - url="https://www.apache.org/licenses/LICENSE-2.0.html", - ), - ), - ), -) - - -@pytest.mark.parametrize(["data", "expected"], data_provider) -def test_build(data: dict[str, Any], expected: Info) -> None: - builder = InfoBuilder() - - assert expected == builder.build(data) - - -def test_build_missing_title() -> None: - builder = InfoBuilder() - - with pytest.raises(ParserError, match="missing required 'title' property"): - builder.build({"version": "1.0.0"}) - - -def test_build_missing_license_name() -> None: - builder = InfoBuilder() - - with pytest.raises(ParserError, match="missing required 'name' property"): - builder.build( - { - "title": "Test", - "version": "1.0", - "license": {"url": "https://example.com"}, - } - ) diff --git a/tests/builders/test_link_builder.py b/tests/builders/test_link_builder.py deleted file mode 100644 index 09da922..0000000 --- a/tests/builders/test_link_builder.py +++ /dev/null @@ -1,92 +0,0 @@ -from typing import Any - -import pytest - -from openapi_parser.builders.link import LinkBuilder -from openapi_parser.specification import Link, Server - -data_provider = ( - ( - { - "operationId": "getUser", - "parameters": { - "userId": "{$request.body#/id}", - }, - }, - Link( - operation_id="getUser", - parameters={"userId": "{$request.body#/id}"}, - ), - ), - ( - { - "operationRef": "#/paths/~1users~1{userId}/get", - "description": "Get user details", - }, - Link( - operation_ref="#/paths/~1users~1{userId}/get", - description="Get user details", - ), - ), - ( - { - "operationId": "getUser", - "requestBody": "{$request.body#/id}", - "server": { - "url": "https://example.com/api", - }, - }, - Link( - operation_id="getUser", - request_body="{$request.body#/id}", - server=Server(url="https://example.com/api"), - ), - ), -) - - -@pytest.mark.parametrize(["data", "expected"], data_provider) -def test_build(data: dict[str, Any], expected: Link) -> None: - builder = LinkBuilder() - - result = builder._build(data) - - assert expected == result - - -def test_build_with_extensions() -> None: - builder = LinkBuilder() - - result = builder._build( - { - "operationId": "getUser", - "x-custom-link": "value", - } - ) - - assert result.operation_id == "getUser" - assert result.extensions == {"custom_link": "value"} - - -def test_build_dict() -> None: - builder = LinkBuilder() - - result = builder.build_dict( - { - "getUserById": { - "operationId": "getUser", - "parameters": {"userId": "{$request.body#/id}"}, - }, - "getUserByEmail": { - "operationId": "getUserByEmail", - }, - } - ) - - assert result == { - "getUserById": Link( - operation_id="getUser", - parameters={"userId": "{$request.body#/id}"}, - ), - "getUserByEmail": Link(operation_id="getUserByEmail"), - } diff --git a/tests/builders/test_not.py b/tests/builders/test_not.py deleted file mode 100644 index 0abf158..0000000 --- a/tests/builders/test_not.py +++ /dev/null @@ -1,75 +0,0 @@ -from typing import Any - -import pytest - -from openapi_parser.builders.schema import SchemaFactory -from openapi_parser.enumeration import DataType -from openapi_parser.specification import ( - AnyOf, - Array, - Boolean, - Integer, - Number, - Object, - Property, - Schema, - String, -) - -data_provider = ( - ( - { - "type": "string", - "not": {"type": "integer"}, - }, - String( - type=DataType.STRING, - not_schema=Integer(type=DataType.INTEGER), - ), - ), - ( - { - "type": "object", - "properties": { - "name": {"type": "string"}, - }, - "not": {"type": "integer"}, - }, - Object( - type=DataType.OBJECT, - properties=[ - Property(name="name", schema=String(type=DataType.STRING)), - ], - not_schema=Integer(type=DataType.INTEGER), - ), - ), - ( - { - "not": {"type": "integer"}, - }, - AnyOf( - type=DataType.ANY_OF, - schemas=[ - Integer(type=DataType.INTEGER), - Number(type=DataType.NUMBER), - String(type=DataType.STRING), - Boolean(type=DataType.BOOLEAN), - Array(type=DataType.ARRAY), - Object(type=DataType.OBJECT), - ], - not_schema=Integer(type=DataType.INTEGER), - ), - ), - ( - { - "type": "string", - }, - String(type=DataType.STRING), - ), -) - - -@pytest.mark.parametrize(["data", "expected"], data_provider) -def test_not_builder(data: dict[str, Any], expected: Schema) -> None: - factory = SchemaFactory() - assert expected == factory.create(data) diff --git a/tests/builders/test_oauth_flow_builder.py b/tests/builders/test_oauth_flow_builder.py deleted file mode 100644 index fc60571..0000000 --- a/tests/builders/test_oauth_flow_builder.py +++ /dev/null @@ -1,76 +0,0 @@ -from typing import Any - -import pytest - -from openapi_parser.builders.oauth_flow import OAuthFlowBuilder -from openapi_parser.enumeration import OAuthFlowType -from openapi_parser.specification import OAuthFlow - -data_provider = ( - ( - { - "clientCredentials": { - "authorizationUrl": "https://example.com/api/oauth/dialog", - "refreshUrl": "https://example.com/api/oauth/dialog", - "tokenUrl": "https://example.com/api/oauth/dialog", - "x-state": "some data to be passed to oath server", - }, - }, - { - OAuthFlowType.CLIENT_CREDENTIALS: OAuthFlow( - authorization_url="https://example.com/api/oauth/dialog", - refresh_url="https://example.com/api/oauth/dialog", - token_url="https://example.com/api/oauth/dialog", - extensions={"state": "some data to be passed to oath server"}, - ), - }, - ), - ( - { - "implicit": { - "authorizationUrl": "https://example.com/api/oauth/dialog", - "scopes": { - "write:pets": "modify pets in your account", - "read:pets": "read your pets", - }, - }, - "authorizationCode": { - "authorizationUrl": "https://example.com/api/oauth/dialog", - "tokenUrl": "https://example.com/api/oauth/token", - "scopes": { - "write:pets": "modify pets in your account", - "read:pets": "read your pets", - }, - }, - "x-customFlow": {"custom_attribute": "custom value"}, - }, - { - OAuthFlowType.IMPLICIT: OAuthFlow( - authorization_url="https://example.com/api/oauth/dialog", - scopes={ - "write:pets": "modify pets in your account", - "read:pets": "read your pets", - }, - ), - OAuthFlowType.AUTHORIZATION_CODE: OAuthFlow( - authorization_url="https://example.com/api/oauth/dialog", - token_url="https://example.com/api/oauth/token", - scopes={ - "write:pets": "modify pets in your account", - "read:pets": "read your pets", - }, - ), - "customFlow": {"custom_attribute": "custom value"}, - }, - ), -) - - -@pytest.mark.parametrize(["data", "expected"], data_provider) -def test_oauth_flow_builder( - data: dict[str, Any], - expected: dict[OAuthFlowType, OAuthFlow], -) -> None: - builder = OAuthFlowBuilder() - - assert builder.build_collection(data) == expected diff --git a/tests/builders/test_operation_builder.py b/tests/builders/test_operation_builder.py deleted file mode 100644 index 4e3a35c..0000000 --- a/tests/builders/test_operation_builder.py +++ /dev/null @@ -1,291 +0,0 @@ -from typing import Any -from unittest.mock import MagicMock - -import pytest - -from openapi_parser.builders.external_doc import ExternalDocBuilder -from openapi_parser.builders.operation import OperationBuilder -from openapi_parser.builders.parameter import ParameterBuilder -from openapi_parser.builders.request import RequestBuilder -from openapi_parser.builders.response import ResponseBuilder -from openapi_parser.enumeration import ( - ContentType, - DataType, - OperationMethod, - ParameterLocation, -) -from openapi_parser.specification import ( - Content, - ExternalDoc, - Object, - Operation, - Parameter, - Property, - RequestBody, - Response, - String, -) - - -def _get_builder_mock(expected: Any) -> MagicMock: - mock_object = MagicMock() - mock_object.build.return_value = expected - - return mock_object - - -def _get_list_builder_mock(expected: Any) -> MagicMock: - mock_object = MagicMock() - mock_object.build_list.return_value = expected - - return mock_object - - -response_schema = Response( - code=200, - description="Pet updated.", - content=[Content(type=ContentType.JSON, schema=Object(type=DataType.OBJECT))], - is_default=False, -) - -parameter_list = [ - Parameter( - name="petId", - location=ParameterLocation.PATH, - description="ID of pet that needs to be updated", - required=True, - schema=String(type=DataType.STRING), - ) -] - -external_doc = ExternalDoc(description="Find more info here", url="https://example.com") - -request_body = RequestBody( - content=[ - Content( - type=ContentType.FORM, - schema=Object( - type=DataType.OBJECT, - required=["status"], - properties=[ - Property( - name="name", - schema=String( - type=DataType.STRING, - description="Updated name of the pet", - ), - ), - Property( - name="status", - schema=String( - type=DataType.STRING, - description="Updated status of the pet", - ), - ), - ], - ), - ), - ] -) - -data_provider = ( - ( - { - "responses": { - "200": { - "description": "Pet updated.", - "content": { - "application/json": { - "schema": { - "type": "object", - } - }, - }, - }, - }, - }, - Operation( - responses=[response_schema], - method=OperationMethod.GET, - ), - _get_builder_mock(response_schema), - _get_builder_mock(None), - _get_builder_mock(None), - _get_list_builder_mock(None), - ), - ( - { - "x-python-method-name": "some_method_name", - "responses": { - "200": { - "description": "Pet updated.", - "content": { - "application/json": { - "schema": { - "type": "object", - } - }, - }, - }, - }, - }, - Operation( - responses=[response_schema], - method=OperationMethod.GET, - extensions={"python_method_name": "some_method_name"}, - ), - _get_builder_mock(response_schema), - _get_builder_mock(None), - _get_builder_mock(None), - _get_list_builder_mock(None), - ), - ( - { - "responses": { - "200": { - "description": "Pet updated.", - "content": { - "application/json": { - "schema": { - "type": "object", - } - }, - }, - }, - }, - "tags": [ - "pet", - ], - "security": [{"Basic": []}], - "summary": "Updates a pet in the store with form data", - "operationId": "updatePetWithForm", - "parameters": [ - { - "name": "petId", - "in": "path", - "description": "ID of pet that needs to be updated", - "required": True, - "schema": {"type": "string"}, - } - ], - "requestBody": { - "content": { - "application/x-www-form-urlencoded": { - "schema": { - "type": "object", - "properties": { - "name": { - "description": "Updated name of the pet", - "type": "string", - }, - "status": { - "description": "Updated status of the pet", - "type": "string", - }, - }, - "required": ["status"], - } - } - } - }, - "externalDocs": { - "description": "Find more info here", - "url": "https://example.com", - }, - }, - Operation( - method=OperationMethod.GET, - responses=[response_schema], - tags=["pet"], - security=[{"Basic": []}], - summary="Updates a pet in the store with form data", - operation_id="updatePetWithForm", - parameters=parameter_list, - request_body=request_body, - external_docs=external_doc, - ), - _get_builder_mock(response_schema), - _get_builder_mock(external_doc), - _get_builder_mock(request_body), - _get_list_builder_mock(parameter_list), - ), - ( - { - "responses": { - "200": { - "description": "Pet updated.", - "content": { - "application/json": { - "schema": { - "type": "object", - } - }, - }, - }, - }, - "callbacks": { - "myCallback": { - "{$request.body#/callbackUrl}": { - "get": { - "responses": { - "200": { - "description": "Callback response", - } - } - } - } - } - }, - }, - Operation( - responses=[response_schema], - method=OperationMethod.GET, - callbacks={ - "myCallback": { - "{$request.body#/callbackUrl}": { - "get": { - "responses": { - "200": { - "description": "Callback response", - } - } - } - } - } - }, - ), - _get_builder_mock(response_schema), - _get_builder_mock(None), - _get_builder_mock(None), - _get_list_builder_mock(None), - ), -) - - -@pytest.mark.parametrize( - [ - "data", - "expected_operation", - "response_builder", - "external_doc_builder", - "request_builder", - "parameter_builder", - ], - data_provider, -) -def test_build( - data: dict[str, Any], - expected_operation: Operation, - response_builder: ResponseBuilder, - external_doc_builder: ExternalDocBuilder, - request_builder: RequestBuilder, - parameter_builder: ParameterBuilder, -) -> None: - builder = OperationBuilder( - response_builder, - external_doc_builder, - request_builder, - parameter_builder, - ) - - assert expected_operation == builder.build(OperationMethod.GET, data) diff --git a/tests/builders/test_parameter_builder.py b/tests/builders/test_parameter_builder.py deleted file mode 100644 index 4fc0abd..0000000 --- a/tests/builders/test_parameter_builder.py +++ /dev/null @@ -1,356 +0,0 @@ -from typing import Any -from unittest.mock import MagicMock - -import pytest - -from openapi_parser.builders.content import ContentBuilder -from openapi_parser.builders.parameter import ParameterBuilder -from openapi_parser.builders.schema import SchemaFactory -from openapi_parser.enumeration import ( - ContentType, - DataType, - HeaderParameterStyle, - ParameterLocation, - QueryParameterStyle, -) -from openapi_parser.errors import ParserError -from openapi_parser.specification import Content, Parameter, Schema, String - - -def _get_schema_factory_mock(expected_value: Schema | None) -> SchemaFactory: - mock_object = MagicMock() - mock_object.create.return_value = expected_value - - return mock_object - - -def _get_content_builder_mock( - expected_value: list[Content] | None, -) -> ContentBuilder: - mock_object = MagicMock() - mock_object.build_list.return_value = expected_value - - return mock_object - - -string_schema = String(type=DataType.STRING) -content_schema = Content( - type=ContentType.JSON, - schema=string_schema, -) - -schema_data_provider = ( - ( - { - "name": "token", - "in": "header", - "required": True, - "style": "simple", - "schema": { - "type": "string", - }, - }, - Parameter( - name="token", - location=ParameterLocation.HEADER, - required=True, - style=HeaderParameterStyle.SIMPLE, - schema=string_schema, - explode=False, - ), - _get_schema_factory_mock(string_schema), - _get_content_builder_mock(None), - ), - ( - { - "name": "token", - "in": "header", - "required": True, - "description": "token to be passed as a header", - "deprecated": True, - "schema": { - "type": "string", - }, - }, - Parameter( - name="token", - location=ParameterLocation.HEADER, - required=True, - description="token to be passed as a header", - deprecated=True, - schema=string_schema, - style=HeaderParameterStyle.SIMPLE, - explode=False, - ), - _get_schema_factory_mock(string_schema), - _get_content_builder_mock(None), - ), - ( - { - "name": "tokensImplodedString", - "in": "query", - "required": True, - "style": "form", - "schema": { - "type": "string", - }, - }, - Parameter( - name="tokensImplodedString", - location=ParameterLocation.QUERY, - required=True, - style=QueryParameterStyle.FORM, - explode=True, - schema=string_schema, - ), - _get_schema_factory_mock(string_schema), - _get_content_builder_mock(None), - ), - ( - { - "name": "some_id", - "in": "query", - "required": True, - "style": "form", - "schema": { - "type": "string", - }, - "x-custom-go-tag": 'binding:"required"', - }, - Parameter( - name="some_id", - location=ParameterLocation.QUERY, - required=True, - style=QueryParameterStyle.FORM, - explode=True, - schema=string_schema, - extensions={"custom_go_tag": 'binding:"required"'}, - ), - _get_schema_factory_mock(string_schema), - _get_content_builder_mock(None), - ), - ( - { - "name": "limit", - "in": "query", - "required": True, - "schema": { - "type": "integer", - }, - "example": 10, - }, - Parameter( - name="limit", - location=ParameterLocation.QUERY, - required=True, - schema=string_schema, - style=QueryParameterStyle.FORM, - explode=True, - example=10, - ), - _get_schema_factory_mock(string_schema), - _get_content_builder_mock(None), - ), - ( - { - "name": "q", - "in": "query", - "required": True, - "allowReserved": True, - "schema": { - "type": "string", - }, - }, - Parameter( - name="q", - location=ParameterLocation.QUERY, - required=True, - allow_reserved=True, - schema=string_schema, - style=QueryParameterStyle.FORM, - explode=True, - ), - _get_schema_factory_mock(string_schema), - _get_content_builder_mock(None), - ), - ( - { - "name": "filter", - "in": "query", - "required": False, - "schema": { - "type": "string", - }, - "examples": { - "foo": { - "summary": "A foo example", - "value": {"foo": "bar"}, - }, - "bar": { - "summary": "A bar example", - "value": {"bar": "baz"}, - }, - }, - }, - Parameter( - name="filter", - location=ParameterLocation.QUERY, - required=False, - schema=string_schema, - style=QueryParameterStyle.FORM, - explode=True, - examples={ - "foo": { - "summary": "A foo example", - "value": {"foo": "bar"}, - }, - "bar": { - "summary": "A bar example", - "value": {"bar": "baz"}, - }, - }, - ), - _get_schema_factory_mock(string_schema), - _get_content_builder_mock(None), - ), -) - -collection_data_provider = ( - ( - [ - { - "name": "token", - "in": "header", - "required": True, - "schema": { - "type": "string", - }, - }, - { - "name": "token", - "in": "header", - "required": True, - "description": "token to be passed as a header", - "deprecated": True, - "schema": { - "type": "string", - }, - }, - ], - [ - Parameter( - name="token", - location=ParameterLocation.HEADER, - required=True, - schema=string_schema, - style=HeaderParameterStyle.SIMPLE, - explode=False, - ), - Parameter( - name="token", - location=ParameterLocation.HEADER, - required=True, - description="token to be passed as a header", - deprecated=True, - schema=string_schema, - style=HeaderParameterStyle.SIMPLE, - explode=False, - ), - ], - _get_schema_factory_mock(string_schema), - _get_content_builder_mock(None), - ), - ( - [ - { - "name": "content-token", - "in": "header", - "required": True, - "content": { - "application/json": { - "schema": { - "type": "string", - }, - } - }, - }, - { - "name": "schema-token", - "in": "header", - "required": True, - "description": "token to be passed as a header", - "deprecated": True, - "schema": { - "type": "string", - }, - }, - ], - [ - Parameter( - name="content-token", - location=ParameterLocation.HEADER, - required=True, - content=[ - Content( - type=ContentType.JSON, - schema=string_schema, - ) - ], - style=HeaderParameterStyle.SIMPLE, - explode=False, - ), - Parameter( - name="schema-token", - location=ParameterLocation.HEADER, - required=True, - description="token to be passed as a header", - deprecated=True, - schema=string_schema, - style=HeaderParameterStyle.SIMPLE, - explode=False, - ), - ], - _get_schema_factory_mock(string_schema), - _get_content_builder_mock([content_schema]), - ), -) - - -@pytest.mark.parametrize( - ["data", "expected", "schema_factory", "content_builder"], - schema_data_provider, -) -def test_build( - data: dict[str, Any], - expected: Parameter, - schema_factory: SchemaFactory, - content_builder: ContentBuilder, -) -> None: - builder = ParameterBuilder(schema_factory, content_builder) - - assert expected == builder.build(data) - - -def test_build_missing_name() -> None: - builder = ParameterBuilder( - _get_schema_factory_mock(None), - _get_content_builder_mock(None), - ) - - with pytest.raises(ParserError, match="missing required 'name' property"): - builder.build({"in": "header"}) - - -@pytest.mark.parametrize( - ["data_list", "expected", "schema_factory", "content_builder"], - collection_data_provider, -) -def test_build_collection( - data_list: list[Any], - expected: list[Parameter], - schema_factory: SchemaFactory, - content_builder: ContentBuilder, -) -> None: - builder = ParameterBuilder(schema_factory, content_builder) - - assert expected == builder.build_list(data_list) diff --git a/tests/builders/test_path_builder.py b/tests/builders/test_path_builder.py deleted file mode 100644 index 72559f3..0000000 --- a/tests/builders/test_path_builder.py +++ /dev/null @@ -1,214 +0,0 @@ -import copy -from typing import Any -from unittest.mock import MagicMock - -import pytest - -from openapi_parser.builders.operation import OperationBuilder -from openapi_parser.builders.parameter import ParameterBuilder -from openapi_parser.builders.path import PathBuilder -from openapi_parser.enumeration import ( - ContentType, - DataType, - OperationMethod, - ParameterLocation, -) -from openapi_parser.specification import ( - Array, - Content, - Operation, - Parameter, - Path, - Response, - String, -) - - -def _get_builder_mock(expected_value: Any) -> MagicMock: - mock_object = MagicMock() - mock_object.build.return_value = expected_value - - return mock_object - - -def _get_builder_list_mock(expected_value: Any) -> MagicMock: - mock_object = MagicMock() - mock_object.build_list.return_value = expected_value - - return mock_object - - -array_schema = Array(type=DataType.ARRAY, items=String(type=DataType.STRING)) - -parameters_list = [ - Parameter( - name="id", - location=ParameterLocation.PATH, - description="ID of pet to use", - required=True, - schema=array_schema, - ) -] - -operation_object = Operation( - method=OperationMethod.GET, - description="Returns pets based on ID", - summary="Find pets by ID", - operation_id="getPetsById", - responses=[ - Response( - code=200, - description="pet response", - content=[Content(type=ContentType.JSON, schema=array_schema)], - is_default=False, - ) - ], -) - -expected_operation_object = copy.deepcopy(operation_object) - - -def add_parameters_to_operation( - operation: Operation, - parameters: list[Parameter], -) -> Operation: - operation_copy = copy.deepcopy(operation) - object.__setattr__(operation_copy, "parameters", parameters) - return operation_copy - - -data_provider = ( - ( - { - "/pets": { - "get": { - "description": "Returns pets based on ID", - "summary": "Find pets by ID", - "operationId": "getPetsById", - "responses": { - "200": { - "description": "pet response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string", - }, - } - } - }, - }, - }, - }, - } - }, - [Path(url="/pets", operations=[expected_operation_object])], - _get_builder_mock(operation_object), - _get_builder_list_mock(None), - ), - ( - { - "/pets": { - "x-python-class": "Pet", - "get": { - "description": "Returns pets based on ID", - "summary": "Find pets by ID", - "operationId": "getPetsById", - "responses": { - "200": { - "description": "pet response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string", - }, - } - } - }, - }, - }, - }, - } - }, - [ - Path( - url="/pets", - operations=[expected_operation_object], - extensions={"python_class": "Pet"}, - ) - ], - _get_builder_mock(operation_object), - _get_builder_list_mock(None), - ), - ( - { - "/pets/{id}": { - "summary": "Summary description", - "description": "Long description", - "get": { - "description": "Returns pets based on ID", - "summary": "Find pets by ID", - "operationId": "getPetsById", - "responses": { - "200": { - "description": "pet response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string", - }, - } - } - }, - }, - }, - }, - "parameters": [ - { - "name": "id", - "in": "path", - "description": "ID of pet to use", - "required": True, - "schema": {"type": "array", "items": {"type": "string"}}, - } - ], - } - }, - [ - Path( - url="/pets/{id}", - summary="Summary description", - description="Long description", - parameters=parameters_list, - operations=[ - add_parameters_to_operation( - expected_operation_object, - parameters_list, - ) - ], - ) - ], - _get_builder_mock(operation_object), - _get_builder_list_mock(parameters_list), - ), -) - - -@pytest.mark.parametrize( - ["data", "expected", "operation_builder", "parameter_builder"], - data_provider, -) -def test_build( - data: dict[str, Any], - expected: list[Path], - operation_builder: OperationBuilder, - parameter_builder: ParameterBuilder, -) -> None: - builder = PathBuilder(operation_builder, parameter_builder) - - assert expected == builder.build_list(data) diff --git a/tests/builders/test_request_builder.py b/tests/builders/test_request_builder.py deleted file mode 100644 index f6a3e52..0000000 --- a/tests/builders/test_request_builder.py +++ /dev/null @@ -1,114 +0,0 @@ -from typing import Any -from unittest.mock import MagicMock - -import pytest - -from openapi_parser.builders.content import ContentBuilder -from openapi_parser.builders.request import RequestBuilder -from openapi_parser.enumeration import ContentType, DataType -from openapi_parser.specification import ( - Content, - Object, - Property, - RequestBody, - String, -) - - -def _get_content_builder_mock(expected_value: Any) -> ContentBuilder: - mock_object = MagicMock() - mock_object.build_list.return_value = expected_value - - return mock_object - - -content_schema = [ - Content( - type=ContentType.JSON, - schema=Object( - type=DataType.OBJECT, - properties=[Property(name="login", schema=String(type=DataType.STRING))], - ), - ) -] - -extended_content_schema = [ - Content( - type=ContentType.JSON, - schema=Object( - type=DataType.OBJECT, - properties=[Property(name="login", schema=String(type=DataType.STRING))], - ), - ), - Content( - type=ContentType.FORM, - schema=Object( - type=DataType.OBJECT, - properties=[Property(name="login", schema=String(type=DataType.STRING))], - ), - ), -] - -data_provider = ( - ( - { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "login": { - "type": "string", - } - }, - }, - }, - } - }, - RequestBody(content=content_schema), - _get_content_builder_mock(content_schema), - ), - ( - { - "description": "user to add to the system", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "login": { - "type": "string", - } - }, - }, - }, - "application/x-www-form-urlencoded": { - "schema": { - "type": "object", - "properties": { - "login": { - "type": "string", - } - }, - }, - }, - }, - }, - RequestBody( - description="user to add to the system", - content=extended_content_schema, - ), - _get_content_builder_mock(extended_content_schema), - ), -) - - -@pytest.mark.parametrize(["data", "expected", "content_builder"], data_provider) -def test_build( - data: dict[str, Any], - expected: RequestBody, - content_builder: ContentBuilder, -) -> None: - builder = RequestBuilder(content_builder) - - assert expected == builder.build(data) diff --git a/tests/builders/test_response_builder.py b/tests/builders/test_response_builder.py deleted file mode 100644 index 05b932a..0000000 --- a/tests/builders/test_response_builder.py +++ /dev/null @@ -1,167 +0,0 @@ -from typing import Any -from unittest.mock import MagicMock - -import pytest - -from openapi_parser.builders.content import ContentBuilder -from openapi_parser.builders.header import HeaderBuilder -from openapi_parser.builders.link import LinkBuilder -from openapi_parser.builders.response import ResponseBuilder -from openapi_parser.enumeration import ContentType, DataType -from openapi_parser.specification import ( - Content, - Header, - Integer, - Object, - Property, - Response, - String, -) - - -def _get_content_builder_mock(expected_value: Any) -> ContentBuilder: - mock_object = MagicMock() - mock_object.build_list.return_value = expected_value - - return mock_object - - -def _get_header_builder_mock(expected_value: Any) -> HeaderBuilder: - mock_object = MagicMock() - mock_object.build_list.return_value = expected_value - - return mock_object - - -def _get_link_builder_mock() -> LinkBuilder: - mock_object = MagicMock() - mock_object.build_dict.return_value = None - - return mock_object - - -content_schema = [ - Content( - type=ContentType.JSON, - schema=Object( - type=DataType.OBJECT, - properties=[Property(name="login", schema=String(type=DataType.STRING))], - ), - ) -] - -header_schema = [ - Header( - name="X-Rate-Limit-Limit", - description="The number of allowed requests in the current period", - schema=Integer(type=DataType.INTEGER), - ) -] - -data_provider = ( - ( - { - "description": "A string response", - "content": { - "application/json": { - "schema": { - "type": "string", - }, - "example": "an example", - } - }, - "headers": { - "X-Rate-Limit-Limit": { - "description": "The number of allowed requests in the current period", - "schema": {"type": "integer"}, - } - }, - }, - Response( - code=200, - description="A string response", - content=content_schema, - headers=header_schema, - is_default=False, - ), - _get_content_builder_mock(content_schema), - _get_header_builder_mock(header_schema), - ), -) - - -@pytest.mark.parametrize( - ["data", "expected", "content_builder", "header_builder"], - data_provider, -) -def test_build( - data: dict[str, Any], - expected: Response, - content_builder: ContentBuilder, - header_builder: HeaderBuilder, -) -> None: - builder = ResponseBuilder( - content_builder, - header_builder, - _get_link_builder_mock(), - ) - - assert expected.code is not None - assert expected == builder.build(expected.code, data) - - -def test_build_default_response() -> None: - builder = ResponseBuilder( - _get_content_builder_mock(None), - _get_header_builder_mock(None), - _get_link_builder_mock(), - ) - - response_data = {"description": "A string response"} - actual = builder.build("default", response_data) - - assert actual.is_default - assert actual.code is None - - -def test_build_no_content_or_headers() -> None: - builder = ResponseBuilder( - _get_content_builder_mock(None), - _get_header_builder_mock(None), - _get_link_builder_mock(), - ) - - actual = builder.build(200, {"description": "No content response"}) - - assert actual.code == 200 - assert actual.description == "No content response" - assert not actual.is_default - assert actual.content is None - assert actual.headers == [] - - -def test_build_with_code_as_string() -> None: - builder = ResponseBuilder( - _get_content_builder_mock([]), - _get_header_builder_mock([]), - _get_link_builder_mock(), - ) - - actual = builder.build("404", {"description": "Not found"}) - - assert actual.code == 404 - assert not actual.is_default - - -@pytest.mark.parametrize("code", [201, 204, 301, 400, 404, 500]) -def test_build_with_various_codes(code: int) -> None: - builder = ResponseBuilder( - _get_content_builder_mock(None), - _get_header_builder_mock(None), - _get_link_builder_mock(), - ) - - actual = builder.build(code, {"description": f"Response {code}"}) - - assert actual.code == code - assert actual.description == f"Response {code}" diff --git a/tests/builders/test_security_builder.py b/tests/builders/test_security_builder.py deleted file mode 100644 index e520d33..0000000 --- a/tests/builders/test_security_builder.py +++ /dev/null @@ -1,194 +0,0 @@ -from typing import Any -from unittest import mock - -import pytest - -from openapi_parser.builders.oauth_flow import OAuthFlowBuilder -from openapi_parser.builders.security import SecurityBuilder -from openapi_parser.enumeration import ( - AuthenticationScheme, - BaseLocation, - OAuthFlowType, - SecurityType, -) -from openapi_parser.specification import OAuthFlow, Security - - -def _get_oauth_flow_builder_mock(expected: Any) -> OAuthFlowBuilder: - mock_object = mock.MagicMock() - mock_object.build_collection.return_value = expected - - return mock_object - - -flows_mock = { - OAuthFlowType.IMPLICIT: OAuthFlow( - authorization_url="https://example.com/api/oauth/dialog", - scopes={ - "write:pets": "modify pets in your account", - "read:pets": "read your pets", - }, - ), - OAuthFlowType.AUTHORIZATION_CODE: OAuthFlow( - authorization_url="https://example.com/api/oauth/dialog", - token_url="https://example.com/api/oauth/token", - scopes={ - "write:pets": "modify pets in your account", - "read:pets": "read your pets", - }, - ), -} - -data_provider = ( - ( - {"type": "http", "scheme": "basic"}, - Security(type=SecurityType.HTTP, scheme=AuthenticationScheme.BASIC), - _get_oauth_flow_builder_mock(None), - ), - ( - { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT", - }, - Security( - type=SecurityType.HTTP, - scheme=AuthenticationScheme.BEARER, - bearer_format="JWT", - ), - _get_oauth_flow_builder_mock(None), - ), - ( - { - "type": "openIdConnect", - "openIdConnectUrl": "https://example.com/api/openid", - }, - Security( - type=SecurityType.OPEN_ID_CONNECT, - url="https://example.com/api/openid", - ), - _get_oauth_flow_builder_mock(None), - ), - ( - { - "type": "apiKey", - "in": "header", - }, - Security( - type=SecurityType.API_KEY, - location=BaseLocation.HEADER, - ), - _get_oauth_flow_builder_mock(None), - ), - ( - { - "type": "apiKey", - "name": "api_key", - "in": "header", - "description": "authorization key to communicate with API", - }, - Security( - type=SecurityType.API_KEY, - location=BaseLocation.HEADER, - name="api_key", - description="authorization key to communicate with API", - ), - _get_oauth_flow_builder_mock(None), - ), - ( - { - "type": "apiKey", - "name": "Authorization", - "in": "header", - "x-amazon-apigateway-authtype": "oauth2", - "x-amazon-apigateway-authorizer": { - "type": "token", - "authorizerUri": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:account-id:function:function-name/invocations", - "authorizerCredentials": "arn:aws:iam::account-id:role", - "identityValidationExpression": "^x-[a-z]+", - "authorizerResultTtlInSeconds": 60, - }, - }, - Security( - type=SecurityType.API_KEY, - location=BaseLocation.HEADER, - name="Authorization", - extensions={ - "amazon_apigateway_authtype": "oauth2", - "amazon_apigateway_authorizer": { - "type": "token", - "authorizerUri": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:account-id:function:function-name/invocations", - "authorizerCredentials": "arn:aws:iam::account-id:role", - "identityValidationExpression": "^x-[a-z]+", - "authorizerResultTtlInSeconds": 60, - }, - }, - ), - _get_oauth_flow_builder_mock(None), - ), - ( - { - "type": "oauth2", - "flows": { - "implicit": { - "authorizationUrl": "https://example.com/api/oauth/dialog", - "scopes": { - "write:pets": "modify pets in your account", - "read:pets": "read your pets", - }, - }, - "authorizationCode": { - "authorizationUrl": "https://example.com/api/oauth/dialog", - "tokenUrl": "https://example.com/api/oauth/token", - "scopes": { - "write:pets": "modify pets in your account", - "read:pets": "read your pets", - }, - }, - }, - }, - Security(type=SecurityType.OAUTH2, flows=flows_mock), - _get_oauth_flow_builder_mock(flows_mock), - ), -) - - -@pytest.mark.parametrize(["data", "expected", "oauth_flow_builder"], data_provider) -def test_build( - data: dict[str, Any], - expected: Security, - oauth_flow_builder: OAuthFlowBuilder, -) -> None: - builder = SecurityBuilder(oauth_flow_builder) - - assert builder.build(data) == expected - - -collection_data_provider = ( - ( - { - "Basic": {"type": "http", "scheme": "basic"}, - }, - { - "Basic": Security( - type=SecurityType.HTTP, - scheme=AuthenticationScheme.BASIC, - ), - }, - _get_oauth_flow_builder_mock(None), - ), -) - - -@pytest.mark.parametrize( - ["data", "expected", "oauth_flow_builder"], - collection_data_provider, -) -def test_build_collection( - data: dict[str, Any], - expected: dict[str, Any], - oauth_flow_builder: OAuthFlowBuilder, -) -> None: - builder = SecurityBuilder(oauth_flow_builder) - - assert builder.build_collection(data) == expected diff --git a/tests/builders/test_server_builder.py b/tests/builders/test_server_builder.py deleted file mode 100644 index 1356ac0..0000000 --- a/tests/builders/test_server_builder.py +++ /dev/null @@ -1,108 +0,0 @@ -from typing import Any - -import pytest - -from openapi_parser.builders.server import ServerBuilder -from openapi_parser.errors import ParserError -from openapi_parser.specification import Server - -data_provider: Any = ( - ( - [], - [], - ), - ( - [ - { - "url": "https://development.gigantic-server.com/v1", - }, - { - "url": "https://staging.gigantic-server.com/v1", - }, - { - "url": "https://api.gigantic-server.com/v1", - }, - ], - [ - Server(url="https://development.gigantic-server.com/v1"), - Server(url="https://staging.gigantic-server.com/v1"), - Server(url="https://api.gigantic-server.com/v1"), - ], - ), - ( - [ - { - "url": "https://development.gigantic-server.com/v1", - "description": "Development server", - }, - { - "url": "https://staging.gigantic-server.com/v1", - "description": "Staging server", - }, - { - "url": "https://api.gigantic-server.com/v1", - "description": "Production server", - }, - ], - [ - Server( - url="https://development.gigantic-server.com/v1", - description="Development server", - ), - Server( - url="https://staging.gigantic-server.com/v1", - description="Staging server", - ), - Server( - url="https://api.gigantic-server.com/v1", - description="Production server", - ), - ], - ), - ( - [ - { - "url": "https://development.gigantic-server.com/v1", - "x-internal": True, - "description": "Development server", - }, - { - "url": "https://staging.gigantic-server.com/v1", - "description": "Staging server", - }, - { - "url": "https://api.gigantic-server.com/v1", - "description": "Production server", - }, - ], - [ - Server( - url="https://development.gigantic-server.com/v1", - description="Development server", - extensions={"internal": True}, - ), - Server( - url="https://staging.gigantic-server.com/v1", - description="Staging server", - ), - Server( - url="https://api.gigantic-server.com/v1", - description="Production server", - ), - ], - ), -) - - -@pytest.mark.parametrize(["data", "expected"], data_provider) -def test_build_list(data: list[Any], expected: list[Server]) -> None: - builder = ServerBuilder() - - assert expected == builder.build_list(data) - - -def test_build_list_missing_url() -> None: - builder = ServerBuilder() - - with pytest.raises(ParserError, match="missing required 'url' property"): - builder.build_list([{"description": "no url"}]) diff --git a/tests/builders/test_tag_builder.py b/tests/builders/test_tag_builder.py deleted file mode 100644 index f5297e0..0000000 --- a/tests/builders/test_tag_builder.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import Any -from unittest import mock - -import pytest - -from openapi_parser.builders.external_doc import ExternalDocBuilder -from openapi_parser.builders.tag import TagBuilder -from openapi_parser.errors import ParserError -from openapi_parser.specification import ExternalDoc, Tag - -data_provider: Any = ( - ( - [], - [], - ), - ( - [ - { - "name": "Users", - }, - {"name": "Users", "description": "User operations"}, - { - "name": "Users", - "description": "User operations", - "externalDocs": { - "description": "Find more info here", - "url": "https://example.com", - }, - }, - ], - [ - Tag(name="Users"), - Tag(name="Users", description="User operations"), - Tag( - name="Users", - description="User operations", - external_docs=ExternalDoc( - url="https://example.com", - description="Find more info here", - ), - ), - ], - ), -) - - -def _create_external_doc_builder_mock(expected_tags: list[Tag]) -> ExternalDocBuilder: - mock_object = mock.MagicMock() - mock_object.build.side_effect = [ - item.external_docs for item in expected_tags if item.external_docs is not None - ] - - return mock_object - - -@pytest.mark.parametrize(["data", "expected"], data_provider) -def test_build_list(data: list[Any], expected: list[Tag]) -> None: - external_doc_builder = _create_external_doc_builder_mock(expected) - builder = TagBuilder(external_doc_builder) - - assert expected == builder.build_list(data) - - -def test_build_list_missing_name() -> None: - builder = TagBuilder(mock.MagicMock()) - - with pytest.raises(ParserError, match="missing required 'name' property"): - builder.build_list([{"description": "no name"}]) diff --git a/tests/data/non-strict.yml b/tests/data/non-strict.yml deleted file mode 100644 index c537ed0..0000000 --- a/tests/data/non-strict.yml +++ /dev/null @@ -1,35 +0,0 @@ ---- - -# minimalistic schema with non-standard but valid spec items: -# - custom content-types -# - 'custom' string type formats -# -# See issue #40 for more context. -openapi: 3.0.3 - -info: - title: 'Non-strict enum schema' - version: 1.0.0 - -paths: - /sample-endpoint-1: - get: - responses: - 200: - description: 'OK' - content: - application/hal+json: - schema: - type: object - properties: - expectedDeliveryDuration: - type: string - format: duration - - 400: - description: 'Bad Request' - content: - application/problem+json: - schema: - type: object - properties: {} diff --git a/tests/data/swagger.yml b/tests/data/openapi_3.0.yaml similarity index 78% rename from tests/data/swagger.yml rename to tests/data/openapi_3.0.yaml index 2d1e7fb..37e5363 100644 --- a/tests/data/swagger.yml +++ b/tests/data/openapi_3.0.yaml @@ -1,7 +1,7 @@ openapi: 3.0.0 security: - - Basic: [ ] + - Basic: [] info: title: 'User example service' @@ -13,19 +13,28 @@ info: name: 'manchenkoff' email: 'artyom@manchenkoff.me' +externalDocs: + description: Find more info here + url: https://example.com/docs + servers: - - url: 'https://users.app' + - url: 'https://users.app/api/v{version}' description: 'production' - + variables: + version: + default: '1' + description: 'API version' - url: 'https://stage.users.app' description: 'staging' - - url: 'https://users.local' description: 'development' tags: - name: Users description: 'User operations' + externalDocs: + description: 'User API docs' + url: 'https://example.com/users/docs' paths: /users: @@ -51,7 +60,7 @@ paths: description: 'Method to add new user' operationId: AddUser security: - - Basic: [ ] + - Basic: [] tags: - Users requestBody: @@ -102,12 +111,72 @@ paths: $ref: '#/components/responses/BadRequest' 500: $ref: '#/components/responses/InternalServerError' + patch: + summary: 'Patch user model' + operationId: PatchUser + tags: + - Users + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + 200: + $ref: '#/components/responses/UserResponse' + delete: + summary: 'Delete user' + operationId: DeleteUser + tags: + - Users + responses: + 204: + description: 'No content' + + /non-strict: + get: + responses: + 200: + description: 'OK' + content: + application/hal+json: + schema: + type: object + properties: + expectedDeliveryDuration: + type: string + format: duration + 400: + description: 'Bad Request' + content: + application/problem+json: + schema: + type: object + properties: {} + + /equipment: + get: + responses: + 200: + description: 'OK' + content: + application/json: + schema: + $ref: '#/components/schemas/Equipment' components: securitySchemes: Basic: type: http scheme: basic + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + ApiKey: + type: apiKey + in: header + name: X-API-Key parameters: Limit: @@ -314,3 +383,25 @@ components: mapping: str: 'SomeTarget' int: 'OtherTarget' + + Equipment: + type: object + properties: + Features: + type: array + items: + $ref: '#/components/schemas/Feature' + Id: + type: integer + format: int64 + + Feature: + type: object + properties: + Equipments: + type: array + items: + $ref: '#/components/schemas/Equipment' + Id: + type: integer + format: int64 diff --git a/tests/data/openapi_3.1.yaml b/tests/data/openapi_3.1.yaml new file mode 100644 index 0000000..c5c5696 --- /dev/null +++ b/tests/data/openapi_3.1.yaml @@ -0,0 +1,436 @@ +openapi: 3.1.0 + +security: + - Basic: [] + +info: + title: 'User example service' + version: 1.0.0 + description: 'Example service specification to work with user storage' + license: + name: 'MIT' + contact: + name: 'manchenkoff' + email: 'artyom@manchenkoff.me' + +externalDocs: + description: Find more info here + url: https://example.com/docs + +jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema + +webhooks: + newPet: + post: + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + responses: + '201': + description: Created + +servers: + - url: 'https://users.app/api/v{version}' + description: 'production' + variables: + version: + default: '1' + description: 'API version' + - url: 'https://stage.users.app' + description: 'staging' + - url: 'https://users.local' + description: 'development' + +tags: + - name: Users + description: 'User operations' + externalDocs: + description: 'User API docs' + url: 'https://example.com/users/docs' + +paths: + /users: + get: + summary: 'Get user list' + description: 'Method to get user list' + operationId: GetUserList + tags: + - Users + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Offset' + - $ref: '#/components/parameters/JsonParameter' + responses: + 200: + $ref: '#/components/responses/GetUserListResponse' + 400: + $ref: '#/components/responses/BadRequest' + 500: + $ref: '#/components/responses/InternalServerError' + post: + summary: 'Add new user' + description: 'Method to add new user' + operationId: AddUser + security: + - Basic: [] + tags: + - Users + requestBody: + $ref: '#/components/requestBodies/AddUserRequest' + callbacks: + onAdd: + '{$request.body#/email}': + post: + summary: 'Callback after user creation' + responses: + '200': + description: 'Callback processed successfully' + responses: + 201: + $ref: '#/components/responses/AddUserResponse' + 400: + $ref: '#/components/responses/BadRequest' + 500: + $ref: '#/components/responses/InternalServerError' + + /users/{uuid}: + parameters: + - $ref: '#/components/parameters/UserUUID' + get: + summary: 'Get user model' + description: 'Method to get user details' + operationId: GetUser + tags: + - Users + responses: + 200: + $ref: '#/components/responses/UserResponse' + 400: + $ref: '#/components/responses/BadRequest' + 500: + $ref: '#/components/responses/InternalServerError' + put: + summary: 'Update existed user model' + operationId: UpdateUser + tags: + - Users + responses: + default: + $ref: '#/components/responses/Empty' + 200: + $ref: '#/components/responses/Empty' + 400: + $ref: '#/components/responses/BadRequest' + 500: + $ref: '#/components/responses/InternalServerError' + patch: + summary: 'Patch user model' + operationId: PatchUser + tags: + - Users + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + 200: + $ref: '#/components/responses/UserResponse' + delete: + summary: 'Delete user' + operationId: DeleteUser + tags: + - Users + responses: + 204: + description: 'No content' + + /non-strict: + get: + responses: + 200: + description: 'OK' + content: + application/hal+json: + schema: + type: object + properties: + expectedDeliveryDuration: + type: string + format: duration + 400: + description: 'Bad Request' + content: + application/problem+json: + schema: + type: object + properties: {} + + /equipment: + get: + responses: + 200: + description: 'OK' + content: + application/json: + schema: + $ref: '#/components/schemas/Equipment' + +components: + securitySchemes: + Basic: + type: http + scheme: basic + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + ApiKey: + type: apiKey + in: header + name: X-API-Key + + parameters: + Limit: + name: limit + in: query + description: 'Result items limit' + allowEmptyValue: false + example: 10 + required: true + allowReserved: true + schema: + type: integer + not: + type: string + + Offset: + name: offset + in: query + description: 'Result items start offset' + allowEmptyValue: false + example: 0 + required: true + schema: + type: integer + + UserUUID: + name: uuid + in: path + description: 'User unique id' + allowEmptyValue: false + example: '12345678-1234-5678-1234-567812345678' + required: true + schema: + type: string + format: uuid + + JsonParameter: + name: json + in: query + description: 'Custom JSON parameter' + required: false + content: + application/json: + schema: + type: object + properties: + key: + type: string + example: 'test' + description: 'Test parameter' + + requestBodies: + AddUserRequest: + description: 'New user model request' + content: + application/json: + schema: + $ref: '#/components/schemas/User' + encoding: + login: + contentType: text/plain + style: form + email: + contentType: text/plain + + responses: + BadRequest: + description: 'Bad request or parameters' + content: + application/json: + schema: + $ref: '#/components/schemas/BadRequestError' + + InternalServerError: + description: 'Internal error' + content: + application/json: + schema: + $ref: '#/components/schemas/InternalServerError' + + Empty: + description: 'Empty successful response' + + GetUserListResponse: + description: 'Successful user list response' + content: + application/json: + schema: + type: object + required: + - total_count + - users + properties: + total_count: + type: integer + description: 'Total count of users' + users: + type: array + items: + $ref: '#/components/schemas/User' + + AddUserResponse: + description: 'Successful addition user response' + content: + application/json: + schema: + type: object + required: + - user + properties: + user: + $ref: '#/components/schemas/User' + + UserResponse: + description: 'Successful user response' + content: + application/json: + schema: + type: object + required: + - user + properties: + user: + $ref: '#/components/schemas/User' + links: + UpdateUser: + operationId: UpdateUser + parameters: + uuid: '$response.body#/user/uuid' + description: Updates the user + + schemas: + BadRequestError: + type: object + required: + - code + - error + properties: + code: + type: integer + example: 1044 + description: 'Internal error code' + error: + type: string + example: 'Invalid user id value' + description: 'Error details' + + InternalServerError: + type: object + required: + - code + - error + properties: + code: + type: integer + example: 1 + description: 'Internal error code' + error: + type: string + example: 'Unexpected server error' + description: 'Error details' + + UUIDObject: + type: object + additionalProperties: false + required: + - uuid + properties: + uuid: + type: string + format: uuid + example: '12345678-1234-5678-1234-567812345678' + description: 'Unique object id' + + User: + allOf: + - $ref: '#/components/schemas/UUIDObject' + - required: + - login + - email + - avatar + - properties: + login: + type: string + example: 'super-admin' + description: 'User login or nickname' + email: + type: string + format: email + example: 'user@mail.com' + description: 'User E-mail address' + avatar: + type: string + format: uri + example: 'https://github.com/manchenkoff/openapi3-parser' + description: 'User Avatar URL' + + Payload: + oneOf: + - type: string + - type: integer + discriminator: + propertyName: payloadType + mapping: + str: 'SomeTarget' + int: 'OtherTarget' + + Pet: + type: + - "object" + - "null" + required: + - id + - name + properties: + id: + type: integer + name: + type: string + + Equipment: + type: object + properties: + Features: + type: array + items: + $ref: '#/components/schemas/Feature' + Id: + type: integer + format: int64 + + Feature: + type: object + properties: + Equipments: + type: array + items: + $ref: '#/components/schemas/Equipment' + Id: + type: integer + format: int64 diff --git a/tests/data/openapi_3.2.yaml b/tests/data/openapi_3.2.yaml new file mode 100644 index 0000000..8cc01e9 --- /dev/null +++ b/tests/data/openapi_3.2.yaml @@ -0,0 +1,445 @@ +openapi: 3.2.0 + +security: + - Basic: [] + +info: + title: 'User example service' + version: 1.0.0 + description: 'Example service specification to work with user storage' + license: + name: 'MIT' + contact: + name: 'manchenkoff' + email: 'artyom@manchenkoff.me' + +externalDocs: + description: Find more info here + url: https://example.com/docs + +jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema + +webhooks: + newPet: + post: + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + responses: + '201': + description: Created + +servers: + - url: 'https://users.app/api/v{version}' + description: 'production' + variables: + version: + default: '1' + description: 'API version' + - url: 'https://stage.users.app' + description: 'staging' + - url: 'https://users.local' + description: 'development' + +tags: + - name: Users + summary: 'User API' + parent: 'API' + kind: 'domain' + description: 'User operations' + externalDocs: + description: 'User API docs' + url: 'https://example.com/users/docs' + +paths: + /users: + get: + summary: 'Get user list' + description: 'Method to get user list' + operationId: GetUserList + tags: + - Users + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Offset' + - $ref: '#/components/parameters/JsonParameter' + responses: + 200: + $ref: '#/components/responses/GetUserListResponse' + 400: + $ref: '#/components/responses/BadRequest' + 500: + $ref: '#/components/responses/InternalServerError' + post: + summary: 'Add new user' + description: 'Method to add new user' + operationId: AddUser + security: + - Basic: [] + tags: + - Users + requestBody: + $ref: '#/components/requestBodies/AddUserRequest' + callbacks: + onAdd: + '{$request.body#/email}': + post: + summary: 'Callback after user creation' + responses: + '200': + description: 'Callback processed successfully' + responses: + 201: + $ref: '#/components/responses/AddUserResponse' + 400: + $ref: '#/components/responses/BadRequest' + 500: + $ref: '#/components/responses/InternalServerError' + + /users/{uuid}: + parameters: + - $ref: '#/components/parameters/UserUUID' + get: + summary: 'Get user model' + description: 'Method to get user details' + operationId: GetUser + tags: + - Users + responses: + 200: + $ref: '#/components/responses/UserResponse' + 400: + $ref: '#/components/responses/BadRequest' + 500: + $ref: '#/components/responses/InternalServerError' + put: + summary: 'Update existed user model' + operationId: UpdateUser + tags: + - Users + responses: + default: + $ref: '#/components/responses/Empty' + 200: + $ref: '#/components/responses/Empty' + 400: + $ref: '#/components/responses/BadRequest' + 500: + $ref: '#/components/responses/InternalServerError' + patch: + summary: 'Patch user model' + operationId: PatchUser + tags: + - Users + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + 200: + $ref: '#/components/responses/UserResponse' + delete: + summary: 'Delete user' + operationId: DeleteUser + tags: + - Users + responses: + 204: + description: 'No content' + + /non-strict: + get: + responses: + 200: + description: 'OK' + content: + application/hal+json: + schema: + type: object + properties: + expectedDeliveryDuration: + type: string + format: duration + 400: + description: 'Bad Request' + content: + application/problem+json: + schema: + type: object + properties: {} + + /equipment: + get: + responses: + 200: + description: 'OK' + content: + application/json: + schema: + $ref: '#/components/schemas/Equipment' + additionalOperations: + query: + summary: 'Query equipment' + responses: + '200': + description: 'Equipment list' + +components: + securitySchemes: + Basic: + type: http + scheme: basic + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + ApiKey: + type: apiKey + in: header + name: X-API-Key + + parameters: + Limit: + name: limit + in: query + description: 'Result items limit' + allowEmptyValue: false + example: 10 + required: true + allowReserved: true + schema: + type: integer + not: + type: string + + Offset: + name: offset + in: query + description: 'Result items start offset' + allowEmptyValue: false + example: 0 + required: true + schema: + type: integer + + UserUUID: + name: uuid + in: path + description: 'User unique id' + allowEmptyValue: false + example: '12345678-1234-5678-1234-567812345678' + required: true + schema: + type: string + format: uuid + + JsonParameter: + name: json + in: query + description: 'Custom JSON parameter' + required: false + content: + application/json: + schema: + type: object + properties: + key: + type: string + example: 'test' + description: 'Test parameter' + + requestBodies: + AddUserRequest: + description: 'New user model request' + content: + application/json: + schema: + $ref: '#/components/schemas/User' + encoding: + login: + contentType: text/plain + style: form + email: + contentType: text/plain + + responses: + BadRequest: + description: 'Bad request or parameters' + content: + application/json: + schema: + $ref: '#/components/schemas/BadRequestError' + + InternalServerError: + description: 'Internal error' + content: + application/json: + schema: + $ref: '#/components/schemas/InternalServerError' + + Empty: + description: 'Empty successful response' + + GetUserListResponse: + description: 'Successful user list response' + content: + application/json: + schema: + type: object + required: + - total_count + - users + properties: + total_count: + type: integer + description: 'Total count of users' + users: + type: array + items: + $ref: '#/components/schemas/User' + + AddUserResponse: + description: 'Successful addition user response' + content: + application/json: + schema: + type: object + required: + - user + properties: + user: + $ref: '#/components/schemas/User' + + UserResponse: + description: 'Successful user response' + content: + application/json: + schema: + type: object + required: + - user + properties: + user: + $ref: '#/components/schemas/User' + links: + UpdateUser: + operationId: UpdateUser + parameters: + uuid: '$response.body#/user/uuid' + description: Updates the user + + schemas: + BadRequestError: + type: object + required: + - code + - error + properties: + code: + type: integer + example: 1044 + description: 'Internal error code' + error: + type: string + example: 'Invalid user id value' + description: 'Error details' + + InternalServerError: + type: object + required: + - code + - error + properties: + code: + type: integer + example: 1 + description: 'Internal error code' + error: + type: string + example: 'Unexpected server error' + description: 'Error details' + + UUIDObject: + type: object + additionalProperties: false + required: + - uuid + properties: + uuid: + type: string + format: uuid + example: '12345678-1234-5678-1234-567812345678' + description: 'Unique object id' + + User: + allOf: + - $ref: '#/components/schemas/UUIDObject' + - required: + - login + - email + - avatar + - properties: + login: + type: string + example: 'super-admin' + description: 'User login or nickname' + email: + type: string + format: email + example: 'user@mail.com' + description: 'User E-mail address' + avatar: + type: string + format: uri + example: 'https://github.com/manchenkoff/openapi3-parser' + description: 'User Avatar URL' + + Payload: + oneOf: + - type: string + - type: integer + discriminator: + propertyName: payloadType + mapping: + str: 'SomeTarget' + int: 'OtherTarget' + + Pet: + type: + - "object" + - "null" + required: + - id + - name + properties: + id: + type: integer + name: + type: string + + Equipment: + type: object + properties: + Features: + type: array + items: + $ref: '#/components/schemas/Feature' + Id: + type: integer + format: int64 + + Feature: + type: object + properties: + Equipments: + type: array + items: + $ref: '#/components/schemas/Equipment' + Id: + type: integer + format: int64 diff --git a/tests/data/recursive.yml b/tests/data/recursive.yml deleted file mode 100644 index cf08afa..0000000 --- a/tests/data/recursive.yml +++ /dev/null @@ -1,40 +0,0 @@ -openapi: 3.0.0 - -info: - title: Recursive schema test - version: 1.0.0 - -paths: - /test: - get: - summary: Test endpoint - operationId: Test - responses: - 200: - description: OK - -components: - schemas: - Equipment: - title: Equipment - type: object - properties: - Features: - type: array - items: - $ref: '#/components/schemas/Feature' - Id: - type: integer - format: int64 - - Feature: - title: Feature - type: object - properties: - Equipments: - type: array - items: - $ref: '#/components/schemas/Equipment' - Id: - type: integer - format: int64 diff --git a/tests/data/swagger.json b/tests/data/swagger.json deleted file mode 100644 index 4945278..0000000 --- a/tests/data/swagger.json +++ /dev/null @@ -1,350 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "User example service", - "version": "1.0.0", - "description": "Example service specification to work with user storage", - "license": { - "name": "MIT" - }, - "contact": { - "name": "manchenkoff", - "email": "artyom@manchenkoff.me" - } - }, - "security": [ - { - "Basic": [] - } - ], - "servers": [ - { - "url": "https://users.app", - "description": "production" - }, - { - "url": "https://stage.users.app", - "description": "staging" - }, - { - "url": "https://users.local", - "description": "development" - } - ], - "tags": [ - { - "name": "Users", - "description": "User operations" - } - ], - "paths": { - "/users": { - "get": { - "summary": "Get user list", - "description": "Method to get user list", - "operationId": "GetUserList", - "tags": [ - "Users" - ], - "parameters": [ - { - "$ref": "#/components/parameters/Limit" - }, - { - "$ref": "#/components/parameters/Offset" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/GetUserListResponse" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - } - }, - "post": { - "summary": "Add new user", - "description": "Method to add new user", - "operationId": "AddUser", - "tags": [ - "Users" - ], - "requestBody": { - "$ref": "#/components/requestBodies/AddUserRequest" - }, - "responses": { - "201": { - "$ref": "#/components/responses/AddUserResponse" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - } - } - }, - "/users/{uuid}": { - "parameters": [ - { - "$ref": "#/components/parameters/UserUUID" - } - ], - "get": { - "summary": "Get user model", - "description": "Method to get user details", - "operationId": "GetUser", - "tags": [ - "Users" - ], - "responses": { - "200": { - "$ref": "#/components/responses/UserResponse" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - } - }, - "put": { - "summary": "Update existed user model", - "operationId": "UpdateUser", - "tags": [ - "Users" - ], - "responses": { - "default": { - "$ref": "#/components/responses/Empty" - }, - "200": { - "$ref": "#/components/responses/Empty" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - } - } - } - }, - "components": { - "securitySchemes": { - "Basic": { - "type": "http", - "scheme": "basic" - } - }, - "parameters": { - "Limit": { - "name": "limit", - "in": "query", - "description": "Result items limit", - "allowEmptyValue": false, - "example": 10, - "required": true, - "schema": { - "type": "integer" - } - }, - "Offset": { - "name": "offset", - "in": "query", - "description": "Result items start offset", - "allowEmptyValue": false, - "example": 0, - "required": true, - "schema": { - "type": "integer" - } - }, - "UserUUID": { - "name": "uuid", - "in": "path", - "description": "User unique id", - "allowEmptyValue": false, - "example": "12345678-1234-5678-1234-567812345678", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - }, - "requestBodies": { - "AddUserRequest": { - "description": "New user model request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/User" - } - } - } - } - }, - "responses": { - "BadRequest": { - "description": "Bad request or parameters", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "InternalServerError": { - "description": "Internal error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InternalServerError" - } - } - } - }, - "Empty": { - "description": "Empty successful response" - }, - "GetUserListResponse": { - "description": "Successful user list response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "total_count", - "users" - ], - "properties": { - "total_count": { - "type": "integer", - "description": "Total count of users" - }, - "users": { - "type": "array", - "items": { - "$ref": "#/components/schemas/User" - } - } - } - } - } - } - }, - "AddUserResponse": { - "description": "Successful addition user response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "user" - ], - "properties": { - "user": { - "$ref": "#/components/schemas/User" - } - } - } - } - } - }, - "UserResponse": { - "description": "Successful user response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "user" - ], - "properties": { - "user": { - "$ref": "#/components/schemas/User" - } - } - } - } - } - } - }, - "schemas": { - "BadRequestError": { - "type": "object", - "required": [ - "code", - "error" - ], - "properties": { - "code": { - "type": "integer", - "example": 1044, - "description": "Internal error code" - }, - "error": { - "type": "string", - "example": "Invalid user id value", - "description": "Error details" - } - } - }, - "InternalServerError": { - "type": "object", - "required": [ - "code", - "error" - ], - "properties": { - "code": { - "type": "integer", - "example": 1, - "description": "Internal error code" - }, - "error": { - "type": "string", - "example": "Unexpected server error", - "description": "Error details" - } - } - }, - "User": { - "type": "object", - "required": [ - "uuid", - "login", - "email" - ], - "properties": { - "uuid": { - "type": "string", - "format": "uuid", - "example": "12345678-1234-5678-1234-567812345678", - "description": "Unique user id" - }, - "login": { - "type": "string", - "example": "super-admin", - "description": "User login or nickname" - }, - "email": { - "type": "string", - "format": "email", - "example": "user@mail.com", - "description": "User E-mail address" - } - } - } - } - } -} \ No newline at end of file diff --git a/tests/data/swagger_v2.yaml b/tests/data/swagger_v2.yaml new file mode 100644 index 0000000..a1e77e6 --- /dev/null +++ b/tests/data/swagger_v2.yaml @@ -0,0 +1,100 @@ +swagger: "2.0" +info: + version: "1.0.0" + title: "Swagger Petstore" +host: "petstore.swagger.io" +basePath: "/v1" +schemes: + - "http" +paths: + /pets: + get: + description: "Returns all pets from the system that the user has access to" + operationId: "findPets" + produces: + - "application/json" + parameters: + - name: "tags" + in: "query" + description: "tags to filter by" + required: false + type: "array" + items: + type: "string" + - name: "limit" + in: "query" + description: "maximum number of results to return" + required: false + type: "integer" + format: "int32" + responses: + "200": + description: "pet response" + schema: + type: "array" + items: + $ref: "#/definitions/Pet" + default: + description: "unexpected error" + schema: + $ref: "#/definitions/Error" + post: + description: "Creates a new pet in the store" + operationId: "addPet" + produces: + - "application/json" + consumes: + - "application/json" + parameters: + - name: "pet" + in: "body" + description: "Pet to add to the store" + required: true + schema: + $ref: "#/definitions/NewPet" + responses: + "200": + description: "pet response" + schema: + $ref: "#/definitions/Pet" + default: + description: "unexpected error" + schema: + $ref: "#/definitions/Error" +definitions: + Pet: + type: "object" + required: + - "id" + - "name" + properties: + id: + type: "integer" + format: "int64" + name: + type: "string" + tag: + type: "string" + NewPet: + type: "object" + required: + - "name" + properties: + id: + type: "integer" + format: "int64" + name: + type: "string" + tag: + type: "string" + Error: + type: "object" + required: + - "code" + - "message" + properties: + code: + type: "integer" + format: "int32" + message: + type: "string" diff --git a/tests/openapi_fixture.py b/tests/openapi_fixture.py deleted file mode 100644 index 206df11..0000000 --- a/tests/openapi_fixture.py +++ /dev/null @@ -1,552 +0,0 @@ -from typing import Any - -from openapi_parser.enumeration import ( - AuthenticationScheme, - ContentType, - DataType, - OperationMethod, - ParameterLocation, - PathParameterStyle, - QueryParameterStyle, - SecurityType, - StringFormat, -) -from openapi_parser.specification import ( - Array, - Contact, - Content, - Discriminator, - Encoding, - Info, - Integer, - License, - Link, - Object, - OneOf, - Operation, - Parameter, - Path, - Property, - RequestBody, - Response, - Schema, - Security, - Server, - Specification, - String, - Tag, -) - -schema_user = Object( - type=DataType.OBJECT, - additional_properties=False, - required=["uuid", "login", "email", "avatar"], - properties=[ - Property( - name="uuid", - schema=String( - type=DataType.STRING, - description="Unique object id", - example="12345678-1234-5678-1234-567812345678", - format=StringFormat.UUID, - ), - ), - Property( - name="login", - schema=String( - type=DataType.STRING, - description="User login or nickname", - example="super-admin", - ), - ), - Property( - name="email", - schema=String( - type=DataType.STRING, - description="User E-mail address", - example="user@mail.com", - format=StringFormat.EMAIL, - ), - ), - Property( - name="avatar", - schema=String( - type=DataType.STRING, - description="User Avatar URL", - example="https://github.com/manchenkoff/openapi3-parser", - format=StringFormat.URI, - ), - ), - ], -) - -user_list_schema = Object( - type=DataType.OBJECT, - required=["total_count", "users"], - properties=[ - Property( - name="total_count", - schema=Integer( - type=DataType.INTEGER, - description="Total count of users", - ), - ), - Property( - name="users", - schema=Array( - type=DataType.ARRAY, - items=schema_user, - ), - ), - ], -) - -get_user_list_response = Response( - code=200, - description="Successful user list response", - content=[ - Content(type=ContentType.JSON, schema=user_list_schema), - ], - is_default=False, -) - -bad_request_response = Response( - code=400, - is_default=False, - description="Bad request or parameters", - content=[ - Content( - type=ContentType.JSON, - schema=Object( - type=DataType.OBJECT, - required=["code", "error"], - properties=[ - Property( - name="code", - schema=Integer( - type=DataType.INTEGER, - example=1044, - description="Internal error code", - ), - ), - Property( - name="error", - schema=String( - type=DataType.STRING, - example="Invalid user id value", - description="Error details", - ), - ), - ], - ), - ), - ], -) - -internal_error_response = Response( - code=500, - is_default=False, - description="Internal error", - content=[ - Content( - type=ContentType.JSON, - schema=Object( - type=DataType.OBJECT, - required=["code", "error"], - properties=[ - Property( - name="code", - schema=Integer( - type=DataType.INTEGER, - example=1, - description="Internal error code", - ), - ), - Property( - name="error", - schema=String( - type=DataType.STRING, - example="Unexpected server error", - description="Error details", - ), - ), - ], - ), - ), - ], -) - - -def create_specification() -> Specification: - info = Info( - title="User example service", - version="1.0.0", - description="Example service specification to work with user storage", - license=License(name="MIT"), - contact=Contact(name="manchenkoff", email="artyom@manchenkoff.me"), - ) - - server_list = [ - Server(url="https://users.app", description="production"), - Server(url="https://stage.users.app", description="staging"), - Server(url="https://users.local", description="development"), - ] - - tag_list = [ - Tag(name="Users", description="User operations"), - ] - - security_schemes = { - "Basic": Security(type=SecurityType.HTTP, scheme=AuthenticationScheme.BASIC), - } - - schemas: dict[str, Schema] = { - "BadRequestError": Object( - type=DataType.OBJECT, - required=["code", "error"], - properties=[ - Property( - name="code", - schema=Integer( - type=DataType.INTEGER, - example=1044, - description="Internal error code", - ), - ), - Property( - name="error", - schema=String( - type=DataType.STRING, - example="Invalid user id value", - description="Error details", - ), - ), - ], - ), - "InternalServerError": Object( - type=DataType.OBJECT, - required=["code", "error"], - properties=[ - Property( - name="code", - schema=Integer( - type=DataType.INTEGER, - example=1, - description="Internal error code", - ), - ), - Property( - name="error", - schema=String( - type=DataType.STRING, - example="Unexpected server error", - description="Error details", - ), - ), - ], - ), - "UUIDObject": Object( - type=DataType.OBJECT, - additional_properties=False, - required=["uuid"], - properties=[ - Property( - name="uuid", - schema=String( - type=DataType.STRING, - format=StringFormat.UUID, - example="12345678-1234-5678-1234-567812345678", - description="Unique object id", - ), - ), - ], - ), - "User": Object( - type=DataType.OBJECT, - additional_properties=False, - required=["uuid", "login", "email", "avatar"], - properties=[ - Property( - name="uuid", - schema=String( - type=DataType.STRING, - format=StringFormat.UUID, - example="12345678-1234-5678-1234-567812345678", - description="Unique object id", - ), - ), - Property( - name="login", - schema=String( - type=DataType.STRING, - example="super-admin", - description="User login or nickname", - ), - ), - Property( - name="email", - schema=String( - type=DataType.STRING, - format=StringFormat.EMAIL, - example="user@mail.com", - description="User E-mail address", - ), - ), - Property( - name="avatar", - schema=String( - type=DataType.STRING, - description="User Avatar URL", - example="https://github.com/manchenkoff/openapi3-parser", - format=StringFormat.URI, - ), - ), - ], - ), - "Payload": OneOf( - type=DataType.ONE_OF, - schemas=[ - String(type=DataType.STRING), - Integer(type=DataType.INTEGER), - ], - discriminator=Discriminator( - property_name="payloadType", - mapping={"str": "SomeTarget", "int": "OtherTarget"}, - ), - ), - } - - security: list[dict[str, Any]] = [{"Basic": []}] - - uuid_parameters = [ - Parameter( - name="uuid", - location=ParameterLocation.PATH, - description="User unique id", - required=True, - explode=False, - style=PathParameterStyle.SIMPLE, - example="12345678-1234-5678-1234-567812345678", - schema=String( - type=DataType.STRING, - format=StringFormat.UUID, - ), - ), - ] - - path_list: list[Path] = [ - Path( - url="/users", - operations=[ - Operation( - method=OperationMethod.GET, - summary="Get user list", - description="Method to get user list", - operation_id="GetUserList", - tags=["Users"], - parameters=[ - Parameter( - name="limit", - location=ParameterLocation.QUERY, - description="Result items limit", - required=True, - explode=True, - style=QueryParameterStyle.FORM, - allow_reserved=True, - example=10, - schema=Integer( - type=DataType.INTEGER, - not_schema=String(type=DataType.STRING), - ), - ), - Parameter( - name="offset", - location=ParameterLocation.QUERY, - description="Result items start offset", - required=True, - explode=True, - style=QueryParameterStyle.FORM, - example=0, - schema=Integer(type=DataType.INTEGER), - ), - Parameter( - name="json", - location=ParameterLocation.QUERY, - description="Custom JSON parameter", - required=False, - explode=True, - style=QueryParameterStyle.FORM, - content=[ - Content( - type=ContentType.JSON, - schema=Object( - type=DataType.OBJECT, - properties=[ - Property( - name="key", - schema=String( - type=DataType.STRING, - description="Test parameter", - example="test", - ), - ), - ], - ), - ) - ], - ), - ], - responses=[ - get_user_list_response, - bad_request_response, - internal_error_response, - ], - ), - Operation( - method=OperationMethod.POST, - summary="Add new user", - description="Method to add new user", - operation_id="AddUser", - tags=["Users"], - security=[{"Basic": []}], - request_body=RequestBody( - description="New user model request", - content=[ - Content( - type=ContentType.JSON, - schema=schema_user, - encoding={ - "login": Encoding( - content_type="text/plain", - style="form", - ), - "email": Encoding( - content_type="text/plain", - ), - }, - ) - ], - ), - callbacks={ - "onAdd": { - "{$request.body#/email}": { - "post": { - "summary": "Callback after user creation", - "responses": { - "200": { - "description": ( - "Callback processed successfully" - ), - }, - }, - }, - }, - }, - }, - responses=[ - Response( - code=201, - is_default=False, - description="Successful addition user response", - content=[ - Content( - type=ContentType.JSON, - schema=Object( - type=DataType.OBJECT, - required=["user"], - properties=[ - Property( - name="user", - schema=schema_user, - ), - ], - ), - ), - ], - ), - bad_request_response, - internal_error_response, - ], - ), - ], - ), - Path( - url="/users/{uuid}", - parameters=uuid_parameters, - operations=[ - Operation( - method=OperationMethod.GET, - summary="Get user model", - description="Method to get user details", - operation_id="GetUser", - tags=["Users"], - parameters=uuid_parameters, - responses=[ - Response( - code=200, - is_default=False, - description="Successful user response", - content=[ - Content( - type=ContentType.JSON, - schema=Object( - type=DataType.OBJECT, - required=["user"], - properties=[ - Property( - name="user", - schema=schema_user, - ), - ], - ), - ), - ], - links={ - "UpdateUser": Link( - operation_id="UpdateUser", - parameters={ - "uuid": ("$response.body#/user/uuid"), - }, - description="Updates the user", - ), - }, - ), - bad_request_response, - internal_error_response, - ], - ), - Operation( - method=OperationMethod.PUT, - summary="Update existed user model", - operation_id="UpdateUser", - tags=["Users"], - parameters=uuid_parameters, - responses=[ - Response( - code=None, - description="Empty successful response", - is_default=True, - ), - Response( - code=200, - description="Empty successful response", - is_default=False, - ), - bad_request_response, - internal_error_response, - ], - ), - ], - ), - ] - - return Specification( - version="3.0.0", - info=info, - servers=server_list, - tags=tag_list, - paths=path_list, - security_schemas=security_schemes, - security=security, - schemas=schemas, - ) diff --git a/tests/test_enumeration.py b/tests/test_enumeration.py deleted file mode 100644 index d9a34ab..0000000 --- a/tests/test_enumeration.py +++ /dev/null @@ -1,243 +0,0 @@ -import pytest - -from openapi_parser.enumeration import ( - AuthenticationScheme, - BaseLocation, - ContentType, - DataType, - IntegerFormat, - NumberFormat, - OAuthFlowType, - OperationMethod, - ParameterLocation, - SecurityType, - StringFormat, -) - -data_type_provider = ( - ("integer", DataType.INTEGER), - ("number", DataType.NUMBER), - ("string", DataType.STRING), - ("boolean", DataType.BOOLEAN), - ("array", DataType.ARRAY), - ("object", DataType.OBJECT), -) - - -@pytest.mark.parametrize(["string_value", "expected"], data_type_provider) -def test_data_type(string_value: str, expected: DataType) -> None: - assert DataType(string_value) == expected - - -def test_data_type_error() -> None: - with pytest.raises(ValueError): - DataType("invalid") - - -integer_format_provider = ( - ("int32", IntegerFormat.INT32), - ("int64", IntegerFormat.INT64), -) - - -@pytest.mark.parametrize(["string_value", "expected"], integer_format_provider) -def test_integer_format(string_value: str, expected: IntegerFormat) -> None: - assert IntegerFormat(string_value) == expected - - -def test_integer_format_error() -> None: - with pytest.raises(ValueError): - IntegerFormat("invalid") - - -number_format_provider = ( - ("float", NumberFormat.FLOAT), - ("double", NumberFormat.DOUBLE), -) - - -@pytest.mark.parametrize(["string_value", "expected"], number_format_provider) -def test_number_format(string_value: str, expected: NumberFormat) -> None: - assert NumberFormat(string_value) == expected - - -def test_number_format_error() -> None: - with pytest.raises(ValueError): - NumberFormat("invalid") - - -string_format_provider = ( - ("byte", StringFormat.BYTE), - ("binary", StringFormat.BINARY), - ("date", StringFormat.DATE), - ("date-time", StringFormat.DATETIME), - ("password", StringFormat.PASSWORD), - ("uuid", StringFormat.UUID), - ("email", StringFormat.EMAIL), - ("uri", StringFormat.URI), - ("hostname", StringFormat.HOSTNAME), - ("ipv4", StringFormat.IPV4), - ("ipv6", StringFormat.IPV6), - ("url", StringFormat.URL), - ("time", StringFormat.TIME), -) - - -@pytest.mark.parametrize(["string_value", "expected"], string_format_provider) -def test_string_format(string_value: str, expected: StringFormat) -> None: - assert StringFormat(string_value) == expected - - -def test_string_format_error() -> None: - with pytest.raises(ValueError): - StringFormat("invalid") - - -operation_method_provider = ( - ("get", OperationMethod.GET), - ("put", OperationMethod.PUT), - ("post", OperationMethod.POST), - ("delete", OperationMethod.DELETE), - ("options", OperationMethod.OPTIONS), - ("head", OperationMethod.HEAD), - ("patch", OperationMethod.PATCH), - ("trace", OperationMethod.TRACE), -) - - -@pytest.mark.parametrize(["string_value", "expected"], operation_method_provider) -def test_operation_method(string_value: str, expected: OperationMethod) -> None: - assert OperationMethod(string_value) == expected - - -def test_operation_method_error() -> None: - with pytest.raises(ValueError): - OperationMethod("invalid") - - -base_location_provider = ( - ("header", BaseLocation.HEADER), - ("query", BaseLocation.QUERY), - ("cookie", BaseLocation.COOKIE), -) - - -@pytest.mark.parametrize(["string_value", "expected"], base_location_provider) -def test_base_location(string_value: str, expected: BaseLocation) -> None: - assert BaseLocation(string_value) == expected - - -def test_base_location_error() -> None: - with pytest.raises(ValueError): - BaseLocation("invalid") - - -parameter_location_provider = ( - ("header", ParameterLocation.HEADER), - ("query", ParameterLocation.QUERY), - ("cookie", ParameterLocation.COOKIE), - ("path", ParameterLocation.PATH), -) - - -@pytest.mark.parametrize(["string_value", "expected"], parameter_location_provider) -def test_parameter_location(string_value: str, expected: ParameterLocation) -> None: - assert ParameterLocation(string_value) == expected - - -def test_parameter_location_error() -> None: - with pytest.raises(ValueError): - ParameterLocation("invalid") - - -media_type_provider = ( - ("application/json", ContentType.JSON), - ("application/*+json", ContentType.JSON_ANY), - ("application/problem+json", ContentType.JSON_PROBLEM), - ("text/json", ContentType.JSON_TEXT), - ("application/xml", ContentType.XML), - ("application/x-www-form-urlencoded", ContentType.FORM), - ("multipart/form-data", ContentType.MULTIPART_FORM), - ("text/plain", ContentType.PLAIN_TEXT), - ("text/html", ContentType.HTML), - ("application/pdf", ContentType.PDF), - ("image/png", ContentType.PNG), - ("image/jpeg", ContentType.JPEG), - ("image/gif", ContentType.GIF), - ("image/svg+xml", ContentType.SVG), - ("image/avif", ContentType.AVIF), - ("image/bmp", ContentType.BMP), - ("image/webp", ContentType.WEBP), - ("image/*", ContentType.Image), - ("application/octet-stream", ContentType.BINARY), -) - - -@pytest.mark.parametrize(["string_value", "expected"], media_type_provider) -def test_media_type(string_value: str, expected: ContentType) -> None: - assert ContentType(string_value) == expected - - -def test_media_type_error() -> None: - with pytest.raises(ValueError): - ContentType("invalid") - - -security_type_provider = ( - ("apiKey", SecurityType.API_KEY), - ("http", SecurityType.HTTP), - ("oauth2", SecurityType.OAUTH2), - ("openIdConnect", SecurityType.OPEN_ID_CONNECT), -) - - -@pytest.mark.parametrize(["string_value", "expected"], security_type_provider) -def test_security_type(string_value: str, expected: SecurityType) -> None: - assert SecurityType(string_value) == expected - - -def test_security_type_error() -> None: - with pytest.raises(ValueError): - SecurityType("invalid") - - -auth_schema_provider = ( - ("basic", AuthenticationScheme.BASIC), - ("bearer", AuthenticationScheme.BEARER), - ("digest", AuthenticationScheme.DIGEST), - ("hoba", AuthenticationScheme.HOBA), - ("mutual", AuthenticationScheme.MUTUAL), - ("negotiate", AuthenticationScheme.NEGOTIATE), - ("oauth", AuthenticationScheme.OAUTH), - ("scram-sha-1", AuthenticationScheme.SCRAM_SHA1), - ("scram-sha-256", AuthenticationScheme.SCRAM_SHA256), - ("vapid", AuthenticationScheme.VAPID), -) - - -@pytest.mark.parametrize(["string_value", "expected"], auth_schema_provider) -def test_auth_schema(string_value: str, expected: AuthenticationScheme) -> None: - assert AuthenticationScheme(string_value) == expected - - -def test_auth_schema_error() -> None: - with pytest.raises(ValueError): - AuthenticationScheme("invalid") - - -auth_flow_provider = ( - ("implicit", OAuthFlowType.IMPLICIT), - ("password", OAuthFlowType.PASSWORD), - ("clientCredentials", OAuthFlowType.CLIENT_CREDENTIALS), - ("authorizationCode", OAuthFlowType.AUTHORIZATION_CODE), -) - - -@pytest.mark.parametrize(["string_value", "expected"], auth_flow_provider) -def test_auth_flow(string_value: str, expected: OAuthFlowType) -> None: - assert OAuthFlowType(string_value) == expected - - -def test_auth_flow_error() -> None: - with pytest.raises(ValueError): - OAuthFlowType("invalid") diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..2eca1fa --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,65 @@ +"""Tests for parser error handling.""" + +import pytest +from pydantic import ValidationError + +from openapi_parser.errors import ParserError +from openapi_parser.parser import parse + + +def test_missing_required_fields() -> None: + """Test validation errors for missing required fields (like info).""" + spec_yaml = """ +openapi: "3.0.0" +paths: {} +""" + with pytest.raises(ParserError) as exc_info: + parse(spec_string=spec_yaml) + + err = exc_info.value + assert isinstance(err.__cause__, ValidationError) + errors = err.errors() + assert len(errors) > 0 + + # Ensure details about the missing field are present + missing_fields = [e["loc"] for e in errors] + assert ("info",) in missing_fields + + +def test_invalid_types() -> None: + """Test validation errors for invalid data types (e.g. non-string title).""" + spec_yaml = """ +openapi: "3.0.0" +info: + title: 123 + version: "1.0.0" +paths: {} +""" + with pytest.raises(ParserError) as exc_info: + parse(spec_string=spec_yaml) + + err = exc_info.value + assert isinstance(err.__cause__, ValidationError) + assert err.errors() + + +def test_broken_ref() -> None: + """Test error raised when a reference cannot be resolved.""" + spec_yaml = """ +openapi: "3.0.0" +info: + title: "Broken Ref API" + version: "1.0.0" +paths: + /users: + get: + responses: + "200": + description: "Success" + content: + application/json: + schema: + $ref: "#/components/schemas/NonExistent" +""" + with pytest.raises(ParserError, match="Failed to resolve references"): + parse(spec_string=spec_yaml) diff --git a/tests/test_logging.py b/tests/test_logging.py deleted file mode 100644 index c299f34..0000000 --- a/tests/test_logging.py +++ /dev/null @@ -1,27 +0,0 @@ -import logging - -from openapi_parser.logging import log_ctx - - -def test_log_context_prefix() -> None: - logger = logging.getLogger(__name__) - handler = logging.StreamHandler() - handler.setFormatter(logging.Formatter("%(message)s")) - records: list[logging.LogRecord] = [] - - class RecordHandler(logging.Handler): - def emit(self, record: logging.LogRecord) -> None: - record.msg = self.format(record) - records.append(record) - - record_handler = RecordHandler() - logger.addHandler(record_handler) - logger.setLevel(logging.DEBUG) - - with log_ctx("test", "path"): - logger.debug("hello") - - logger.removeHandler(record_handler) - - assert len(records) == 1 - assert records[0].msg == "[test.path] hello" diff --git a/tests/test_parse/test_parse_v2.py b/tests/test_parse/test_parse_v2.py new file mode 100644 index 0000000..2332d93 --- /dev/null +++ b/tests/test_parse/test_parse_v2.py @@ -0,0 +1,122 @@ +"""Tests for Swagger 2.0 normalization.""" + +import os + +from openapi_parser.models.v3_0 import Specification +from openapi_parser.parser import parse + + +def test_parse_swagger_v2_petstore() -> None: + """Test full parsing and normalization of Swagger 2.0 petstore.""" + fixture = os.path.join(os.path.dirname(__file__), "..", "data", "swagger_v2.yaml") + spec = parse(fixture) + assert isinstance(spec, Specification) + assert spec.openapi == "3.0.0" # Normalized to 3.0 + assert spec.info.title == "Swagger Petstore" + + # host + basePath + schemes -> servers + assert len(spec.servers) == 1 + assert spec.servers[0].url == "http://petstore.swagger.io/v1" + + # definitions -> components.schemas + assert spec.components is not None + assert spec.components.schemas is not None + assert "Pet" in spec.components.schemas + assert "NewPet" in spec.components.schemas + assert "Error" in spec.components.schemas + + # body parameter -> requestBody + post_pets = spec.paths["/pets"].post + assert post_pets is not None + assert post_pets.request_body is not None + assert "application/json" in post_pets.request_body.content + body_schema = post_pets.request_body.content["application/json"].schema_object + assert body_schema is not None + assert body_schema.ref_name == "#/components/schemas/NewPet" + + +def test_parse_swagger_v2_formdata() -> None: + """Test normalization of formData parameters into requestBody.""" + spec_yaml = """ +swagger: "2.0" +info: + title: "Form API" + version: "1.0.0" +paths: + /submit: + post: + consumes: + - "application/x-www-form-urlencoded" + parameters: + - name: "username" + in: "formData" + type: "string" + required: true + - name: "password" + in: "formData" + type: "string" + responses: + "200": + description: "OK" +""" + spec = parse(spec_string=spec_yaml) + post_op = spec.paths["/submit"].post + assert post_op is not None + assert post_op.request_body is not None + content = post_op.request_body.content["application/x-www-form-urlencoded"] + schema = content.schema_object + assert schema is not None + assert schema.type == "object" + assert schema.properties is not None + assert "username" in schema.properties + assert "password" in schema.properties + assert schema.required == ["username"] + + +def test_parse_swagger_v2_file_ref(tmp_path: object) -> None: + """File $refs in Swagger 2.0 resolve after normalization.""" + schemas_dir = os.path.join(str(tmp_path), "schemas") + os.makedirs(schemas_dir, exist_ok=True) + + main_spec = """ +swagger: "2.0" +info: + title: "File Ref API" + version: "1.0.0" +paths: + /users: + get: + responses: + "200": + description: "OK" + schema: + $ref: "schemas/user.yaml" +""" + + user_spec = """ +type: object +properties: + name: + $ref: "#/definitions/Name" +definitions: + Name: + type: string +""" + + main_path = os.path.join(str(tmp_path), "main.yaml") + user_path = os.path.join(schemas_dir, "user.yaml") + + with open(main_path, "w") as f: + f.write(main_spec) + with open(user_path, "w") as f: + f.write(user_spec) + + spec = parse(main_path) + get_op = spec.paths["/users"].get + assert get_op is not None + media_type = get_op.responses["200"].content + assert media_type is not None + schema = media_type["application/json"].schema_object + assert schema is not None + assert schema.properties is not None + assert schema.properties["name"].type == "string" diff --git a/tests/test_parse/test_parse_v30.py b/tests/test_parse/test_parse_v30.py new file mode 100644 index 0000000..6f70850 --- /dev/null +++ b/tests/test_parse/test_parse_v30.py @@ -0,0 +1,914 @@ +"""Tests for full OpenAPI 3.0 spec parsing. + +Exercises version detection, $ref resolution, and every supported model type. +""" + +import os + +from openapi_parser.enumeration import ( + ApiKeyLocation, + DataType, + ParameterLocation, + QueryParameterStyle, + SecurityType, +) +from openapi_parser.models.base import ( + Contact, + Discriminator, + ExternalDoc, + Info, + License, + Link, + Server, + ServerVariable, +) +from openapi_parser.models.v3_0 import ( + Callback, + Components, + Encoding, + MediaType, + Operation, + Parameter, + PathItem, + RequestBody, + Response, + Schema, + SecurityScheme, + Specification, + Tag, +) + +FIXTURE = os.path.join(os.path.dirname(__file__), "..", "data", "openapi_3.0.yaml") + +_UUID_SCHEMA = Schema( + type=DataType.OBJECT, + additionalProperties=False, + required=["uuid"], + properties={ + "uuid": Schema( + type=DataType.STRING, + format="uuid", + example="12345678-1234-5678-1234-567812345678", + description="Unique object id", + ), + }, +) + +_USER_ALLOF = [ + _UUID_SCHEMA, + Schema(required=["login", "email", "avatar"]), + Schema( + properties={ + "login": Schema( + type=DataType.STRING, + example="super-admin", + description="User login or nickname", + ), + "email": Schema( + type=DataType.STRING, + format="email", + example="user@mail.com", + description="User E-mail address", + ), + "avatar": Schema( + type=DataType.STRING, + format="uri", + example="https://github.com/manchenkoff/openapi3-parser", + description="User Avatar URL", + ), + }, + ), +] + + +def _break_circular_refs(spec: Specification) -> None: + """Break Equipment↔Feature circular reference for model_dump compatibility.""" + components = spec.components + assert components is not None + schemas = components.schemas + assert schemas is not None + if "Equipment" in schemas and "Feature" in schemas: + fe = schemas["Feature"] + assert fe.properties is not None + fe.properties["Equipments"].items = Schema.model_construct() + + +def _strip_ref_name(d: object) -> object: + """Recursively remove ref_name keys from model_dump output.""" + if isinstance(d, dict): + return {k: _strip_ref_name(v) for k, v in d.items() if k != "ref_name"} + if isinstance(d, list): + return [_strip_ref_name(v) for v in d] + return d + + +def test_openapi_3_0_full() -> None: + from openapi_parser.parser import parse + + spec = parse(FIXTURE) + expected = Specification( + openapi="3.0.0", + security=[{"Basic": []}], + info=Info( + title="User example service", + version="1.0.0", + description="Example service specification to work with user storage", + license=License(name="MIT"), + contact=Contact( + name="manchenkoff", + email="artyom@manchenkoff.me", + ), + ), + externalDocs=ExternalDoc( + description="Find more info here", + url="https://example.com/docs", + ), + servers=[ + Server( + url="https://users.app/api/v{version}", + description="production", + variables={ + "version": ServerVariable(default="1", description="API version"), + }, + ), + Server(url="https://stage.users.app", description="staging"), + Server(url="https://users.local", description="development"), + ], + tags=[ + Tag( + name="Users", + description="User operations", + externalDocs=ExternalDoc( + description="User API docs", + url="https://example.com/users/docs", + ), + ), + ], + paths={ + "/users": PathItem( + get=Operation( + summary="Get user list", + description="Method to get user list", + operationId="GetUserList", + tags=["Users"], + parameters=[ + Parameter( + name="limit", + location=ParameterLocation.QUERY, + description="Result items limit", + allowEmptyValue=False, + example=10, + required=True, + allowReserved=True, + schema=Schema( + type=DataType.INTEGER, + not_schema=Schema(type=DataType.STRING), + ), + ), + Parameter( + name="offset", + location=ParameterLocation.QUERY, + description="Result items start offset", + allowEmptyValue=False, + example=0, + required=True, + schema=Schema(type=DataType.INTEGER), + ), + Parameter( + name="json", + location=ParameterLocation.QUERY, + description="Custom JSON parameter", + required=False, + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + properties={ + "key": Schema( + type=DataType.STRING, + example="test", + description="Test parameter", + ), + }, + ), + ), + }, + ), + ], + responses={ + "200": Response( + description="Successful user list response", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["total_count", "users"], + properties={ + "total_count": Schema( + type=DataType.INTEGER, + description="Total count of users", + ), + "users": Schema( + type=DataType.ARRAY, + items=Schema(allOf=_USER_ALLOF), + ), + }, + ), + ), + }, + ), + "400": Response( + description="Bad request or parameters", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1044, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Invalid user id value", + description="Error details", + ), + }, + ), + ), + }, + ), + "500": Response( + description="Internal error", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Unexpected server error", + description="Error details", + ), + }, + ), + ), + }, + ), + }, + ), + post=Operation( + summary="Add new user", + description="Method to add new user", + operationId="AddUser", + security=[{"Basic": []}], + tags=["Users"], + requestBody=RequestBody( + description="New user model request", + content={ + "application/json": MediaType( + schema=Schema(allOf=_USER_ALLOF), + encoding={ + "login": Encoding( + contentType="text/plain", + style=QueryParameterStyle.FORM, + ), + "email": Encoding( + contentType="text/plain", + ), + }, + ), + }, + ), + callbacks={ + "onAdd": Callback( + expressions={ + "{$request.body#/email}": PathItem( + post=Operation( + summary="Callback after user creation", + responses={ + "200": Response( + description="Callback processed successfully", + ), + }, + ), + ), + }, + ), + }, + responses={ + "201": Response( + description="Successful addition user response", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["user"], + properties={"user": Schema(allOf=_USER_ALLOF)}, + ), + ), + }, + ), + "400": Response( + description="Bad request or parameters", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1044, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Invalid user id value", + description="Error details", + ), + }, + ), + ), + }, + ), + "500": Response( + description="Internal error", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Unexpected server error", + description="Error details", + ), + }, + ), + ), + }, + ), + }, + ), + ), + "/users/{uuid}": PathItem( + parameters=[ + Parameter( + name="uuid", + location=ParameterLocation.PATH, + description="User unique id", + allowEmptyValue=False, + example="12345678-1234-5678-1234-567812345678", + required=True, + schema=Schema(type=DataType.STRING, format="uuid"), + ), + ], + get=Operation( + summary="Get user model", + description="Method to get user details", + operationId="GetUser", + tags=["Users"], + responses={ + "200": Response( + description="Successful user response", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["user"], + properties={"user": Schema(allOf=_USER_ALLOF)}, + ), + ), + }, + links={ + "UpdateUser": Link( + operationId="UpdateUser", + parameters={ + "uuid": "$response.body#/user/uuid", + }, + description="Updates the user", + ), + }, + ), + "400": Response( + description="Bad request or parameters", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1044, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Invalid user id value", + description="Error details", + ), + }, + ), + ), + }, + ), + "500": Response( + description="Internal error", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Unexpected server error", + description="Error details", + ), + }, + ), + ), + }, + ), + }, + ), + put=Operation( + summary="Update existed user model", + operationId="UpdateUser", + tags=["Users"], + responses={ + "default": Response(description="Empty successful response"), + "200": Response(description="Empty successful response"), + "400": Response( + description="Bad request or parameters", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1044, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Invalid user id value", + description="Error details", + ), + }, + ), + ), + }, + ), + "500": Response( + description="Internal error", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Unexpected server error", + description="Error details", + ), + }, + ), + ), + }, + ), + }, + ), + patch=Operation( + summary="Patch user model", + operationId="PatchUser", + tags=["Users"], + requestBody=RequestBody( + content={ + "application/json": MediaType( + schema=Schema(allOf=_USER_ALLOF), + ), + }, + ), + responses={ + "200": Response( + description="Successful user response", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["user"], + properties={"user": Schema(allOf=_USER_ALLOF)}, + ), + ), + }, + links={ + "UpdateUser": Link( + operationId="UpdateUser", + parameters={ + "uuid": "$response.body#/user/uuid", + }, + description="Updates the user", + ), + }, + ), + }, + ), + delete=Operation( + summary="Delete user", + operationId="DeleteUser", + tags=["Users"], + responses={ + "204": Response(description="No content"), + }, + ), + ), + "/non-strict": PathItem( + get=Operation( + responses={ + "200": Response( + description="OK", + content={ + "application/hal+json": MediaType( + schema=Schema( + type=DataType.OBJECT, + properties={ + "expectedDeliveryDuration": Schema( + type=DataType.STRING, + format="duration", + ), + }, + ), + ), + }, + ), + "400": Response( + description="Bad Request", + content={ + "application/problem+json": MediaType( + schema=Schema( + type=DataType.OBJECT, + properties={}, + ), + ), + }, + ), + }, + ), + ), + "/equipment": PathItem( + get=Operation( + responses={ + "200": Response( + description="OK", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + properties={ + "Features": Schema( + type=DataType.ARRAY, + items=Schema.model_construct( + ref_name="#/components/schemas/Feature", + type=DataType.OBJECT, + properties={ + "Equipments": Schema( + type=DataType.ARRAY, + items=Schema.model_construct( + ref_name="#/components/schemas/Equipment", + ), + ), + "Id": Schema( + type=DataType.INTEGER, + format="int64", + ), + }, + ), + ), + "Id": Schema( + type=DataType.INTEGER, + format="int64", + ), + }, + ), + ), + }, + ), + }, + ), + ), + }, + components=Components( + securitySchemes={ + "Basic": SecurityScheme( + type=SecurityType.HTTP, + scheme="basic", + ), + "BearerAuth": SecurityScheme( + type=SecurityType.HTTP, + scheme="bearer", + bearerFormat="JWT", + ), + "ApiKey": SecurityScheme( + type=SecurityType.API_KEY, + location=ApiKeyLocation.HEADER, + name="X-API-Key", + ), + }, + parameters={ + "Limit": Parameter( + name="limit", + location=ParameterLocation.QUERY, + description="Result items limit", + allowEmptyValue=False, + example=10, + required=True, + allowReserved=True, + schema=Schema( + type=DataType.INTEGER, + not_schema=Schema(type=DataType.STRING), + ), + ), + "Offset": Parameter( + name="offset", + location=ParameterLocation.QUERY, + description="Result items start offset", + allowEmptyValue=False, + example=0, + required=True, + schema=Schema(type=DataType.INTEGER), + ), + "UserUUID": Parameter( + name="uuid", + location=ParameterLocation.PATH, + description="User unique id", + allowEmptyValue=False, + example="12345678-1234-5678-1234-567812345678", + required=True, + schema=Schema(type=DataType.STRING, format="uuid"), + ), + "JsonParameter": Parameter( + name="json", + location=ParameterLocation.QUERY, + description="Custom JSON parameter", + required=False, + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + properties={ + "key": Schema( + type=DataType.STRING, + example="test", + description="Test parameter", + ), + }, + ), + ), + }, + ), + }, + responses={ + "BadRequest": Response( + description="Bad request or parameters", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1044, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Invalid user id value", + description="Error details", + ), + }, + ), + ), + }, + ), + "InternalServerError": Response( + description="Internal error", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Unexpected server error", + description="Error details", + ), + }, + ), + ), + }, + ), + "Empty": Response(description="Empty successful response"), + "GetUserListResponse": Response( + description="Successful user list response", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["total_count", "users"], + properties={ + "total_count": Schema( + type=DataType.INTEGER, + description="Total count of users", + ), + "users": Schema( + type=DataType.ARRAY, + items=Schema(allOf=_USER_ALLOF), + ), + }, + ), + ), + }, + ), + "AddUserResponse": Response( + description="Successful addition user response", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["user"], + properties={"user": Schema(allOf=_USER_ALLOF)}, + ), + ), + }, + ), + "UserResponse": Response( + description="Successful user response", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["user"], + properties={"user": Schema(allOf=_USER_ALLOF)}, + ), + ), + }, + links={ + "UpdateUser": Link( + operationId="UpdateUser", + parameters={ + "uuid": "$response.body#/user/uuid", + }, + description="Updates the user", + ), + }, + ), + }, + requestBodies={ + "AddUserRequest": RequestBody( + description="New user model request", + content={ + "application/json": MediaType( + schema=Schema(allOf=_USER_ALLOF), + encoding={ + "login": Encoding( + contentType="text/plain", + style=QueryParameterStyle.FORM, + ), + "email": Encoding( + contentType="text/plain", + ), + }, + ), + }, + ), + }, + schemas={ + "BadRequestError": Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1044, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Invalid user id value", + description="Error details", + ), + }, + ), + "InternalServerError": Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Unexpected server error", + description="Error details", + ), + }, + ), + "UUIDObject": _UUID_SCHEMA, + "User": Schema(allOf=_USER_ALLOF), + "Payload": Schema( + oneOf=[ + Schema(type=DataType.STRING), + Schema(type=DataType.INTEGER), + ], + discriminator=Discriminator( + property_name="payloadType", + mapping={"str": "SomeTarget", "int": "OtherTarget"}, + ), + ), + "Equipment": Schema( + type=DataType.OBJECT, + properties={ + "Features": Schema( + type=DataType.ARRAY, + items=Schema.model_construct( + ref_name="#/components/schemas/Feature", + type=DataType.OBJECT, + properties={ + "Equipments": Schema( + type=DataType.ARRAY, + items=Schema.model_construct( + ref_name="#/components/schemas/Equipment", + ), + ), + "Id": Schema( + type=DataType.INTEGER, + format="int64", + ), + }, + ), + ), + "Id": Schema( + type=DataType.INTEGER, + format="int64", + ), + }, + ), + "Feature": Schema( + type=DataType.OBJECT, + properties={ + "Equipments": Schema( + type=DataType.ARRAY, + items=Schema.model_construct( + ref_name="#/components/schemas/Equipment", + ), + ), + "Id": Schema( + type=DataType.INTEGER, + format="int64", + ), + }, + ), + }, + ), + ) + _break_circular_refs(spec) + assert _strip_ref_name(spec.model_dump()) == _strip_ref_name(expected.model_dump()) diff --git a/tests/test_parse/test_parse_v31.py b/tests/test_parse/test_parse_v31.py new file mode 100644 index 0000000..24e8be8 --- /dev/null +++ b/tests/test_parse/test_parse_v31.py @@ -0,0 +1,955 @@ +"""Tests for full OpenAPI 3.1 spec parsing. + +Exercises 3.1-specific features: jsonSchemaDialect, webhooks, array type syntax. +""" + +import os + +from openapi_parser.enumeration import ( + ApiKeyLocation, + DataType, + ParameterLocation, + QueryParameterStyle, + SecurityType, +) +from openapi_parser.models.base import ( + Contact, + Discriminator, + ExternalDoc, + Info, + License, + Link, + Server, + ServerVariable, +) +from openapi_parser.models.v3_0 import ( + SecurityScheme, +) +from openapi_parser.models.v3_0 import ( + Specification as SpecificationV3_0, +) +from openapi_parser.models.v3_1 import ( + Callback, + Components, + Encoding, + MediaType, + Operation, + Parameter, + PathItem, + RequestBody, + Response, + Schema, + Specification, + Tag, +) + +FIXTURE = os.path.join(os.path.dirname(__file__), "..", "data", "openapi_3.1.yaml") + + +_UUID_SCHEMA = Schema( + type=DataType.OBJECT, + additionalProperties=False, + required=["uuid"], + properties={ + "uuid": Schema( + type=DataType.STRING, + format="uuid", + example="12345678-1234-5678-1234-567812345678", + description="Unique object id", + ), + }, +) + +_USER_ALLOF = [ + _UUID_SCHEMA, + Schema(required=["login", "email", "avatar"]), + Schema( + properties={ + "login": Schema( + type=DataType.STRING, + example="super-admin", + description="User login or nickname", + ), + "email": Schema( + type=DataType.STRING, + format="email", + example="user@mail.com", + description="User E-mail address", + ), + "avatar": Schema( + type=DataType.STRING, + format="uri", + example="https://github.com/manchenkoff/openapi3-parser", + description="User Avatar URL", + ), + }, + ), +] + + +def _break_circular_refs(spec: SpecificationV3_0) -> None: + """Break Equipment↔Feature circular reference for model_dump compatibility.""" + components = spec.components + assert components is not None + schemas = components.schemas + assert schemas is not None + if "Equipment" in schemas and "Feature" in schemas: + fe = schemas["Feature"] + assert fe.properties is not None + fe.properties["Equipments"].items = Schema.model_construct() + + +def _strip_ref_name(d: object) -> object: + """Recursively remove ref_name keys from model_dump output.""" + if isinstance(d, dict): + return {k: _strip_ref_name(v) for k, v in d.items() if k != "ref_name"} + if isinstance(d, list): + return [_strip_ref_name(v) for v in d] + return d + + +def test_openapi_3_1_full() -> None: + from openapi_parser.parser import parse + + spec = parse(FIXTURE) + expected = Specification( + openapi="3.1.0", + jsonSchemaDialect="https://json-schema.org/draft/2020-12/schema", + security=[{"Basic": []}], + info=Info( + title="User example service", + version="1.0.0", + description="Example service specification to work with user storage", + license=License(name="MIT"), + contact=Contact( + name="manchenkoff", + email="artyom@manchenkoff.me", + ), + ), + externalDocs=ExternalDoc( + description="Find more info here", + url="https://example.com/docs", + ), + servers=[ + Server( + url="https://users.app/api/v{version}", + description="production", + variables={ + "version": ServerVariable(default="1", description="API version"), + }, + ), + Server(url="https://stage.users.app", description="staging"), + Server(url="https://users.local", description="development"), + ], + tags=[ + Tag( + name="Users", + description="User operations", + externalDocs=ExternalDoc( + description="User API docs", + url="https://example.com/users/docs", + ), + ), + ], + webhooks={ + "newPet": PathItem( + post=Operation( + requestBody=RequestBody( + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.ARRAY, + items=Schema( + type=[DataType.OBJECT, DataType.NULL], + required=["id", "name"], + properties={ + "id": Schema(type=DataType.INTEGER), + "name": Schema(type=DataType.STRING), + }, + ), + ), + ), + }, + ), + responses={ + "201": Response(description="Created"), + }, + ), + ), + }, + paths={ + "/users": PathItem( + get=Operation( + summary="Get user list", + description="Method to get user list", + operationId="GetUserList", + tags=["Users"], + parameters=[ + Parameter( + name="limit", + location=ParameterLocation.QUERY, + description="Result items limit", + allowEmptyValue=False, + example=10, + required=True, + allowReserved=True, + schema=Schema( + type=DataType.INTEGER, + not_schema=Schema(type=DataType.STRING), + ), + ), + Parameter( + name="offset", + location=ParameterLocation.QUERY, + description="Result items start offset", + allowEmptyValue=False, + example=0, + required=True, + schema=Schema(type=DataType.INTEGER), + ), + Parameter( + name="json", + location=ParameterLocation.QUERY, + description="Custom JSON parameter", + required=False, + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + properties={ + "key": Schema( + type=DataType.STRING, + example="test", + description="Test parameter", + ), + }, + ), + ), + }, + ), + ], + responses={ + "200": Response( + description="Successful user list response", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["total_count", "users"], + properties={ + "total_count": Schema( + type=DataType.INTEGER, + description="Total count of users", + ), + "users": Schema( + type=DataType.ARRAY, + items=Schema(allOf=_USER_ALLOF), + ), + }, + ), + ), + }, + ), + "400": Response( + description="Bad request or parameters", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1044, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Invalid user id value", + description="Error details", + ), + }, + ), + ), + }, + ), + "500": Response( + description="Internal error", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Unexpected server error", + description="Error details", + ), + }, + ), + ), + }, + ), + }, + ), + post=Operation( + summary="Add new user", + description="Method to add new user", + operationId="AddUser", + security=[{"Basic": []}], + tags=["Users"], + requestBody=RequestBody( + description="New user model request", + content={ + "application/json": MediaType( + schema=Schema(allOf=_USER_ALLOF), + encoding={ + "login": Encoding( + contentType="text/plain", + style=QueryParameterStyle.FORM, + ), + "email": Encoding( + contentType="text/plain", + ), + }, + ), + }, + ), + callbacks={ + "onAdd": Callback( + expressions={ + "{$request.body#/email}": PathItem( + post=Operation( + summary="Callback after user creation", + responses={ + "200": Response( + description="Callback processed successfully", + ), + }, + ), + ), + }, + ), + }, + responses={ + "201": Response( + description="Successful addition user response", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["user"], + properties={"user": Schema(allOf=_USER_ALLOF)}, + ), + ), + }, + ), + "400": Response( + description="Bad request or parameters", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1044, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Invalid user id value", + description="Error details", + ), + }, + ), + ), + }, + ), + "500": Response( + description="Internal error", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Unexpected server error", + description="Error details", + ), + }, + ), + ), + }, + ), + }, + ), + ), + "/users/{uuid}": PathItem( + parameters=[ + Parameter( + name="uuid", + location=ParameterLocation.PATH, + description="User unique id", + allowEmptyValue=False, + example="12345678-1234-5678-1234-567812345678", + required=True, + schema=Schema(type=DataType.STRING, format="uuid"), + ), + ], + get=Operation( + summary="Get user model", + description="Method to get user details", + operationId="GetUser", + tags=["Users"], + responses={ + "200": Response( + description="Successful user response", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["user"], + properties={"user": Schema(allOf=_USER_ALLOF)}, + ), + ), + }, + links={ + "UpdateUser": Link( + operationId="UpdateUser", + parameters={ + "uuid": "$response.body#/user/uuid", + }, + description="Updates the user", + ), + }, + ), + "400": Response( + description="Bad request or parameters", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1044, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Invalid user id value", + description="Error details", + ), + }, + ), + ), + }, + ), + "500": Response( + description="Internal error", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Unexpected server error", + description="Error details", + ), + }, + ), + ), + }, + ), + }, + ), + put=Operation( + summary="Update existed user model", + operationId="UpdateUser", + tags=["Users"], + responses={ + "default": Response(description="Empty successful response"), + "200": Response(description="Empty successful response"), + "400": Response( + description="Bad request or parameters", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1044, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Invalid user id value", + description="Error details", + ), + }, + ), + ), + }, + ), + "500": Response( + description="Internal error", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Unexpected server error", + description="Error details", + ), + }, + ), + ), + }, + ), + }, + ), + patch=Operation( + summary="Patch user model", + operationId="PatchUser", + tags=["Users"], + requestBody=RequestBody( + content={ + "application/json": MediaType( + schema=Schema(allOf=_USER_ALLOF), + ), + }, + ), + responses={ + "200": Response( + description="Successful user response", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["user"], + properties={"user": Schema(allOf=_USER_ALLOF)}, + ), + ), + }, + links={ + "UpdateUser": Link( + operationId="UpdateUser", + parameters={ + "uuid": "$response.body#/user/uuid", + }, + description="Updates the user", + ), + }, + ), + }, + ), + delete=Operation( + summary="Delete user", + operationId="DeleteUser", + tags=["Users"], + responses={ + "204": Response(description="No content"), + }, + ), + ), + "/non-strict": PathItem( + get=Operation( + responses={ + "200": Response( + description="OK", + content={ + "application/hal+json": MediaType( + schema=Schema( + type=DataType.OBJECT, + properties={ + "expectedDeliveryDuration": Schema( + type=DataType.STRING, + format="duration", + ), + }, + ), + ), + }, + ), + "400": Response( + description="Bad Request", + content={ + "application/problem+json": MediaType( + schema=Schema( + type=DataType.OBJECT, + properties={}, + ), + ), + }, + ), + }, + ), + ), + "/equipment": PathItem( + get=Operation( + responses={ + "200": Response( + description="OK", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + properties={ + "Features": Schema( + type=DataType.ARRAY, + items=Schema.model_construct( + ref_name="#/components/schemas/Feature", + type=DataType.OBJECT, + properties={ + "Equipments": Schema( + type=DataType.ARRAY, + items=Schema.model_construct( + ref_name="#/components/schemas/Equipment", + ), + ), + "Id": Schema( + type=DataType.INTEGER, + format="int64", + ), + }, + ), + ), + "Id": Schema( + type=DataType.INTEGER, + format="int64", + ), + }, + ), + ), + }, + ), + }, + ), + ), + }, + components=Components( + securitySchemes={ + "Basic": SecurityScheme( + type=SecurityType.HTTP, + scheme="basic", + ), + "BearerAuth": SecurityScheme( + type=SecurityType.HTTP, + scheme="bearer", + bearerFormat="JWT", + ), + "ApiKey": SecurityScheme( + type=SecurityType.API_KEY, + location=ApiKeyLocation.HEADER, + name="X-API-Key", + ), + }, + parameters={ + "Limit": Parameter( + name="limit", + location=ParameterLocation.QUERY, + description="Result items limit", + allowEmptyValue=False, + example=10, + required=True, + allowReserved=True, + schema=Schema( + type=DataType.INTEGER, + not_schema=Schema(type=DataType.STRING), + ), + ), + "Offset": Parameter( + name="offset", + location=ParameterLocation.QUERY, + description="Result items start offset", + allowEmptyValue=False, + example=0, + required=True, + schema=Schema(type=DataType.INTEGER), + ), + "UserUUID": Parameter( + name="uuid", + location=ParameterLocation.PATH, + description="User unique id", + allowEmptyValue=False, + example="12345678-1234-5678-1234-567812345678", + required=True, + schema=Schema(type=DataType.STRING, format="uuid"), + ), + "JsonParameter": Parameter( + name="json", + location=ParameterLocation.QUERY, + description="Custom JSON parameter", + required=False, + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + properties={ + "key": Schema( + type=DataType.STRING, + example="test", + description="Test parameter", + ), + }, + ), + ), + }, + ), + }, + responses={ + "BadRequest": Response( + description="Bad request or parameters", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1044, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Invalid user id value", + description="Error details", + ), + }, + ), + ), + }, + ), + "InternalServerError": Response( + description="Internal error", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Unexpected server error", + description="Error details", + ), + }, + ), + ), + }, + ), + "Empty": Response(description="Empty successful response"), + "GetUserListResponse": Response( + description="Successful user list response", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["total_count", "users"], + properties={ + "total_count": Schema( + type=DataType.INTEGER, + description="Total count of users", + ), + "users": Schema( + type=DataType.ARRAY, + items=Schema(allOf=_USER_ALLOF), + ), + }, + ), + ), + }, + ), + "AddUserResponse": Response( + description="Successful addition user response", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["user"], + properties={"user": Schema(allOf=_USER_ALLOF)}, + ), + ), + }, + ), + "UserResponse": Response( + description="Successful user response", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["user"], + properties={"user": Schema(allOf=_USER_ALLOF)}, + ), + ), + }, + links={ + "UpdateUser": Link( + operationId="UpdateUser", + parameters={ + "uuid": "$response.body#/user/uuid", + }, + description="Updates the user", + ), + }, + ), + }, + requestBodies={ + "AddUserRequest": RequestBody( + description="New user model request", + content={ + "application/json": MediaType( + schema=Schema(allOf=_USER_ALLOF), + encoding={ + "login": Encoding( + contentType="text/plain", + style=QueryParameterStyle.FORM, + ), + "email": Encoding( + contentType="text/plain", + ), + }, + ), + }, + ), + }, + schemas={ + "BadRequestError": Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1044, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Invalid user id value", + description="Error details", + ), + }, + ), + "InternalServerError": Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Unexpected server error", + description="Error details", + ), + }, + ), + "UUIDObject": _UUID_SCHEMA, + "User": Schema(allOf=_USER_ALLOF), + "Payload": Schema( + oneOf=[ + Schema(type=DataType.STRING), + Schema(type=DataType.INTEGER), + ], + discriminator=Discriminator( + property_name="payloadType", + mapping={"str": "SomeTarget", "int": "OtherTarget"}, + ), + ), + "Pet": Schema( + type=[DataType.OBJECT, DataType.NULL], + required=["id", "name"], + properties={ + "id": Schema(type=DataType.INTEGER), + "name": Schema(type=DataType.STRING), + }, + ), + "Equipment": Schema( + type=DataType.OBJECT, + properties={ + "Features": Schema( + type=DataType.ARRAY, + items=Schema.model_construct( + ref_name="#/components/schemas/Feature", + type=DataType.OBJECT, + properties={ + "Equipments": Schema( + type=DataType.ARRAY, + items=Schema.model_construct( + ref_name="#/components/schemas/Equipment", + ), + ), + "Id": Schema( + type=DataType.INTEGER, + format="int64", + ), + }, + ), + ), + "Id": Schema( + type=DataType.INTEGER, + format="int64", + ), + }, + ), + "Feature": Schema( + type=DataType.OBJECT, + properties={ + "Equipments": Schema( + type=DataType.ARRAY, + items=Schema.model_construct( + ref_name="#/components/schemas/Equipment", + ), + ), + "Id": Schema( + type=DataType.INTEGER, + format="int64", + ), + }, + ), + }, + ), + ) + _break_circular_refs(spec) + assert _strip_ref_name(spec.model_dump()) == _strip_ref_name(expected.model_dump()) diff --git a/tests/test_parse/test_parse_v32.py b/tests/test_parse/test_parse_v32.py new file mode 100644 index 0000000..f0f2b1c --- /dev/null +++ b/tests/test_parse/test_parse_v32.py @@ -0,0 +1,966 @@ +"""Tests for full OpenAPI 3.2 spec parsing. + +Exercises 3.2-specific features: jsonSchemaDialect, webhooks, array type syntax. +""" + +import os + +from openapi_parser.enumeration import ( + ApiKeyLocation, + DataType, + ParameterLocation, + QueryParameterStyle, + SecurityType, +) +from openapi_parser.models.base import ( + Contact, + Discriminator, + ExternalDoc, + Info, + License, + Link, + Server, + ServerVariable, +) +from openapi_parser.models.v3_0 import ( + SecurityScheme, +) +from openapi_parser.models.v3_0 import ( + Specification as SpecificationV3_0, +) +from openapi_parser.models.v3_1 import ( + Callback, + Components, + Encoding, + MediaType, + Operation, + Parameter, + PathItem, + RequestBody, + Response, + Schema, + Specification, + Tag, +) + +FIXTURE = os.path.join(os.path.dirname(__file__), "..", "data", "openapi_3.2.yaml") + + +_UUID_SCHEMA = Schema( + type=DataType.OBJECT, + additionalProperties=False, + required=["uuid"], + properties={ + "uuid": Schema( + type=DataType.STRING, + format="uuid", + example="12345678-1234-5678-1234-567812345678", + description="Unique object id", + ), + }, +) + +_USER_ALLOF = [ + _UUID_SCHEMA, + Schema(required=["login", "email", "avatar"]), + Schema( + properties={ + "login": Schema( + type=DataType.STRING, + example="super-admin", + description="User login or nickname", + ), + "email": Schema( + type=DataType.STRING, + format="email", + example="user@mail.com", + description="User E-mail address", + ), + "avatar": Schema( + type=DataType.STRING, + format="uri", + example="https://github.com/manchenkoff/openapi3-parser", + description="User Avatar URL", + ), + }, + ), +] + + +def _break_circular_refs(spec: SpecificationV3_0) -> None: + """Break Equipment↔Feature circular reference for model_dump compatibility.""" + components = spec.components + assert components is not None + schemas = components.schemas + assert schemas is not None + if "Equipment" in schemas and "Feature" in schemas: + fe = schemas["Feature"] + assert fe.properties is not None + fe.properties["Equipments"].items = Schema.model_construct() + + +def _strip_ref_name(d: object) -> object: + """Recursively remove ref_name keys from model_dump output.""" + if isinstance(d, dict): + return {k: _strip_ref_name(v) for k, v in d.items() if k != "ref_name"} + if isinstance(d, list): + return [_strip_ref_name(v) for v in d] + return d + + +def test_openapi_3_2_full() -> None: + from openapi_parser.parser import parse + + spec = parse(FIXTURE) + expected = Specification( + openapi="3.2.0", + jsonSchemaDialect="https://json-schema.org/draft/2020-12/schema", + security=[{"Basic": []}], + info=Info( + title="User example service", + version="1.0.0", + description="Example service specification to work with user storage", + license=License(name="MIT"), + contact=Contact( + name="manchenkoff", + email="artyom@manchenkoff.me", + ), + ), + externalDocs=ExternalDoc( + description="Find more info here", + url="https://example.com/docs", + ), + servers=[ + Server( + url="https://users.app/api/v{version}", + description="production", + variables={ + "version": ServerVariable(default="1", description="API version"), + }, + ), + Server(url="https://stage.users.app", description="staging"), + Server(url="https://users.local", description="development"), + ], + tags=[ + Tag( + name="Users", + summary="User API", + parent="API", + kind="domain", + description="User operations", + externalDocs=ExternalDoc( + description="User API docs", + url="https://example.com/users/docs", + ), + ), + ], + webhooks={ + "newPet": PathItem( + post=Operation( + requestBody=RequestBody( + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.ARRAY, + items=Schema( + type=[DataType.OBJECT, DataType.NULL], + required=["id", "name"], + properties={ + "id": Schema(type=DataType.INTEGER), + "name": Schema(type=DataType.STRING), + }, + ), + ), + ), + }, + ), + responses={ + "201": Response(description="Created"), + }, + ), + ), + }, + paths={ + "/users": PathItem( + get=Operation( + summary="Get user list", + description="Method to get user list", + operationId="GetUserList", + tags=["Users"], + parameters=[ + Parameter( + name="limit", + location=ParameterLocation.QUERY, + description="Result items limit", + allowEmptyValue=False, + example=10, + required=True, + allowReserved=True, + schema=Schema( + type=DataType.INTEGER, + not_schema=Schema(type=DataType.STRING), + ), + ), + Parameter( + name="offset", + location=ParameterLocation.QUERY, + description="Result items start offset", + allowEmptyValue=False, + example=0, + required=True, + schema=Schema(type=DataType.INTEGER), + ), + Parameter( + name="json", + location=ParameterLocation.QUERY, + description="Custom JSON parameter", + required=False, + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + properties={ + "key": Schema( + type=DataType.STRING, + example="test", + description="Test parameter", + ), + }, + ), + ), + }, + ), + ], + responses={ + "200": Response( + description="Successful user list response", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["total_count", "users"], + properties={ + "total_count": Schema( + type=DataType.INTEGER, + description="Total count of users", + ), + "users": Schema( + type=DataType.ARRAY, + items=Schema(allOf=_USER_ALLOF), + ), + }, + ), + ), + }, + ), + "400": Response( + description="Bad request or parameters", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1044, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Invalid user id value", + description="Error details", + ), + }, + ), + ), + }, + ), + "500": Response( + description="Internal error", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Unexpected server error", + description="Error details", + ), + }, + ), + ), + }, + ), + }, + ), + post=Operation( + summary="Add new user", + description="Method to add new user", + operationId="AddUser", + security=[{"Basic": []}], + tags=["Users"], + requestBody=RequestBody( + description="New user model request", + content={ + "application/json": MediaType( + schema=Schema(allOf=_USER_ALLOF), + encoding={ + "login": Encoding( + contentType="text/plain", + style=QueryParameterStyle.FORM, + ), + "email": Encoding( + contentType="text/plain", + ), + }, + ), + }, + ), + callbacks={ + "onAdd": Callback( + expressions={ + "{$request.body#/email}": PathItem( + post=Operation( + summary="Callback after user creation", + responses={ + "200": Response( + description="Callback processed successfully", + ), + }, + ), + ), + }, + ), + }, + responses={ + "201": Response( + description="Successful addition user response", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["user"], + properties={"user": Schema(allOf=_USER_ALLOF)}, + ), + ), + }, + ), + "400": Response( + description="Bad request or parameters", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1044, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Invalid user id value", + description="Error details", + ), + }, + ), + ), + }, + ), + "500": Response( + description="Internal error", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Unexpected server error", + description="Error details", + ), + }, + ), + ), + }, + ), + }, + ), + ), + "/users/{uuid}": PathItem( + parameters=[ + Parameter( + name="uuid", + location=ParameterLocation.PATH, + description="User unique id", + allowEmptyValue=False, + example="12345678-1234-5678-1234-567812345678", + required=True, + schema=Schema(type=DataType.STRING, format="uuid"), + ), + ], + get=Operation( + summary="Get user model", + description="Method to get user details", + operationId="GetUser", + tags=["Users"], + responses={ + "200": Response( + description="Successful user response", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["user"], + properties={"user": Schema(allOf=_USER_ALLOF)}, + ), + ), + }, + links={ + "UpdateUser": Link( + operationId="UpdateUser", + parameters={ + "uuid": "$response.body#/user/uuid", + }, + description="Updates the user", + ), + }, + ), + "400": Response( + description="Bad request or parameters", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1044, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Invalid user id value", + description="Error details", + ), + }, + ), + ), + }, + ), + "500": Response( + description="Internal error", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Unexpected server error", + description="Error details", + ), + }, + ), + ), + }, + ), + }, + ), + put=Operation( + summary="Update existed user model", + operationId="UpdateUser", + tags=["Users"], + responses={ + "default": Response(description="Empty successful response"), + "200": Response(description="Empty successful response"), + "400": Response( + description="Bad request or parameters", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1044, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Invalid user id value", + description="Error details", + ), + }, + ), + ), + }, + ), + "500": Response( + description="Internal error", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Unexpected server error", + description="Error details", + ), + }, + ), + ), + }, + ), + }, + ), + patch=Operation( + summary="Patch user model", + operationId="PatchUser", + tags=["Users"], + requestBody=RequestBody( + content={ + "application/json": MediaType( + schema=Schema(allOf=_USER_ALLOF), + ), + }, + ), + responses={ + "200": Response( + description="Successful user response", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["user"], + properties={"user": Schema(allOf=_USER_ALLOF)}, + ), + ), + }, + links={ + "UpdateUser": Link( + operationId="UpdateUser", + parameters={ + "uuid": "$response.body#/user/uuid", + }, + description="Updates the user", + ), + }, + ), + }, + ), + delete=Operation( + summary="Delete user", + operationId="DeleteUser", + tags=["Users"], + responses={ + "204": Response(description="No content"), + }, + ), + ), + "/non-strict": PathItem( + get=Operation( + responses={ + "200": Response( + description="OK", + content={ + "application/hal+json": MediaType( + schema=Schema( + type=DataType.OBJECT, + properties={ + "expectedDeliveryDuration": Schema( + type=DataType.STRING, + format="duration", + ), + }, + ), + ), + }, + ), + "400": Response( + description="Bad Request", + content={ + "application/problem+json": MediaType( + schema=Schema( + type=DataType.OBJECT, + properties={}, + ), + ), + }, + ), + }, + ), + ), + "/equipment": PathItem( + get=Operation( + responses={ + "200": Response( + description="OK", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + properties={ + "Features": Schema( + type=DataType.ARRAY, + items=Schema.model_construct( + ref_name="#/components/schemas/Feature", + type=DataType.OBJECT, + properties={ + "Equipments": Schema( + type=DataType.ARRAY, + items=Schema.model_construct( + ref_name="#/components/schemas/Equipment", + ), + ), + "Id": Schema( + type=DataType.INTEGER, + format="int64", + ), + }, + ), + ), + "Id": Schema( + type=DataType.INTEGER, + format="int64", + ), + }, + ), + ), + }, + ), + }, + ), + additional_operations={ + "query": Operation( + summary="Query equipment", + responses={ + "200": Response(description="Equipment list"), + }, + ), + }, + ), + }, + components=Components( + securitySchemes={ + "Basic": SecurityScheme( + type=SecurityType.HTTP, + scheme="basic", + ), + "BearerAuth": SecurityScheme( + type=SecurityType.HTTP, + scheme="bearer", + bearerFormat="JWT", + ), + "ApiKey": SecurityScheme( + type=SecurityType.API_KEY, + location=ApiKeyLocation.HEADER, + name="X-API-Key", + ), + }, + parameters={ + "Limit": Parameter( + name="limit", + location=ParameterLocation.QUERY, + description="Result items limit", + allowEmptyValue=False, + example=10, + required=True, + allowReserved=True, + schema=Schema( + type=DataType.INTEGER, + not_schema=Schema(type=DataType.STRING), + ), + ), + "Offset": Parameter( + name="offset", + location=ParameterLocation.QUERY, + description="Result items start offset", + allowEmptyValue=False, + example=0, + required=True, + schema=Schema(type=DataType.INTEGER), + ), + "UserUUID": Parameter( + name="uuid", + location=ParameterLocation.PATH, + description="User unique id", + allowEmptyValue=False, + example="12345678-1234-5678-1234-567812345678", + required=True, + schema=Schema(type=DataType.STRING, format="uuid"), + ), + "JsonParameter": Parameter( + name="json", + location=ParameterLocation.QUERY, + description="Custom JSON parameter", + required=False, + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + properties={ + "key": Schema( + type=DataType.STRING, + example="test", + description="Test parameter", + ), + }, + ), + ), + }, + ), + }, + responses={ + "BadRequest": Response( + description="Bad request or parameters", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1044, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Invalid user id value", + description="Error details", + ), + }, + ), + ), + }, + ), + "InternalServerError": Response( + description="Internal error", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Unexpected server error", + description="Error details", + ), + }, + ), + ), + }, + ), + "Empty": Response(description="Empty successful response"), + "GetUserListResponse": Response( + description="Successful user list response", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["total_count", "users"], + properties={ + "total_count": Schema( + type=DataType.INTEGER, + description="Total count of users", + ), + "users": Schema( + type=DataType.ARRAY, + items=Schema(allOf=_USER_ALLOF), + ), + }, + ), + ), + }, + ), + "AddUserResponse": Response( + description="Successful addition user response", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["user"], + properties={"user": Schema(allOf=_USER_ALLOF)}, + ), + ), + }, + ), + "UserResponse": Response( + description="Successful user response", + content={ + "application/json": MediaType( + schema=Schema( + type=DataType.OBJECT, + required=["user"], + properties={"user": Schema(allOf=_USER_ALLOF)}, + ), + ), + }, + links={ + "UpdateUser": Link( + operationId="UpdateUser", + parameters={ + "uuid": "$response.body#/user/uuid", + }, + description="Updates the user", + ), + }, + ), + }, + requestBodies={ + "AddUserRequest": RequestBody( + description="New user model request", + content={ + "application/json": MediaType( + schema=Schema(allOf=_USER_ALLOF), + encoding={ + "login": Encoding( + contentType="text/plain", + style=QueryParameterStyle.FORM, + ), + "email": Encoding( + contentType="text/plain", + ), + }, + ), + }, + ), + }, + schemas={ + "BadRequestError": Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1044, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Invalid user id value", + description="Error details", + ), + }, + ), + "InternalServerError": Schema( + type=DataType.OBJECT, + required=["code", "error"], + properties={ + "code": Schema( + type=DataType.INTEGER, + example=1, + description="Internal error code", + ), + "error": Schema( + type=DataType.STRING, + example="Unexpected server error", + description="Error details", + ), + }, + ), + "UUIDObject": _UUID_SCHEMA, + "User": Schema(allOf=_USER_ALLOF), + "Payload": Schema( + oneOf=[ + Schema(type=DataType.STRING), + Schema(type=DataType.INTEGER), + ], + discriminator=Discriminator( + property_name="payloadType", + mapping={"str": "SomeTarget", "int": "OtherTarget"}, + ), + ), + "Pet": Schema( + type=[DataType.OBJECT, DataType.NULL], + required=["id", "name"], + properties={ + "id": Schema(type=DataType.INTEGER), + "name": Schema(type=DataType.STRING), + }, + ), + "Equipment": Schema( + type=DataType.OBJECT, + properties={ + "Features": Schema( + type=DataType.ARRAY, + items=Schema.model_construct( + ref_name="#/components/schemas/Feature", + type=DataType.OBJECT, + properties={ + "Equipments": Schema( + type=DataType.ARRAY, + items=Schema.model_construct( + ref_name="#/components/schemas/Equipment", + ), + ), + "Id": Schema( + type=DataType.INTEGER, + format="int64", + ), + }, + ), + ), + "Id": Schema( + type=DataType.INTEGER, + format="int64", + ), + }, + ), + "Feature": Schema( + type=DataType.OBJECT, + properties={ + "Equipments": Schema( + type=DataType.ARRAY, + items=Schema.model_construct( + ref_name="#/components/schemas/Equipment", + ), + ), + "Id": Schema( + type=DataType.INTEGER, + format="int64", + ), + }, + ), + }, + ), + ) + _break_circular_refs(spec) + assert _strip_ref_name(spec.model_dump()) == _strip_ref_name(expected.model_dump()) diff --git a/tests/test_parse_inputs.py b/tests/test_parse_inputs.py new file mode 100644 index 0000000..3eb0023 --- /dev/null +++ b/tests/test_parse_inputs.py @@ -0,0 +1,708 @@ +"""Tests for parse() input type handling. + +Covers local files (YAML/JSON), `file://` URIs, +and `http://`/`https://` URLs. +""" + +import json +import os +import threading +from concurrent.futures import ThreadPoolExecutor +from typing import Any +from unittest.mock import patch + +import pytest +import yaml + +from openapi_parser.errors import ParserError +from openapi_parser.models.v3_0 import Specification +from openapi_parser.parser import parse + +MINIMAL_SPEC = { + "openapi": "3.0.0", + "info": {"title": "Test", "version": "1.0.0"}, + "paths": {}, +} + + +def _write_minimal(path: str) -> str: + """Write a minimal valid spec to *path* and return its absolute path.""" + with open(path, "w") as f: + if path.endswith(".json"): + json.dump(MINIMAL_SPEC, f) + else: + yaml.dump(MINIMAL_SPEC, f) + return os.path.abspath(path) + + +# --------------------------------------------------------------------------- +# Local file (YAML) +# --------------------------------------------------------------------------- + + +def test_parse_local_yaml(tmp_path: object) -> None: + p = os.path.join(str(tmp_path), "spec.yaml") + path = _write_minimal(p) + spec = parse(uri=path) + assert isinstance(spec, Specification) + assert spec.openapi == "3.0.0" + assert spec.info.title == "Test" + + +# --------------------------------------------------------------------------- +# Local file (JSON) +# --------------------------------------------------------------------------- + + +def test_parse_local_json(tmp_path: object) -> None: + p = os.path.join(str(tmp_path), "spec.json") + path = _write_minimal(p) + spec = parse(uri=path) + assert isinstance(spec, Specification) + assert spec.info.version == "1.0.0" + + +# --------------------------------------------------------------------------- +# file:// URI +# --------------------------------------------------------------------------- + + +def test_parse_file_uri(tmp_path: object) -> None: + p = os.path.join(str(tmp_path), "spec.yaml") + path = _write_minimal(p) + file_uri = "file://" + path + spec = parse(uri=file_uri) + assert isinstance(spec, Specification) + assert spec.info.title == "Test" + + +def test_parse_file_uri_json(tmp_path: object) -> None: + p = os.path.join(str(tmp_path), "spec.json") + path = _write_minimal(p) + file_uri = "file://" + path + spec = parse(uri=file_uri) + assert isinstance(spec, Specification) + assert spec.info.version == "1.0.0" + + +# --------------------------------------------------------------------------- +# HTTP / HTTPS URL (mocked) +# --------------------------------------------------------------------------- + + +def _mock_urlopen(spec_dict: dict[str, Any]) -> Any: + """Return a mock for `urlopen` in parser that returns *spec_dict*.""" + raw_bytes = yaml.dump(spec_dict).encode("utf-8") + + class _Response: + def __enter__(self) -> "_Response": + return self + + def __exit__(self, *args: Any) -> None: + pass + + def read(self) -> bytes: + return raw_bytes + + return patch( + "openapi_parser.resolver.urlopen", + return_value=_Response(), + ) + + +def test_parse_http_url() -> None: + with _mock_urlopen(MINIMAL_SPEC): + spec = parse(uri="http://example.com/spec.yaml") + assert isinstance(spec, Specification) + assert spec.info.title == "Test" + + +def test_parse_https_url() -> None: + with _mock_urlopen(MINIMAL_SPEC): + spec = parse(uri="https://example.com/spec.yaml") + assert isinstance(spec, Specification) + assert spec.info.version == "1.0.0" + + +def test_parse_https_url_json() -> None: + raw_bytes = json.dumps(MINIMAL_SPEC).encode("utf-8") + + class _Response: + def __enter__(self) -> "_Response": + return self + + def __exit__(self, *args: Any) -> None: + pass + + def read(self) -> bytes: + return raw_bytes + + with patch( + "openapi_parser.resolver.urlopen", + return_value=_Response(), + ): + spec = parse(uri="https://api.example.org/v3/spec.json") + assert isinstance(spec, Specification) + assert spec.openapi == "3.0.0" + + +# --------------------------------------------------------------------------- +# Error cases +# --------------------------------------------------------------------------- + + +def test_no_args_raises() -> None: + with pytest.raises(ParserError, match="Either uri or spec_string"): + parse() + + +def test_nonexistent_file_raises() -> None: + with pytest.raises(ParserError, match="Failed to load spec"): + parse(uri="/nonexistent/path/spec.yaml") + + +def test_nonexistent_file_uri_raises() -> None: + with pytest.raises(ParserError, match="Failed to load spec"): + parse(uri="file:///nonexistent/spec.yaml") + + +def test_http_url_open_failure() -> None: + """Simulate a network error when fetching a URL.""" + + def _fail(*args: Any, **kwargs: Any) -> None: + raise OSError("Connection refused") + + with ( + patch( + "openapi_parser.resolver.urlopen", + _fail, + ), + pytest.raises(ParserError, match="Failed to load spec"), + ): + parse(uri="http://unknown.example.com/spec.yaml") + + +def test_callback_shorthand_preserves_extensions() -> None: + """Callback shorthand (without ``expressions`` key) must keep ``x-*`` keys + so that ExtensionsMixin._extract_extensions can move them to the + extensions field.""" + raw = { + "openapi": "3.0.0", + "info": {"title": "Test", "version": "1.0.0"}, + "paths": { + "/test": { + "get": { + "responses": {"200": {"description": "OK"}}, + "callbacks": { + "onData": { + "{$request.body#/id}": { + "post": { + "responses": {"200": {"description": "Callback OK"}} + } + }, + "x-custom": "works", + } + }, + } + } + }, + } + spec = parse(spec_string=yaml.dump(raw)) + operation = spec.paths["/test"].get + assert operation is not None + assert operation.callbacks is not None + cb = operation.callbacks["onData"] + assert cb.extensions == {"x-custom": "works"} + + +# --------------------------------------------------------------------------- +# Reuse a real fixture via file:// (verifies full parse, not just minimal) +# --------------------------------------------------------------------------- + + +def test_parse_full_via_file_uri() -> None: + """Parse the openapi_3.0.yaml fixture via a file:// URI.""" + path = os.path.join(os.path.dirname(__file__), "data", "openapi_3.0.yaml") + file_uri = "file://" + os.path.abspath(path) + spec = parse(uri=file_uri) + assert isinstance(spec, Specification) + assert spec.info.title == "User example service" + + +def test_ref_cache_isolation_between_parses() -> None: + """RefCacheMixin caches must not leak between consecutive parse() calls + when specs share ``$ref`` names. Each call should produce independent + Python objects.""" + spec_a = """ +openapi: "3.0.0" +info: + title: "A" + version: "1.0.0" +paths: + /items: + get: + responses: + "200": + description: "Success" + content: + application/json: + schema: + $ref: "#/components/schemas/Foo" +components: + schemas: + Foo: + type: object +""" + + spec_b = """ +openapi: "3.0.0" +info: + title: "B" + version: "1.0.0" +paths: + /items: + get: + responses: + "200": + description: "Success" + content: + application/json: + schema: + $ref: "#/components/schemas/Foo" +components: + schemas: + Foo: + type: string +""" + + parsed_a = parse(spec_string=spec_a) + parsed_b = parse(spec_string=spec_b) + + assert parsed_a.components is not None + assert parsed_a.components.schemas is not None + assert parsed_b.components is not None + assert parsed_b.components.schemas is not None + foo_a = parsed_a.components.schemas["Foo"] + foo_b = parsed_b.components.schemas["Foo"] + + assert foo_a is not foo_b, "Each parse() must produce independent Schema objects" + assert foo_a.type is not None and foo_a.type.value == "object" + assert foo_b.type is not None and foo_b.type.value == "string" + + +# --------------------------------------------------------------------------- +# Nested relative reference resolution +# --------------------------------------------------------------------------- + + +def test_parse_nested_relative_references(tmp_path: object) -> None: + """Test resolution of nested relative file references.""" + from openapi_parser.enumeration import DataType + from openapi_parser.models.v3_0 import Specification + + # Create directory structure + base_dir = str(tmp_path) + nested_dir = os.path.join(base_dir, "nested") + os.makedirs(nested_dir, exist_ok=True) + + main_spec = """ +openapi: "3.0.0" +info: + title: "Main API" + version: "1.0.0" +paths: + /users: + get: + responses: + "200": + description: "Success" + content: + application/json: + schema: + $ref: "nested/first.yaml" +""" + + first_spec = """ +type: object +properties: + value: + $ref: "second.yaml" +""" + + second_spec = """ +type: string +""" + + main_path = os.path.join(base_dir, "main.yaml") + first_path = os.path.join(nested_dir, "first.yaml") + second_path = os.path.join(nested_dir, "second.yaml") + + with open(main_path, "w") as f: + f.write(main_spec) + with open(first_path, "w") as f: + f.write(first_spec) + with open(second_path, "w") as f: + f.write(second_spec) + + spec = parse(uri=main_path) + assert isinstance(spec, Specification) + + # Verify resolution + get_op = spec.paths["/users"].get + assert get_op is not None + assert get_op.responses["200"].content is not None + schema = get_op.responses["200"].content["application/json"].schema_object + assert schema is not None + assert schema.properties is not None + second_prop = schema.properties["value"] + assert second_prop.type == DataType.STRING + + +# --------------------------------------------------------------------------- +# pathlib.Path and Extra Fields support +# --------------------------------------------------------------------------- + + +def test_parse_file_ref_chain(tmp_path: object) -> None: + """Test resolution when a file ref resolves to content that is itself a $ref. + + main.yaml -> nested/alias.yaml -> nested/target.yaml + This exercises the recursive resolution inside _resolve_ref_node. + """ + base_dir = str(tmp_path) + nested_dir = os.path.join(base_dir, "nested") + os.makedirs(nested_dir, exist_ok=True) + + main_spec = """ +openapi: "3.0.0" +info: + title: "Main API" + version: "1.0.0" +paths: + /items: + get: + responses: + "200": + description: "Success" + content: + application/json: + schema: + $ref: "nested/alias.yaml" +""" + + alias_spec = """ +$ref: "target.yaml" +""" + + target_spec = """ +type: string +""" + + def _write(path: str, content: str) -> None: + with open(path, "w") as f: + f.write(content) + + _write(os.path.join(base_dir, "main.yaml"), main_spec) + _write(os.path.join(nested_dir, "alias.yaml"), alias_spec) + _write(os.path.join(nested_dir, "target.yaml"), target_spec) + + from openapi_parser.enumeration import DataType + + spec = parse(uri=os.path.join(base_dir, "main.yaml")) + get_op = spec.paths["/items"].get + assert get_op is not None + media_type = get_op.responses["200"].content + assert media_type is not None + schema = media_type["application/json"].schema_object + assert schema is not None + assert schema.type == DataType.STRING + + +def test_parse_path_object(tmp_path: object) -> None: + """Test parsing using a pathlib.Path object instead of a string.""" + from pathlib import Path + + p = Path(str(tmp_path)) / "spec.yaml" + path = _write_minimal(str(p)) + spec = parse(uri=Path(path)) + assert isinstance(spec, Specification) + assert spec.info.title == "Test" + + +def test_schema_allows_extra_fields() -> None: + """Test that extra fields on Schema (e.g. const) are preserved.""" + spec_yaml = """ +openapi: "3.0.0" +info: + title: "Extra Fields Test" + version: "1.0.0" +paths: + /test: + get: + responses: + "200": + description: "Success" + content: + application/json: + schema: + type: string + const: "expected_value" + prefixItems: + - type: integer +""" + spec = parse(spec_string=spec_yaml) + get_op = spec.paths["/test"].get + assert get_op is not None + assert get_op.responses["200"].content is not None + schema = get_op.responses["200"].content["application/json"].schema_object + assert schema is not None + # Verify extra fields are preserved in model_extra + assert schema.model_extra is not None + assert schema.model_extra.get("const") == "expected_value" + assert schema.model_extra.get("prefixItems") == [{"type": "integer"}] + + +# --------------------------------------------------------------------------- +# Thread safety +# --------------------------------------------------------------------------- + + +def test_parse_isolated_across_threads() -> None: + """Concurrent parse() calls in different threads must not share ref caches.""" + spec_a = """ +openapi: "3.0.0" +info: + title: "A" + version: "1.0.0" +paths: + /items: + get: + responses: + "200": + description: "Success" + content: + application/json: + schema: + $ref: "#/components/schemas/Foo" +components: + schemas: + Foo: + type: object +""" + + spec_b = """ +openapi: "3.0.0" +info: + title: "B" + version: "1.0.0" +paths: + /items: + get: + responses: + "200": + description: "Success" + content: + application/json: + schema: + $ref: "#/components/schemas/Foo" +components: + schemas: + Foo: + type: string +""" + + barrier = threading.Barrier(2) + results: dict[str, Specification] = {} + + def run(key: str, text: str) -> None: + barrier.wait() + results[key] = parse(spec_string=text) + + with ThreadPoolExecutor(max_workers=2) as pool: + pool.submit(run, "a", spec_a) + pool.submit(run, "b", spec_b) + + components_a = results["a"].components + components_b = results["b"].components + assert components_a is not None and components_a.schemas is not None + assert components_b is not None and components_b.schemas is not None + foo_a = components_a.schemas["Foo"] + foo_b = components_b.schemas["Foo"] + + assert foo_a is not foo_b + assert foo_a.type is not None and foo_a.type.value == "object" + assert foo_b.type is not None and foo_b.type.value == "string" + + +# --------------------------------------------------------------------------- +# spec_string + base_uri (external refs) +# --------------------------------------------------------------------------- + + +def test_parse_spec_string_with_base_uri(tmp_path: object) -> None: + """External $refs resolve when parsing spec_string with a base_uri.""" + refs_dir = os.path.join(str(tmp_path), "refs") + os.makedirs(refs_dir, exist_ok=True) + + ref_path = os.path.join(refs_dir, "user.yaml") + with open(ref_path, "w") as f: + f.write("type: string\n") + + spec_text = """ +openapi: "3.0.0" +info: + title: "Base URI API" + version: "1.0.0" +paths: + /users: + get: + responses: + "200": + description: "Success" + content: + application/json: + schema: + $ref: "user.yaml" +""" + base = os.path.join(refs_dir, "main.yaml") + + spec = parse(spec_string=spec_text, base_uri=base) + get_op = spec.paths["/users"].get + assert get_op is not None + media_type = get_op.responses["200"].content + assert media_type is not None + schema = media_type["application/json"].schema_object + assert schema is not None + assert schema.type is not None and schema.type.value == "string" + + +# --------------------------------------------------------------------------- +# Callback serialization +# --------------------------------------------------------------------------- + + +def test_callback_roundtrip_dump() -> None: + """Callback.model_dump() must produce the spec-compliant flat map.""" + spec_yaml = """ +openapi: "3.0.0" +info: + title: "Callback Test" + version: "1.0.0" +paths: + /test: + get: + responses: + "200": + description: "OK" + callbacks: + onData: + "{$request.body#/id}": + post: + responses: + "200": + description: "Callback OK" + x-custom: "works" +""" + spec = parse(spec_string=spec_yaml) + get_op = spec.paths["/test"].get + assert get_op is not None + callbacks = get_op.callbacks + assert callbacks is not None + callback = callbacks["onData"] + + dumped = callback.model_dump() + assert "{$request.body#/id}" in dumped + assert ( + dumped["{$request.body#/id}"]["post"]["responses"]["200"]["description"] + == "Callback OK" + ) + assert dumped["x-custom"] == "works" + assert "expressions" not in dumped + + +# --------------------------------------------------------------------------- +# PathItem servers + mutualTLS security scheme +# --------------------------------------------------------------------------- + + +def test_path_item_servers() -> None: + """PathItem must support the spec-compliant servers field.""" + spec_yaml = """ +openapi: "3.0.0" +info: + title: "Servers on Path" + version: "1.0.0" +paths: + /test: + servers: + - url: "https://staging.example.com" + get: + responses: + "200": + description: "OK" +""" + spec = parse(spec_string=spec_yaml) + path_item = spec.paths["/test"] + assert path_item.servers is not None + assert path_item.servers[0].url == "https://staging.example.com" + + +def test_mutual_tls_security_scheme() -> None: + """OpenAPI 3.1 mutualTLS security scheme type must parse.""" + from openapi_parser.enumeration import SecurityType + + spec_yaml = """ +openapi: "3.1.0" +info: + title: "mutualTLS Test" + version: "1.0.0" +paths: {} +components: + securitySchemes: + mTLS: + type: mutualTLS +""" + spec = parse(spec_string=spec_yaml) + assert spec.components is not None + assert spec.components.security_schemes is not None + assert spec.components.security_schemes["mTLS"].type == SecurityType.MUTUAL_TLS + + +# --------------------------------------------------------------------------- +# Enumeration export & Component sections resolution +# --------------------------------------------------------------------------- + + +def test_enumeration_export() -> None: + """Ensure that the enumeration module is exported at the top-level package.""" + import openapi_parser + + assert hasattr(openapi_parser, "enumeration") + from openapi_parser.enumeration import DataType + + assert openapi_parser.enumeration.DataType is DataType + + +def test_path_items_resolver_annotation() -> None: + """Ensure pathItems components are correctly resolved and annotated with ref_name in v3.1.""" + spec_yaml = """ +openapi: "3.1.0" +info: + title: "pathItems component test" + version: "1.0.0" +paths: + /users: + $ref: "#/components/pathItems/UserPath" +components: + pathItems: + UserPath: + get: + responses: + "200": + description: "OK" +""" + spec = parse(spec_string=spec_yaml) + assert "/users" in spec.paths + path_item = spec.paths["/users"] + assert path_item.ref_name == "#/components/pathItems/UserPath" + assert path_item.get is not None diff --git a/tests/test_parser.py b/tests/test_parser.py deleted file mode 100644 index 4d2f0d1..0000000 --- a/tests/test_parser.py +++ /dev/null @@ -1,95 +0,0 @@ -from typing import Any -from unittest import mock - -import pytest - -from openapi_parser.errors import ParserError -from openapi_parser.parser import Parser -from openapi_parser.resolver import OpenAPIResolver -from openapi_parser.specification import Specification - -from .openapi_fixture import create_specification - -SWAGGER_JSON_FILEPATH = "./tests/data/swagger.json" - - -@pytest.fixture() -def swagger_specification() -> Specification: - return create_specification() - - -def _create_builder_mock(item: Any) -> mock.MagicMock: - mock_object = mock.MagicMock() - mock_object.build.return_value = item - - return mock_object - - -def _create_list_builder_mock(items: Any) -> mock.MagicMock: - mock_object = mock.MagicMock() - mock_object.build_list.return_value = items - - return mock_object - - -def _create_collection_builder_mock(collection: Any) -> mock.MagicMock: - mock_object = mock.MagicMock() - mock_object.build_collection.return_value = collection - - return mock_object - - -def test_load_specification(swagger_specification: Specification) -> None: - info_builder = _create_builder_mock(swagger_specification.info) - server_list_builder = _create_list_builder_mock(swagger_specification.servers) - tag_list_builder = _create_list_builder_mock(swagger_specification.tags) - external_doc_builder = _create_builder_mock(swagger_specification.external_docs) - path_builder = _create_list_builder_mock(swagger_specification.paths) - security_builder = _create_collection_builder_mock( - swagger_specification.security_schemas, - ) - schemas_builder = _create_collection_builder_mock(swagger_specification.schemas) - - parser = Parser( - info_builder, - server_list_builder, - tag_list_builder, - external_doc_builder, - path_builder, - security_builder, - schemas_builder, - ) - - swagger_json = OpenAPIResolver(SWAGGER_JSON_FILEPATH).resolve() - - assert swagger_specification == parser.load_specification(swagger_json) - - -def test_load_specification_missing_openapi_version() -> None: - parser = Parser( - _create_builder_mock(None), - _create_list_builder_mock([]), - _create_list_builder_mock([]), - _create_builder_mock(None), - _create_list_builder_mock([]), - _create_collection_builder_mock({}), - _create_collection_builder_mock({}), - ) - - with pytest.raises(ParserError, match="Invalid OpenAPI version"): - parser.load_specification({"info": {"title": "test", "version": "1.0.0"}}) - - -def test_load_specification_missing_info() -> None: - parser = Parser( - _create_builder_mock(None), - _create_list_builder_mock([]), - _create_list_builder_mock([]), - _create_builder_mock(None), - _create_list_builder_mock([]), - _create_collection_builder_mock({}), - _create_collection_builder_mock({}), - ) - - with pytest.raises(ParserError, match="missing required 'info' property"): - parser.load_specification({"openapi": "3.0.0"}) diff --git a/tests/test_parser_options.py b/tests/test_parser_options.py deleted file mode 100644 index b860718..0000000 --- a/tests/test_parser_options.py +++ /dev/null @@ -1,33 +0,0 @@ -import pytest - -from openapi_parser import parse -from openapi_parser.errors import ParserError -from openapi_parser.specification import Object, String - -TEST_SCHEMA = "./tests/data/non-strict.yml" - - -def test_default_strict_enum_errors() -> None: - with pytest.raises(ParserError): - parse(TEST_SCHEMA) - - -def test_with_non_strict_enum_succeeds() -> None: - specification = parse(TEST_SCHEMA, strict_enum=False) - - response_200, response_400 = specification.paths[0].operations[0].responses - - assert response_200.content is not None - response_hal_json = response_200.content[0] - assert response_hal_json.type.value == "application/hal+json" - assert isinstance(response_hal_json.schema, Object) - duration_property = response_hal_json.schema.properties[0] - assert duration_property.name == "expectedDeliveryDuration" - assert duration_property.schema.type.value == "string" - assert isinstance(duration_property.schema, String) - assert duration_property.schema.format is not None - assert duration_property.schema.format.value == "duration" - - assert response_400.content is not None - response_problem_json = response_400.content[0] - assert response_problem_json.type.value == "application/problem+json" diff --git a/tests/test_resolver.py b/tests/test_resolver.py deleted file mode 100644 index ad98de6..0000000 --- a/tests/test_resolver.py +++ /dev/null @@ -1,54 +0,0 @@ -from unittest import mock - -import prance -import pytest - -from openapi_parser.errors import ParserError -from openapi_parser.resolver import ( - OpenAPIResolver, - _default_recursion_limit_handler, -) - - -@mock.patch("openapi_parser.resolver.prance.ResolvingParser") -def test_resolve_validation_error(mock_resolving_parser: mock.MagicMock) -> None: - mock_instance = mock_resolving_parser.return_value - mock_instance.parse.side_effect = prance.ValidationError("invalid spec") - - resolver = OpenAPIResolver("fake.yaml") - - with pytest.raises(ParserError, match="OpenAPI validation error"): - resolver.resolve() - - -@mock.patch("openapi_parser.resolver.prance.ResolvingParser") -def test_resolve_generic_error(mock_resolving_parser: mock.MagicMock) -> None: - mock_instance = mock_resolving_parser.return_value - mock_instance.parse.side_effect = RuntimeError("connection failed") - - resolver = OpenAPIResolver("fake.yaml") - - with pytest.raises(ParserError, match="OpenAPI file parsing error"): - resolver.resolve() - - -@mock.patch("openapi_parser.resolver.prance.ResolvingParser") -def test_custom_recursion_limit( - mock_resolving_parser: mock.MagicMock, -) -> None: - OpenAPIResolver("fake.yaml", recursion_limit=10) - - mock_resolving_parser.assert_called_once_with( - "fake.yaml", - spec_string=None, - backend=mock.ANY, - strict=False, - lazy=True, - recursion_limit=10, - recursion_limit_handler=_default_recursion_limit_handler, - ) - - -def test_default_recursion_limit_handler_returns_placeholder() -> None: - result = _default_recursion_limit_handler(1, "http://example.com#/test") - assert result == {"type": "object"} diff --git a/tests/test_runner.py b/tests/test_runner.py deleted file mode 100644 index 69c144c..0000000 --- a/tests/test_runner.py +++ /dev/null @@ -1,68 +0,0 @@ -import pytest - -from openapi_parser import parse -from openapi_parser.enumeration import DataType -from openapi_parser.specification import Array, Object, Specification -from tests.openapi_fixture import create_specification - - -@pytest.fixture() -def swagger_specification() -> Specification: - return create_specification() - - -def test_run_parser(swagger_specification: Specification) -> None: - actual_specification = parse("tests/data/swagger.yml") - - assert actual_specification == swagger_specification - - -def test_parse_recursive_schema() -> None: - actual_specification = parse("tests/data/recursive.yml") - - assert actual_specification.version == "3.0.0" - assert actual_specification.info.title == "Recursive schema test" - assert "Equipment" in actual_specification.schemas - assert "Feature" in actual_specification.schemas - - -def test_parse_recursive_schema_with_recursion_limit_2() -> None: - spec = parse("tests/data/recursive.yml", recursion_limit=2) - - equipment = spec.schemas["Equipment"] - assert isinstance(equipment, Object) - - features = equipment.properties[0] - assert features.name == "Features" - assert isinstance(features.schema, Array) - - feature_level_1 = features.schema.items - assert isinstance(feature_level_1, Object) - - equipment_level_2_schema = feature_level_1.properties[0].schema - assert isinstance(equipment_level_2_schema, Array) - equipment_level_2 = equipment_level_2_schema.items - assert isinstance(equipment_level_2, Object) - assert equipment_level_2.type == DataType.OBJECT - assert len(equipment_level_2.properties) == 2 - assert equipment_level_2.properties[0].name == "Features" - - feature_level_3_schema = equipment_level_2.properties[0].schema - assert isinstance(feature_level_3_schema, Array) - feature_level_3 = feature_level_3_schema.items - assert isinstance(feature_level_3, Object) - assert len(feature_level_3.properties) == 2 - assert feature_level_3.properties[0].name == "Equipments" - - equipment_level_4_schema = feature_level_3.properties[0].schema - assert isinstance(equipment_level_4_schema, Array) - equipment_level_4 = equipment_level_4_schema.items - assert isinstance(equipment_level_4, Object) - assert len(equipment_level_4.properties) == 2 - assert equipment_level_4.properties[0].name == "Features" - - placeholder_schema = equipment_level_4.properties[0].schema - assert isinstance(placeholder_schema, Array) - placeholder = placeholder_schema.items - assert isinstance(placeholder, Object) - assert len(placeholder.properties) == 0 diff --git a/uv.lock b/uv.lock index fb095a8..b09b261 100644 --- a/uv.lock +++ b/uv.lock @@ -73,49 +73,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] -[[package]] -name = "chardet" -version = "7.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/b6/9df434a8eeba2e6628c465a1dfa31034228ef79b26f76f46278f4ef7e49d/chardet-7.4.3.tar.gz", hash = "sha256:cc1d4eb92a4ec1c2df3b490836ffa46922e599d34ce0bb75cf41fd2bf6303d56", size = 784800, upload-time = "2026-04-13T21:33:39.803Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/1b/7f73766c119a1344eb69e31890ede7c5825ce03d69a9d29292d1bd1cfa1b/chardet-7.4.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0c79b13c9908ac7dfe0a74116ebc9a0f28b2319d23c32f3dfcdfbe1279c7eaf", size = 874121, upload-time = "2026-04-13T21:32:47.065Z" }, - { url = "https://files.pythonhosted.org/packages/8b/02/b677c8203d34dad6c2af48287bb1f8c5dff63db2094636fbe634b555b7fb/chardet-7.4.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bba8bea1b28d927b3e99e47deafe53658d34497c0a891d95ff1ba8ff6663f01c", size = 856900, upload-time = "2026-04-13T21:32:48.893Z" }, - { url = "https://files.pythonhosted.org/packages/c4/4b/1361a485a999d97cac4c895e615326f69a639532a52ef365a468bd09bad1/chardet-7.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23163921dccf3103ce59540b0443c106d2c0a0ff2e0503e05196f5e6fdea453f", size = 876634, upload-time = "2026-04-13T21:32:50.238Z" }, - { url = "https://files.pythonhosted.org/packages/87/23/e31c8ad33aa448f0845fd58af5fc22da1626407616d09df4973b2b34f477/chardet-7.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfb54563fe5f130da17c44c6a4e2e8052ba628e5ab4eab7ef8190f736f0f8f72", size = 886497, upload-time = "2026-04-13T21:32:52.111Z" }, - { url = "https://files.pythonhosted.org/packages/18/ef/ea4edec8c87f7e6eda02673acc68fe48725e564fc5a1865782efb53d5598/chardet-7.4.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3990fffcc6a6045f2234ab72752ad037e3b2d48c72037f244d42738db397eb75", size = 881061, upload-time = "2026-04-13T21:32:53.755Z" }, - { url = "https://files.pythonhosted.org/packages/f2/11/fc10600da98541777d720ad9e6bc040c0e0af1adb92e27142e35158957cb/chardet-7.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c7116b0452994734ccff35e154b44240090eb0f4f74b9106292668133557c175", size = 942533, upload-time = "2026-04-13T21:32:55.134Z" }, - { url = "https://files.pythonhosted.org/packages/19/52/505c207f334d51e937cbaa27ff95776e16e2d120e13cbe491cd7b3a70b50/chardet-7.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:25a862cddc6a9ac07023e808aedd297115345fbaabc2690479481ddc0f980e09", size = 870747, upload-time = "2026-04-13T21:32:56.916Z" }, - { url = "https://files.pythonhosted.org/packages/14/4b/d3c79495dee4831b8bebca2790e72cb90f0c5849c940570a7c7e5b70b952/chardet-7.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7005c88da26fd95d8abb8acbe6281d833e9a9181b03cf49b4546c4555389bd97", size = 853210, upload-time = "2026-04-13T21:32:58.309Z" }, - { url = "https://files.pythonhosted.org/packages/b9/99/f6a822ad1bde25a4c38dc3e770485e78e0893dfd871cd6e18ed3ea3a795e/chardet-7.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc50f28bad067393cce0af9091052c3b8df7a23115afd8ba7b2e0947f0cef1f8", size = 873625, upload-time = "2026-04-13T21:32:59.606Z" }, - { url = "https://files.pythonhosted.org/packages/b1/10/31932775c94a86814f76b41c4a772b52abfb0e6125324f32c6da1196c297/chardet-7.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3da294de1a681097848ab58bd3f2771a674f8039d2d87a5538b28856b815e9", size = 883436, upload-time = "2026-04-13T21:33:01.351Z" }, - { url = "https://files.pythonhosted.org/packages/6c/63/0f43e3acf2c436fdb32a0f904aeb03a2904d2126eed34a042a194d235926/chardet-7.4.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c45e116dd51b66226a53ade3f9f635e870de5399b90e00ce45dcc311093bf4", size = 876589, upload-time = "2026-04-13T21:33:02.636Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a6/e9b8f8a3e99602792b01fa7d0a731737615ab56d8bfd0b52935a0ef88b85/chardet-7.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:ccc1f83ab4bcfb901cf39e0c4ba6bc6e726fc6264735f10e24ceb5cb47387578", size = 941866, upload-time = "2026-04-13T21:33:04.282Z" }, - { url = "https://files.pythonhosted.org/packages/61/33/29de185079e6675c3f375546e30a559b7ddc75ce972f18d6e566cd9ea4eb/chardet-7.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:75d3c65cc16bddf40b8da1fd25ba84fca5f8070f2b14e86083653c1c85aee971", size = 874870, upload-time = "2026-04-13T21:33:05.977Z" }, - { url = "https://files.pythonhosted.org/packages/9c/2f/4c5af01fd1a7506a1d5375403d68925eac70289229492db5aa68b58103d8/chardet-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:29af5999f654e8729d251f1724a62b538b1262d9292cccaefddf8a02aae1ef6a", size = 854859, upload-time = "2026-04-13T21:33:07.381Z" }, - { url = "https://files.pythonhosted.org/packages/36/21/edb36ad5dfa48d7f8eed97ab43931ecdaa8c15166c21b1d614967e49d681/chardet-7.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:626f00299ad62dfe937058a09572beed442ccc7b58f87aa667949b20fd3db235", size = 875032, upload-time = "2026-04-13T21:33:08.741Z" }, - { url = "https://files.pythonhosted.org/packages/e5/59/a32a241d861cf180853a11c8e5a67641cb1b2af13c3a5ccce83ec07e2c9f/chardet-7.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a4904dd5f071b7a7d7f50b4a67a86db3c902d243bf31708f1d5cde2f68239cb", size = 888283, upload-time = "2026-04-13T21:33:10.213Z" }, - { url = "https://files.pythonhosted.org/packages/87/2e/e1ee6a77abf3782c00e05b89c4d4328c8353bf9500661c4348df1dd68614/chardet-7.4.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d2879598bc220689e8ce509fe9c3f37ad2fca53a36be9c9bd91abdd91dd364f", size = 879974, upload-time = "2026-04-13T21:33:11.448Z" }, - { url = "https://files.pythonhosted.org/packages/32/60/fca69c534602a7ced04280c952a246ad1edde2a6ca3a164f65d32ac41fe7/chardet-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:4b2799bd58e7245cfa8d4ab2e8ad1d76a5c3a5b1f32318eb6acca4c69a3e7101", size = 943973, upload-time = "2026-04-13T21:33:12.756Z" }, - { url = "https://files.pythonhosted.org/packages/7c/43/79ac9b4db5bc87020c9dbc419125371d80882d1d197e9c4765ba8682b605/chardet-7.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e4486df251b8962e86ea9f139ca235aa6e0542a00f7844c9a04160afb99aa9", size = 873769, upload-time = "2026-04-13T21:33:14.002Z" }, - { url = "https://files.pythonhosted.org/packages/55/5f/25bdec773905bff0ff6cf35ca73b17bd05593b4f87bd8c5fa43705f7167d/chardet-7.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fbff1907925b0c5a1064cffb5e040cd5e338585c9c552625f30de6bc2f3107a", size = 853991, upload-time = "2026-04-13T21:33:15.564Z" }, - { url = "https://files.pythonhosted.org/packages/b4/07/a29380ee0b215d23d77733b5ad60c5c0c7969650e080c667acdf9462040d/chardet-7.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:365135eaf37ba65a828f8e668eb0a8c38c479dcbec724dc25f4dfd781049c357", size = 874024, upload-time = "2026-04-13T21:33:16.915Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b1/3338e121cbd4c8a126b8ccb1061170c2ce51a53f678c502793ea49c6fd6d/chardet-7.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfc134b70c846c21ead8e43ada3ae1a805fff732f6922f8abcf2ff27b8f6493d", size = 887410, upload-time = "2026-04-13T21:33:18.368Z" }, - { url = "https://files.pythonhosted.org/packages/63/1c/44a9a9e0c59c185a5d307ceaeee8768afa1558f0a24f7a4b5fa11b67586b/chardet-7.4.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9acd9988a93e09390f3cd231201ea7166c415eb8da1b735928990ffc05cb9fbb", size = 879269, upload-time = "2026-04-13T21:33:20.377Z" }, - { url = "https://files.pythonhosted.org/packages/1b/b3/5d0e77ea774bd3224321c248880ea0c0379000ac5c2bb6d77609549de247/chardet-7.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:e1b98790c284ff813f18f7cf7de5f05ea2435a080030c7f1a8318f3a4f80b131", size = 944155, upload-time = "2026-04-13T21:33:21.694Z" }, - { url = "https://files.pythonhosted.org/packages/70/a8/bf0811d859e13801279a2ae64f37a408027b282f2047bc0001c75dd356ad/chardet-7.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d892d3dcd652fdef53e3d6327d39b17c0df40a899dfc919abaeb64c974497531", size = 872887, upload-time = "2026-04-13T21:33:23.328Z" }, - { url = "https://files.pythonhosted.org/packages/51/ac/b9d68ebddfe1b02c77af5bf81120e12b036b4432dc6af7a303d90e2bc38b/chardet-7.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:acc46d1b8b7d5783216afe15db56d1c179b9a40e5a1558bc13164c4fd20674c4", size = 853964, upload-time = "2026-04-13T21:33:24.724Z" }, - { url = "https://files.pythonhosted.org/packages/2a/81/17fa103ea9caf5d325a5e4051ab2ba65996fd66baa60b81ee41af1f54e10/chardet-7.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ac3bf11c645734a1701a3804e43eabd98851838192267d08c353a834ab79fea", size = 876006, upload-time = "2026-04-13T21:33:26.098Z" }, - { url = "https://files.pythonhosted.org/packages/c2/20/193faab46a68ea550587331a698c3dca8099f8901d10937c4443135c7ed9/chardet-7.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e3bd9f936e04bae89c254262af08d9e5b98f805175ba1e29d454e6cba3107b7", size = 887680, upload-time = "2026-04-13T21:33:27.49Z" }, - { url = "https://files.pythonhosted.org/packages/40/c6/94a3c673327392652ee8bdea9a45bc8a5f5365197a7387d68f0eed007115/chardet-7.4.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:27cc23da03630cdecc9aa81a895aa86629c211f995cd57651f0fbc280717bf93", size = 879865, upload-time = "2026-04-13T21:33:29.052Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2c/cad8b5e3623a987f3c930b68e2bdd06cfc388cd91cd42ed05f1227701b73/chardet-7.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:b95c934b9ad59e2ba8abb9be49df70d3ad1b0d95d864b9fdb7588d4fa8bd921c", size = 939594, upload-time = "2026-04-13T21:33:31.391Z" }, - { url = "https://files.pythonhosted.org/packages/33/e0/d06e42fd6f02a58e5e227e5106587751cb38adcff0aaf949add744b78b6e/chardet-7.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c77867f0c1cb8bd819502249fcdc500364aedb07881e11b743726fa2148e7b6e", size = 889714, upload-time = "2026-04-13T21:33:32.772Z" }, - { url = "https://files.pythonhosted.org/packages/d4/ed/40d091954d48abea037baae6be8fb79905e5f78d34d12ea955132c7d8011/chardet-7.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf1efeaf65a6ef2f5b9cc3a1df6f08ba2831b369ccaa4c7018eaf90aa757bb11", size = 872319, upload-time = "2026-04-13T21:33:34.427Z" }, - { url = "https://files.pythonhosted.org/packages/bb/77/82a46821dbfbdfe062710d2bf2ede13426304e3567a23c57d919c0c31630/chardet-7.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f3504c139a2ad544077dd2d9e412cd08b01786843d76997cd43bb6de311723c", size = 892021, upload-time = "2026-04-13T21:33:35.766Z" }, - { url = "https://files.pythonhosted.org/packages/49/57/42d30c562bda5b4a839766c1aad8d5856b798ad2a1c3247b72a679afec94/chardet-7.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457f619882ba66327d4d8d14c6c342269bdb1e4e1c38e8117df941d14d351b04", size = 902509, upload-time = "2026-04-13T21:33:37.096Z" }, - { url = "https://files.pythonhosted.org/packages/8c/6c/0a40afdb50a0fe041ab95553b835a8160b6cf0e81edf2ae2fe9f5224cbf9/chardet-7.4.3-py3-none-any.whl", hash = "sha256:1173b74051570cf08099d7429d92e4882d375ad4217f92a6e5240ccfb26f231e", size = 626562, upload-time = "2026-04-13T21:33:38.559Z" }, -] - [[package]] name = "charset-normalizer" version = "3.4.7" @@ -398,7 +355,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -468,92 +425,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "jsonschema" -version = "4.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, -] - -[[package]] -name = "jsonschema-path" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pathable" }, - { name = "pyyaml" }, - { name = "referencing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/01/86/cfee6dd25843bec0760f456599a4f7e7e40221a934b9229fda0662c859bc/jsonschema_path-0.4.6.tar.gz", hash = "sha256:c89eb635f4d497c9ac328eeff359c489755838806a7d033510a692e9576f5c4b", size = 15302, upload-time = "2026-04-27T18:57:08.412Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/43/3d3065c05a04bb550c143bfbb8e4fd7022cd327e1082bf257bac74923783/jsonschema_path-0.4.6-py3-none-any.whl", hash = "sha256:451354b5311fa955c3144e6e4e255388c751c0121c5570ec5bb9291dd42d08c9", size = 19565, upload-time = "2026-04-27T18:57:06.792Z" }, -] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "referencing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, -] - -[[package]] -name = "lazy-object-proxy" -version = "1.12.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/08/a2/69df9c6ba6d316cfd81fe2381e464db3e6de5db45f8c43c6a23504abf8cb/lazy_object_proxy-1.12.0.tar.gz", hash = "sha256:1f5a462d92fd0cfb82f1fab28b51bfb209fabbe6aabf7f0d51472c0c124c0c61", size = 43681, upload-time = "2025-08-22T13:50:06.783Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/2b/d5e8915038acbd6c6a9fcb8aaf923dc184222405d3710285a1fec6e262bc/lazy_object_proxy-1.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:61d5e3310a4aa5792c2b599a7a78ccf8687292c8eb09cf187cca8f09cf6a7519", size = 26658, upload-time = "2025-08-22T13:42:23.373Z" }, - { url = "https://files.pythonhosted.org/packages/da/8f/91fc00eeea46ee88b9df67f7c5388e60993341d2a406243d620b2fdfde57/lazy_object_proxy-1.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1ca33565f698ac1aece152a10f432415d1a2aa9a42dfe23e5ba2bc255ab91f6", size = 68412, upload-time = "2025-08-22T13:42:24.727Z" }, - { url = "https://files.pythonhosted.org/packages/07/d2/b7189a0e095caedfea4d42e6b6949d2685c354263bdf18e19b21ca9b3cd6/lazy_object_proxy-1.12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d01c7819a410f7c255b20799b65d36b414379a30c6f1684c7bd7eb6777338c1b", size = 67559, upload-time = "2025-08-22T13:42:25.875Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ad/b013840cc43971582ff1ceaf784d35d3a579650eb6cc348e5e6ed7e34d28/lazy_object_proxy-1.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:029d2b355076710505c9545aef5ab3f750d89779310e26ddf2b7b23f6ea03cd8", size = 66651, upload-time = "2025-08-22T13:42:27.427Z" }, - { url = "https://files.pythonhosted.org/packages/7e/6f/b7368d301c15612fcc4cd00412b5d6ba55548bde09bdae71930e1a81f2ab/lazy_object_proxy-1.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc6e3614eca88b1c8a625fc0a47d0d745e7c3255b21dac0e30b3037c5e3deeb8", size = 66901, upload-time = "2025-08-22T13:42:28.585Z" }, - { url = "https://files.pythonhosted.org/packages/61/1b/c6b1865445576b2fc5fa0fbcfce1c05fee77d8979fd1aa653dd0f179aefc/lazy_object_proxy-1.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:be5fe974e39ceb0d6c9db0663c0464669cf866b2851c73971409b9566e880eab", size = 26536, upload-time = "2025-08-22T13:42:29.636Z" }, - { url = "https://files.pythonhosted.org/packages/01/b3/4684b1e128a87821e485f5a901b179790e6b5bc02f89b7ee19c23be36ef3/lazy_object_proxy-1.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1cf69cd1a6c7fe2dbcc3edaa017cf010f4192e53796538cc7d5e1fedbfa4bcff", size = 26656, upload-time = "2025-08-22T13:42:30.605Z" }, - { url = "https://files.pythonhosted.org/packages/3a/03/1bdc21d9a6df9ff72d70b2ff17d8609321bea4b0d3cffd2cea92fb2ef738/lazy_object_proxy-1.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:efff4375a8c52f55a145dc8487a2108c2140f0bec4151ab4e1843e52eb9987ad", size = 68832, upload-time = "2025-08-22T13:42:31.675Z" }, - { url = "https://files.pythonhosted.org/packages/3d/4b/5788e5e8bd01d19af71e50077ab020bc5cce67e935066cd65e1215a09ff9/lazy_object_proxy-1.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1192e8c2f1031a6ff453ee40213afa01ba765b3dc861302cd91dbdb2e2660b00", size = 69148, upload-time = "2025-08-22T13:42:32.876Z" }, - { url = "https://files.pythonhosted.org/packages/79/0e/090bf070f7a0de44c61659cb7f74c2fe02309a77ca8c4b43adfe0b695f66/lazy_object_proxy-1.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3605b632e82a1cbc32a1e5034278a64db555b3496e0795723ee697006b980508", size = 67800, upload-time = "2025-08-22T13:42:34.054Z" }, - { url = "https://files.pythonhosted.org/packages/cf/d2/b320325adbb2d119156f7c506a5fbfa37fcab15c26d13cf789a90a6de04e/lazy_object_proxy-1.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a61095f5d9d1a743e1e20ec6d6db6c2ca511961777257ebd9b288951b23b44fa", size = 68085, upload-time = "2025-08-22T13:42:35.197Z" }, - { url = "https://files.pythonhosted.org/packages/6a/48/4b718c937004bf71cd82af3713874656bcb8d0cc78600bf33bb9619adc6c/lazy_object_proxy-1.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:997b1d6e10ecc6fb6fe0f2c959791ae59599f41da61d652f6c903d1ee58b7370", size = 26535, upload-time = "2025-08-22T13:42:36.521Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1b/b5f5bd6bda26f1e15cd3232b223892e4498e34ec70a7f4f11c401ac969f1/lazy_object_proxy-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ee0d6027b760a11cc18281e702c0309dd92da458a74b4c15025d7fc490deede", size = 26746, upload-time = "2025-08-22T13:42:37.572Z" }, - { url = "https://files.pythonhosted.org/packages/55/64/314889b618075c2bfc19293ffa9153ce880ac6153aacfd0a52fcabf21a66/lazy_object_proxy-1.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4ab2c584e3cc8be0dfca422e05ad30a9abe3555ce63e9ab7a559f62f8dbc6ff9", size = 71457, upload-time = "2025-08-22T13:42:38.743Z" }, - { url = "https://files.pythonhosted.org/packages/11/53/857fc2827fc1e13fbdfc0ba2629a7d2579645a06192d5461809540b78913/lazy_object_proxy-1.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14e348185adbd03ec17d051e169ec45686dcd840a3779c9d4c10aabe2ca6e1c0", size = 71036, upload-time = "2025-08-22T13:42:40.184Z" }, - { url = "https://files.pythonhosted.org/packages/2b/24/e581ffed864cd33c1b445b5763d617448ebb880f48675fc9de0471a95cbc/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c4fcbe74fb85df8ba7825fa05eddca764138da752904b378f0ae5ab33a36c308", size = 69329, upload-time = "2025-08-22T13:42:41.311Z" }, - { url = "https://files.pythonhosted.org/packages/78/be/15f8f5a0b0b2e668e756a152257d26370132c97f2f1943329b08f057eff0/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:563d2ec8e4d4b68ee7848c5ab4d6057a6d703cb7963b342968bb8758dda33a23", size = 70690, upload-time = "2025-08-22T13:42:42.51Z" }, - { url = "https://files.pythonhosted.org/packages/5d/aa/f02be9bbfb270e13ee608c2b28b8771f20a5f64356c6d9317b20043c6129/lazy_object_proxy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:53c7fd99eb156bbb82cbc5d5188891d8fdd805ba6c1e3b92b90092da2a837073", size = 26563, upload-time = "2025-08-22T13:42:43.685Z" }, - { url = "https://files.pythonhosted.org/packages/f4/26/b74c791008841f8ad896c7f293415136c66cc27e7c7577de4ee68040c110/lazy_object_proxy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:86fd61cb2ba249b9f436d789d1356deae69ad3231dc3c0f17293ac535162672e", size = 26745, upload-time = "2025-08-22T13:42:44.982Z" }, - { url = "https://files.pythonhosted.org/packages/9b/52/641870d309e5d1fb1ea7d462a818ca727e43bfa431d8c34b173eb090348c/lazy_object_proxy-1.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81d1852fb30fab81696f93db1b1e55a5d1ff7940838191062f5f56987d5fcc3e", size = 71537, upload-time = "2025-08-22T13:42:46.141Z" }, - { url = "https://files.pythonhosted.org/packages/47/b6/919118e99d51c5e76e8bf5a27df406884921c0acf2c7b8a3b38d847ab3e9/lazy_object_proxy-1.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9045646d83f6c2664c1330904b245ae2371b5c57a3195e4028aedc9f999655", size = 71141, upload-time = "2025-08-22T13:42:47.375Z" }, - { url = "https://files.pythonhosted.org/packages/e5/47/1d20e626567b41de085cf4d4fb3661a56c159feaa73c825917b3b4d4f806/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:67f07ab742f1adfb3966c40f630baaa7902be4222a17941f3d85fd1dae5565ff", size = 69449, upload-time = "2025-08-22T13:42:48.49Z" }, - { url = "https://files.pythonhosted.org/packages/58/8d/25c20ff1a1a8426d9af2d0b6f29f6388005fc8cd10d6ee71f48bff86fdd0/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ba769017b944fcacbf6a80c18b2761a1795b03f8899acdad1f1c39db4409be", size = 70744, upload-time = "2025-08-22T13:42:49.608Z" }, - { url = "https://files.pythonhosted.org/packages/c0/67/8ec9abe15c4f8a4bcc6e65160a2c667240d025cbb6591b879bea55625263/lazy_object_proxy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:7b22c2bbfb155706b928ac4d74c1a63ac8552a55ba7fff4445155523ea4067e1", size = 26568, upload-time = "2025-08-22T13:42:57.719Z" }, - { url = "https://files.pythonhosted.org/packages/23/12/cd2235463f3469fd6c62d41d92b7f120e8134f76e52421413a0ad16d493e/lazy_object_proxy-1.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4a79b909aa16bde8ae606f06e6bbc9d3219d2e57fb3e0076e17879072b742c65", size = 27391, upload-time = "2025-08-22T13:42:50.62Z" }, - { url = "https://files.pythonhosted.org/packages/60/9e/f1c53e39bbebad2e8609c67d0830cc275f694d0ea23d78e8f6db526c12d3/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:338ab2f132276203e404951205fe80c3fd59429b3a724e7b662b2eb539bb1be9", size = 80552, upload-time = "2025-08-22T13:42:51.731Z" }, - { url = "https://files.pythonhosted.org/packages/4c/b6/6c513693448dcb317d9d8c91d91f47addc09553613379e504435b4cc8b3e/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c40b3c9faee2e32bfce0df4ae63f4e73529766893258eca78548bac801c8f66", size = 82857, upload-time = "2025-08-22T13:42:53.225Z" }, - { url = "https://files.pythonhosted.org/packages/12/1c/d9c4aaa4c75da11eb7c22c43d7c90a53b4fca0e27784a5ab207768debea7/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:717484c309df78cedf48396e420fa57fc8a2b1f06ea889df7248fdd156e58847", size = 80833, upload-time = "2025-08-22T13:42:54.391Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ae/29117275aac7d7d78ae4f5a4787f36ff33262499d486ac0bf3e0b97889f6/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b7ea5ea1ffe15059eb44bcbcb258f97bcb40e139b88152c40d07b1a1dfc9ac", size = 79516, upload-time = "2025-08-22T13:42:55.812Z" }, - { url = "https://files.pythonhosted.org/packages/19/40/b4e48b2c38c69392ae702ae7afa7b6551e0ca5d38263198b7c79de8b3bdf/lazy_object_proxy-1.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:08c465fb5cd23527512f9bd7b4c7ba6cec33e28aad36fbbe46bf7b858f9f3f7f", size = 27656, upload-time = "2025-08-22T13:42:56.793Z" }, - { url = "https://files.pythonhosted.org/packages/ef/3a/277857b51ae419a1574557c0b12e0d06bf327b758ba94cafc664cb1e2f66/lazy_object_proxy-1.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c9defba70ab943f1df98a656247966d7729da2fe9c2d5d85346464bf320820a3", size = 26582, upload-time = "2025-08-22T13:49:49.366Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b6/c5e0fa43535bb9c87880e0ba037cdb1c50e01850b0831e80eb4f4762f270/lazy_object_proxy-1.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6763941dbf97eea6b90f5b06eb4da9418cc088fce0e3883f5816090f9afcde4a", size = 71059, upload-time = "2025-08-22T13:49:50.488Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/7dcad19c685963c652624702f1a968ff10220b16bfcc442257038216bf55/lazy_object_proxy-1.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdc70d81235fc586b9e3d1aeef7d1553259b62ecaae9db2167a5d2550dcc391a", size = 71034, upload-time = "2025-08-22T13:49:54.224Z" }, - { url = "https://files.pythonhosted.org/packages/12/ac/34cbfb433a10e28c7fd830f91c5a348462ba748413cbb950c7f259e67aa7/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0a83c6f7a6b2bfc11ef3ed67f8cbe99f8ff500b05655d8e7df9aab993a6abc95", size = 69529, upload-time = "2025-08-22T13:49:55.29Z" }, - { url = "https://files.pythonhosted.org/packages/6f/6a/11ad7e349307c3ca4c0175db7a77d60ce42a41c60bcb11800aabd6a8acb8/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:256262384ebd2a77b023ad02fbcc9326282bcfd16484d5531154b02bc304f4c5", size = 70391, upload-time = "2025-08-22T13:49:56.35Z" }, - { url = "https://files.pythonhosted.org/packages/59/97/9b410ed8fbc6e79c1ee8b13f8777a80137d4bc189caf2c6202358e66192c/lazy_object_proxy-1.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7601ec171c7e8584f8ff3f4e440aa2eebf93e854f04639263875b8c2971f819f", size = 26988, upload-time = "2025-08-22T13:49:57.302Z" }, - { url = "https://files.pythonhosted.org/packages/41/a0/b91504515c1f9a299fc157967ffbd2f0321bce0516a3d5b89f6f4cad0355/lazy_object_proxy-1.12.0-pp39.pp310.pp311.graalpy311-none-any.whl", hash = "sha256:c3b2e0af1f7f77c4263759c4824316ce458fabe0fceadcd24ef8ca08b2d1e402", size = 15072, upload-time = "2025-08-22T13:50:05.498Z" }, -] - [[package]] name = "librt" version = "0.11.0" @@ -813,47 +684,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] -[[package]] -name = "openapi-schema-validator" -version = "0.8.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jsonschema" }, - { name = "jsonschema-specifications" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "referencing" }, - { name = "rfc3339-validator" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/21/4b/67b24b2b23d96ea862be2cca3632a546f67a22461200831213e80c3c6011/openapi_schema_validator-0.8.1.tar.gz", hash = "sha256:4c57266ce8cbfa37bb4eb4d62cdb7d19356c3a468e3535743c4562863e1790da", size = 23134, upload-time = "2026-03-02T08:46:29.807Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/87/e9f29f463b230d4b47d65e17858c595153a8ca8c1775f16e406aa82d455d/openapi_schema_validator-0.8.1-py3-none-any.whl", hash = "sha256:0f5859794c5bfa433d478dc5ac5e5768d50adc56b14380c8a6fd3a8113e89c9b", size = 19211, upload-time = "2026-03-02T08:46:28.154Z" }, -] - -[[package]] -name = "openapi-spec-validator" -version = "0.8.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jsonschema" }, - { name = "jsonschema-path" }, - { name = "lazy-object-proxy" }, - { name = "openapi-schema-validator" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/79/3f/aa0c1150627b4e683ae5673486b7d5cf2623a8821601863ee389e430965a/openapi_spec_validator-0.8.5.tar.gz", hash = "sha256:93b04ef5321d5866b2502371123d86333e5c1444f051d323e02525d9e83c7622", size = 1756845, upload-time = "2026-04-24T15:25:21.334Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/96/d7dfe1cc0be2df22d7a97ffb0f8bb00b10d92749aa6e64ffa7cc9a041580/openapi_spec_validator-0.8.5-py3-none-any.whl", hash = "sha256:3669106361856934153991e30714616a294865a33f6411a4c25d1dc2d08cfbc2", size = 50334, upload-time = "2026-04-24T15:25:19.65Z" }, -] - [[package]] name = "openapi3-parser" version = "1.3.0" source = { editable = "." } dependencies = [ - { name = "openapi-spec-validator" }, - { name = "prance" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "referencing" }, ] [package.dev-dependencies] @@ -864,12 +702,14 @@ dev = [ { name = "python-semantic-release" }, { name = "ruff" }, { name = "ty" }, + { name = "types-pyyaml" }, ] [package.metadata] requires-dist = [ - { name = "openapi-spec-validator", specifier = ">=0.8.5" }, - { name = "prance", specifier = ">=25.4.8.0" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "referencing", specifier = ">=0.35" }, ] [package.metadata.requires-dev] @@ -877,9 +717,10 @@ dev = [ { name = "mypy", specifier = ">=2.1.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-cov", specifier = ">=6.1.0" }, - { name = "python-semantic-release", specifier = ">=10.5.3" }, + { name = "python-semantic-release", specifier = ">=10.6.1,<11" }, { name = "ruff", specifier = ">=0.15.13" }, { name = "ty", specifier = ">=0.0.37" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20260518" }, ] [[package]] @@ -891,15 +732,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] -[[package]] -name = "pathable" -version = "0.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/55/b748445cb4ea6b125626f15379be7c96d1035d4fa3e8fee362fa92298abf/pathable-0.5.0.tar.gz", hash = "sha256:d81938348a1cacb525e7c75166270644782c0fb9c8cecc16be033e71427e0ef1", size = 16655, upload-time = "2026-02-20T08:47:00.748Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867, upload-time = "2026-02-20T08:46:59.536Z" }, -] - [[package]] name = "pathspec" version = "1.1.1" @@ -918,21 +750,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] -[[package]] -name = "prance" -version = "25.4.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "chardet" }, - { name = "packaging" }, - { name = "requests" }, - { name = "ruamel-yaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ae/5c/afa384b91354f0dbc194dfbea89bbd3e07dbe47d933a0a2c4fb989fc63af/prance-25.4.8.0.tar.gz", hash = "sha256:2f72d2983d0474b6f53fd604eb21690c1ebdb00d79a6331b7ec95fb4f25a1f65", size = 2808091, upload-time = "2025-04-07T22:22:36.739Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/a8/fc509e514c708f43102542cdcbc2f42dc49f7a159f90f56d072371629731/prance-25.4.8.0-py3-none-any.whl", hash = "sha256:d3c362036d625b12aeee495621cb1555fd50b2af3632af3d825176bfb50e073b", size = 36386, upload-time = "2025-04-07T22:22:35.183Z" }, -] - [[package]] name = "pydantic" version = "2.13.4" @@ -1064,20 +881,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.14.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -1119,15 +922,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, -] - [[package]] name = "python-gitlab" version = "6.5.0" @@ -1143,7 +937,7 @@ wheels = [ [[package]] name = "python-semantic-release" -version = "10.5.3" +version = "10.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1160,9 +954,9 @@ dependencies = [ { name = "shellingham" }, { name = "tomlkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/3a/7332b822825ed0e902c6e950e0d1e90e8f666fd12eb27855d1c8b6677eff/python_semantic_release-10.5.3.tar.gz", hash = "sha256:de4da78635fa666e5774caaca2be32063cae72431eb75e2ac23b9f2dfd190785", size = 618034, upload-time = "2025-12-14T22:37:29.782Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f6/06d5aa54b46bb192b00ef3e74234300ea5932dd310d142a3c8070e770e93/python_semantic_release-10.6.1.tar.gz", hash = "sha256:ee6369238f72e75a009b3724481232c8b813416191be099bc0266e375fd02b2b", size = 626453, upload-time = "2026-07-06T06:14:35.507Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/01/ada29a1215df601bded0a2efd3b6d53864a0a9e0a9ea52aeaebe14fd03fd/python_semantic_release-10.5.3-py3-none-any.whl", hash = "sha256:1be0e07c36fa1f1ec9da4f438c1f6bbd7bc10eb0d6ac0089b0643103708c2823", size = 152716, upload-time = "2025-12-14T22:37:28.089Z" }, + { url = "https://files.pythonhosted.org/packages/18/97/be812cd1eb350551d2f3cc38426864cce493a03ed31f4749133bcff8d053/python_semantic_release-10.6.1-py3-none-any.whl", hash = "sha256:36f7319515f218719d0972bc9535813a930f04cd19556767599e9863242efcdc", size = 155674, upload-time = "2026-07-06T06:14:33.575Z" }, ] [[package]] @@ -1270,18 +1064,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] -[[package]] -name = "rfc3339-validator" -version = "0.1.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, -] - [[package]] name = "rich" version = "14.3.4" @@ -1417,15 +1199,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] -[[package]] -name = "ruamel-yaml" -version = "0.19.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, -] - [[package]] name = "ruff" version = "0.15.13" @@ -1460,15 +1233,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - [[package]] name = "smmap" version = "5.0.3" @@ -1566,6 +1330,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/ed/5ec4b501479bc5dad55467e2fe72e797cb9c178468c0d1a514536872ebc5/ty-0.0.37-py3-none-win_arm64.whl", hash = "sha256:6c3c2b997f68c71e14242b96d48cba3c086439556af02bb4613aa458950d5c23", size = 10958817, upload-time = "2026-05-16T05:57:08.907Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"