From 85057887257d0382af5abb583ca8e3b644071ac5 Mon Sep 17 00:00:00 2001 From: Jordan Eremieff <1376648+jordaneremieff@users.noreply.github.com> Date: Sun, 13 Feb 2022 00:08:21 +1100 Subject: [PATCH] :fire: Remove WebSocket support, reduce some of the noise generally in preparation for refactoring. (#234) --- docs/adapter.md | 5 +- docs/index.md | 4 +- docs/websockets.md | 300 ------------------- mangum/adapter.py | 43 +-- mangum/backends/__init__.py | 199 ------------ mangum/backends/base.py | 44 --- mangum/backends/dynamodb.py | 93 ------ mangum/backends/postgresql.py | 45 --- mangum/backends/redis.py | 27 -- mangum/backends/s3.py | 90 ------ mangum/backends/sqlite.py | 48 --- mangum/exceptions.py | 8 - mangum/handlers/__init__.py | 2 - mangum/handlers/abstract_handler.py | 34 +-- mangum/handlers/aws_alb.py | 12 - mangum/handlers/aws_api_gateway.py | 10 - mangum/handlers/aws_cf_lambda_at_edge.py | 10 - mangum/handlers/aws_http_gateway.py | 1 - mangum/handlers/aws_ws_gateway.py | 75 ----- mangum/protocols/__init__.py | 8 +- mangum/protocols/websockets.py | 198 ------------ mangum/types.py | 29 +- mkdocs.yml | 25 +- pytest.ini | 3 +- requirements.txt | 11 - tests/conftest.py | 191 ------------ tests/handlers/test_aws_alb.py | 2 - tests/handlers/test_aws_api_gateway.py | 3 - tests/handlers/test_aws_cf_lambda_at_edge.py | 3 - tests/handlers/test_aws_http_gateway.py | 6 - tests/handlers/test_aws_ws_gateway.py | 68 ----- tests/integration/__init__.py | 0 tests/integration/docker-compose.yml | 14 - tests/integration/mock_server.py | 81 ----- tests/integration/test_backends.py | 237 --------------- tests/test_http.py | 3 - tests/test_websockets.py | 109 ------- 37 files changed, 27 insertions(+), 2014 deletions(-) delete mode 100644 docs/websockets.md delete mode 100644 mangum/backends/__init__.py delete mode 100644 mangum/backends/base.py delete mode 100644 mangum/backends/dynamodb.py delete mode 100644 mangum/backends/postgresql.py delete mode 100644 mangum/backends/redis.py delete mode 100644 mangum/backends/s3.py delete mode 100644 mangum/backends/sqlite.py delete mode 100644 mangum/handlers/aws_ws_gateway.py delete mode 100644 mangum/protocols/websockets.py delete mode 100644 tests/handlers/test_aws_ws_gateway.py delete mode 100644 tests/integration/__init__.py delete mode 100644 tests/integration/docker-compose.yml delete mode 100644 tests/integration/mock_server.py delete mode 100644 tests/integration/test_backends.py delete mode 100644 tests/test_websockets.py diff --git a/docs/adapter.md b/docs/adapter.md index 3400d0f2..a94718d6 100644 --- a/docs/adapter.md +++ b/docs/adapter.md @@ -8,13 +8,10 @@ handler = Mangum( lifespan="auto", api_gateway_base_path=None, text_mime_types=None, - dsn=None, - api_gateway_endpoint_url=None, - api_gateway_region_name=None ) ``` -All arguments are optional, but some may be necessary for specific use-cases (e.g. dsn is only required for WebSocket support). +All arguments are optional. ## Configuring an adapter instance diff --git a/docs/index.md b/docs/index.md index 84c564e6..e871acfb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,9 +14,7 @@ Mangum is an adapter for using [ASGI](https://asgi.readthedocs.io/en/latest/) ap ## Features -- API Gateway support for [HTTP](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api.html), [REST](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-rest-api.html), and [WebSocket](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api.html) APIs. - -- Multiple storage backend interfaces for managing WebSocket connections. +- API Gateway support for [HTTP](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api.html) and [REST](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-rest-api.html) APIs. - Compatibility with ASGI application frameworks, such as [Starlette](https://www.starlette.io/), [FastAPI](https://fastapi.tiangolo.com/), and [Quart](https://pgjones.gitlab.io/quart/). diff --git a/docs/websockets.md b/docs/websockets.md deleted file mode 100644 index 52fecd6b..00000000 --- a/docs/websockets.md +++ /dev/null @@ -1,300 +0,0 @@ -# WebSockets - -Mangum provides support for [WebSocket API](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api.html) events in API Gateway. The adapter class handles parsing the incoming requests and managing the ASGI cycle using a configured storage backend. - -```python -import os - -from fastapi import FastAPI, WebSocket -from fastapi.responses import HTMLResponse -from mangum import Mangum - -DSN_URL = os.environ["DSN_URL"] -WEBSOCKET_URL = os.environ["WEBSOCKET_URL"] -HTML = """ - - - - Chat - - - -

WebSocket Chat

-
- - -
- - - - - - -""" % WEBSOCKET_URL - -app = FastAPI() - -@app.get("/") -async def get(): - return HTMLResponse(HTML) - -@app.websocket("/") -async def websocket_endpoint(websocket: WebSocket): - await websocket.accept() - while True: - data = await websocket.receive_text() - await websocket.send_text(f"Message text was: {data}") - -handler = Mangum(app, dsn=DSN_URL) -``` - -## Dependencies - -The WebSocket implementation requires the following extra packages: - -``` -pip install httpx boto3 -``` - -## Configuring a storage backend - -A data source is required in order to persist the WebSocket client connections stored in API Gateway*. Any data source can be used as long as it is accessible remotely to the AWS Lambda function. All supported backends require a `dsn` connection string argument to configure the connection between the adapter and the data source. - -```python -handler = Mangum(app, dsn="[postgresql|redis|dynamodb|s3|sqlite]://[...]") -``` - -*Read the section on ([handling events in API Gateway](https://mangum.io/websockets/#handling-api-gateway-events) for more information.) - -### Supported backends - -The following backends are currently supported: - - - `dynamodb` - - `s3` - - `postgresql` - - `redis` - - `sqlite` (for local debugging) - -#### DynamoDB - -The `DynamoDBBackend` uses a [DynamoDB](https://aws.amazon.com/dynamodb/) table to store the connection details. - -##### Usage - -```python -handler = Mangum( - app, - dsn="dynamodb://mytable" -) -``` - -###### Parameters - -The DynamoDB backend `dsn` uses the following connection string syntax: - -``` -dynamodb://[?region=&endpoint_url=] -``` - -- `table_name` (Required) - - The name of the table in DynamoDB. - -- `region_name` - - The region name of the DynamoDB table. - -- `endpoint_url` - - The endpoint url to use in DynamoDB calls. This is useful if you are debugging locally with a package such as [serverless-dynamodb-local](https://github.com/99xt/serverless-dynamodb-local). - -###### Dependencies - -This backend requires the following extra package: - -``` -pip install aioboto3 -``` - -#### S3 - -The `S3Backend` uses an [S3](https://aws.amazon.com/s3/) bucket as a key-value store to store the connection details. - -##### Usage - -```python -handler = Mangum( - app, - dsn="s3://my-bucket-12345" -) -``` - -###### Parameters - -The S3 backend `dsn` uses the following connection string syntax: - -``` -s3://[/key/...][?region=&endpoint_url=] -``` - -- `bucket` (Required) - - The name of the bucket in S3. - -- `region_name` - - The region name of the S3 bucket. - -- `endpoint_url` - - The endpoint url to use in S3 calls. This is useful if you are debugging locally with a package such as [serverless-s3-local](https://github.com/ar90n/serverless-s3-local). - -###### Dependencies - -This backend requires the following extra package: - -``` -pip install aioboto3 -``` - -#### PostgreSQL - -The `PostgreSQLBackend` requires [psycopg2](https://github.com/psycopg/psycopg2) and access to a remote PostgreSQL database. - -##### Usage - -```python -handler = Mangum( - app, - dsn="postgresql://myuser:mysecret@my.host:5432/mydb" -) -``` - -###### Parameters - -The PostgreSQL backend `dsn` uses the following connection string syntax: - -``` -postgresql://[user[:password]@][host][:port][,...][/dbname][?param1=value1&...] -``` - -- `host` (Required) - - The network location of the PostgreSQL database - -Read more about the supported uri schemes and additional parameters [here](https://www.postgresql.org/docs/10/libpq-connect.html#LIBPQ-CONNSTRING). - -###### Dependencies - -This backend requires the following extra package: - -``` -pip install aiopg -``` - -#### Redis - -The `RedisBackend` requires [redis-py](https://github.com/andymccurdy/redis-py) and access to a Redis server. - -##### Usage - -```python -handler = Mangum( - app, - dsn="redis://:mysecret@my.host:6379/0" -) -``` - -##### Parameters - -The Redis backend `dsn` uses the following connection string syntax: - -``` -redis://[[user:]password@]host[:port][/database] -``` - -- `host` (Required) - - The network location of the Redis server. - -Read more about the supported uri schemes and additional parameters [here](https://www.iana.org/assignments/uri-schemes/prov/redis). - -###### Dependencies - -This backend requires the following extra package: - -``` -pip install aioredis -``` - -#### SQLite - -The `sqlite` backend uses a local [sqlite3](https://docs.python.org/3/library/sqlite3.html) database to store connection. It is intended for local debugging. - -##### Usage - -```python -handler = Mangum( - app, - dsn="sqlite://mydbfile.sqlite3" -) -``` - -##### Parameters - -The SQLite backend uses the following connection string syntax: - -``` -sqlite://[file_path].db -``` - -- `file_path` (Required) - - The file name or path to an sqlite3 database file. If one does not exist, then it will be created automatically. - -## State machine - -The `WebSocketCycle` is used by the adapter to communicate message events between the application and WebSocket client connections in API Gateway using a storage backend to persist the connection `scope`. It is a state machine that handles the ASGI request and response cycle for each individual message sent by a client. - -### WebSocketCycle - -::: mangum.protocols.websockets.WebSocketCycle - :docstring: - :members: run receive send - -#### API Gateway events - -There are three WebSocket events sent by API Gateway for a WebSocket API connection. Each event requires returning a response immediately, and the information required to create the connection scope is only available in the initial `CONNECT` event. Messages are only sent in `MESSAGE` events that occur after the initial connection is established, and they do not include the details of the initial connect event. Due to the stateless nature of AWS Lambda, a storage backend is required to persist the WebSocket connection details for the duration of a client connection. - -##### CONNECT - -A persistent connection between the client and a WebSocket API is being initiated. The adapter uses a supported WebSocket backend to store the connection id and initial request information. - -##### MESSAGE - -A connected client has sent a message. The adapter will retrieve the initial request information from the backend using the connection id to form the ASGI connection `scope` and run the ASGI application cycle. - -##### DISCONNECT - -The client or the server disconnects from the API. The adapter will remove the connection from the backend. - -### WebSocketCycleState - -::: mangum.protocols.websockets.WebSocketCycleState - :docstring: diff --git a/mangum/adapter.py b/mangum/adapter.py index 52dffc00..7811feed 100644 --- a/mangum/adapter.py +++ b/mangum/adapter.py @@ -1,17 +1,11 @@ import logging from contextlib import ExitStack -from typing import ( - Any, - Dict, - Optional, - TYPE_CHECKING, -) +from typing import Any, Dict, TYPE_CHECKING from .exceptions import ConfigurationError from .handlers import AbstractHandler -from .protocols import HTTPCycle, WebSocketCycle, LifespanCycle -from .backends import WebSocket -from .types import ASGIApp, WsRequest +from .protocols import HTTPCycle, LifespanCycle +from .types import ASGIApp if TYPE_CHECKING: # pragma: no cover @@ -38,34 +32,21 @@ class Mangum: specification. This will usually be an ASGI framework application instance. * **lifespan** - A string to configure lifespan support. Choices are `auto`, `on`, and `off`. Default is `auto`. - * **api_gateway_base_path** - Base path to strip from URL when using a custom - domain name. * **text_mime_types** - A list of MIME types to include with the defaults that should not return a binary response in API Gateway. - * **dsn** - A connection string required to configure a supported WebSocket backend. * **api_gateway_base_path** - A string specifying the part of the url path after which the server routing begins. - * **api_gateway_endpoint_url** - A string endpoint url to use for API Gateway when - sending data to WebSocket connections. Default is to determine this automatically. - * **api_gateway_region_name** - A string region name to use for API Gateway when - sending data to WebSocket connections. Default is `AWS_REGION` environment variable. """ def __init__( self, app: ASGIApp, lifespan: str = "auto", - dsn: Optional[str] = None, api_gateway_base_path: str = "/", - api_gateway_endpoint_url: Optional[str] = None, - api_gateway_region_name: Optional[str] = None, ) -> None: self.app = app self.lifespan = lifespan - self.dsn = dsn self.api_gateway_base_path = api_gateway_base_path - self.api_gateway_endpoint_url = api_gateway_endpoint_url - self.api_gateway_region_name = api_gateway_region_name if self.lifespan not in ("auto", "on", "off"): raise ConfigurationError( @@ -85,21 +66,7 @@ def __call__(self, event: Dict[str, Any], context: "LambdaContext") -> dict: ) request = handler.request - if isinstance(request, WsRequest): - api_gateway_endpoint_url = ( - self.api_gateway_endpoint_url or handler.api_gateway_endpoint_url - ) - websocket = WebSocket( - dsn=self.dsn, - api_gateway_endpoint_url=api_gateway_endpoint_url, - api_gateway_region_name=self.api_gateway_region_name, - ) - websocket_cycle = WebSocketCycle( - request, handler.message_type, handler.connection_id, websocket - ) - response = websocket_cycle(self.app, handler.body) - else: - http_cycle = HTTPCycle(request) - response = http_cycle(self.app, handler.body) + http_cycle = HTTPCycle(request) + response = http_cycle(self.app, handler.body) return handler.transform_response(response) diff --git a/mangum/backends/__init__.py b/mangum/backends/__init__.py deleted file mode 100644 index fd71c010..00000000 --- a/mangum/backends/__init__.py +++ /dev/null @@ -1,199 +0,0 @@ -import asyncio -import logging -from typing import Dict, Optional, Type -import json -from functools import partial -from dataclasses import dataclass -from urllib.parse import urlparse - -try: - import httpx - from httpx import AsyncClient, Response -except ImportError: # pragma: no cover - httpx = None # type: ignore - -try: - import boto3 - from botocore.auth import SigV4Auth - from botocore.awsrequest import AWSRequest -except ImportError: # pragma: no cover - boto3 = None # type: ignore - - -from .base import WebSocketBackend -from ..exceptions import WebSocketError, ConfigurationError -from ..types import Scope - - -def get_sigv4_headers( - method: str, - url: str, - data: Optional[bytes] = None, - region_name: Optional[str] = None, -) -> Dict: - if boto3 is None: # pragma: no cover - raise WebSocketError("boto3 must be installed to use WebSockets.") - session = boto3.Session() - credentials = session.get_credentials() - creds = credentials.get_frozen_credentials() - region = region_name or session.region_name - - request = AWSRequest(method=method, url=url, data=data) - SigV4Auth(creds, "execute-api", region).add_auth(request) - - return dict(request.headers) - - -@dataclass -class WebSocket: - """ - A `WebSocket` connection handler interface for the - selected `WebSocketBackend` subclass - """ - - dsn: Optional[str] - api_gateway_endpoint_url: str - api_gateway_region_name: Optional[str] = None - - def __post_init__(self) -> None: - if not httpx: # pragma: no cover - raise WebSocketError("httpx must be installed to use WebSockets.") - - if self.dsn is None: - raise ConfigurationError( - "The `dsn` parameter must be provided for WebSocket connections." - ) - - self.logger: logging.Logger = logging.getLogger("mangum.backends") - parsed_dsn = urlparse(self.dsn) - if not any((parsed_dsn.hostname, parsed_dsn.path)): - raise ConfigurationError("Invalid value for `dsn` provided.") - - scheme = parsed_dsn.scheme - self.logger.debug( - f"Attempting WebSocket backend connection using scheme: {scheme}" - ) - - self._Backend: Type[WebSocketBackend] - if scheme == "sqlite": - self.logger.info( - "The `SQLiteBackend` should be only be used for local " - "debugging. It will not work in a deployed environment." - ) - from mangum.backends.sqlite import SQLiteBackend - - self._Backend = SQLiteBackend - - elif scheme == "dynamodb": - from mangum.backends.dynamodb import DynamoDBBackend - - self._Backend = DynamoDBBackend - - elif scheme == "s3": - from mangum.backends.s3 import S3Backend - - self._Backend = S3Backend - - elif scheme in ("postgresql", "postgres"): - from mangum.backends.postgresql import PostgreSQLBackend - - self._Backend = PostgreSQLBackend - - elif scheme == "redis": - from mangum.backends.redis import RedisBackend - - self._Backend = RedisBackend - - else: - raise ConfigurationError(f"{scheme} does not match a supported backend.") - - self.logger.info("WebSocket backend connection established.") - - async def load_scope(self, backend: WebSocketBackend, connection_id: str) -> Scope: - loaded_scope = await backend.retrieve(connection_id) - scope = json.loads(loaded_scope) - scope.update( - { - "query_string": scope["query_string"].encode(), - "headers": [[h[0].encode(), h[1].encode()] for h in scope["headers"]], - "client": tuple(scope["client"]), - "server": tuple(scope["server"]), - } - ) - - return scope - - async def save_scope( - self, backend: WebSocketBackend, connection_id: str, scope: Scope - ) -> None: - scope.update( - { - "query_string": scope["query_string"].decode(), - "headers": [[h[0].decode(), h[1].decode()] for h in scope["headers"]], - } - ) - json_scope = json.dumps(scope) - await backend.save(connection_id, json_scope=json_scope) - - async def on_connect(self, connection_id: str, initial_scope: Scope) -> None: - self.logger.debug("Creating scope entry for %s", connection_id) - async with self._Backend(self.dsn) as backend: # type: ignore - await self.save_scope(backend, connection_id, initial_scope) - - async def on_message(self, connection_id: str) -> Scope: - self.logger.debug("Retrieving scope entry for %s", connection_id) - async with self._Backend(self.dsn) as backend: # type: ignore - scope = await self.load_scope(backend, connection_id) - return scope - - async def on_disconnect(self, connection_id: str) -> None: - self.logger.debug("Deleting scope entry for %s", connection_id) - async with self._Backend(self.dsn) as backend: # type: ignore - await backend.delete(connection_id) - - async def post_to_connection(self, connection_id: str, body: bytes) -> None: - async with AsyncClient() as client: - await self._post_to_connection(connection_id, client=client, body=body) - - async def delete_connection(self, connection_id: str) -> None: - async with AsyncClient() as client: - await self._request_to_connection("DELETE", connection_id, client=client) - - async def _post_to_connection( - self, - connection_id: str, - *, - client: "AsyncClient", - body: bytes, - ) -> None: # pragma: no cover - response = await self._request_to_connection( - "POST", connection_id, client=client, body=body - ) - - if response.status_code == 410: - await self.on_disconnect(connection_id) - elif response.status_code != 200: - raise WebSocketError(f"Error: {response.status_code}") - - async def _request_to_connection( - self, - method: str, - connection_id: str, - *, - client: "AsyncClient", - body: Optional[bytes] = None, - ) -> "Response": - loop = asyncio.get_event_loop() - url = f"{self.api_gateway_endpoint_url}/{connection_id}" - headers = await loop.run_in_executor( - None, - partial( - get_sigv4_headers, - method, - url, - body, - self.api_gateway_region_name, - ), - ) - - return await client.request(method, url, content=body, headers=headers) diff --git a/mangum/backends/base.py b/mangum/backends/base.py deleted file mode 100644 index cd0b3fb1..00000000 --- a/mangum/backends/base.py +++ /dev/null @@ -1,44 +0,0 @@ -from dataclasses import dataclass -from typing import Any - - -@dataclass -class WebSocketBackend: # pragma: no cover - """ - Base class for implementing WebSocket backends to store API Gateway connections. - - Data source backends are required to implement configuration using the `dsn` - connection string setting. - """ - - dsn: str - - async def __aenter__(self) -> "WebSocketBackend": - """ - Establish the connection to a data source. - """ - raise NotImplementedError() - - async def __aexit__(self, *exc_info: Any) -> None: - """ - Closes the connection to a data source. - """ - raise NotImplementedError() - - async def save(self, connection_id: str, *, json_scope: str) -> None: - """ - Save the JSON scope for a connection. - """ - raise NotImplementedError() - - async def retrieve(self, connection_id: str) -> str: - """ - Retrieve the JSON scope for a connection. - """ - raise NotImplementedError() - - async def delete(self, connection_id: str) -> None: - """ - Delete the JSON scope for a connection. - """ - raise NotImplementedError() diff --git a/mangum/backends/dynamodb.py b/mangum/backends/dynamodb.py deleted file mode 100644 index ddaeb9c6..00000000 --- a/mangum/backends/dynamodb.py +++ /dev/null @@ -1,93 +0,0 @@ -import os -from typing import Any -from urllib.parse import ParseResult, urlparse, parse_qs -import logging - -import aioboto3 -from botocore.exceptions import ClientError - -from .base import WebSocketBackend -from ..exceptions import WebSocketError - -logger = logging.getLogger("mangum.backends.dynamodb") - - -def get_table_name(parsed_dsn: ParseResult) -> str: - netloc = parsed_dsn.netloc - _, _, hostinfo = netloc.rpartition("@") - hostname, _, _ = hostinfo.partition(":") - return hostname - - -class DynamoDBBackend(WebSocketBackend): - async def __aenter__(self) -> WebSocketBackend: - parsed_dsn = urlparse(self.dsn) - parsed_query = parse_qs(parsed_dsn.query) - self.table_name = get_table_name(parsed_dsn) - self.region_name = ( - parsed_query["region"][0] - if "region" in parsed_query - else os.environ["AWS_REGION"] - ) - self.endpoint_url = ( - parsed_query["endpoint_url"][0] if "endpoint_url" in parsed_query else None - ) - - session = aioboto3.Session() - self.resource = await session.resource( - "dynamodb", - region_name=self.region_name, - endpoint_url=self.endpoint_url, - ).__aenter__() - - create_table = False - - try: - await self.resource.meta.client.describe_table(TableName=self.table_name) - except ClientError as exc: - if exc.response["Error"]["Code"] == "ResourceNotFoundException": - logger.info(f"Table {self.table_name} not found, creating.") - create_table = True - else: # pragma: no cover - await self.__aexit__(None, None, None) - raise WebSocketError(exc) - - self.table = await self.resource.Table(self.table_name) - - if create_table: - client = self.resource.meta.client - await client.create_table( - TableName=self.table_name, - KeySchema=[{"AttributeName": "connectionId", "KeyType": "HASH"}], - AttributeDefinitions=[ - {"AttributeName": "connectionId", "AttributeType": "S"} - ], - ProvisionedThroughput={ - "ReadCapacityUnits": 5, - "WriteCapacityUnits": 5, - }, - ) - await self.table.wait_until_exists() - - return self - - async def __aexit__(self, *exc_info: Any) -> None: - await self.resource.__aexit__(*exc_info) - - async def save(self, connection_id: str, *, json_scope: str) -> None: - await self.table.put_item( - Item={"connectionId": connection_id, "initial_scope": json_scope}, - ConditionExpression="attribute_not_exists(connectionId)", - ) - - async def retrieve(self, connection_id: str) -> str: - try: - response = await self.table.get_item(Key={"connectionId": connection_id}) - item = response["Item"] - except KeyError: - raise WebSocketError(f"Connection not found: {connection_id}") - initial_scope = item["initial_scope"] - return initial_scope - - async def delete(self, connection_id: str) -> None: - await self.table.delete_item(Key={"connectionId": connection_id}) diff --git a/mangum/backends/postgresql.py b/mangum/backends/postgresql.py deleted file mode 100644 index e665b3bd..00000000 --- a/mangum/backends/postgresql.py +++ /dev/null @@ -1,45 +0,0 @@ -from typing import Any - -import aiopg - -from .base import WebSocketBackend -from ..exceptions import WebSocketError - - -class PostgreSQLBackend(WebSocketBackend): - async def __aenter__(self) -> WebSocketBackend: - self.connection = await aiopg.connect(self.dsn) - self.cursor = await self.connection.cursor() - - await self.cursor.execute( - "create table if not exists mangum_websockets " - "(id varchar(64) primary key, initial_scope text)" - ) - - return self - - async def __aexit__(self, *exc_info: Any) -> None: - self.cursor.close() - await self.connection.close() - - async def save(self, connection_id: str, *, json_scope: str) -> None: - await self.cursor.execute( - "insert into mangum_websockets values (%s, %s)", - (connection_id, json_scope), - ) - - async def retrieve(self, connection_id: str) -> str: - await self.cursor.execute( - "select initial_scope from mangum_websockets where id = %s", - (connection_id,), - ) - row = await self.cursor.fetchone() - if not row: - raise WebSocketError(f"Connection not found: {connection_id}") - initial_scope = row[0] - return initial_scope - - async def delete(self, connection_id: str) -> None: - await self.cursor.execute( - "delete from mangum_websockets where id = %s", (connection_id,) - ) diff --git a/mangum/backends/redis.py b/mangum/backends/redis.py deleted file mode 100644 index 3c411742..00000000 --- a/mangum/backends/redis.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import Any -import aioredis - -from .base import WebSocketBackend -from ..exceptions import WebSocketError - - -class RedisBackend(WebSocketBackend): - async def __aenter__(self) -> WebSocketBackend: - self.connection = await aioredis.create_redis(self.dsn) - - return self - - async def __aexit__(self, *exc_info: Any) -> None: - self.connection.close() - - async def save(self, connection_id: str, *, json_scope: str) -> None: - await self.connection.set(connection_id, json_scope) - - async def retrieve(self, connection_id: str) -> str: - scope = await self.connection.get(connection_id) - if not scope: - raise WebSocketError(f"Connection not found: {connection_id}") - return scope - - async def delete(self, connection_id: str) -> None: - await self.connection.delete(connection_id) diff --git a/mangum/backends/s3.py b/mangum/backends/s3.py deleted file mode 100644 index 35a0df46..00000000 --- a/mangum/backends/s3.py +++ /dev/null @@ -1,90 +0,0 @@ -import os -import logging -from typing import Any -from urllib.parse import ParseResult, urlparse, parse_qs - -import aioboto3 -from botocore.exceptions import ClientError - -from .base import WebSocketBackend -from ..exceptions import WebSocketError - -logger = logging.getLogger("mangum.backends.s3") - - -def get_file_key(parsed_dsn: ParseResult) -> str: - if parsed_dsn.path and parsed_dsn.path != "/": - if not parsed_dsn.path.endswith("/"): - return parsed_dsn.path + "/" - return parsed_dsn.path - - return "" - - -class S3Backend(WebSocketBackend): - async def __aenter__(self) -> WebSocketBackend: - parsed_dsn = urlparse(self.dsn) - parsed_query = parse_qs(parsed_dsn.query) - self.bucket = parsed_dsn.hostname - self.key = get_file_key(parsed_dsn) - self.region_name = ( - parsed_query["region"][0] - if "region" in parsed_query - else os.environ["AWS_REGION"] - ) - self.endpoint_url = ( - parsed_query["endpoint_url"][0] if "endpoint_url" in parsed_query else None - ) - - session = aioboto3.Session() - self.client = await session.client( - "s3", - region_name=self.region_name, - endpoint_url=self.endpoint_url, - ).__aenter__() - - create_bucket = False - - try: - await self.client.head_bucket(Bucket=self.bucket) - except ClientError as exc: - error_code = int(exc.response["Error"]["Code"]) - if error_code == 403: # pragma: no cover - logger.error("S3 bucket access forbidden!") - elif error_code == 404: - logger.info(f"Bucket {self.bucket} not found, creating.") - create_bucket = True - - if create_bucket: - await self.client.create_bucket(Bucket=self.bucket) - - return self - - async def __aexit__(self, *exc_info: Any) -> None: - await self.client.__aexit__(*exc_info) - - async def save(self, connection_id: str, *, json_scope: str) -> None: - await self.client.put_object( - Body=json_scope.encode(), - Bucket=self.bucket, - Key=f"{self.key}{connection_id}", - ) - - async def retrieve(self, connection_id: str) -> str: - try: - s3_object = await self.client.get_object( - Bucket=self.bucket, Key=f"{self.key}{connection_id}" - ) - except self.client.exceptions.NoSuchKey: - raise WebSocketError(f"Connection not found: {connection_id}") - - async with s3_object["Body"] as body: - scope = await body.read() - json_scope = scope.decode() - - return json_scope - - async def delete(self, connection_id: str) -> None: - await self.client.delete_object( - Bucket=self.bucket, Key=f"{self.key}{connection_id}" - ) diff --git a/mangum/backends/sqlite.py b/mangum/backends/sqlite.py deleted file mode 100644 index dc0c2bee..00000000 --- a/mangum/backends/sqlite.py +++ /dev/null @@ -1,48 +0,0 @@ -from typing import Any -from urllib.parse import urlparse - -import aiosqlite - -from .base import WebSocketBackend -from ..exceptions import WebSocketError - - -class SQLiteBackend(WebSocketBackend): - async def __aenter__(self) -> WebSocketBackend: - parsed_dsn = urlparse(self.dsn) - self.connection = await aiosqlite.connect(parsed_dsn.path) - await self.connection.execute( - "create table if not exists mangum_websockets " - "(id varchar(64) primary key, initial_scope text)" - ) - await self.connection.commit() - - return self - - async def __aexit__(self, *exc_info: Any) -> None: - await self.connection.close() - - async def save(self, connection_id: str, *, json_scope: str) -> None: - await self.connection.execute( - "insert into mangum_websockets values (?, ?)", - (connection_id, json_scope), - ) - await self.connection.commit() - - async def retrieve(self, connection_id: str) -> str: - async with self.connection.execute( - "select initial_scope from mangum_websockets where id = ?", - (connection_id,), - ) as cursor: - row = await cursor.fetchone() - if not row: - raise WebSocketError(f"Connection not found: {connection_id}") - scope = row[0] - - return scope - - async def delete(self, connection_id: str) -> None: - await self.connection.execute( - "delete from mangum_websockets where id = ?", (connection_id,) - ) - await self.connection.commit() diff --git a/mangum/exceptions.py b/mangum/exceptions.py index 6bdc468b..d14234c9 100644 --- a/mangum/exceptions.py +++ b/mangum/exceptions.py @@ -10,13 +10,5 @@ class UnexpectedMessage(Exception): """Raise when an unexpected message type is received during an ASGI cycle.""" -class WebSocketClosed(Exception): - """Raise when an application closes the connection during the handshake.""" - - -class WebSocketError(Exception): - """Raise when an error occurs in a WebSocket event.""" - - class ConfigurationError(Exception): """Raise when an error occurs parsing configuration.""" diff --git a/mangum/handlers/__init__.py b/mangum/handlers/__init__.py index cf157f6a..9ab3303d 100644 --- a/mangum/handlers/__init__.py +++ b/mangum/handlers/__init__.py @@ -3,7 +3,6 @@ from .aws_api_gateway import AwsApiGateway from .aws_cf_lambda_at_edge import AwsCfLambdaAtEdge from .aws_http_gateway import AwsHttpGateway -from .aws_ws_gateway import AwsWsGateway __all__ = [ "AbstractHandler", @@ -11,5 +10,4 @@ "AwsApiGateway", "AwsCfLambdaAtEdge", "AwsHttpGateway", - "AwsWsGateway", ] diff --git a/mangum/handlers/abstract_handler.py b/mangum/handlers/abstract_handler.py index 4c7ce7ab..2bf5c437 100644 --- a/mangum/handlers/abstract_handler.py +++ b/mangum/handlers/abstract_handler.py @@ -1,8 +1,8 @@ import base64 from abc import ABCMeta, abstractmethod -from typing import Dict, Any, TYPE_CHECKING, Tuple, List, Union +from typing import Dict, Any, TYPE_CHECKING, Tuple, List -from ..types import Response, Request, WsRequest +from ..types import Response, Request if TYPE_CHECKING: # pragma: no cover from awslambdaric.lambda_context import LambdaContext @@ -19,7 +19,7 @@ def __init__( @property @abstractmethod - def request(self) -> Union[Request, WsRequest]: + def request(self) -> Request: """ Parse an ASGI scope from the request event """ @@ -38,25 +38,6 @@ def transform_response(self, response: Response) -> Dict[str, Any]: this handler """ - @property - def message_type(self) -> str: - request_context = self.trigger_event["requestContext"] - return request_context["eventType"] - - @property - def connection_id(self) -> str: - request_context = self.trigger_event["requestContext"] - return request_context["connectionId"] - - @property - def api_gateway_endpoint_url(self) -> str: - request_context = self.trigger_event["requestContext"] - domain = request_context["domainName"] - stage = request_context["stage"] - api_gateway_endpoint_url = f"https://{domain}/{stage}/@connections" - - return api_gateway_endpoint_url - @staticmethod def from_trigger( trigger_event: Dict[str, Any], @@ -77,15 +58,6 @@ def from_trigger( from . import AwsAlb return AwsAlb(trigger_event, trigger_context) - - if ( - "requestContext" in trigger_event - and "connectionId" in trigger_event["requestContext"] - ): - from . import AwsWsGateway - - return AwsWsGateway(trigger_event, trigger_context) - if ( "Records" in trigger_event and len(trigger_event["Records"]) > 0 diff --git a/mangum/handlers/aws_alb.py b/mangum/handlers/aws_alb.py index f178c323..9e265cc5 100644 --- a/mangum/handlers/aws_alb.py +++ b/mangum/handlers/aws_alb.py @@ -40,17 +40,6 @@ def case_mutated_headers(multi_value_headers: Dict[str, List[str]]) -> Dict[str, class AwsAlb(AbstractHandler): - """ - Handles AWS Elastic Load Balancer, really Application Load Balancer events - transforming them into ASGI Scope and handling responses - - See: - 1. https://docs.aws.amazon.com/lambda/latest/dg/services-alb.html - 2. https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html # noqa: E501 - """ - - TYPE = "AWS_ALB" - def _encode_query_string(self) -> bytes: """ Encodes the queryStringParameters. @@ -128,7 +117,6 @@ def request(self) -> Request: client=client, trigger_event=self.trigger_event, trigger_context=self.trigger_context, - event_type=self.TYPE, ) @property diff --git a/mangum/handlers/aws_api_gateway.py b/mangum/handlers/aws_api_gateway.py index c20558f2..aaebc7a6 100644 --- a/mangum/handlers/aws_api_gateway.py +++ b/mangum/handlers/aws_api_gateway.py @@ -13,15 +13,6 @@ class AwsApiGateway(AbstractHandler): - """ - Handles AWS API Gateway events, transforming them into ASGI Scope and handling - responses - - See: https://docs.aws.amazon.com/lambda/latest/dg/services-apigateway.html - """ - - TYPE = "AWS_API_GATEWAY" - def __init__( self, trigger_event: Dict[str, Any], @@ -78,7 +69,6 @@ def request(self) -> Request: client=client, trigger_event=self.trigger_event, trigger_context=self.trigger_context, - event_type=self.TYPE, ) def _encode_query_string(self) -> bytes: diff --git a/mangum/handlers/aws_cf_lambda_at_edge.py b/mangum/handlers/aws_cf_lambda_at_edge.py index b43132c3..c9d2f314 100644 --- a/mangum/handlers/aws_cf_lambda_at_edge.py +++ b/mangum/handlers/aws_cf_lambda_at_edge.py @@ -6,15 +6,6 @@ class AwsCfLambdaAtEdge(AbstractHandler): - """ - Handles AWS Elastic Load Balancer, really Application Load Balancer events - transforming them into ASGI Scope and handling responses - - See: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html # noqa: E501 - """ - - TYPE = "AWS_CF_LAMBDA_AT_EDGE" - @property def request(self) -> Request: event = self.trigger_event @@ -49,7 +40,6 @@ def request(self) -> Request: client=client, trigger_event=self.trigger_event, trigger_context=self.trigger_context, - event_type=self.TYPE, ) @property diff --git a/mangum/handlers/aws_http_gateway.py b/mangum/handlers/aws_http_gateway.py index f6372082..93543b34 100644 --- a/mangum/handlers/aws_http_gateway.py +++ b/mangum/handlers/aws_http_gateway.py @@ -84,7 +84,6 @@ def request(self) -> Request: client=client, trigger_event=self.trigger_event, trigger_context=self.trigger_context, - event_type=self.TYPE, ) @property diff --git a/mangum/handlers/aws_ws_gateway.py b/mangum/handlers/aws_ws_gateway.py deleted file mode 100644 index 3fdf6e97..00000000 --- a/mangum/handlers/aws_ws_gateway.py +++ /dev/null @@ -1,75 +0,0 @@ -from typing import Any, Dict, Tuple -import base64 - - -from ..types import Response, WsRequest -from .abstract_handler import AbstractHandler - - -def get_server_and_headers(event: Dict[str, Any]) -> Tuple: # pragma: no cover - if event.get("multiValueHeaders"): - headers = { - k.lower(): ", ".join(v) if isinstance(v, list) else "" - for k, v in event.get("multiValueHeaders", {}).items() - } - elif event.get("headers"): - headers = {k.lower(): v for k, v in event.get("headers", {}).items()} - else: - headers = {} - - # Subprotocols are not supported, so drop Sec-WebSocket-Protocol to be safe - headers.pop("sec-websocket-protocol", None) - - server_name = headers.get("host", "mangum") - if ":" not in server_name: - server_port = headers.get("x-forwarded-port", 80) - else: - server_name, server_port = server_name.split(":") - server = (server_name, int(server_port)) - - return server, headers - - -class AwsWsGateway(AbstractHandler): - """ - Handles AWS API Gateway Websocket events, transforming - them into ASGI Scope and handling responses - - See: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format # noqa: E501 - """ - - TYPE = "AWS_WS_GATEWAY" - - @property - def request(self) -> WsRequest: - request_context = self.trigger_event["requestContext"] - server, headers = get_server_and_headers(self.trigger_event) - source_ip = request_context.get("identity", {}).get("sourceIp") - client = (source_ip, 0) - headers_list = [[k.encode(), v.encode()] for k, v in headers.items()] - - return WsRequest( - headers=headers_list, - path="/", - scheme=headers.get("x-forwarded-proto", "wss"), - query_string=b"", - server=server, - client=client, - trigger_event=self.trigger_event, - trigger_context=self.trigger_context, - event_type=self.TYPE, - ) - - @property - def body(self) -> bytes: - body = self.trigger_event.get("body", b"") or b"" - - if self.trigger_event.get("isBase64Encoded", False): - return base64.b64decode(body) - if not isinstance(body, bytes): - body = body.encode() - - return body - - def transform_response(self, response: Response) -> Dict[str, Any]: - return {"statusCode": response.status} diff --git a/mangum/protocols/__init__.py b/mangum/protocols/__init__.py index 27df010c..f8d83845 100644 --- a/mangum/protocols/__init__.py +++ b/mangum/protocols/__init__.py @@ -1,10 +1,4 @@ from .http import HTTPCycle -from .websockets import WebSocketCycle from .lifespan import LifespanCycleState, LifespanCycle -__all__ = [ - "HTTPCycle", - "WebSocketCycle", - "LifespanCycleState", - "LifespanCycle", -] +__all__ = ["HTTPCycle", "LifespanCycleState", "LifespanCycle"] diff --git a/mangum/protocols/websockets.py b/mangum/protocols/websockets.py deleted file mode 100644 index ef15725b..00000000 --- a/mangum/protocols/websockets.py +++ /dev/null @@ -1,198 +0,0 @@ -import enum -import asyncio -import copy -import typing -import logging -from io import BytesIO -from dataclasses import dataclass - -from ..backends import WebSocket -from ..exceptions import UnexpectedMessage, WebSocketClosed, WebSocketError -from ..types import ASGIApp, Message, WsRequest, Response - - -class WebSocketCycleState(enum.Enum): - """ - The state of the ASGI WebSocket connection. - - * **CONNECTING** - Initial state. The ASGI application instance will be run with the - connection scope containing the `websocket` type. - * **HANDSHAKE** - The ASGI `websocket` connection with the application has been - established, and a `websocket.connect` event has been pushed to the application - queue. The application will respond by accepting or rejecting the connection. - If rejected, a 403 response will be returned to the client, and it will be removed - from API Gateway. - * **RESPONSE** - Handshake accepted by the application. Data received in the API - Gateway message event will be sent to the application. A `websocket.receive` event - will be pushed to the application queue. - * **DISCONNECTING** - The ASGI connection cycle is complete and should be - disconnected from the application. A `websocket.disconnect` event will be pushed to - the queue, and a response will be returned to the client connection. - * **CLOSED** - The application has sent a `websocket.close` message. This will - either be in response to a `websocket.disconnect` event or occurs when a connection - is rejected in response to a `websocket.connect` event. - """ - - CONNECTING = enum.auto() - HANDSHAKE = enum.auto() - RESPONSE = enum.auto() - DISCONNECTING = enum.auto() - CLOSED = enum.auto() - - -@dataclass -class WebSocketCycle: - """ - Manages the application cycle for an ASGI `websocket` connection. - - * **websocket** - A `WebSocket` connection handler interface for the selected - `WebSocketBackend` subclass. Contains the ASGI connection `scope` and client - connection identifier. - * **state** - An enumerated `WebSocketCycleState` type that indicates the state of - the ASGI connection. - * **app_queue** - An asyncio queue (FIFO) containing messages to be received by the - application. - """ - - request: WsRequest - message_type: str - connection_id: str - websocket: WebSocket - state: WebSocketCycleState = WebSocketCycleState.CONNECTING - - def __post_init__(self) -> None: - self.logger: logging.Logger = logging.getLogger("mangum.websocket") - self.loop = asyncio.get_event_loop() - self.app_queue: asyncio.Queue[typing.Dict[str, typing.Any]] = asyncio.Queue() - self.body: BytesIO = BytesIO() - self.response: Response = Response(200, [], b"") - - def __call__(self, app: ASGIApp, initial_body: bytes) -> Response: - self.logger.debug("WebSocket cycle starting.") - self.initial_body = initial_body - - if self.message_type == "CONNECT": - scope = self.request.scope - del scope["aws.event"] - del scope["aws.context"] - self.loop.run_until_complete( - self.websocket.on_connect(self.connection_id, scope) - ) - elif self.message_type == "MESSAGE": - self.app_queue.put_nowait({"type": "websocket.connect"}) - asgi_instance = self.run(app) - asgi_task = self.loop.create_task(asgi_instance) - self.loop.run_until_complete(asgi_task) - elif self.message_type == "DISCONNECT": - self.loop.run_until_complete( - self.websocket.on_disconnect(self.connection_id) - ) - - return self.response - - async def run(self, app: ASGIApp) -> None: - """ - Calls the application with the `websocket` connection scope. - """ - self.scope = await self.websocket.on_message(self.connection_id) - scope = copy.copy(self.scope) - scope.update( - { - "aws.event": self.request.trigger_event, - "aws.context": self.request.trigger_context, - } - ) - try: - await app(scope, self.receive, self.send) - except WebSocketClosed: - self.response.status = 403 - except UnexpectedMessage: - self.response.status = 500 - except BaseException as exc: - self.logger.error("Exception in ASGI application", exc_info=exc) - self.response.status = 500 - - async def receive(self) -> Message: - """ - Awaited by the application to receive ASGI `websocket` events. - """ - if self.state is WebSocketCycleState.CONNECTING: - - # Initial ASGI connection established. The next event returned by the queue - # will be `websocket.connect` to initiate the handshake. - self.state = WebSocketCycleState.HANDSHAKE - - elif self.state is WebSocketCycleState.HANDSHAKE: - - # ASGI connection handshake accepted. The next event returned by the queue - # will be `websocket.receive` containing the message data from API Gateway. - self.state = WebSocketCycleState.RESPONSE - - return await self.app_queue.get() - - async def send(self, message: Message) -> None: - """ - Awaited by the application to send ASGI `websocket` events. - """ - message_type = message["type"] - self.logger.info( - "%s: '%s' event received from application.", self.state, message_type - ) - - if self.state is WebSocketCycleState.HANDSHAKE and message_type in ( - "websocket.accept", - "websocket.close", - ): - - # API Gateway handles the WebSocket client handshake in the connect event, - # and it cannot be negotiated by the application directly. The application - # may choose to close the connection at this point. This process does not - # support subprotocols. - if message_type == "websocket.accept": - await self.app_queue.put( - { - "type": "websocket.receive", - "bytes": None, - "text": self.initial_body.decode(), - } - ) - elif message_type == "websocket.close": - self.state = WebSocketCycleState.CLOSED - await self.websocket.delete_connection(self.connection_id) - raise WebSocketClosed - - elif ( - self.state is WebSocketCycleState.RESPONSE - and message_type == "websocket.close" - ): - - # The application is explicitly closing the connection. It should be - # disconnected and removed in API Gateway. - await self.websocket.delete_connection(self.connection_id) - - elif ( - self.state is WebSocketCycleState.RESPONSE - and message_type == "websocket.send" - ): - - # The application requested to send some data in response to the - # "websocket.receive" event. After this, a "websocket.disconnect" - # event is pushed to let the application finish gracefully. - # Then the lambda's execution is ended. - - if message.get("body") is not None: - raise WebSocketError( - "Application attemped to send a binary payload, " - "but it's unsupported!" - ) - - message_text = message.get("text", "") - body = message_text.encode() - - await self.websocket.post_to_connection(self.connection_id, body=body) - await self.app_queue.put({"type": "websocket.disconnect", "code": 1000}) - - else: - raise UnexpectedMessage( - f"{self.state}: Unexpected '{message_type}' event received." - ) diff --git a/mangum/types.py b/mangum/types.py index c1a42917..e37d4ea4 100644 --- a/mangum/types.py +++ b/mangum/types.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import ( List, Tuple, @@ -47,17 +47,14 @@ class BaseRequest: # Invocation event trigger_event: Dict[str, Any] trigger_context: Union["LambdaContext", Dict[str, Any]] - event_type: str - http_version: str = "1.1" raw_path: Optional[str] = None root_path: str = "" - asgi: Dict[str, str] = field(default_factory=lambda: {"version": "3.0"}) @property def scope(self) -> Scope: return { - "http_version": self.http_version, + "http_version": "1.1", "headers": self.headers, "path": self.path, "raw_path": self.raw_path, @@ -66,11 +63,9 @@ def scope(self) -> Scope: "query_string": self.query_string, "server": self.server, "client": self.client, - "asgi": self.asgi, - # Meta data to pass along to the application in case they need it + "asgi": {"version": "3.0"}, "aws.event": self.trigger_event, "aws.context": self.trigger_context, - "aws.eventType": self.event_type, } @@ -92,24 +87,6 @@ def scope(self) -> Scope: return scope -@dataclass -class WsRequest(BaseRequest): - """ - A holder for an ASGI scope. Specific for usage with WebSocket connections. - - https://asgi.readthedocs.io/en/latest/specs/www.html#websocket-connection-scope - """ - - type: str = "websocket" - subprotocols: List[str] = field(default_factory=lambda: []) - - @property - def scope(self) -> Scope: - scope = super().scope - scope.update({"type": self.type, "subprotocols": self.subprotocols}) - return scope - - @dataclass class Response: status: int diff --git a/mkdocs.yml b/mkdocs.yml index ae3e892d..e929207c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -3,25 +3,24 @@ site_description: AWS Lambda & API Gateway support for ASGI site_url: http://mangum.io/ theme: - name: 'material' - palette: - primary: 'brown' - accent: 'orange' + name: "material" + palette: + primary: "brown" + accent: "orange" repo_name: jordaneremieff/mangum repo_url: https://github.com/jordaneremieff/mangum edit_uri: "" nav: - - Introduction: 'index.md' - - Adapter: 'adapter.md' - - HTTP: 'http.md' - - WebSockets: 'websockets.md' - - Lifespan: 'lifespan.md' - - ASGI Frameworks: 'asgi-frameworks.md' - - External Links: 'external-links.md' - - Contributing: 'contributing.md' - - Release Notes: 'release-notes.md' + - Introduction: "index.md" + - Adapter: "adapter.md" + - HTTP: "http.md" + - Lifespan: "lifespan.md" + - ASGI Frameworks: "asgi-frameworks.md" + - External Links: "external-links.md" + - Contributing: "contributing.md" + - Release Notes: "release-notes.md" markdown_extensions: - mkautodoc diff --git a/pytest.ini b/pytest.ini index e5be7db1..f861f05e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,5 +2,4 @@ log_cli = 1 log_cli_level = INFO log_cli_format = %(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s) -log_cli_date_format=%Y-%m-%d %H:%M:%S -addopts = --docker-compose-remove-volumes --docker-compose=tests/integration/docker-compose.yml +log_cli_date_format=%Y-%m-%d %H:%M:%S \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index ec8d79fa..12d5e910 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,21 +6,10 @@ black flake8 starlette quart; python_version >= '3.7' -moto[server] mypy brotli brotli-asgi awslambdaric-stubs -requests -pytest-docker-compose -aioboto3 -aioredis==1.3.1 -aiosqlite -aiopg -sqlalchemy -redis -httpx -respx types-dataclasses; python_version < '3.7' # For mypy # Docs mkdocs diff --git a/tests/conftest.py b/tests/conftest.py index ab04eafc..aa172e6b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -253,197 +253,6 @@ def mock_lambda_at_edge_event(request): ) -@pytest.fixture -def mock_ws_connect_event() -> dict: - return { - "headers": { - "Accept-Encoding": "gzip, deflate, br", - "Accept-Language": "en-US,en;q=0.9", - "Cache-Control": "no-cache", - "Host": "test.execute-api.ap-southeast-1.amazonaws.com", - "Origin": "https://test.execute-api.ap-southeast-1.amazonaws.com", - "Pragma": "no-cache", - "Sec-WebSocket-Extensions": "permessage-deflate; " "client_max_window_bits", - "Sec-WebSocket-Key": "bnfeqmh9SSPr5Sg9DvFIBw==", - "Sec-WebSocket-Version": "13", - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/75.0.3770.100 Safari/537.36", - "X-Amzn-Trace-Id": "Root=1-5d465cb6-78ddcac1e21f89203d004a89", - "X-Forwarded-For": "192.168.100.1", - "X-Forwarded-Port": "443", - "X-Forwarded-Proto": "https", - }, - "isBase64Encoded": False, - "multiValueHeaders": { - "Accept-Encoding": ["gzip, deflate, br"], - "Accept-Language": ["en-US,en;q=0.9"], - "Cache-Control": ["no-cache"], - "Host": ["test.execute-api.ap-southeast-1.amazonaws.com"], - "Origin": ["https://test.execute-api.ap-southeast-1.amazonaws.com"], - "Pragma": ["no-cache"], - "Sec-WebSocket-Extensions": [ - "permessage-deflate; " "client_max_window_bits" - ], - "Sec-WebSocket-Key": ["bnfeqmh9SSPr5Sg9DvFIBw=="], - "Sec-WebSocket-Version": ["13"], - "User-Agent": [ - "Mozilla/5.0 (Macintosh; Intel Mac OS X " - "10_14_5) AppleWebKit/537.36 (KHTML, " - "like Gecko) Chrome/75.0.3770.100 " - "Safari/537.36" - ], - "X-Amzn-Trace-Id": ["Root=1-5d465cb6-78ddcac1e21f89203d004a89"], - "X-Forwarded-For": ["192.168.100.1"], - "X-Forwarded-Port": ["443"], - "X-Forwarded-Proto": ["https"], - }, - "requestContext": { - "apiId": "test", - "connectedAt": 1564892342293, - "connectionId": "d4NsecoByQ0CH-Q=", - "domainName": "test.execute-api.ap-southeast-1.amazonaws.com", - "eventType": "CONNECT", - "extendedRequestId": "d4NseGc4yQ0FsSA=", - "identity": { - "accessKey": None, - "accountId": None, - "caller": None, - "cognitoAuthenticationProvider": None, - "cognitoAuthenticationType": None, - "cognitoIdentityId": None, - "cognitoIdentityPoolId": None, - "principalOrgId": None, - "sourceIp": "192.168.100.1", - "user": None, - "userAgent": "Mozilla/5.0 (Macintosh; Intel " - "Mac OS X 10_14_5) " - "AppleWebKit/537.36 (KHTML, like " - "Gecko) Chrome/75.0.3770.100 " - "Safari/537.36", - "userArn": None, - }, - "messageDirection": "IN", - "messageId": None, - "requestId": "d4NseGc4yQ0FsSA=", - "requestTime": "04/Aug/2019:04:19:02 +0000", - "requestTimeEpoch": 1564892342293, - "routeKey": "$connect", - "stage": "Prod", - }, - } - - -@pytest.fixture -def mock_ws_send_event() -> dict: - return { - "body": '{"action": "sendmessage", "data": "Hello world"}', - "isBase64Encoded": False, - "requestContext": { - "apiId": "test", - "connectedAt": 1564984321285, - "connectionId": "d4NsecoByQ0CH-Q=", - "domainName": "test.execute-api.ap-southeast-1.amazonaws.com", - "eventType": "MESSAGE", - "extendedRequestId": "d7uRtFvnyQ0FYmw=", - "identity": { - "accessKey": None, - "accountId": None, - "caller": None, - "cognitoAuthenticationProvider": None, - "cognitoAuthenticationType": None, - "cognitoIdentityId": None, - "cognitoIdentityPoolId": None, - "principalOrgId": None, - "sourceIp": "192.168.100.1", - "user": None, - "userAgent": None, - "userArn": None, - }, - "messageDirection": "IN", - "messageId": "d7uRtfaKSQ0CE4Q=", - "requestId": "d7uRtFvnyQ0FYmw=", - "requestTime": "05/Aug/2019:05:52:10 +0000", - "requestTimeEpoch": 1564984330952, - "routeKey": "sendmessage", - "stage": "Prod", - }, - } - - -@pytest.fixture -def mock_ws_disconnect_event() -> dict: - return { - "headers": { - "Host": "test.execute-api.ap-southeast-1.amazonaws.com", - "x-api-key": "", - "x-restapi": "", - }, - "isBase64Encoded": False, - "multiValueHeaders": { - "Host": ["test.execute-api.ap-southeast-1.amazonaws.com"], - "x-api-key": [""], - "x-restapi": [""], - }, - "requestContext": { - "apiId": "test", - "connectedAt": 1565140098258, - "connectionId": "d4NsecoByQ0CH-Q=", - "domainName": "test.execute-api.ap-southeast-1.amazonaws.com", - "eventType": "DISCONNECT", - "extendedRequestId": "eBql1FJmSQ0FrjA=", - "identity": { - "accessKey": None, - "accountId": None, - "caller": None, - "cognitoAuthenticationProvider": None, - "cognitoAuthenticationType": None, - "cognitoIdentityId": None, - "cognitoIdentityPoolId": None, - "principalOrgId": None, - "sourceIp": "101.164.35.219", - "user": None, - "userAgent": "Mozilla/5.0 (Macintosh; Intel " - "Mac OS X 10_14_6) " - "AppleWebKit/537.36 (KHTML, like " - "Gecko) Chrome/75.0.3770.142 " - "Safari/537.36", - "userArn": None, - }, - "messageDirection": "IN", - "messageId": None, - "requestId": "eBql1FJmSQ0FrjA=", - "requestTime": "07/Aug/2019:01:08:27 +0000", - "requestTimeEpoch": 1565140107779, - "routeKey": "$disconnect", - "stage": "Prod", - }, - } - - -@pytest.fixture -def mock_websocket_app(): - async def app(scope, receive, send): - if scope["type"] == "websocket": - while True: - message = await receive() - if message["type"] == "websocket.connect": - await send({"type": "websocket.accept", "subprotocol": None}) - elif message["type"] == "websocket.receive": - await send({"type": "websocket.send", "text": "Hello world!"}) - elif message["type"] == "websocket.disconnect": - close_code = message.get("code", 1000) - await send({"type": "websocket.close", "code": close_code}) - return - - return app - - -@pytest.fixture -def sqlite3_dsn(tmp_path): - return f"sqlite://{tmp_path}/mangum.sqlite3" - - @pytest.fixture(scope="session", autouse=True) def aws_credentials(): """Mocked AWS Credentials for moto.""" diff --git a/tests/handlers/test_aws_alb.py b/tests/handlers/test_aws_alb.py index 055c1286..4027efaf 100644 --- a/tests/handlers/test_aws_alb.py +++ b/tests/handlers/test_aws_alb.py @@ -209,7 +209,6 @@ def test_aws_alb_scope_real( "asgi": {"version": "3.0"}, "aws.context": {}, "aws.event": event, - "aws.eventType": "AWS_ALB", "client": ("72.12.164.125", 0), "headers": [ [ @@ -311,7 +310,6 @@ def test_aws_alb_response( method, content_type, raw_res_body, res_body, res_base64_encoded ): async def app(scope, receive, send): - assert scope["aws.eventType"] == "AWS_ALB" await send( { "type": "http.response.start", diff --git a/tests/handlers/test_aws_api_gateway.py b/tests/handlers/test_aws_api_gateway.py index eac4a2d3..58a6ca56 100644 --- a/tests/handlers/test_aws_api_gateway.py +++ b/tests/handlers/test_aws_api_gateway.py @@ -110,7 +110,6 @@ def test_aws_api_gateway_scope_basic(): "asgi": {"version": "3.0"}, "aws.context": {}, "aws.event": example_event, - "aws.eventType": "AWS_API_GATEWAY", "client": (None, 0), "headers": [ [ @@ -211,7 +210,6 @@ def test_aws_api_gateway_scope_real( "asgi": {"version": "3.0"}, "aws.context": {}, "aws.event": event, - "aws.eventType": "AWS_API_GATEWAY", "client": ("192.168.100.1", 0), "headers": [ [ @@ -339,7 +337,6 @@ def test_aws_api_gateway_response( method, content_type, raw_res_body, res_body, res_base64_encoded ): async def app(scope, receive, send): - assert scope["aws.eventType"] == "AWS_API_GATEWAY" await send( { "type": "http.response.start", diff --git a/tests/handlers/test_aws_cf_lambda_at_edge.py b/tests/handlers/test_aws_cf_lambda_at_edge.py index 4692e791..f7b28061 100644 --- a/tests/handlers/test_aws_cf_lambda_at_edge.py +++ b/tests/handlers/test_aws_cf_lambda_at_edge.py @@ -141,7 +141,6 @@ def test_aws_cf_lambda_at_edge_scope_basic(): "asgi": {"version": "3.0"}, "aws.context": {}, "aws.event": example_event, - "aws.eventType": "AWS_CF_LAMBDA_AT_EDGE", "client": ("203.0.113.178", 0), "headers": [ [b"x-forwarded-for", b"203.0.113.178"], @@ -230,7 +229,6 @@ def test_aws_api_gateway_scope_real( "asgi": {"version": "3.0"}, "aws.context": {}, "aws.event": event, - "aws.eventType": "AWS_CF_LAMBDA_AT_EDGE", "client": ("192.168.100.1", 0), "headers": [ [b"accept-encoding", b"gzip,deflate"], @@ -275,7 +273,6 @@ def test_aws_lambda_at_edge_response( method, content_type, raw_res_body, res_body, res_base64_encoded ): async def app(scope, receive, send): - assert scope["aws.eventType"] == "AWS_CF_LAMBDA_AT_EDGE" await send( { "type": "http.response.start", diff --git a/tests/handlers/test_aws_http_gateway.py b/tests/handlers/test_aws_http_gateway.py index bdedfc99..8c0c764f 100644 --- a/tests/handlers/test_aws_http_gateway.py +++ b/tests/handlers/test_aws_http_gateway.py @@ -201,7 +201,6 @@ def test_aws_http_gateway_scope_basic_v1(): "asgi": {"version": "3.0"}, "aws.context": {}, "aws.event": example_event, - "aws.eventType": "AWS_HTTP_GATEWAY", "client": ("IP", 0), "headers": [[b"header1", b"value1"], [b"header2", b"value1, value2"]], "http_version": "1.1", @@ -305,7 +304,6 @@ def test_aws_http_gateway_scope_basic_v2(): "asgi": {"version": "3.0"}, "aws.context": {}, "aws.event": example_event, - "aws.eventType": "AWS_HTTP_GATEWAY", "client": ("IP", 0), "headers": [ [b"header1", b"value1"], @@ -381,7 +379,6 @@ def test_aws_http_gateway_scope_real_v1( "asgi": {"version": "3.0"}, "aws.context": {}, "aws.event": event, - "aws.eventType": "AWS_HTTP_GATEWAY", "client": ("192.168.100.1", 0), "headers": [ [b"accept-encoding", b"gzip, deflate"], @@ -448,7 +445,6 @@ def test_aws_http_gateway_scope_real_v2( "asgi": {"version": "3.0"}, "aws.context": {}, "aws.event": event, - "aws.eventType": "AWS_HTTP_GATEWAY", "client": ("192.168.100.1", 0), "headers": [ [b"accept-encoding", b"gzip,deflate"], @@ -512,7 +508,6 @@ def test_aws_http_gateway_response_v1( """ async def app(scope, receive, send): - assert scope["aws.eventType"] == "AWS_HTTP_GATEWAY" headers = [] if content_type is not None: headers.append([b"content-type", content_type]) @@ -579,7 +574,6 @@ def test_aws_http_gateway_response_v2( method, content_type, raw_res_body, res_body, res_base64_encoded ): async def app(scope, receive, send): - assert scope["aws.eventType"] == "AWS_HTTP_GATEWAY" headers = [] if content_type is not None: headers.append([b"content-type", content_type]) diff --git a/tests/handlers/test_aws_ws_gateway.py b/tests/handlers/test_aws_ws_gateway.py deleted file mode 100644 index e4e4168c..00000000 --- a/tests/handlers/test_aws_ws_gateway.py +++ /dev/null @@ -1,68 +0,0 @@ -from mangum import Mangum - - -def test_aws_ws_gateway_scope( - sqlite3_dsn, mock_ws_connect_event, mock_ws_send_event, mock_ws_disconnect_event -): - def make_app(event): - async def wrapped(scope, receive, send): - assert scope == { - "asgi": {"version": "3.0"}, - "aws.context": {}, - "aws.event": event, - "aws.eventType": "AWS_WS_GATEWAY", - "client": ("192.168.100.1", 0), - "headers": [ - [b"accept-encoding", b"gzip, deflate, br"], - [b"accept-language", b"en-US,en;q=0.9"], - [b"cache-control", b"no-cache"], - [b"host", b"test.execute-api.ap-southeast-1.amazonaws.com"], - [ - b"origin", - b"https://test.execute-api.ap-southeast-1.amazonaws.com", - ], - [b"pragma", b"no-cache"], - [ - b"sec-websocket-extensions", - b"permessage-deflate; client_max_window_bits", - ], - [b"sec-websocket-key", b"bnfeqmh9SSPr5Sg9DvFIBw=="], - [b"sec-websocket-version", b"13"], - [ - b"user-agent", - b"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) " - b"AppleWebKit/537.36 (KHTML, like Gecko) " - b"Chrome/75.0.3770.100 Safari/537.36", - ], - [b"x-amzn-trace-id", b"Root=1-5d465cb6-78ddcac1e21f89203d004a89"], - [b"x-forwarded-for", b"192.168.100.1"], - [b"x-forwarded-port", b"443"], - [b"x-forwarded-proto", b"https"], - ], - "http_version": "1.1", - "path": "/", - "query_string": b"", - "raw_path": None, - "root_path": "", - "scheme": "https", - "server": ("test.execute-api.ap-southeast-1.amazonaws.com", 443), - "subprotocols": [], - "type": "websocket", - } - - return wrapped - - app = make_app(mock_ws_connect_event) - handler = Mangum(app, lifespan="off", dsn=sqlite3_dsn) - response = handler(mock_ws_connect_event, {}) - assert response == {"statusCode": 200} - - app = make_app(mock_ws_send_event) - handler = Mangum(app, lifespan="off", dsn=sqlite3_dsn) - response = handler(mock_ws_send_event, {}) - assert response == {"statusCode": 200} - - app = make_app(mock_ws_disconnect_event) - handler = Mangum(app, lifespan="off", dsn=sqlite3_dsn) - response = handler(mock_ws_disconnect_event, {}) - assert response == {"statusCode": 200} diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/integration/docker-compose.yml b/tests/integration/docker-compose.yml deleted file mode 100644 index c206d5d1..00000000 --- a/tests/integration/docker-compose.yml +++ /dev/null @@ -1,14 +0,0 @@ -version: '3.1' - -services: - postgres: - image: postgres:13 - environment: - POSTGRES_PASSWORD: mangum - ports: - - "5432" - - redis: - image: redis:6.2 - ports: - - "6379" diff --git a/tests/integration/mock_server.py b/tests/integration/mock_server.py deleted file mode 100644 index d5f47c31..00000000 --- a/tests/integration/mock_server.py +++ /dev/null @@ -1,81 +0,0 @@ -import signal -import subprocess as sp -import time -import logging -import shutil - -import pytest -import requests -import psycopg2 -import redis - -_proxy_bypass = { - "http": None, - "https": None, -} - - -def start_service(service_name, host, port): - moto_svr_path = shutil.which("moto_server") - args = [moto_svr_path, service_name, "-H", host, "-p", str(port)] - process = sp.Popen(args, stdin=sp.PIPE, stdout=sp.PIPE, stderr=sp.PIPE) - url = f"http://{host}:{port}" - - for i in range(0, 30): - output = process.poll() - if output is not None: - logging.info(f"moto_server exited status {output}") - stdout, stderr = process.communicate() - logging.info(f"moto_server stdout: {stdout}") - logging.info(f"moto_server stderr: {stderr}") - pytest.fail(f"Can not start service: {service_name}") - - try: - # we need to bypass the proxies due to monkeypatches - requests.get(url, timeout=5, proxies=_proxy_bypass) - break - except requests.exceptions.ConnectionError: - time.sleep(0.5) - else: - stop_process(process) # pytest.fail doesn't call stop_process - pytest.fail(f"Can not start service: {service_name}") - - return process - - -def stop_process(process): - try: - process.send_signal(signal.SIGTERM) - process.communicate(timeout=20) - except sp.TimeoutExpired: - process.kill() - outs, errors = process.communicate(timeout=20) - exit_code = process.returncode - msg = f"Child process finished {exit_code} not in clean way: {outs} {errors}" - raise RuntimeError(msg) - - -def wait_postgres_server(dsn): - for i in range(0, 30): - try: - conn = psycopg2.connect(dsn) - conn.close() - return - except Exception as e: - logging.debug(f"PostgreSQL unavailable yet: {e}") - time.sleep(1) - else: - pytest.fail("Couldn't reach PostgreSQL server") - - -def wait_redis_server(hostname, host_port): - for i in range(0, 30): - try: - conn = redis.Redis(hostname, host_port) - conn.close() - return - except Exception as e: - logging.debug(f"Redis unavailable yet: {e}") - time.sleep(1) - else: - pytest.fail("Couldn't reach Redis server") diff --git a/tests/integration/test_backends.py b/tests/integration/test_backends.py deleted file mode 100644 index 9d6d9a54..00000000 --- a/tests/integration/test_backends.py +++ /dev/null @@ -1,237 +0,0 @@ -import pytest -import boto3 -import respx - -from mangum import Mangum -from mangum.exceptions import WebSocketError, ConfigurationError -from .mock_server import ( - start_service, - stop_process, - wait_postgres_server, - wait_redis_server, -) - -pytest_plugins = ["docker_compose"] - - -@pytest.fixture(scope="session") -def dynamodb2_server(): - host = "localhost" - port = 5001 - url = f"http://{host}:{port}" - process = start_service("dynamodb2", host, port) - yield url - stop_process(process) - - -@pytest.fixture(scope="session") -def s3_server(): - host = "localhost" - port = 5002 - url = f"http://{host}:{port}" - process = start_service("s3", host, port) - yield url - stop_process(process) - - -@pytest.fixture(scope="module") -def postgres_server(module_scoped_container_getter): - container = module_scoped_container_getter.get("postgres") - network = container.network_info[0] - hostname = network.hostname - host_port = network.host_port - dsn = f"postgresql://postgres:mangum@{hostname}:{host_port}/postgres" - wait_postgres_server(dsn) - - try: - yield dsn - finally: - container.stop() - - -@pytest.fixture(scope="module") -def redis_server(module_scoped_container_getter): - container = module_scoped_container_getter.get("redis") - network = container.network_info[0] - hostname = network.hostname - host_port = int(network.host_port) - dsn = f"redis://{hostname}:{host_port}" - wait_redis_server(hostname, host_port) - - try: - yield dsn - finally: - container.stop() - - -@pytest.mark.parametrize( - "dsn", ["???://unknown/", "postgresql://", None, "http://localhost"] -) -def test_invalid_dsn(mock_ws_connect_event, mock_websocket_app, dsn): - handler = Mangum(mock_websocket_app, dsn=dsn) - with pytest.raises(ConfigurationError): - handler(mock_ws_connect_event, {}) - - -@respx.mock(assert_all_mocked=False) -def test_sqlite_3_backend( - tmp_path, - mock_ws_connect_event, - mock_ws_send_event, - mock_ws_disconnect_event, - mock_websocket_app, -) -> None: - dsn = f"sqlite://{tmp_path}/mangum.sqlite3" - - handler = Mangum(mock_websocket_app, dsn=dsn) - with pytest.raises(WebSocketError): - response = handler(mock_ws_send_event, {}) - - handler = Mangum(mock_websocket_app, dsn=dsn) - response = handler(mock_ws_connect_event, {}) - assert response == {"statusCode": 200} - - handler = Mangum(mock_websocket_app, dsn=dsn) - response = handler(mock_ws_send_event, {}) - assert response == {"statusCode": 200} - - handler = Mangum(mock_websocket_app, dsn=dsn) - response = handler(mock_ws_disconnect_event, {}) - assert response == {"statusCode": 200} - - -@respx.mock(assert_all_mocked=False) -@pytest.mark.parametrize( - "table_name", - ["man", "mangum", "Mangum.Dev.001", "Mangum-Dev-001", "Mangum_Dev_002"], -) -def test_dynamodb_backend( - dynamodb2_server, # noqa: F811 - mock_ws_connect_event, - mock_ws_send_event, - mock_ws_disconnect_event, - mock_websocket_app, - table_name, -) -> None: - region_name = "ap-southeast-1" - - dsn = ( - f"dynamodb://{table_name}?region={region_name}&endpoint_url={dynamodb2_server}" - ) - - handler = Mangum(mock_websocket_app, dsn=dsn) - response = handler(mock_ws_connect_event, {}) - assert response == {"statusCode": 200} - - handler = Mangum(mock_websocket_app, dsn=dsn) - response = handler(mock_ws_send_event, {}) - assert response == {"statusCode": 200} - - handler = Mangum(mock_websocket_app, dsn=dsn) - response = handler(mock_ws_disconnect_event, {}) - assert response == {"statusCode": 200} - - dynamodb_resource = boto3.resource( - "dynamodb", region_name=region_name, endpoint_url=dynamodb2_server - ) - table = dynamodb_resource.Table(table_name) - table.delete_item(Key={"connectionId": "d4NsecoByQ0CH-Q="}) - - handler = Mangum(mock_websocket_app, dsn=dsn) - with pytest.raises(WebSocketError): - response = handler(mock_ws_send_event, {}) - - -@respx.mock(assert_all_mocked=False) -@pytest.mark.parametrize( - "dsn", - [ - "s3://mangum-bucket-12345", - "s3://mangum-bucket-12345/", - "s3://mangum-bucket-12345/mykey/", - "s3://mangum-bucket-12345/mykey", - ], -) -def test_s3_backend( - s3_server, # noqa: F811 - mock_ws_connect_event, - mock_ws_send_event, - mock_ws_disconnect_event, - mock_websocket_app, - dsn, -) -> None: - dsn = f"{dsn}?region=ap-southeast-1&endpoint_url={s3_server}" - - handler = Mangum(mock_websocket_app, dsn=dsn) - with pytest.raises(WebSocketError): - handler(mock_ws_send_event, {}) - - handler = Mangum(mock_websocket_app, dsn=dsn) - response = handler(mock_ws_connect_event, {}) - assert response == {"statusCode": 200} - - handler = Mangum(mock_websocket_app, dsn=dsn) - response = handler(mock_ws_send_event, {}) - assert response == {"statusCode": 200} - - handler = Mangum(mock_websocket_app, dsn=dsn) - response = handler(mock_ws_disconnect_event, {}) - assert response == {"statusCode": 200} - - -@respx.mock(assert_all_mocked=False) -def test_postgresql_backend( - postgres_server, - mock_ws_connect_event, - mock_ws_send_event, - mock_ws_disconnect_event, - mock_websocket_app, -): - dsn = postgres_server - - handler = Mangum(mock_websocket_app, dsn=dsn) - with pytest.raises(WebSocketError): - handler(mock_ws_send_event, {}) - - handler = Mangum(mock_websocket_app, dsn=dsn) - response = handler(mock_ws_connect_event, {}) - assert response == {"statusCode": 200} - - handler = Mangum(mock_websocket_app, dsn=dsn) - response = handler(mock_ws_send_event, {}) - assert response == {"statusCode": 200} - - handler = Mangum(mock_websocket_app, dsn=dsn) - response = handler(mock_ws_disconnect_event, {}) - assert response == {"statusCode": 200} - - handler = Mangum(mock_websocket_app, dsn=dsn) - response = handler(mock_ws_connect_event, {}) - assert response == {"statusCode": 200} - - -@respx.mock(assert_all_mocked=False) -def test_redis_backend( - redis_server, - mock_ws_connect_event, - mock_ws_send_event, - mock_ws_disconnect_event, - mock_websocket_app, -): - dsn = redis_server - - handler = Mangum(mock_websocket_app, dsn=dsn) - with pytest.raises(WebSocketError): - handler(mock_ws_send_event, {}) - - handler = Mangum(mock_websocket_app, dsn=dsn) - response = handler(mock_ws_connect_event, {}) - assert response == {"statusCode": 200} - - handler = Mangum(mock_websocket_app, dsn=dsn) - response = handler(mock_ws_send_event, {}) - assert response == {"statusCode": 200} - - handler = Mangum(mock_websocket_app, dsn=dsn) - response = handler(mock_ws_disconnect_event, {}) - assert response == {"statusCode": 200} diff --git a/tests/test_http.py b/tests/test_http.py index 4328aba3..820fe324 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -20,7 +20,6 @@ def test_http_response(mock_aws_api_gateway_event) -> None: async def app(scope, receive, send): assert scope == { "asgi": {"version": "3.0"}, - "aws.eventType": "AWS_API_GATEWAY", "aws.context": {}, "aws.event": { "body": None, @@ -273,7 +272,6 @@ def test_set_cookies_v2(mock_http_api_event_v2) -> None: async def app(scope, receive, send): assert scope == { "asgi": {"version": "3.0"}, - "aws.eventType": "AWS_HTTP_GATEWAY", "aws.context": {}, "aws.event": { "version": "2.0", @@ -391,7 +389,6 @@ def test_set_cookies_v1(mock_http_api_event_v1) -> None: async def app(scope, receive, send): assert scope == { "asgi": {"version": "3.0"}, - "aws.eventType": "AWS_HTTP_GATEWAY", "aws.context": {}, "aws.event": { "version": "1.0", diff --git a/tests/test_websockets.py b/tests/test_websockets.py deleted file mode 100644 index ec16adcc..00000000 --- a/tests/test_websockets.py +++ /dev/null @@ -1,109 +0,0 @@ -import respx - -from mangum import Mangum - - -@respx.mock(assert_all_mocked=False) -def test_websocket_close( - sqlite3_dsn, mock_ws_connect_event, mock_ws_send_event -) -> None: - async def app(scope, receive, send): - if scope["type"] == "websocket": - while True: - message = await receive() - if message["type"] == "websocket.connect": - await send({"type": "websocket.close"}) - - handler = Mangum(app, lifespan="off", dsn=sqlite3_dsn) - response = handler(mock_ws_connect_event, {}) - assert response == {"statusCode": 200} - - response = handler(mock_ws_send_event, {}) - assert response == {"statusCode": 403} - - -@respx.mock(assert_all_mocked=False) -def test_websocket_disconnect( - sqlite3_dsn, mock_ws_connect_event, mock_ws_send_event, mock_websocket_app -) -> None: - handler = Mangum(mock_websocket_app, lifespan="off", dsn=sqlite3_dsn) - response = handler(mock_ws_connect_event, {}) - assert response == {"statusCode": 200} - - response = handler(mock_ws_send_event, {}) - assert response == {"statusCode": 200} - - -def test_websocket_exception( - sqlite3_dsn, mock_ws_connect_event, mock_ws_send_event -) -> None: - async def app(scope, receive, send): - raise Exception() - - handler = Mangum(app, dsn=sqlite3_dsn) - handler(mock_ws_connect_event, {}) - - handler = Mangum(app, dsn=sqlite3_dsn) - response = handler(mock_ws_send_event, {}) - assert response == {"statusCode": 500} - - -def test_websocket_unexpected_message_error( - sqlite3_dsn, mock_ws_connect_event, mock_ws_send_event -) -> None: - async def app(scope, receive, send): - await send({"type": "websocket.oops", "subprotocol": None}) - - handler = Mangum(app, dsn=sqlite3_dsn) - handler(mock_ws_connect_event, {}) - - handler = Mangum(app, dsn=sqlite3_dsn) - response = handler(mock_ws_send_event, {}) - assert response == {"statusCode": 500} - - -@respx.mock(assert_all_mocked=False) -def test_websocket_without_body( - sqlite3_dsn, mock_ws_connect_event, mock_ws_send_event, mock_websocket_app -) -> None: - handler = Mangum(mock_websocket_app, lifespan="off", dsn=sqlite3_dsn) - response = handler(mock_ws_connect_event, {}) - assert response == {"statusCode": 200} - - del mock_ws_send_event["body"] - response = handler(mock_ws_send_event, {}) - assert response == {"statusCode": 200} - - -@respx.mock(assert_all_mocked=False) -def test_base64_encoded_body_on_request( - sqlite3_dsn, mock_ws_connect_event, mock_ws_send_event, mock_websocket_app -): - handler = Mangum(mock_websocket_app, dsn=sqlite3_dsn) - response = handler(mock_ws_connect_event, {}) - assert response == {"statusCode": 200} - - mock_ws_send_event["body"] = b"bWFuZ3Vt=" - mock_ws_send_event["isBase64Encoded"] = True - response = handler(mock_ws_send_event, {}) - assert response == {"statusCode": 200} - - -def test_binary_response( - sqlite3_dsn, mock_ws_connect_event, mock_ws_send_event, mock_websocket_app -): - async def app(scope, receive, send): - if scope["type"] == "websocket": - while True: - message = await receive() - if message["type"] == "websocket.connect": - await send({"type": "websocket.accept"}) - elif message["type"] == "websocket.receive": - await send({"type": "websocket.send", "body": b"bWFuZ3Vt="}) - - handler = Mangum(app, dsn=sqlite3_dsn) - response = handler(mock_ws_connect_event, {}) - assert response == {"statusCode": 200} - - response = handler(mock_ws_send_event, {}) - assert response == {"statusCode": 500}