From 93ad67a445b98971fc2a3ee18bf0b17f35947595 Mon Sep 17 00:00:00 2001 From: Sharpz7 Date: Tue, 26 Nov 2024 18:42:05 +0000 Subject: [PATCH] api: Add schema validation framework This is effectively a carbon copy of the code from Nova, Manila, Cinder et al but modified to work with pecan instead of Routes. We do not use all of the new code yet, but we will in a future change. Related-Bug: 2086121 Change-Id: I76c1600036c82ead436cd0fb7e7dee1e34e21907 Signed-off-by: Stephen Finucane --- ironic/api/controllers/v1/shard.py | 5 +- ironic/api/schemas/v1/shard.py | 39 +++ ironic/api/validation/__init__.py | 293 ++++++++++++++++++ ironic/api/validation/validators.py | 121 ++++++++ ironic/tests/unit/api/validation/__init__.py | 0 .../unit/api/validation/test_validators.py | 46 +++ ...validation_framework-eaac62cfecb132b0.yaml | 6 + 7 files changed, 509 insertions(+), 1 deletion(-) create mode 100644 ironic/api/schemas/v1/shard.py create mode 100644 ironic/api/validation/validators.py create mode 100644 ironic/tests/unit/api/validation/__init__.py create mode 100644 ironic/tests/unit/api/validation/test_validators.py create mode 100644 releasenotes/notes/add_schema_validation_framework-eaac62cfecb132b0.yaml diff --git a/ironic/api/controllers/v1/shard.py b/ironic/api/controllers/v1/shard.py index 71c91a12d2..5835481d53 100644 --- a/ironic/api/controllers/v1/shard.py +++ b/ironic/api/controllers/v1/shard.py @@ -18,6 +18,7 @@ from ironic import api from ironic.api.controllers.v1 import utils as api_utils from ironic.api.controllers.v1 import versions from ironic.api import method +from ironic.api.schemas.v1 import shard as schema from ironic.api import validation from ironic.common.i18n import _ @@ -36,6 +37,8 @@ class ShardController(pecan.rest.RestController): min_version=versions.MINOR_82_NODE_SHARD, message=_('The API version does not allow shards'), ) + @validation.request_query_schema(schema.index_request_query) + @validation.response_body_schema(schema.index_response_body) def get_all(self): """Retrieve a list of shards. @@ -53,6 +56,6 @@ class ShardController(pecan.rest.RestController): min_version=versions.MINOR_82_NODE_SHARD, message=_('The API version does not allow shards'), ) - def get_one(self, __): + def get_one(self, _): """Explicitly do not support getting one.""" pecan.abort(404) diff --git a/ironic/api/schemas/v1/shard.py b/ironic/api/schemas/v1/shard.py new file mode 100644 index 0000000000..e293e70485 --- /dev/null +++ b/ironic/api/schemas/v1/shard.py @@ -0,0 +1,39 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +# TODO(stephenfin): Switch additionalProperties to False in a future version +index_request_query = { + 'type': 'object', + 'properties': {}, + 'required': [], + 'additionalProperties': True, +} + +index_response_body = { + 'type': 'object', + 'properties': { + 'shards': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'count': {'type': 'integer', 'minimum': 1}, + 'name': {'type': 'string'}, + }, + 'required': ['count', 'name'], + 'additionalProperties': False, + }, + }, + }, + 'required': ['shards'], + 'additionalProperties': False, +} diff --git a/ironic/api/validation/__init__.py b/ironic/api/validation/__init__.py index e0c45831f3..ed21affd71 100644 --- a/ironic/api/validation/__init__.py +++ b/ironic/api/validation/__init__.py @@ -10,12 +10,17 @@ # License for the specific language governing permissions and limitations # under the License. +"""API request/response validating middleware.""" + import functools +import inspect import typing as ty +from oslo_serialization import jsonutils from webob import exc as webob_exc from ironic import api +from ironic.api.validation import validators from ironic.common.i18n import _ @@ -70,3 +75,291 @@ def api_version( return wrapper return add_validator + + +class Schemas: + """A microversion-aware schema container. + + Allow definition and retrieval of schemas on a microversion-aware basis. + """ + + def __init__(self) -> None: + self._schemas: list[ + tuple[dict[str, object], ty.Optional[int], ty.Optional[int]] + ] = [] + + def add_schema( + self, + schema: tuple[dict[str, object]], + min_version: ty.Optional[int], + max_version: ty.Optional[int], + ) -> None: + self._schemas.append((schema, min_version, max_version)) + + def __call__(self) -> ty.Optional[dict[str, object]]: + for schema, min_version, max_version in self._schemas: + if ( + min_version and not api.request.version.minor >= min_version + ) or ( + max_version and not api.request.version.minor <= max_version + ): + continue + + return schema + + return None + + +def _schema_validator( + schema: ty.Dict[str, ty.Any], + target: ty.Dict[str, ty.Any], + min_version: ty.Optional[int], + max_version: ty.Optional[int], + is_body: bool = True, +): + """A helper method to execute JSON Schema Validation. + + This method checks the request version whether matches the specified + ``max_version`` and ``min_version``. If the version range matches the + request, we validate ``schema`` against ``target``. A failure will result + in ``ValidationError`` being raised. + + :param schema: The JSON Schema schema used to validate the target. + :param target: The target to be validated by the schema. + :param min_version: An integer indicating the minimum API version + ``schema`` applies against. + :param max_version: An integer indicating the maximum API version + ``schema`` applies against. + :param args: Positional arguments which passed into original method. + :param kwargs: Keyword arguments which passed into original method. + :param is_body: Whether ``target`` is a HTTP request body or not. + :returns: None. + :raises: ``ValidationError`` if validation fails. + """ + # Only validate against the schema if it lies within + # the version range specified. Note that if both min + # and max are not specified the validator will always + # be run. + if ( + (min_version and api.request.version.minor < min_version) + or (max_version and api.request.version.minor > max_version) + ): + return + + schema_validator = validators.SchemaValidator(schema, is_body=is_body) + schema_validator.validate(target) + + +def _extract_parameters(function): + sig = inspect.signature(function) + params = [] + + for param in sig.parameters.values(): + if param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: + if param.name == 'self': # skip validating self + continue + + params.append(param) + return params + + +def request_parameter_schema( + schema: ty.Dict[str, ty.Any], + min_version: ty.Optional[int] = None, + max_version: ty.Optional[int] = None, +): + """Decorator for registering a request parameter schema on API methods. + + ``schema`` will be used for validating request parameters just before + the API method is executed. + + :param schema: The JSON Schema schema used to validate the target. + :param min_version: An integer indicating the minimum API version + ``schema`` applies against. + :param max_version: An integer indicating the maximum API version + ``schema`` applies against. + """ + + def add_validator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + # we need to convert positional arguments to a dict mapping token + # name to value so that we have a reference to compare against + parameters = _extract_parameters(func) + if func.__name__ in ('patch', 'post'): + # if this a create or update method, we need to ignore the + # request body parameter + parameters = parameters[:-1] + + parameters = { + p.name: args[i + 1] for i, p in enumerate(parameters) + if p.name != '_' and p.default is p.empty + } + _schema_validator( + schema, + parameters, + min_version, + max_version, + is_body=True, + ) + return func(*args, **kwargs) + + if not hasattr(wrapper, 'request_parameter_schemas'): + wrapper.request_parameter_schemas = Schemas() + + wrapper.request_parameter_schemas .add_schema( + schema, min_version, max_version + ) + + return wrapper + + return add_validator + + +def request_query_schema( + schema: ty.Dict[str, ty.Any], + min_version: ty.Optional[int] = None, + max_version: ty.Optional[int] = None, +): + """Decorator for registering a request query string schema on API methods. + + ``schema`` will be used for validating request query strings just before + the API method is executed. + + :param schema: The JSON Schema schema used to validate the target. + :param min_version: An integer indicating the minimum API version + ``schema`` applies against. + :param max_version: An integer indicating the maximum API version + ``schema`` applies against. + """ + + def add_validator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + _schema_validator( + schema, + kwargs, + min_version, + max_version, + is_body=True, + ) + return func(*args, **kwargs) + + if not hasattr(wrapper, 'request_query_schemas'): + wrapper.request_query_schemas = Schemas() + + wrapper.request_query_schemas .add_schema( + schema, min_version, max_version + ) + + return wrapper + + return add_validator + + +def request_body_schema( + schema: ty.Dict[str, ty.Any], + min_version: ty.Optional[str] = None, + max_version: ty.Optional[str] = None, +): + """Decorator for registering a request body schema on API methods. + + ``schema`` will be used for validating the request body just before the API + method is executed. + + :param schema: The JSON Schema schema used to validate the target. + :param min_version: A string indicating the minimum API version ``schema`` + applies against. + :param max_version: A string indicating the maximum API version ``schema`` + applies against. + """ + + def add_validator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + parameters = _extract_parameters(func) + if not parameters: + # TODO(stephenfin): this would be a better check if we + # distinguished between 'create' operations (which should have + # at least one parameter, the body) and "update" operations + # (which should have at least two, the IDs and the body) + raise RuntimeError( + 'The ironic.api.method.body decorator must be placed ' + 'outside the validation helpers to ensure it runs first.' + ) + + _schema_validator( + schema, + # The body argument will always be the last one + kwargs[parameters[-1].name], + min_version, + max_version, + is_body=True, + ) + return func(*args, **kwargs) + + if not hasattr(wrapper, 'request_body_schemas'): + wrapper.request_body_schemas = Schemas() + + wrapper.request_body_schemas .add_schema( + schema, min_version, max_version + ) + + return wrapper + + return add_validator + + +def response_body_schema( + schema: ty.Dict[str, ty.Any], + min_version: ty.Optional[str] = None, + max_version: ty.Optional[str] = None, +): + """Decorator for registering a response body schema on API methods. + + ``schema`` will be used for validating the response body just after the API + method is executed. + + :param schema: The JSON Schema schema used to validate the target. + :param min_version: A string indicating the minimum API version ``schema`` + applies against. + :param max_version: A string indicating the maximum API version ``schema`` + applies against. + """ + + def add_validator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + response = func(*args, **kwargs) + + # FIXME(stephenfin): How is ironic/pecan doing jsonification? The + # below will fail on e.g. date-time fields + + # NOTE(stephenfin): If our response is an object, we need to + # serialize and deserialize to convert e.g. date-time to strings + _body = jsonutils.dumps(response) + + if _body == b'': + body = None + else: + body = jsonutils.loads(_body) + + _schema_validator( + schema, + body, + min_version, + max_version, + is_body=True, + ) + return response + + if not hasattr(wrapper, 'response_body_schemas'): + wrapper.response_body_schemas = Schemas() + + wrapper.response_body_schemas .add_schema( + schema, min_version, max_version + ) + + return wrapper + + return add_validator diff --git a/ironic/api/validation/validators.py b/ironic/api/validation/validators.py new file mode 100644 index 0000000000..a2f6d509be --- /dev/null +++ b/ironic/api/validation/validators.py @@ -0,0 +1,121 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Internal implementation of request/response validating middleware.""" + +import jsonschema +from jsonschema import exceptions as jsonschema_exc +from oslo_utils import timeutils +from oslo_utils import uuidutils + +from ironic.common import exception +from ironic.common.i18n import _ + + +@jsonschema.FormatChecker.cls_checks('date-time') +def _validate_datetime_format(instance: object) -> bool: + # format checks constrain to the relevant primitive type + # https://github.com/OAI/OpenAPI-Specification/issues/3148 + if not isinstance(instance, str): + return True + try: + timeutils.parse_isotime(instance) + except ValueError: + return False + else: + return True + + +@jsonschema.FormatChecker.cls_checks('uuid') +def _validate_uuid_format(instance: object) -> bool: + # format checks constrain to the relevant primitive type + # https://github.com/OAI/OpenAPI-Specification/issues/3148 + if not isinstance(instance, str): + return True + + return uuidutils.is_uuid_like(instance) + + +class FormatChecker(jsonschema.FormatChecker): + """A FormatChecker can output the message from cause exception + + We need understandable validation errors messages for users. When a + custom checker has an exception, the FormatChecker will output a + readable message provided by the checker. + """ + + def check(self, param_value, format): + """Check whether the param_value conforms to the given format. + + :param param_value: the param_value to check + :type: any primitive type (str, number, bool) + :param str format: the format that param_value should conform to + :raises: :exc:`FormatError` if param_value does not conform to format + """ + + if format not in self.checkers: + return + + # For safety reasons custom checkers can be registered with + # allowed exception types. Anything else will fall into the + # default formatter. + func, raises = self.checkers[format] + result, cause = None, None + + try: + result = func(param_value) + except raises as e: + cause = e + if not result: + msg = '%r is not a %r' % (param_value, format) + raise jsonschema_exc.FormatError(msg, cause=cause) + + +class SchemaValidator: + """A validator class + + This class is changed from Draft202012Validator to add format checkers for + data formats that are common in the Ironic API as well as add better error + messages. + """ + + validator = None + validator_org = jsonschema.Draft202012Validator + + def __init__( + self, schema, is_body=True + ): + self.is_body = is_body + validator_cls = jsonschema.validators.extend(self.validator_org) + format_checker = FormatChecker() + try: + self.validator = validator_cls( + schema, format_checker=format_checker + ) + except Exception: + raise + + def validate(self, *args, **kwargs): + try: + self.validator.validate(*args, **kwargs) + except jsonschema.ValidationError as e: + error_msg = _('Schema error: %s') % e.message + # Sometimes the root message is too generic, try to find a possible + # root cause: + cause = None + current = e + while current.context: + current = jsonschema.exceptions.best_match(current.context) + cause = current.message + if cause is not None: + error_msg += _('. Possible root cause: %s') % cause + raise exception.InvalidParameterValue(error_msg) diff --git a/ironic/tests/unit/api/validation/__init__.py b/ironic/tests/unit/api/validation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/tests/unit/api/validation/test_validators.py b/ironic/tests/unit/api/validation/test_validators.py new file mode 100644 index 0000000000..da3c1d79c2 --- /dev/null +++ b/ironic/tests/unit/api/validation/test_validators.py @@ -0,0 +1,46 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from ironic.api.validation import validators +from ironic.common import exception +from ironic.tests import base as test_base + + +class TestSchemaValidator(test_base.TestCase): + + def test_uuid_format(self): + schema = {'type': 'string', 'format': 'uuid'} + validator = validators.SchemaValidator(schema) + + # passes + validator.validate('d1903ad5-c774-4bfe-8cf4-8e08d8dbb4d3') + + # fails + self.assertRaises( + exception.InvalidParameterValue, + validator.validate, + 'invalid uuid' + ) + + def test_datetime_format(self): + schema = {'type': 'string', 'format': 'date-time'} + validator = validators.SchemaValidator(schema) + + # passes + validator.validate('2019-10-12T07:20:50.52Z') + + # fails + self.assertRaises( + exception.InvalidParameterValue, + validator.validate, + 'invalid date-time' + ) diff --git a/releasenotes/notes/add_schema_validation_framework-eaac62cfecb132b0.yaml b/releasenotes/notes/add_schema_validation_framework-eaac62cfecb132b0.yaml new file mode 100644 index 0000000000..f7e486420b --- /dev/null +++ b/releasenotes/notes/add_schema_validation_framework-eaac62cfecb132b0.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Adds a schema validation framework to the API. This allows for the validation + of incoming requests and outgoing responses against a JSON schema, right at + the beginning and end of the request processing pipeline. \ No newline at end of file