From d122d981951413efd0309cf59b0d897c21331390 Mon Sep 17 00:00:00 2001 From: Matthias Dellweg Date: Thu, 4 Dec 2025 17:06:00 +0100 Subject: [PATCH 1/2] Add some pydantic models for validation --- lower_bounds_constraints.lock | 1 + pulp-glue/pyproject.toml | 2 + pulp-glue/src/pulp_glue/common/openapi.py | 2 + .../src/pulp_glue/common/pydantic_oas.py | 341 ++++++++++++++++++ pulp-glue/tests/test_auth_provider.py | 6 +- pulp-glue/tests/test_pydantic_oas.py | 168 +++++++++ 6 files changed, 519 insertions(+), 1 deletion(-) create mode 100644 pulp-glue/src/pulp_glue/common/pydantic_oas.py create mode 100644 pulp-glue/tests/test_pydantic_oas.py diff --git a/lower_bounds_constraints.lock b/lower_bounds_constraints.lock index a2e1857e1..4dd70f3c4 100644 --- a/lower_bounds_constraints.lock +++ b/lower_bounds_constraints.lock @@ -7,5 +7,6 @@ schema==0.7.5 tomli==2.0.0 tomli-w==1.0.0 pygments==2.17.2 +pydantic==2.11.7 click-shell==2.1 SecretStorage==3.3.3 diff --git a/pulp-glue/pyproject.toml b/pulp-glue/pyproject.toml index a56f6e5ce..4f606d06d 100644 --- a/pulp-glue/pyproject.toml +++ b/pulp-glue/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ dependencies = [ "multidict>=6.0.5,<6.8", "packaging>=22.0,<=26.0", # CalVer + "pydantic>=2.11.7,<2.13", "requests>=2.24.0,<2.33", "tomli>=2.0.0,<2.1;python_version<'3.11'", ] @@ -53,6 +54,7 @@ files = "src/**/*.py, tests/**/*.py" mypy_path = ["src"] namespace_packages = true explicit_package_bases = true +plugins = ["pydantic.mypy"] [[tool.mypy.overrides]] # This section is managed by the cookiecutter templates. diff --git a/pulp-glue/src/pulp_glue/common/openapi.py b/pulp-glue/src/pulp_glue/common/openapi.py index 443817d32..d59bea8f0 100644 --- a/pulp-glue/src/pulp_glue/common/openapi.py +++ b/pulp-glue/src/pulp_glue/common/openapi.py @@ -27,6 +27,7 @@ ValidationError, ) from pulp_glue.common.i18n import get_translation +from pulp_glue.common.pydantic_oas import OpenAPISpec from pulp_glue.common.schema import encode_json, encode_param, validate translation = get_translation(__package__) @@ -217,6 +218,7 @@ def load_api(self, refresh_cache: bool = False) -> None: def _parse_api(self, data: bytes) -> None: raw_spec = self._patch_api_hook(json.loads(data)) + OpenAPISpec.model_validate(raw_spec) self.api_spec: dict[str, t.Any] = raw_spec if self.api_spec.get("openapi", "").startswith("3."): self.openapi_version: int = 3 diff --git a/pulp-glue/src/pulp_glue/common/pydantic_oas.py b/pulp-glue/src/pulp_glue/common/pydantic_oas.py new file mode 100644 index 000000000..61decae8f --- /dev/null +++ b/pulp-glue/src/pulp_glue/common/pydantic_oas.py @@ -0,0 +1,341 @@ +import typing as t + +import pydantic +import pydantic.alias_generators +from pydantic_core import PydanticCustomError + + +def to_alias(value: str) -> str: + return pydantic.alias_generators.to_camel(value.rstrip("_")) + + +class OASBase(pydantic.BaseModel, alias_generator=to_alias, extra="forbid"): + pass + + +class ExtensibleOASBase(OASBase, extra="allow"): + @pydantic.model_validator(mode="after") + def _check_extensions(self) -> t.Self: + if self.__pydantic_extra__ is not None: + invalid_keys = [ + key for key in self.__pydantic_extra__.keys() if not key.startswith("x-") + ] + if invalid_keys: + raise PydanticCustomError( + "invalid_extensions", + "Extra inputs are only permitted as '^x-' extensions. {invalid_keys}", + {"invalid_keys": invalid_keys}, + ) + + return self + + +class Reference(OASBase): + _ref: t.Annotated[str, pydantic.Field(alias="$ref")] + summary: str | None = None + description: str | None = None + + +class OAuthFlow(OASBase): + refresh_url: str | None = None + scopes: dict[str, str] + + +class OAuthFlowToken(OAuthFlow): + token_url: str + + +class OAuthFlowAuthorization(OAuthFlow): + authorization_url: str + + +class OAuthFlowAuthorizationCode(OAuthFlow): + authorization_url: str + token_url: str + + +class OAuthFlows(OASBase): + implicit: OAuthFlowAuthorization | None = None + password: OAuthFlowToken | None = None + client_credentials: OAuthFlowToken | None = None + authorization_code: OAuthFlowAuthorizationCode | None = None + + +class SecuritySchemeBase(OASBase): + type_: str + description: str | None = None + + +class SecuritySchemeApiKey(SecuritySchemeBase): + type_: t.Literal["apiKey"] + name: str + in_: t.Literal["query", "header", "cookie"] + + +class SecuritySchemeHttp(SecuritySchemeBase): + type_: t.Literal["http"] + scheme: str + bearer_format: str | None = None + + +class SecuritySchemeMutualTLS(SecuritySchemeBase): + type_: t.Literal["mutualTLS"] + + +class SecuritySchemeOAuth2(SecuritySchemeBase): + type_: t.Literal["oauth2"] + flows: OAuthFlows + + +class SecuritySchemeOpenIdConnect(SecuritySchemeBase): + type_: t.Literal["openIdConnect"] + open_id_connect_url: str + + +SecurityScheme = t.Annotated[ + SecuritySchemeApiKey + | SecuritySchemeHttp + | SecuritySchemeMutualTLS + | SecuritySchemeOAuth2 + | SecuritySchemeOpenIdConnect, + pydantic.Field(discriminator="type_"), +] + + +class Contact(ExtensibleOASBase): + name: str | None = None + url: str | None = None + email: str | None = None + + +class License(ExtensibleOASBase): + name: str + identifier: str | None = None + url: str | None = None + + +class Info(ExtensibleOASBase): + title: str + summary: str | None = None + description: str | None = None + terms_of_service: str | None = None + contact: Contact | None = None + license: License | None = None + version: str + + +class ServerVariable(ExtensibleOASBase): + enum: list[str] | None = None + default: str + description: str | None = None + + +class Server(ExtensibleOASBase): + url: str + description: str | None = None + variables: dict[str, ServerVariable] | None = None + + +class ExternalDocumentation(ExtensibleOASBase): + url: str + description: str | None = None + + +class Example(ExtensibleOASBase): + summary: str | None = None + description: str | None = None + value: t.Any = None + external_value: str | None = None + + +SecurityRequirements = list[dict[str, list[str]]] + + +class Discriminator(ExtensibleOASBase): + property_name: str + mapping: dict[str, str] | None = None + + +class XML(ExtensibleOASBase): + name: str | None = None + namespace: str | None = None + prefix: str | None = None + attribute: bool = False + wrapped: bool = False + + +class Schema(OASBase, extra="allow"): + discriminator: Discriminator | None = None + xml: XML | None = None + external_docs: ExternalDocumentation | None = None + example: t.Any = None + + +class Link(ExtensibleOASBase): + operation_ref: str | None = None + operation_id: str | None = None + parameters: dict[str, t.Any] | None = None + request_body: t.Any = None + description: str | None = None + server: Server | None = None + + +class Tag(ExtensibleOASBase): + name: str + description: str | None = None + external_docs: ExternalDocumentation | None = None + + +class Encoding(ExtensibleOASBase): + content_type: str | None = None + headers: dict[str, t.Union["Header", Reference]] | None = None + # TODO These only apply to RFC6570 + style: ( + t.Literal[ + "simple", "form", "matrix", "label", "spaceDelimited", "pipeDelimited", "deepObject" + ] + | None + ) = None + explode: t.Annotated[bool, pydantic.Field(default_factory=lambda data: data["style"] == "form")] + allow_reserved: bool = False + + +class MediaType(ExtensibleOASBase): + schema_: Schema + example: t.Any = None + examples: dict[str, Example | Reference] | None = None + encoding: dict[str, Encoding] = {} + + +class HeaderBase(ExtensibleOASBase): + description: str | None = None + required: bool | None = False + deprecated: bool = False + + +class ContentHeader(HeaderBase): + content: dict[str, MediaType] + + +class SchemaHeader(HeaderBase): + schema_: Schema | Reference + style: t.Literal["simple"] = "simple" + explide: bool = False + example: t.Any = None + examples: dict[str, Example | Reference] | None = None + + +Header = ContentHeader | SchemaHeader + + +class ParameterBase(ExtensibleOASBase): + name: str + in_: t.Literal["query", "header", "path", "cookie"] + description: str | None = None + required: bool | None = False + deprecated: bool = False + allow_empty_value: bool = False + + @pydantic.model_validator(mode="after") + def _path_required(self) -> t.Self: + if self.in_ == "path": + assert self.required + return self + + +class ContentParameter(ParameterBase): + content: dict[str, MediaType] + + +class SchemaParameter(ParameterBase): + schema_: Schema + style: t.Annotated[ + t.Literal[ + "simple", "form", "matrix", "label", "spaceDelimited", "pipeDelimited", "deepObject" + ], + pydantic.Field( + default_factory=lambda data: "simple" if data["in_"] in ["header", "path"] else "form" + ), + ] + explode: t.Annotated[bool, pydantic.Field(default_factory=lambda data: data["style"] == "form")] + allowed_reserved: bool = False + example: t.Any = None + examples: dict[str, Example | Reference] | None = None + + +Parameter = ContentParameter | SchemaParameter + + +class RequestBody(ExtensibleOASBase): + description: str | None = None + content: dict[str, MediaType] + required: bool = False + + +class Response(ExtensibleOASBase): + description: str + headers: dict[str, Header | Reference] | None = None + content: dict[str, MediaType] | None = None + links: dict[str, Link | Reference] | None = None + + +Responses = dict[str, Response | Reference] # TODO key linting ?? + + +Callback = dict[str, "PathItem"] + + +class Operation(ExtensibleOASBase): + tags: list[str] | None = None + summary: str | None = None + description: str | None = None + external_docs: ExternalDocumentation | None = None + operation_id: str + parameters: list[Parameter | Reference] | None = None + request_body: RequestBody | Reference | None = None + responses: Responses | None = None + callbacks: dict[str, Callback | Reference] | None = None + deprecated: bool = False + security: SecurityRequirements | None = None + servers: list[Server] | None = None + + +class PathItem(Reference, ExtensibleOASBase): + get: Operation | None = None + put: Operation | None = None + post: Operation | None = None + patch: Operation | None = None + delete: Operation | None = None + options: Operation | None = None + head: Operation | None = None + trace: Operation | None = None + servers: list[Server] | None = None + parameters: list[Parameter | Reference] | None = None + + +class Components(ExtensibleOASBase): + schemas: dict[str, Schema] | None = None + responses: dict[str, Response | Reference] | None = None + parameters: dict[str, Parameter | Reference] | None = None + examples: dict[str, Example | Reference] | None = None + request_bodies: dict[str, RequestBody | Reference] | None = None + headers: dict[str, Header | Reference] | None = None + security_schemes: dict[str, SecurityScheme | Reference] | None = None + links: dict[str, Link | Reference] | None = None + callbacks: dict[str, Callback | Reference] | None = None + path_items: dict[str, PathItem] | None = None + + +class OpenAPISpec(ExtensibleOASBase): + openapi: str + info: Info + json_schema_dialect: str | None = None + servers: list[Server] | None = None + # In the specification there is a Paths Object, + # probably because paths as keys can get extra validation. + paths: dict[str, PathItem] | None = None + webhooks: dict[str, PathItem] | None = None + components: Components | None = None + security: SecurityRequirements | None = None + tags: list[Tag] | None = None + external_docs: ExternalDocumentation | None = None diff --git a/pulp-glue/tests/test_auth_provider.py b/pulp-glue/tests/test_auth_provider.py index 0bcde6da0..79b92d291 100644 --- a/pulp-glue/tests/test_auth_provider.py +++ b/pulp-glue/tests/test_auth_provider.py @@ -3,7 +3,11 @@ import pytest -from pulp_glue.common.authentication import AuthProviderBase, BasicAuthProvider, GlueAuthProvider +from pulp_glue.common.authentication import ( + AuthProviderBase, + BasicAuthProvider, + GlueAuthProvider, +) pytestmark = pytest.mark.glue diff --git a/pulp-glue/tests/test_pydantic_oas.py b/pulp-glue/tests/test_pydantic_oas.py new file mode 100644 index 000000000..854c7619b --- /dev/null +++ b/pulp-glue/tests/test_pydantic_oas.py @@ -0,0 +1,168 @@ +import typing as t + +import pydantic +import pytest + +from pulp_glue.common.pydantic_oas import OpenAPISpec, Parameter, SchemaParameter + +pytestmark = pytest.mark.glue + + +SECURITY_SCHEMES: dict[str, dict[str, t.Any]] = { + "A": {"type": "http", "scheme": "bearer"}, + "B": {"type": "http", "scheme": "basic"}, + "C": { + "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", + }, + }, + }, + }, + "D": { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "https://example.com/api/oauth/dialog", + "scopes": { + "write:pets": "modify pets in your account", + "read:pets": "read your pets", + }, + }, + "clientCredentials": { + "tokenUrl": "https://example.com/api/oauth/token", + "scopes": { + "write:pets": "modify pets in your account", + "read:pets": "read your pets", + }, + }, + }, + }, + "E": {"type": "mutualTLS"}, +} + +TEST_SCHEMA = { + "openapi": "3.1.1", + "info": { + "title": "Test API", + "version": "3.141592653", + "license": {"name": "Creative Commons Zero v1.0 Universal", "identifier": "CC0-1.0"}, + "x-this-is-something": True, + }, + "paths": { + "test/": { + "get": { + "operationId": "get_test_id", + "parameters": [ + { + "name": "sort", + "in": "query", + "content": {"text/plain": {"schema": {}, "encoding": {}}}, + }, + {"name": "gingerbread", "in": "cookie", "schema": {}}, + ], + "responses": {"200": {"description": "SUCCESS"}}, + }, + "post": { + "operationId": "post_test_id", + "requestBody": { + "required": True, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/testBody"}} + }, + }, + "responses": {"200": {"description": "SUCCESS"}}, + "security": [{"B": []}], + }, + } + }, + "security": [{}], + "components": { + "schemas": { + "testBody": { + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + } + }, + "securitySchemes": SECURITY_SCHEMES, + }, +} + +ParameterAdapter = pydantic.TypeAdapter[Parameter](Parameter) + + +class TestPydanticOpenAPISpec: + def test_validate(self) -> None: + spec = OpenAPISpec.model_validate(TEST_SCHEMA) + assert spec.components is not None + assert spec.components.security_schemes is not None + assert spec.components.security_schemes["A"].type_ == "http" + assert spec.paths is not None + assert spec.paths["test/"].get is not None + assert spec.paths["test/"].get.parameters is not None + assert isinstance(spec.paths["test/"].get.parameters[0], Parameter) + assert spec.paths["test/"].get.parameters[0].in_ == "query" + + @pytest.mark.skip + def test_validate_with_context_enumerates_operations(self) -> None: + operations: dict[str, tuple[str, str]] = {} + OpenAPISpec.model_validate(TEST_SCHEMA, context={"path": "", "operations": operations}) + assert operations["get_test_id"] == ("test/", "get") + + def test_path_parameter_is_required(self) -> None: + with pytest.raises(pydantic.ValidationError): + ParameterAdapter.validate_python( + {"in": "path", "name": "cannotbeoptional", "schema": {}} + ) + with pytest.raises(pydantic.ValidationError): + ParameterAdapter.validate_python( + {"in": "path", "name": "cannotbeoptional", "required": False, "schema": {}} + ) + + @pytest.mark.parametrize( + "in_,style", + ( + ("query", "form"), + ("path", "simple"), + ("header", "simple"), + ("cookie", "form"), + ), + ) + def test_parameter_style_defaults_to(self, in_: str, style: str) -> None: + parameter = ParameterAdapter.validate_python( + {"in": in_, "name": "test", "required": True, "schema": {}} + ) + assert isinstance(parameter, SchemaParameter) + assert parameter.style == style + + @pytest.mark.parametrize( + "style,explode", + ( + ("simple", False), + ("form", True), + ("matrix", False), + ("label", False), + ("spaceDelimited", False), + ("pipeDelimited", False), + ("deepObject", False), + ), + ) + def test_parameter_explode_defautls_to(self, style: str, explode: bool) -> None: + parameter = ParameterAdapter.validate_python( + {"in": "query", "name": "test", "required": True, "style": style, "schema": {}} + ) + assert isinstance(parameter, SchemaParameter) + assert parameter.explode == explode From 9f67c89666e054e179589f42442c625a8ae6d6eb Mon Sep 17 00:00:00 2001 From: Matthias Dellweg Date: Mon, 16 Feb 2026 14:39:50 +0100 Subject: [PATCH 2/2] Use pydantic in openapi spec --- lower_bounds_constraints.lock | 2 +- pulp-glue/pyproject.toml | 2 +- pulp-glue/src/pulp_glue/common/exceptions.py | 4 + pulp-glue/src/pulp_glue/common/openapi.py | 221 +++++++------- .../src/pulp_glue/common/pydantic_oas.py | 59 +++- pulp-glue/src/pulp_glue/common/schema.py | 9 +- pulp-glue/tests/test_openapi.py | 273 ++++++++++++++---- pulp-glue/tests/test_pydantic_oas.py | 33 ++- pulp-glue/tests/test_schema.py | 3 +- 9 files changed, 414 insertions(+), 192 deletions(-) diff --git a/lower_bounds_constraints.lock b/lower_bounds_constraints.lock index 4dd70f3c4..f75192244 100644 --- a/lower_bounds_constraints.lock +++ b/lower_bounds_constraints.lock @@ -7,6 +7,6 @@ schema==0.7.5 tomli==2.0.0 tomli-w==1.0.0 pygments==2.17.2 -pydantic==2.11.7 +pydantic==2.9.2 click-shell==2.1 SecretStorage==3.3.3 diff --git a/pulp-glue/pyproject.toml b/pulp-glue/pyproject.toml index 4f606d06d..614af7130 100644 --- a/pulp-glue/pyproject.toml +++ b/pulp-glue/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ dependencies = [ "multidict>=6.0.5,<6.8", "packaging>=22.0,<=26.0", # CalVer - "pydantic>=2.11.7,<2.13", + "pydantic>=2.9.2,<2.13", "requests>=2.24.0,<2.33", "tomli>=2.0.0,<2.1;python_version<'3.11'", ] diff --git a/pulp-glue/src/pulp_glue/common/exceptions.py b/pulp-glue/src/pulp_glue/common/exceptions.py index 9ce587146..2a424626f 100644 --- a/pulp-glue/src/pulp_glue/common/exceptions.py +++ b/pulp-glue/src/pulp_glue/common/exceptions.py @@ -59,5 +59,9 @@ class ValidationError(OpenAPIError): """Exception raised for failed client side validation of parameters or request bodies.""" +class SchemaError(OpenAPIError): + """Exception raised for unsurmountable inconsistencies of the openapi schema.""" + + class UnsafeCallError(OpenAPIError): """Exception raised for POST, PUT, PATCH or DELETE calls with `safe_calls_only=True`.""" diff --git a/pulp-glue/src/pulp_glue/common/openapi.py b/pulp-glue/src/pulp_glue/common/openapi.py index d59bea8f0..8c50b782b 100644 --- a/pulp-glue/src/pulp_glue/common/openapi.py +++ b/pulp-glue/src/pulp_glue/common/openapi.py @@ -16,6 +16,7 @@ import urllib3 from multidict import CIMultiDict, CIMultiDictProxy, MutableMultiMapping +import pulp_glue.common.pydantic_oas as s from pulp_glue.common import __version__ from pulp_glue.common.authentication import AuthProviderBase from pulp_glue.common.exceptions import ( @@ -23,11 +24,11 @@ PulpAuthenticationFailed, PulpHTTPError, PulpNotAutorized, + SchemaError, UnsafeCallError, ValidationError, ) from pulp_glue.common.i18n import get_translation -from pulp_glue.common.pydantic_oas import OpenAPISpec from pulp_glue.common.schema import encode_json, encode_param, validate translation = get_translation(__package__) @@ -37,7 +38,7 @@ UploadType = bytes | t.IO[bytes] METHODS = {"get", "put", "post", "delete", "options", "head", "patch", "trace"} -SAFE_METHODS = ["GET", "HEAD", "OPTIONS"] +SAFE_METHODS = ["get", "head", "options"] @dataclass @@ -81,6 +82,9 @@ class OpenAPI: safe_calls_only: DEPRECATED use dry_run instead. """ + _api_spec: s.OpenAPISpec + operations: dict[str, tuple[s.OperationName, str]] + def __init__( self, base_url: str, @@ -218,13 +222,13 @@ def load_api(self, refresh_cache: bool = False) -> None: def _parse_api(self, data: bytes) -> None: raw_spec = self._patch_api_hook(json.loads(data)) - OpenAPISpec.model_validate(raw_spec) + self._api_spec = s.OpenAPISpec.model_validate(raw_spec) self.api_spec: dict[str, t.Any] = raw_spec if self.api_spec.get("openapi", "").startswith("3."): self.openapi_version: int = 3 else: raise OpenAPIError(_("Unknown schema version")) - self.operations: dict[str, t.Any] = { + self.operations = { method_entry["operationId"]: (method, path) for path, path_entry in self.api_spec.get("paths", {}).items() for method, method_entry in path_entry.items() @@ -277,67 +281,77 @@ def param_spec( param_spec = {k: v for k, v in param_spec.items() if v.get("required", False)} return param_spec - def _extract_params( + def _validate_schema(self, schema: s.Schema, name: str, value: t.Any) -> None: + validate( + schema.model_dump(by_alias=True), + name, + value, + self.api_spec["components"]["schemas"], + ) + + def _param_specs( self, - param_in: str, - path_spec: dict[str, t.Any], - method_spec: dict[str, t.Any], - params: dict[str, t.Any], - ) -> dict[str, t.Any]: - param_specs = { - entry["name"]: entry - for entry in path_spec.get("parameters", []) - if entry["in"] == param_in + path_spec: s.PathItem, + operation_spec: s.Operation, + ) -> dict[str, s.Parameter]: + param_specs: dict[str, s.Parameter] = {} + for param_spec in path_spec.parameters + operation_spec.parameters: + while isinstance(param_spec, s.Reference): + if not param_spec.ref.startswith("#/components/parameters/"): + raise SchemaError("Invalid parameter reference") + param_spec = self._api_spec.components.parameters[param_spec.ref[24:]] + param_specs[param_spec.name] = param_spec + return param_specs + + def _render_parameters( + self, + path_spec: s.PathItem, + operation_spec: s.Operation, + parameters: dict[str, t.Any], + ) -> dict[t.Literal["query", "header", "path", "cookie"], dict[str, t.Any]]: + param_specs: dict[str, s.Parameter] = self._param_specs(path_spec, operation_spec) + result: dict[t.Literal["query", "header", "path", "cookie"], dict[str, t.Any]] = { + "query": {}, + "header": {}, + "path": {}, + "cookie": {}, } - param_specs.update( - { - entry["name"]: entry - for entry in method_spec.get("parameters", []) - if entry["in"] == param_in - } - ) - result: dict[str, t.Any] = {} - for name in list(params.keys()): - if name in param_specs: - param = params.pop(name) + for name, value in parameters.items(): + try: param_spec = param_specs.pop(name) - param_schema = param_spec.get("schema") - if param_schema is not None: - self.validate_schema(param_schema, name, param) - - param = encode_param(param) - if isinstance(param, list): - if not param: - # Don't propagate an empty list here - continue - # Check if we need to implode the list - style = ( - param_spec.get("style") or "form" - if param_in in ("query", "cookie") - else "simple" + except KeyError: + raise ValidationError( + _("Parameter '{name}' not available for '{operation_id}'.").format( + name=name, operation_id=operation_spec.operation_id ) - explode: bool = param_spec.get("explode", style == "form") - if not explode: - # Not exploding means comma separated list - param = ",".join(param) - result[name] = param - remaining_required = [ - item["name"] for item in param_specs.values() if item.get("required", False) - ] - if any(remaining_required): - raise RuntimeError( - _("Required parameters [{required}] missing in {param_in}.").format( - required=", ".join(remaining_required), param_in=param_in + ) + if isinstance(param_spec, s.SchemaParameter): + self._validate_schema(param_spec.schema_, name, value) + else: + raise NotImplementedError("Content Type Parameters") + value = encode_param(value) + if isinstance(value, list): + if not value: + # TODO this is a workaround. We should absolutely be able to pass empty lists. + # Don't propagate an empty list here, but put the spec up again. + param_specs[name] = param_spec + continue + if not param_spec.explode: + # Not exploding means comma separated list + value = ",".join(value) + result[param_spec.in_][name] = value + required_parameters = [item.name for item in param_specs.values() if item.required] + if len(required_parameters) > 0: + raise ValidationError( + _("Required parameter(s) [{required}] missing.").format( + required=", ".join(required_parameters) ) ) return result - def validate_schema(self, schema: t.Any, name: str, value: t.Any) -> None: - validate(schema, name, value, self.api_spec["components"]["schemas"]) - def _render_request_body( self, - method_spec: dict[str, t.Any], + method_spec: s.Operation, body: dict[str, t.Any] | None = None, validate_body: bool = True, ) -> tuple[ @@ -346,18 +360,17 @@ def _render_request_body( dict[str, tuple[str, UploadType, str]] | None, ]: content_types: list[str] = [] - try: - request_body_spec = method_spec["requestBody"] - except KeyError: + if method_spec.request_body is None: if body is not None: raise OpenAPIError(_("This operation does not expect a request body.")) return None, None, None - else: - body_required = request_body_spec.get("required", False) - if body is None and not body_required: - # shortcut - return None, None, None - content_types = list(request_body_spec["content"].keys()) + if isinstance(method_spec.request_body, s.Reference): + raise NotImplementedError() + request_body_spec: s.RequestBody = method_spec.request_body + if body is None and not request_body_spec.required: + # shortcut + return None, None, None + content_types = list(request_body_spec.content.keys()) assert body is not None content_type: str | None = None @@ -385,8 +398,8 @@ def _render_request_body( if content_type: if validate_body: try: - self.validate_schema( - request_body_spec["content"][content_type]["schema"], + self._validate_schema( + request_body_spec.content[content_type].schema_, "body", body, ) @@ -420,7 +433,7 @@ def _render_request_body( if errors: raise ValidationError( _("Validation failed for '{operation_id}':\n ").format( - operation_id=method_spec["operationId"] + operation_id=method_spec.operation_id ) + "\n ".join(errors) ) @@ -431,7 +444,7 @@ def _render_request_body( def _render_request( self, - path_spec: dict[str, t.Any], + path_spec: s.PathItem, method: str, url: str, params: dict[str, t.Any], @@ -439,16 +452,16 @@ def _render_request( body: dict[str, t.Any] | None = None, validate_body: bool = True, ) -> _Request: - method_spec = path_spec[method] + method_spec: s.Operation = getattr(path_spec, method) _headers = CIMultiDict(self._headers) _headers.update(headers) security: list[dict[str, list[str]]] | None if self._auth_provider and "Authorization" not in self._headers: - security = method_spec.get("security", self.api_spec.get("security")) + security = method_spec.security or self._api_spec.security else: # No auth required? Don't provide it. - # No auth_provider available? Hope for the best (should do the trick for cert auth). + # No auth_provider available? Hope for the best (cert auth is now coverd by the provider). # Authorization header present? You wanted it that way... security = None @@ -459,7 +472,7 @@ def _render_request( _headers["Content-Type"] = content_type return _Request( - operation_id=method_spec["operationId"], + operation_id=method_spec.operation_id, method=method, url=url, headers=_headers, @@ -538,6 +551,7 @@ async def _authenticate_request( raise NotImplementedError("OAuth2: Only client credential flow is available.") # Allow retry if the token was taken from cache. may_retry = not await self._fetch_oauth2_token(flow) + # TODO Should we add, amend or replace the existing auth header? request.headers["Authorization"] = f"Bearer {self._oauth2_token}" elif scheme["type"] == "mutualTLS": # At this point, we assume the cert has already been loaded into the sslcontext. @@ -616,35 +630,41 @@ def _log_response(self, response: _Response) -> None: if response.body: self._debug_callback(3, f"{response.body!r}") - def _parse_response(self, method_spec: dict[str, t.Any], response: _Response) -> t.Any: + def _parse_response(self, operation_spec: s.Operation, response: _Response) -> t.Any: if "Correlation-Id" in response.headers: self._set_correlation_id(response.headers["Correlation-Id"]) if response.status_code == 401: - raise PulpAuthenticationFailed(method_spec["operationId"]) + raise PulpAuthenticationFailed(operation_spec.operation_id) elif response.status_code == 403: - raise PulpNotAutorized(method_spec["operationId"]) + raise PulpNotAutorized(operation_spec.operation_id) elif response.status_code >= 300: raise PulpHTTPError(response.body.decode(), response.status_code) if response.status_code == 204: return {} + status_key = str(response.status_code) try: - response_spec = method_spec["responses"][str(response.status_code)] + response_spec = operation_spec.responses[status_key] except KeyError: - # Fallback 201 -> 200 + # Fallback 201 -> 2xx -> 200 try: - response_spec = method_spec["responses"][str(100 * int(response.status_code / 100))] + response_spec = operation_spec.responses[status_key[0] + "xx"] except KeyError: - raise OpenAPIError( - _("Unexpected response '{code}' (expected '{expected}').").format( - code=response.status_code, - expected=(", ").join(method_spec["responses"].keys()), + try: + response_spec = operation_spec.responses[status_key[0] + "00"] + except KeyError: + raise OpenAPIError( + _("Unexpected response '{code}' (expected '{expected}').").format( + code=response.status_code, + expected=(", ").join(operation_spec.responses.keys()), + ) ) - ) + if isinstance(response_spec, s.Reference): + raise NotImplementedError("Respose References") content_type = response.headers.get("content-type") if content_type is not None and content_type.startswith("application/json"): - assert content_type in response_spec["content"] + assert content_type in response_spec.content return json.loads(response.body) return None @@ -670,33 +690,28 @@ def call( Raises: ValidationError: on failed input validation (no request was sent to the server). OpenAPIError: on failures related to the HTTP call made. + + NotImplementedError: well, the name really says is all. """ method, path = self.operations[operation_id] - path_spec = self.api_spec["paths"][path] - method_spec = path_spec[method] + path_spec = self._api_spec.paths[path] + operation_spec = getattr(path_spec, method) if parameters is None: parameters = {} - else: - parameters = parameters.copy() + rendered_parameters = self._render_parameters(path_spec, operation_spec, parameters) - if any(self._extract_params("cookie", path_spec, method_spec, parameters)): - raise NotImplementedError(_("Cookie parameters are not implemented.")) + if len(rendered_parameters["cookie"]) > 0: + raise NotImplementedError("Cookie Parameters") - headers = self._extract_params("header", path_spec, method_spec, parameters) + headers = rendered_parameters["header"] rel_url = path - for name, value in self._extract_params("path", path_spec, method_spec, parameters).items(): + for name, value in rendered_parameters["path"].items(): rel_url = path.replace("{" + name + "}", value) - query_params = self._extract_params("query", path_spec, method_spec, parameters) + query_params = rendered_parameters["query"] - if any(parameters): - raise OpenAPIError( - _("Parameter [{names}] not available for {operation_id}.").format( - names=", ".join(parameters.keys()), operation_id=operation_id - ) - ) url = urljoin(self._base_url, rel_url) request = self._render_request( @@ -710,12 +725,14 @@ def call( ) self._log_request(request) - if self._dry_run and request.method.upper() not in SAFE_METHODS: + if self._dry_run and request.method.lower() not in SAFE_METHODS: raise UnsafeCallError(_("Call aborted due to safe mode")) may_retry = False if proposal := self._select_proposal(request): - assert len(proposal) == 1, "More complex security proposals are not implemented." + if len(proposal) != 1: + warnings.warn("The following exception statement may no longer be true.") + raise NotImplementedError("More complex security proposals are not implemented.") may_retry = asyncio.run(self._authenticate_request(request, proposal)) response = self._send_request(request) @@ -741,4 +758,4 @@ def call( ) self._log_response(response) - return self._parse_response(method_spec, response) + return self._parse_response(operation_spec, response) diff --git a/pulp-glue/src/pulp_glue/common/pydantic_oas.py b/pulp-glue/src/pulp_glue/common/pydantic_oas.py index 61decae8f..ef9d74ca7 100644 --- a/pulp-glue/src/pulp_glue/common/pydantic_oas.py +++ b/pulp-glue/src/pulp_glue/common/pydantic_oas.py @@ -15,7 +15,7 @@ class OASBase(pydantic.BaseModel, alias_generator=to_alias, extra="forbid"): class ExtensibleOASBase(OASBase, extra="allow"): @pydantic.model_validator(mode="after") - def _check_extensions(self) -> t.Self: + def _check_extensions(self) -> "t.Self": if self.__pydantic_extra__ is not None: invalid_keys = [ key for key in self.__pydantic_extra__.keys() if not key.startswith("x-") @@ -30,8 +30,20 @@ def _check_extensions(self) -> t.Self: return self +OperationName = t.Literal[ + "get", + "put", + "post", + "patch", + "delete", + "options", + "head", + "trace", +] + + class Reference(OASBase): - _ref: t.Annotated[str, pydantic.Field(alias="$ref")] + ref: t.Annotated[str, pydantic.Field(alias="$ref")] summary: str | None = None description: str | None = None @@ -192,7 +204,13 @@ class Encoding(ExtensibleOASBase): # TODO These only apply to RFC6570 style: ( t.Literal[ - "simple", "form", "matrix", "label", "spaceDelimited", "pipeDelimited", "deepObject" + "simple", + "form", + "matrix", + "label", + "spaceDelimited", + "pipeDelimited", + "deepObject", ] | None ) = None @@ -237,7 +255,7 @@ class ParameterBase(ExtensibleOASBase): allow_empty_value: bool = False @pydantic.model_validator(mode="after") - def _path_required(self) -> t.Self: + def _path_required(self) -> "t.Self": if self.in_ == "path": assert self.required return self @@ -251,10 +269,18 @@ class SchemaParameter(ParameterBase): schema_: Schema style: t.Annotated[ t.Literal[ - "simple", "form", "matrix", "label", "spaceDelimited", "pipeDelimited", "deepObject" + "simple", + "form", + "matrix", + "label", + "spaceDelimited", + "pipeDelimited", + "deepObject", ], pydantic.Field( - default_factory=lambda data: "simple" if data["in_"] in ["header", "path"] else "form" + default_factory=lambda data: ( + "simple" if data.get("in_") in ["header", "path"] else "form" + ) ), ] explode: t.Annotated[bool, pydantic.Field(default_factory=lambda data: data["style"] == "form")] @@ -275,7 +301,7 @@ class RequestBody(ExtensibleOASBase): class Response(ExtensibleOASBase): description: str headers: dict[str, Header | Reference] | None = None - content: dict[str, MediaType] | None = None + content: dict[str, MediaType] = {} links: dict[str, Link | Reference] | None = None @@ -291,16 +317,17 @@ class Operation(ExtensibleOASBase): description: str | None = None external_docs: ExternalDocumentation | None = None operation_id: str - parameters: list[Parameter | Reference] | None = None + parameters: list[Parameter | Reference] = [] request_body: RequestBody | Reference | None = None - responses: Responses | None = None + responses: Responses = {} callbacks: dict[str, Callback | Reference] | None = None deprecated: bool = False security: SecurityRequirements | None = None servers: list[Server] | None = None -class PathItem(Reference, ExtensibleOASBase): +class PathItem(ExtensibleOASBase): + # TODO $ref get: Operation | None = None put: Operation | None = None post: Operation | None = None @@ -310,17 +337,17 @@ class PathItem(Reference, ExtensibleOASBase): head: Operation | None = None trace: Operation | None = None servers: list[Server] | None = None - parameters: list[Parameter | Reference] | None = None + parameters: list[Parameter | Reference] = [] class Components(ExtensibleOASBase): - schemas: dict[str, Schema] | None = None + schemas: dict[str, Schema] | None = {} responses: dict[str, Response | Reference] | None = None - parameters: dict[str, Parameter | Reference] | None = None + parameters: dict[str, Parameter | Reference] = {} examples: dict[str, Example | Reference] | None = None request_bodies: dict[str, RequestBody | Reference] | None = None headers: dict[str, Header | Reference] | None = None - security_schemes: dict[str, SecurityScheme | Reference] | None = None + security_schemes: dict[str, SecurityScheme | Reference] = {} links: dict[str, Link | Reference] | None = None callbacks: dict[str, Callback | Reference] | None = None path_items: dict[str, PathItem] | None = None @@ -333,9 +360,9 @@ class OpenAPISpec(ExtensibleOASBase): servers: list[Server] | None = None # In the specification there is a Paths Object, # probably because paths as keys can get extra validation. - paths: dict[str, PathItem] | None = None + paths: dict[str, PathItem] = {} webhooks: dict[str, PathItem] | None = None - components: Components | None = None + components: Components = Components() security: SecurityRequirements | None = None tags: list[Tag] | None = None external_docs: ExternalDocumentation | None = None diff --git a/pulp-glue/src/pulp_glue/common/schema.py b/pulp-glue/src/pulp_glue/common/schema.py index be54c15a9..5d9ce7fc4 100644 --- a/pulp-glue/src/pulp_glue/common/schema.py +++ b/pulp-glue/src/pulp_glue/common/schema.py @@ -4,6 +4,7 @@ import typing as t from contextlib import suppress +from pulp_glue.common.exceptions import SchemaError, ValidationError from pulp_glue.common.i18n import get_translation translation = get_translation(__package__) @@ -13,14 +14,6 @@ ISO_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" -class SchemaError(ValueError): - pass - - -class ValidationError(ValueError): - pass - - class OpenApi3JsonEncoder(json.JSONEncoder): def default(self, o: t.Any) -> t.Any: if isinstance(o, datetime.datetime): diff --git a/pulp-glue/tests/test_openapi.py b/pulp-glue/tests/test_openapi.py index 0d9e32668..123afae8f 100644 --- a/pulp-glue/tests/test_openapi.py +++ b/pulp-glue/tests/test_openapi.py @@ -2,11 +2,17 @@ import datetime import json import logging +import typing as t import pytest from multidict import CIMultiDict -from pulp_glue.common.authentication import AuthProviderBase, BasicAuthProvider, GlueAuthProvider +from pulp_glue.common.authentication import ( + AuthProviderBase, + BasicAuthProvider, + GlueAuthProvider, +) +from pulp_glue.common.exceptions import ValidationError from pulp_glue.common.openapi import OpenAPI, _Request, _Response pytestmark = pytest.mark.glue @@ -57,12 +63,12 @@ } TEST_SCHEMA = json.dumps( { - "openapi": "3.0.3", + "openapi": "3.1.1", "paths": { "test/": { "get": { "operationId": "get_test_id", - "responses": {200: {}}, + "responses": {"200": {"description": "get test"}}, }, "post": { "operationId": "post_test_id", @@ -74,13 +80,100 @@ } }, }, - "responses": {200: {}}, + "responses": { + "200": { + "description": "post test", + "content": {"application/json": {"schema": {}}}, + } + }, "security": [{"B": []}], }, - } + }, + "render_params/": { + "head": { + "operationId": "render_params_ref", + "parameters": [{"$ref": "#/components/parameters/limit"}], + }, + "get": { + "operationId": "render_params_none", + }, + "trace": { + "operationId": "render_params_query", + "parameters": [ + {"name": "query1", "in": "query", "schema": {"type": "string"}}, + {"name": "query2", "in": "query", "schema": {"type": "string"}}, + { + "name": "date", + "in": "query", + "schema": {"type": "string", "format": "date"}, + }, + ], + }, + "delete": { + "operationId": "render_params_lists", + "parameters": [ + { + "name": "qlist", + "in": "query", + "explode": False, + "schema": {"type": "array", "items": {"type": "string"}}, + }, + { + "name": "hlist", + "in": "header", + "schema": {"type": "array", "items": {"type": "string"}}, + }, + { + "name": "clist", + "in": "cookie", + "explode": False, + "schema": {"type": "array", "items": {"type": "string"}}, + }, + { + "name": "eqlist", + "in": "query", + "schema": {"type": "array", "items": {"type": "string"}}, + }, + { + "name": "ehlist", + "in": "header", + "explode": True, + "schema": {"type": "array", "items": {"type": "string"}}, + }, + { + "name": "eclist", + "in": "cookie", + "schema": {"type": "array", "items": {"type": "string"}}, + }, + ], + }, + }, + "render_params/{pk}/": { + "head": { + "operationId": "render_params_pk_query", + "parameters": [ + {"name": "query1", "in": "query", "schema": {"type": "string"}}, + {"name": "query2", "in": "query", "schema": {"type": "string"}}, + ], + }, + "get": { + "operationId": "render_params_pk_", + }, + "parameters": [ + { + "name": "pk", + "in": "path", + "required": True, + "schema": {"type": "integer"}, + }, + ], + }, }, "security": [{}], "components": { + "parameters": { + "limit": {"name": "limit", "in": "query", "schema": {"type": "integer"}}, + }, "schemas": { "testBody": { "type": "object", @@ -117,9 +210,10 @@ def mock_send_request(request: _Request) -> _Response: @pytest.fixture def mock_openapi(monkeypatch: pytest.MonkeyPatch) -> OpenAPI: - monkeypatch.setattr(OpenAPI, "load_api", lambda self, refresh_cache: TEST_SCHEMA) + monkeypatch.setattr( + OpenAPI, "load_api", lambda self, refresh_cache: self._parse_api(TEST_SCHEMA) + ) openapi = OpenAPI("base_url", "doc_path", user_agent="test agent") - openapi._parse_api(TEST_SCHEMA) monkeypatch.setattr(openapi, "_send_request", mock_send_request) return openapi @@ -161,7 +255,7 @@ def test_request_has_no_auth( basic_auth_provider: AuthProviderBase, ) -> None: method, path = mock_openapi.operations["get_test_id"] - path_spec = mock_openapi.api_spec["paths"][path] + path_spec = mock_openapi._api_spec.paths[path] request = mock_openapi._render_request(path_spec, method, "test/", {}, {}, None) assert request.security == [{}] @@ -171,7 +265,7 @@ def test_request_has_security( basic_auth_provider: AuthProviderBase, ) -> None: method, path = mock_openapi.operations["post_test_id"] - path_spec = mock_openapi.api_spec["paths"][path] + path_spec = mock_openapi._api_spec.paths[path] request = mock_openapi._render_request( path_spec, method, "test/", {}, {}, {"text": "TRACE"} ) @@ -184,7 +278,11 @@ def test_returns_dict_for_no_content( mock_openapi: OpenAPI, ) -> None: response = _Response(204, {}, b"") - result = mock_openapi._parse_response({}, response) + operation_spec = mock_openapi._api_spec.paths["test/"].get + assert operation_spec is not None + + result = mock_openapi._parse_response(operation_spec, response) + assert result == {} def test_decodes_json( @@ -192,58 +290,119 @@ def test_decodes_json( mock_openapi: OpenAPI, ) -> None: response = _Response(200, {"content-type": "application/json"}, b'{"a": 1, "b": "Hallo!"}') - result = mock_openapi._parse_response( - {"responses": {"200": {"content": {"application/json": {}}}}}, response - ) + operation_spec = mock_openapi._api_spec.paths["test/"].post + assert operation_spec is not None + + result = mock_openapi._parse_response(operation_spec, response) + assert result == {"a": 1, "b": "Hallo!"} -class TestExtractParams: - def test_with_no_match(self, mock_openapi: OpenAPI) -> None: - parameters = {"a": 1, "b": "C"} - query_params = mock_openapi._extract_params("query", {}, {}, parameters) - assert query_params == {} - assert parameters == {"a": 1, "b": "C"} - - def test_raises_for_missing_required_parameter(self, mock_openapi: OpenAPI) -> None: - parameters = {"a": 1, "b": "C"} - with pytest.raises(RuntimeError, match=r"Required parameters \[c\] missing"): - mock_openapi._extract_params( - "query", - {"parameters": [{"name": "c", "in": "query", "required": True}]}, - {}, - parameters, - ) - assert parameters == {"a": 1, "b": "C"} - - def test_removes_matches(self, mock_openapi: OpenAPI) -> None: - parameters = {"a": 1, "c": "C"} - query_params = mock_openapi._extract_params( - "query", - {"parameters": [{"name": "c", "in": "query", "required": True}]}, - {}, - parameters, - ) - assert query_params == {"c": "C"} - assert parameters == {"a": 1} +class TestRenderParameters: + @pytest.mark.parametrize( + "operation_id,parameters,match", + ( + pytest.param( + "render_params_none", + {"superfluous": "1234"}, + r"superfluous", + id="remaining_parameters", + ), + pytest.param( + "render_params_pk_query", + {"query1": "asdf"}, + r"pk", + id="missing_required_parameter", + ), + pytest.param( + "render_params_query", + {"query1": "asdf", "query2": 35}, + r"query2", + id="wrong_type", + ), + ), + ) + def test_fails_validation_for( + self, + mock_openapi: OpenAPI, + operation_id: str, + parameters: dict[str, t.Any], + match: str, + ) -> None: + method, path = mock_openapi.operations[operation_id] + path_spec = mock_openapi._api_spec.paths[path] + method_spec = getattr(path_spec, method) + + with pytest.raises(ValidationError, match=match): + mock_openapi._render_parameters(path_spec, method_spec, parameters) + + def test_references_is_implemented(self, mock_openapi: OpenAPI) -> None: + parameters: dict[str, t.Any] = {"limit": 2} + method, path = mock_openapi.operations["render_params_ref"] + path_spec = mock_openapi._api_spec.paths[path] + method_spec = getattr(path_spec, method) + + res = mock_openapi._render_parameters(path_spec, method_spec, parameters) + + assert res["query"] == {"limit": 2} + + def test_no_parameters_none_specified(self, mock_openapi: OpenAPI) -> None: + parameters: dict[str, t.Any] = {} + method, path = mock_openapi.operations["render_params_none"] + path_spec = mock_openapi._api_spec.paths[path] + method_spec = getattr(path_spec, method) + + res = mock_openapi._render_parameters(path_spec, method_spec, parameters) + + assert res == {"query": {}, "header": {}, "path": {}, "cookie": {}} + assert parameters == {} + + def test_provided_parameters_are_rendered(self, mock_openapi: OpenAPI) -> None: + parameters: dict[str, t.Any] = {"query1": "asdf", "pk": 42} + method, path = mock_openapi.operations["render_params_pk_query"] + path_spec = mock_openapi._api_spec.paths[path] + method_spec = getattr(path_spec, method) + + res = mock_openapi._render_parameters(path_spec, method_spec, parameters) + + assert res == { + "query": {"query1": "asdf"}, + "header": {}, + "path": {"pk": 42}, + "cookie": {}, + } + assert parameters == {"query1": "asdf", "pk": 42} + + def test_lists_are_rendered_according_to_explode(self, mock_openapi: OpenAPI) -> None: + parameters: dict[str, t.Any] = { + "qlist": ["1", "2", "3"], + "eqlist": ["1", "2", "3"], + "hlist": ["1", "2", "3"], + "ehlist": ["1", "2", "3"], + "clist": ["1", "2", "3"], + "eclist": ["1", "2", "3"], + } + method, path = mock_openapi.operations["render_params_lists"] + path_spec = mock_openapi._api_spec.paths[path] + method_spec = getattr(path_spec, method) + + res = mock_openapi._render_parameters(path_spec, method_spec, parameters) + + assert res == { + "query": {"qlist": "1,2,3", "eqlist": ["1", "2", "3"]}, + "header": {"hlist": "1,2,3", "ehlist": ["1", "2", "3"]}, + "path": {}, + "cookie": {"clist": "1,2,3", "eclist": ["1", "2", "3"]}, + } def test_encodes_date(self, mock_openapi: OpenAPI) -> None: - parameters = {"a": datetime.date(2000, 1, 1)} - query_params = mock_openapi._extract_params( - "query", - { - "parameters": [ - { - "name": "a", - "in": "query", - "schema": {"type": "string", "format": "date"}, - } - ] - }, - {}, - parameters, - ) - assert query_params == {"a": "2000-01-01"} + parameters = {"date": datetime.date(2000, 1, 1)} + method, path = mock_openapi.operations["render_params_query"] + path_spec = mock_openapi._api_spec.paths[path] + method_spec = getattr(path_spec, method) + + res = mock_openapi._render_parameters(path_spec, method_spec, parameters) + assert res["query"] == {"date": "2000-01-01"} class TestOpenAPILogs: diff --git a/pulp-glue/tests/test_pydantic_oas.py b/pulp-glue/tests/test_pydantic_oas.py index 854c7619b..3997e020e 100644 --- a/pulp-glue/tests/test_pydantic_oas.py +++ b/pulp-glue/tests/test_pydantic_oas.py @@ -3,7 +3,12 @@ import pydantic import pytest -from pulp_glue.common.pydantic_oas import OpenAPISpec, Parameter, SchemaParameter +from pulp_glue.common.pydantic_oas import ( + OpenAPISpec, + Parameter, + SchemaParameter, + SecuritySchemeHttp, +) pytestmark = pytest.mark.glue @@ -58,7 +63,10 @@ "info": { "title": "Test API", "version": "3.141592653", - "license": {"name": "Creative Commons Zero v1.0 Universal", "identifier": "CC0-1.0"}, + "license": { + "name": "Creative Commons Zero v1.0 Universal", + "identifier": "CC0-1.0", + }, "x-this-is-something": True, }, "paths": { @@ -86,6 +94,7 @@ "responses": {"200": {"description": "SUCCESS"}}, "security": [{"B": []}], }, + "parameters": [{"$ref": "#/components/parameters/query1"}], } }, "security": [{}], @@ -97,6 +106,9 @@ "required": ["text"], } }, + "parameters": { + "query1": {"name": "query1", "in": "query", "schema": {}}, + }, "securitySchemes": SECURITY_SCHEMES, }, } @@ -109,7 +121,7 @@ def test_validate(self) -> None: spec = OpenAPISpec.model_validate(TEST_SCHEMA) assert spec.components is not None assert spec.components.security_schemes is not None - assert spec.components.security_schemes["A"].type_ == "http" + assert isinstance(spec.components.security_schemes["A"], SecuritySchemeHttp) assert spec.paths is not None assert spec.paths["test/"].get is not None assert spec.paths["test/"].get.parameters is not None @@ -129,7 +141,12 @@ def test_path_parameter_is_required(self) -> None: ) with pytest.raises(pydantic.ValidationError): ParameterAdapter.validate_python( - {"in": "path", "name": "cannotbeoptional", "required": False, "schema": {}} + { + "in": "path", + "name": "cannotbeoptional", + "required": False, + "schema": {}, + } ) @pytest.mark.parametrize( @@ -162,7 +179,13 @@ def test_parameter_style_defaults_to(self, in_: str, style: str) -> None: ) def test_parameter_explode_defautls_to(self, style: str, explode: bool) -> None: parameter = ParameterAdapter.validate_python( - {"in": "query", "name": "test", "required": True, "style": style, "schema": {}} + { + "in": "query", + "name": "test", + "required": True, + "style": style, + "schema": {}, + } ) assert isinstance(parameter, SchemaParameter) assert parameter.explode == explode diff --git a/pulp-glue/tests/test_schema.py b/pulp-glue/tests/test_schema.py index 4ac037c20..7eee0e803 100644 --- a/pulp-glue/tests/test_schema.py +++ b/pulp-glue/tests/test_schema.py @@ -4,9 +4,8 @@ import pytest +from pulp_glue.common.exceptions import SchemaError, ValidationError from pulp_glue.common.schema import ( - SchemaError, - ValidationError, encode_json, encode_param, validate,