Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 78 additions & 31 deletions localstack-core/localstack/aws/handlers/validation.py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my opinion, this handler and the plugin definition deserves quite a lot more documentation. It's a cool new feature where only a very small amount of our contributors know about. Please make sure that everyone can take a look at this file and knows what it does, and how one can interact with it (enable it, disable it, register OpenAPI Spec plugins,...). ;)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i second alex' comment here and think it can do with even a bit more doc. specifically i think it needs a more concrete example on how to use it. where do i put the openapi.yml? how do i correctly instantiate a new plugin? For some inspiration, maybe the docs on the WebAppExtension can help.

Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@

import logging
import os
import sys
from pathlib import Path

import yaml
from openapi_core import OpenAPI
from openapi_core.contrib.werkzeug import WerkzeugOpenAPIRequest, WerkzeugOpenAPIResponse
from openapi_core.exceptions import OpenAPIError
from openapi_core.validation.request.exceptions import (
RequestValidationError,
)
from openapi_core.validation.response.exceptions import ResponseValidationError
from plux import Plugin, PluginManager

from localstack import config
from localstack.aws.api import RequestContext
Expand All @@ -23,18 +26,60 @@
LOG = logging.getLogger(__name__)


# TODO: replace with from importlib.resources.files when https://github.com/python/importlib_resources/issues/311 is
# resolved. Import from a namespace package is broken when installing in editable mode.
oas_path = os.path.join(os.path.dirname(__file__), "..", "..", "openapi.yaml")
class OASPlugin(Plugin):
"""
This plugin allows to register an arbitrary number of OpenAPI specs, e.g., the spec for the public endpoints
of localstack.core.
The OpenAPIValidator handler uses (as opt-in) all the collected specs to validate the requests and the responses
to these public endpoints.

An OAS plugin assumes the following directory layout.

my_package
├── sub_package
│ ├── __init__.py <-- spec file
│ ├── openapi.yaml
│ └── plugins.py <-- plugins
├── plugins.py <-- plugins
└── openapi.yaml <-- spec file

Each package can have its own OpenAPI yaml spec which is loaded by the correspondent plugin in plugins.py
You can simply create a plugin like the following:

class MyPackageOASPlugin(OASPlugin):
name = "my_package"

The only convention is that plugins.py and openapi.yaml have the same pathname.
"""

namespace = "localstack.openapi.spec"

def __init__(self) -> None:
# By convention a plugins.py is at the same level (i.e., same pathname) of the openapi.yaml file.
# importlib.resources would be a better approach but has issues with namespace packages in editable mode
_module = sys.modules[self.__module__]
self.spec_path = Path(
os.path.join(os.path.dirname(os.path.abspath(_module.__file__)), "openapi.yaml")
)
assert self.spec_path.exists()
self.spec = {}

def load(self):
with self.spec_path.open("r") as f:
self.spec = yaml.safe_load(f)


class OpenAPIValidator(Handler):
openapi: "OpenAPI"
open_apis: list["OpenAPI"]

def __init__(self) -> None:
path = Path(oas_path)
assert path.exists()
self.openapi = OpenAPI.from_path(path)
# avoid to load the specs if we don't have to perform any validation
if not (config.OPENAPI_VALIDATE_REQUEST or config.OPENAPI_VALIDATE_RESPONSE):
return
specs = PluginManager("localstack.openapi.spec").load_all()
self.open_apis = []
for spec in specs:
self.open_apis.append(OpenAPI.from_path(spec.spec_path))


class OpenAPIRequestValidator(OpenAPIValidator):
Expand All @@ -50,20 +95,21 @@ def __call__(self, chain: HandlerChain, context: RequestContext, response: Respo
path = context.request.path

if path.startswith(f"{INTERNAL_RESOURCE_PATH}/") or path.startswith("/_aws/"):
try:
self.openapi.validate_request(WerkzeugOpenAPIRequest(context.request))
except RequestValidationError as e:
# Note: in this handler we only check validation errors, e.g., wrong body, missing required in the body.
response.status_code = 400
response.set_json({"error": "Bad Request", "message": str(e)})
chain.stop()
except OpenAPIError:
# Other errors can be raised when validating a request against the OpenAPI specification.
# The most common are: ServerNotFound, OperationNotFound, or PathNotFound.
# We explicitly do not check any other error but RequestValidationError ones.
# We shallow the exception to avoid excessive logging (e.g., a lot of ServerNotFound), as the only
# purpose of this handler is to check for request validation errors.
pass
for openapi in self.open_apis:
try:
openapi.validate_request(WerkzeugOpenAPIRequest(context.request))
except RequestValidationError as e:
# Note: in this handler we only check validation errors, e.g., wrong body, missing required.
response.status_code = 400
response.set_json({"error": "Bad Request", "message": str(e)})
chain.stop()
except OpenAPIError:
# Other errors can be raised when validating a request against the OpenAPI specification.
# The most common are: ServerNotFound, OperationNotFound, or PathNotFound.
# We explicitly do not check any other error but RequestValidationError ones.
# We shallow the exception to avoid excessive logging (e.g., a lot of ServerNotFound), as the only
# purpose of this handler is to check for request validation errors.
pass


class OpenAPIResponseValidator(OpenAPIValidator):
Expand All @@ -76,13 +122,14 @@ def __call__(self, chain: HandlerChain, context: RequestContext, response: Respo
path = context.request.path

if path.startswith(f"{INTERNAL_RESOURCE_PATH}/") or path.startswith("/_aws/"):
try:
self.openapi.validate_response(
WerkzeugOpenAPIRequest(context.request),
WerkzeugOpenAPIResponse(response),
)
except ResponseValidationError as exc:
LOG.error("Response validation failed for %s: $s", path, exc)
response.status_code = 500
response.set_json({"error": exc.__class__.__name__, "message": str(exc)})
chain.stop()
for openapi in self.open_apis:
try:
openapi.validate_response(
WerkzeugOpenAPIRequest(context.request),
WerkzeugOpenAPIResponse(response),
)
except ResponseValidationError as exc:
LOG.error("Response validation failed for %s: $s", path, exc)
response.status_code = 500
response.set_json({"error": exc.__class__.__name__, "message": str(exc)})
chain.stop()
5 changes: 5 additions & 0 deletions localstack-core/localstack/plugins.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging

from localstack import config
from localstack.aws.handlers.validation import OASPlugin
from localstack.runtime import hooks
from localstack.utils.files import rm_rf
from localstack.utils.ssl import get_cert_pem_file_path
Expand All @@ -21,3 +22,7 @@ def delete_cached_certificate():
LOG.debug("Removing the cached local SSL certificate")
target_file = get_cert_pem_file_path()
rm_rf(target_file)


class CoreOASPlugin(OASPlugin):
name = "localstack"
49 changes: 49 additions & 0 deletions tests/unit/aws/handlers/openapi.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import json

import pytest
import yaml
from openapi_core import OpenAPI
from rolo import Request, Response
from rolo.gateway import RequestContext
from rolo.gateway.handlers import EmptyResponseHandler
Expand All @@ -9,6 +11,35 @@
from localstack.aws.chain import HandlerChain
from localstack.aws.handlers.validation import OpenAPIRequestValidator

test_spec = """
openapi: 3.0.0
info:
title: Test API
version: 0.0.1
description: Sample
paths:
/_localstack/dummy/{entityId}:
get:
parameters:
- name: entityId
in: path
required: true
schema:
type: number
example: 4
responses:
'200':
description: Response list
content:
application/json: {}
"""


@pytest.fixture()
def openapi() -> OpenAPI:
spec = yaml.safe_load(test_spec)
return OpenAPI.from_dict(spec)


@pytest.fixture(autouse=True)
def enable_validation_flag(monkeypatch):
Expand Down Expand Up @@ -127,3 +158,21 @@ def test_body_validation_errors(self):
assert response.status_code == 400
assert response.json["error"] == "Bad Request"
assert response.json["message"] == "Request body validation error"

def test_multiple_specs(self, openapi):
validator = OpenAPIRequestValidator()
validator.open_apis.append(openapi)
chain = HandlerChain([validator])
context = RequestContext(
Request(
path="/_localstack/dummy/dummyName",
method="GET",
scheme="http",
headers={"Host": "localhost.localstack.cloud:4566"},
)
)
response = Response()
chain.handle(context=context, response=response)
assert response.status_code == 400
assert response.json["error"] == "Bad Request"
assert "Path parameter error" in response.json["message"]
Loading