├── .coveragerc ├── .github └── workflows │ └── py.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── asgiproxy ├── __init__.py ├── __main__.py ├── base.py ├── config.py ├── context.py ├── proxies │ ├── __init__.py │ ├── http.py │ └── websocket.py ├── py.typed ├── simple_proxy.py └── utils │ ├── __init__.py │ └── streams.py ├── pyproject.toml ├── requirements-dev.in ├── requirements-dev.txt └── tests ├── __init__.py ├── configs.py ├── test_asgiproxy.py ├── test_e2e.py └── utils.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | setup.py 4 | 5 | [report] 6 | exclude_lines = 7 | pragma: no cover 8 | def __repr__ 9 | if self.debug: 10 | if settings.DEBUG 11 | raise AssertionError 12 | raise NotImplementedError 13 | if 0: 14 | if __name__ == .__main__.: 15 | if TYPE_CHECKING: 16 | -------------------------------------------------------------------------------- /.github/workflows/py.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: akx/pre-commit-uv-action@v0.1.0 11 | mypy: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: astral-sh/setup-uv@v5 16 | with: 17 | python-version: "3.12" 18 | - run: uv pip install -e . -r requirements-dev.txt 19 | - run: uv run mypy asgiproxy 20 | test: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: astral-sh/setup-uv@v5 25 | with: 26 | python-version: "3.8" 27 | - run: uv pip install -e . -r requirements-dev.txt 28 | - run: uv run pytest --cov . 29 | package: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: astral-sh/setup-uv@v5 34 | - run: uv build --wheel 35 | - uses: actions/upload-artifact@v4 36 | with: 37 | name: dist 38 | path: dist/ 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.py[cod] 3 | .cache 4 | .coverage 5 | .eggs 6 | .idea 7 | .tox 8 | .venv 9 | build/ 10 | dist/ 11 | htmlcov/ 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: debug-statements 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | - repo: https://github.com/astral-sh/ruff-pre-commit 9 | rev: v0.9.5 10 | hooks: 11 | - id: ruff 12 | args: 13 | - --fix 14 | - id: ruff-format 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2019 Valohai 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | asgiproxy 2 | ========= 3 | 4 | Tools for building HTTP and Websocket proxies for the asynchronous ASGI protocol. 5 | 6 | ## Usage 7 | 8 | ### Command line usage 9 | 10 | `asgiproxy` includes a small command-line tool that transparently (aside from rewriting the "Host" header) 11 | proxies all HTTP and WebSocket requests to another endpoint. 12 | 13 | It may be useful on its own, and also serves as a reference on how to use the library. 14 | 15 | While the library itself does not require Uvicorn, the CLI tool does. 16 | 17 | ```bash 18 | $ python -m asgiproxy http://example.com/ 19 | ``` 20 | 21 | starts a HTTP server on http://0.0.0.0:40404/ which should show you the example.com content. 22 | 23 | ### API usage 24 | 25 | Documentation forthcoming. For the time being, see `asgiproxy/__main__.py`. 26 | 27 | ### Running tests 28 | 29 | Tests are run with Py.test. 30 | 31 | ```bash 32 | py.test 33 | ``` 34 | -------------------------------------------------------------------------------- /asgiproxy/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.2.0" 2 | -------------------------------------------------------------------------------- /asgiproxy/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import asyncio 3 | from typing import Tuple 4 | from urllib.parse import urlparse 5 | 6 | from starlette.types import ASGIApp 7 | 8 | from asgiproxy.config import BaseURLProxyConfigMixin, ProxyConfig 9 | from asgiproxy.context import ProxyContext 10 | from asgiproxy.simple_proxy import make_simple_proxy_app 11 | 12 | try: 13 | import uvicorn 14 | except ImportError: 15 | uvicorn = None # type: ignore 16 | 17 | 18 | def make_app(upstream_base_url: str) -> Tuple[ASGIApp, ProxyContext]: 19 | config = type( 20 | "Config", 21 | (BaseURLProxyConfigMixin, ProxyConfig), 22 | { 23 | "upstream_base_url": upstream_base_url, 24 | "rewrite_host_header": urlparse(upstream_base_url).netloc, 25 | }, 26 | )() 27 | proxy_context = ProxyContext(config) 28 | app = make_simple_proxy_app(proxy_context) 29 | return (app, proxy_context) 30 | 31 | 32 | def main(): 33 | ap = argparse.ArgumentParser() 34 | ap.add_argument("target") 35 | ap.add_argument("--port", type=int, default=40404) 36 | ap.add_argument("--host", type=str, default="0.0.0.0") # noqa: S104 37 | args = ap.parse_args() 38 | if not uvicorn: 39 | ap.error("The `uvicorn` ASGI server package is required for the command line client.") 40 | app, proxy_context = make_app(upstream_base_url=args.target) 41 | try: 42 | return uvicorn.run(host=args.host, port=int(args.port), app=app) 43 | finally: 44 | asyncio.run(proxy_context.close()) 45 | 46 | 47 | if __name__ == "__main__": 48 | main() 49 | -------------------------------------------------------------------------------- /asgiproxy/base.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valohai/asgiproxy/8c80ee93a5d7eee191561461b13471505dee4210/asgiproxy/base.py -------------------------------------------------------------------------------- /asgiproxy/config.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Optional, Union 2 | from urllib.parse import urljoin 3 | 4 | import aiohttp 5 | from starlette.datastructures import Headers 6 | from starlette.requests import Request 7 | from starlette.types import Scope 8 | from starlette.websockets import WebSocket 9 | 10 | Headerlike = Union[dict, Headers] 11 | 12 | 13 | class ProxyConfig: 14 | def get_upstream_url(self, *, scope: Scope) -> str: 15 | """ 16 | Get the upstream URL for a client request. 17 | """ 18 | raise NotImplementedError("...") 19 | 20 | def get_upstream_url_with_query(self, *, scope: Scope) -> str: 21 | """ 22 | Get the upstream URL for a client request, including any query parameters to include. 23 | """ 24 | # The default implementation simply appends the original URL's query string to the 25 | # upstream URL generated by `get_upstream_url`. 26 | url = self.get_upstream_url(scope=scope) 27 | query_string = scope.get("query_string") 28 | if query_string: 29 | sep = "&" if "?" in url else "?" 30 | url += "{}{}".format(sep, query_string.decode("utf-8")) 31 | return url 32 | 33 | def process_client_headers(self, *, scope: Scope, headers: Headers) -> Headerlike: 34 | """ 35 | Process client HTTP headers before they're passed upstream. 36 | """ 37 | return headers 38 | 39 | def get_client_protocols(self, *, scope: Scope, headers: Headers) -> Iterable[str]: 40 | """ 41 | Get client subprotocol list so it can be passed upstream. 42 | """ 43 | return scope.get("subprotocols", []) 44 | 45 | def process_upstream_headers( 46 | self, *, scope: Scope, proxy_response: aiohttp.ClientResponse 47 | ) -> Headerlike: 48 | """ 49 | Process upstream HTTP headers before they're passed to the client. 50 | """ 51 | return proxy_response.headers # type: ignore 52 | 53 | def get_upstream_http_options(self, *, scope: Scope, client_request: Request, data) -> dict: 54 | """ 55 | Get request options (as passed to aiohttp.ClientSession.request). 56 | """ 57 | return dict( 58 | method=client_request.method, 59 | url=self.get_upstream_url_with_query(scope=scope), 60 | data=data, 61 | headers=self.process_client_headers( 62 | scope=scope, 63 | headers=client_request.headers, 64 | ), 65 | allow_redirects=False, 66 | ) 67 | 68 | def get_upstream_websocket_options(self, *, scope: Scope, client_ws: WebSocket) -> dict: 69 | """ 70 | Get websocket connection options (as passed to aiohttp.ClientSession.ws_connect). 71 | """ 72 | return dict( 73 | method=scope.get("method", "GET"), 74 | url=self.get_upstream_url_with_query(scope=scope), 75 | headers=self.process_client_headers(scope=scope, headers=client_ws.headers), 76 | protocols=self.get_client_protocols(scope=scope, headers=client_ws.headers), 77 | ) 78 | 79 | 80 | class BaseURLProxyConfigMixin: 81 | upstream_base_url: str 82 | rewrite_host_header: Optional[str] = None 83 | 84 | def get_upstream_url(self, scope: Scope) -> str: 85 | return urljoin(self.upstream_base_url, scope["path"]) 86 | 87 | def process_client_headers(self, *, scope: Scope, headers: Headerlike) -> Headerlike: 88 | """ 89 | Process client HTTP headers before they're passed upstream. 90 | """ 91 | if self.rewrite_host_header: 92 | headers = headers.mutablecopy() # type: ignore 93 | headers["host"] = self.rewrite_host_header 94 | return super().process_client_headers(scope=scope, headers=headers) # type: ignore 95 | -------------------------------------------------------------------------------- /asgiproxy/context.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Optional 3 | 4 | import aiohttp 5 | 6 | from asgiproxy.config import ProxyConfig 7 | 8 | 9 | class ProxyContext: 10 | semaphore: asyncio.Semaphore 11 | _session: Optional[aiohttp.ClientSession] = None 12 | 13 | def __init__( 14 | self, 15 | config: ProxyConfig, 16 | max_concurrency: int = 20, 17 | ) -> None: 18 | self.config = config 19 | self.semaphore = asyncio.Semaphore(max_concurrency) 20 | 21 | @property 22 | def session(self) -> aiohttp.ClientSession: 23 | if not self._session: 24 | self._session = aiohttp.ClientSession( 25 | cookie_jar=aiohttp.DummyCookieJar(), auto_decompress=False 26 | ) 27 | return self._session 28 | 29 | async def __aenter__(self) -> "ProxyContext": 30 | return self 31 | 32 | async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: 33 | await self.close() 34 | 35 | async def close(self) -> None: 36 | if self._session: 37 | await self._session.close() 38 | -------------------------------------------------------------------------------- /asgiproxy/proxies/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valohai/asgiproxy/8c80ee93a5d7eee191561461b13471505dee4210/asgiproxy/proxies/__init__.py -------------------------------------------------------------------------------- /asgiproxy/proxies/http.py: -------------------------------------------------------------------------------- 1 | from typing import AsyncGenerator, Union 2 | 3 | import aiohttp 4 | from starlette.requests import Request 5 | from starlette.responses import Response, StreamingResponse 6 | from starlette.types import Receive, Scope, Send 7 | 8 | from asgiproxy.context import ProxyContext 9 | from asgiproxy.utils.streams import read_stream_in_chunks 10 | 11 | # TODO: make these configurable? 12 | INCOMING_STREAMING_THRESHOLD = 512 * 1024 13 | OUTGOING_STREAMING_THRESHOLD = 1024 * 1024 * 5 14 | 15 | 16 | def determine_incoming_streaming(request: Request) -> bool: 17 | if request.method in ("GET", "HEAD"): 18 | return False 19 | 20 | try: 21 | return int(request.headers["content-length"]) < INCOMING_STREAMING_THRESHOLD 22 | except (TypeError, ValueError, KeyError): 23 | # Malformed or missing content-length header; assume a very large payload 24 | return True 25 | 26 | 27 | def determine_outgoing_streaming(proxy_response: aiohttp.ClientResponse) -> bool: 28 | if proxy_response.status != 200: 29 | return False 30 | try: 31 | return int(proxy_response.headers["content-length"]) > OUTGOING_STREAMING_THRESHOLD 32 | except (TypeError, ValueError, KeyError): 33 | # Malformed or missing content-length header; assume a streaming payload 34 | return True 35 | 36 | 37 | async def get_proxy_response( 38 | *, 39 | context: ProxyContext, 40 | scope: Scope, 41 | receive: Receive, 42 | ) -> aiohttp.ClientResponse: 43 | request = Request(scope, receive) 44 | should_stream_incoming = determine_incoming_streaming(request) 45 | async with context.semaphore: 46 | data: Union[None, AsyncGenerator[bytes, None], bytes] = None 47 | if request.method not in ("GET", "HEAD"): 48 | if should_stream_incoming: 49 | data = request.stream() 50 | else: 51 | data = await request.body() 52 | 53 | kwargs = context.config.get_upstream_http_options( 54 | scope=scope, client_request=request, data=data 55 | ) 56 | 57 | return await context.session.request(**kwargs) 58 | 59 | 60 | async def convert_proxy_response_to_user_response( 61 | *, 62 | context: ProxyContext, 63 | scope: Scope, 64 | proxy_response: aiohttp.ClientResponse, 65 | ) -> Response: 66 | headers_to_client = context.config.process_upstream_headers( 67 | scope=scope, proxy_response=proxy_response 68 | ) 69 | status_to_client = proxy_response.status 70 | if determine_outgoing_streaming(proxy_response): 71 | return StreamingResponse( 72 | content=read_stream_in_chunks(proxy_response.content), 73 | status_code=status_to_client, 74 | headers=headers_to_client, # type: ignore 75 | ) 76 | return Response( 77 | content=await proxy_response.read(), 78 | status_code=status_to_client, 79 | headers=headers_to_client, # type: ignore 80 | ) 81 | 82 | 83 | async def proxy_http( 84 | *, 85 | context: ProxyContext, 86 | scope: Scope, 87 | receive: Receive, 88 | send: Send, 89 | ) -> None: 90 | proxy_response = await get_proxy_response(context=context, scope=scope, receive=receive) 91 | user_response = await convert_proxy_response_to_user_response( 92 | context=context, scope=scope, proxy_response=proxy_response 93 | ) 94 | return await user_response(scope, receive, send) 95 | -------------------------------------------------------------------------------- /asgiproxy/proxies/websocket.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import uuid 4 | from typing import Optional 5 | 6 | from aiohttp import ClientWebSocketResponse, WSMessage, WSMsgType 7 | from starlette.types import Receive, Scope, Send 8 | from starlette.websockets import WebSocket 9 | from websockets.exceptions import ConnectionClosed 10 | 11 | from asgiproxy.context import ProxyContext 12 | 13 | log = logging.getLogger(__name__) 14 | 15 | 16 | class UnknownMessage(ValueError): 17 | pass 18 | 19 | 20 | class WebSocketProxyContext: 21 | def __init__( 22 | self, 23 | *, 24 | id: Optional[str] = None, 25 | client_ws: WebSocket, 26 | upstream_ws: ClientWebSocketResponse, 27 | ) -> None: 28 | self.id = str(id or uuid.uuid4()) 29 | self.client_ws = client_ws 30 | self.upstream_ws = upstream_ws 31 | 32 | async def client_to_upstream_loop(self): 33 | while True: 34 | client_msg: dict = await self.client_ws.receive() 35 | if client_msg["type"] == "websocket.disconnect": 36 | log.info(f"WSP {self.id}: Client closed connection.") 37 | return 38 | log.debug(f"C->U: {client_msg}") 39 | await self.send_client_to_upstream(client_msg) 40 | 41 | async def send_client_to_upstream(self, client_msg: dict): 42 | if client_msg.get("text"): 43 | await self.upstream_ws.send_str(client_msg["text"]) 44 | return True 45 | 46 | if client_msg.get("bytes"): 47 | await self.upstream_ws.send_bytes(client_msg["bytes"]) 48 | return True 49 | 50 | raise UnknownMessage(f"WSP {self.id}: Unknown client WS message: {client_msg}", client_msg) 51 | 52 | async def send_upstream_to_client(self, upstream_msg: WSMessage): 53 | if upstream_msg.type == WSMsgType.text: 54 | await self.client_ws.send_text(upstream_msg.data) 55 | return 56 | if upstream_msg.type == WSMsgType.binary: 57 | await self.client_ws.send_bytes(upstream_msg.data) 58 | return 59 | raise UnknownMessage( 60 | f"WSP {self.id}: Unknown upstream WS message: {upstream_msg}", upstream_msg 61 | ) 62 | 63 | async def upstream_to_client_loop(self): 64 | while True: 65 | upstream_msg: WSMessage = await self.upstream_ws.receive() 66 | log.debug(f"WSP {self.id}: U->C: {upstream_msg}") 67 | 68 | if upstream_msg.type == WSMsgType.closed: 69 | log.info(f"WSP {self.id}: Upstream closed connection.") 70 | return 71 | 72 | try: 73 | await self.send_upstream_to_client(upstream_msg=upstream_msg) 74 | except ConnectionClosed as cc: 75 | log.info( 76 | f"WSP {self.id}: Upstream-to-client loop: client connection had closed ({cc})." 77 | ) 78 | return 79 | 80 | async def loop(self): 81 | log.debug(f"WSP {self.id}: Starting main loop.") 82 | ctu_task = asyncio.create_task(self.client_to_upstream_loop()) 83 | utc_task = asyncio.create_task(self.upstream_to_client_loop()) 84 | try: 85 | await asyncio.wait([ctu_task, utc_task], return_when=asyncio.FIRST_COMPLETED) 86 | ctu_task.cancel() 87 | utc_task.cancel() 88 | except Exception: 89 | log.warning(f"WSP {self.id}: Unexpected exception!", exc_info=True) 90 | raise 91 | log.debug(f"WSP {self.id}: Ending main loop.") 92 | 93 | 94 | async def proxy_websocket( 95 | *, context: ProxyContext, scope: Scope, receive: Receive, send: Send 96 | ) -> None: 97 | client_ws: Optional[WebSocket] = None 98 | upstream_ws: Optional[ClientWebSocketResponse] = None 99 | try: 100 | client_ws = WebSocket(scope=scope, receive=receive, send=send) 101 | 102 | async with context.session.ws_connect( 103 | **context.config.get_upstream_websocket_options(scope=scope, client_ws=client_ws) 104 | ) as upstream_ws: 105 | await client_ws.accept(subprotocol=upstream_ws.protocol) 106 | ctx = WebSocketProxyContext(client_ws=client_ws, upstream_ws=upstream_ws) 107 | await ctx.loop() 108 | finally: 109 | if upstream_ws: 110 | try: 111 | await upstream_ws.close() 112 | except Exception: 113 | pass 114 | if client_ws: 115 | try: 116 | await client_ws.close() 117 | except Exception: 118 | pass 119 | -------------------------------------------------------------------------------- /asgiproxy/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valohai/asgiproxy/8c80ee93a5d7eee191561461b13471505dee4210/asgiproxy/py.typed -------------------------------------------------------------------------------- /asgiproxy/simple_proxy.py: -------------------------------------------------------------------------------- 1 | from starlette.types import ASGIApp, Receive, Scope, Send 2 | 3 | from asgiproxy.context import ProxyContext 4 | from asgiproxy.proxies.http import proxy_http 5 | from asgiproxy.proxies.websocket import proxy_websocket 6 | 7 | 8 | def make_simple_proxy_app( 9 | proxy_context: ProxyContext, 10 | *, 11 | proxy_http_handler=proxy_http, 12 | proxy_websocket_handler=proxy_websocket, 13 | ) -> ASGIApp: 14 | """ 15 | Given a ProxyContext, return a simple ASGI application that can proxy 16 | HTTP and WebSocket connections. 17 | 18 | The handlers for the protocols can be overridden and/or removed with the 19 | respective parameters. 20 | """ 21 | 22 | async def app(scope: Scope, receive: Receive, send: Send): 23 | if scope["type"] == "lifespan": 24 | return None # We explicitly do nothing here for this simple app. 25 | 26 | if scope["type"] == "http" and proxy_http_handler: 27 | return await proxy_http_handler( 28 | context=proxy_context, scope=scope, receive=receive, send=send 29 | ) 30 | 31 | if scope["type"] == "websocket" and proxy_websocket_handler: 32 | return await proxy_websocket_handler( 33 | context=proxy_context, scope=scope, receive=receive, send=send 34 | ) 35 | 36 | raise NotImplementedError(f"Scope {scope} is not understood") 37 | 38 | return app 39 | -------------------------------------------------------------------------------- /asgiproxy/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valohai/asgiproxy/8c80ee93a5d7eee191561461b13471505dee4210/asgiproxy/utils/__init__.py -------------------------------------------------------------------------------- /asgiproxy/utils/streams.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Union 3 | 4 | import aiohttp 5 | 6 | Streamable = Union[asyncio.StreamReader, aiohttp.StreamReader] 7 | 8 | 9 | async def read_stream_in_chunks(stream: Streamable, chunk_size: int = 524_288): 10 | while True: 11 | chunk = await stream.read(chunk_size) 12 | yield chunk 13 | if not chunk: 14 | break 15 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "asgiproxy" 7 | dynamic = ["version"] 8 | description = "Tools for building HTTP and Websocket proxies for the asynchronous ASGI protocol" 9 | readme = "README.md" 10 | license = "MIT" 11 | requires-python = ">=3.8" 12 | authors = [ 13 | { name = "Valohai", email = "dev@valohai.com" }, 14 | ] 15 | dependencies = [ 16 | "aiohttp", 17 | "starlette", 18 | "websockets", 19 | ] 20 | 21 | [project.urls] 22 | Homepage = "https://github.com/valohai/asgiproxy" 23 | 24 | [tool.hatch.version] 25 | path = "asgiproxy/__init__.py" 26 | 27 | [tool.hatch.build.targets.sdist] 28 | include = [ 29 | "/asgiproxy", 30 | ] 31 | 32 | [tool.ruff] 33 | line-length = 99 34 | 35 | [tool.ruff.lint] 36 | select = [ 37 | "B", 38 | "C901", 39 | "E", 40 | "F", 41 | "I", 42 | "RUF100", 43 | "S", 44 | "W", 45 | ] 46 | ignore = [ 47 | "S110", 48 | ] 49 | 50 | [tool.ruff.lint.per-file-ignores] 51 | "tests/*" = [ 52 | "ANN201", 53 | "S101", 54 | ] 55 | 56 | [tool.mypy] 57 | 58 | [[tool.mypy.overrides]] 59 | module = "uvicorn.*" 60 | ignore_missing_imports = true 61 | -------------------------------------------------------------------------------- /requirements-dev.in: -------------------------------------------------------------------------------- 1 | asgiref 2 | mypy 3 | pytest 4 | pytest-asyncio 5 | pytest-cov 6 | uvicorn 7 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile requirements-dev.in -o requirements-dev.txt --python-version=3.8 3 | asgiref==3.8.1 4 | # via -r requirements-dev.in 5 | click==8.1.8 6 | # via uvicorn 7 | coverage==7.6.1 8 | # via pytest-cov 9 | exceptiongroup==1.2.2 10 | # via pytest 11 | h11==0.14.0 12 | # via uvicorn 13 | iniconfig==2.0.0 14 | # via pytest 15 | mypy==1.14.1 16 | # via -r requirements-dev.in 17 | mypy-extensions==1.0.0 18 | # via mypy 19 | packaging==24.2 20 | # via pytest 21 | pluggy==1.5.0 22 | # via pytest 23 | pytest==8.3.4 24 | # via 25 | # -r requirements-dev.in 26 | # pytest-asyncio 27 | # pytest-cov 28 | pytest-asyncio==0.24.0 29 | # via -r requirements-dev.in 30 | pytest-cov==5.0.0 31 | # via -r requirements-dev.in 32 | tomli==2.2.1 33 | # via 34 | # coverage 35 | # mypy 36 | # pytest 37 | typing-extensions==4.12.2 38 | # via 39 | # asgiref 40 | # mypy 41 | # uvicorn 42 | uvicorn==0.33.0 43 | # via -r requirements-dev.in 44 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valohai/asgiproxy/8c80ee93a5d7eee191561461b13471505dee4210/tests/__init__.py -------------------------------------------------------------------------------- /tests/configs.py: -------------------------------------------------------------------------------- 1 | from asgiproxy.config import BaseURLProxyConfigMixin, ProxyConfig 2 | 3 | 4 | class ExampleComProxyConfig(BaseURLProxyConfigMixin, ProxyConfig): 5 | upstream_base_url = "http://example.com" 6 | rewrite_host_header = "example.com" 7 | -------------------------------------------------------------------------------- /tests/test_asgiproxy.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from asgiref.testing import ApplicationCommunicator 3 | from starlette.requests import Request 4 | 5 | from asgiproxy.context import ProxyContext 6 | from asgiproxy.simple_proxy import make_simple_proxy_app 7 | from tests.configs import ExampleComProxyConfig 8 | from tests.utils import http_response_from_asgi_messages, make_http_scope 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "full_url, expected_url", 13 | [ 14 | ( 15 | "http://127.0.0.1/pathlet/?encode&flep&murp", 16 | "http://example.com/pathlet/?encode&flep&murp", 17 | ), 18 | ("http://127.0.0.1/pathlet/", "http://example.com/pathlet/"), 19 | ], 20 | ) 21 | def test_query_string_passthrough(full_url, expected_url): 22 | proxy_config = ExampleComProxyConfig() 23 | scope = make_http_scope( 24 | full_url=full_url, 25 | headers={ 26 | "Accept": "text/html", 27 | "User-Agent": "Foo", 28 | }, 29 | ) 30 | client_request = Request(scope) 31 | opts = proxy_config.get_upstream_http_options( 32 | scope=scope, client_request=client_request, data=None 33 | ) 34 | assert opts["url"] == expected_url 35 | 36 | 37 | @pytest.mark.asyncio 38 | async def test_asgiproxy(): 39 | scope = make_http_scope( 40 | full_url="http://127.0.0.1/", 41 | headers={ 42 | "Accept": "text/html", 43 | "User-Agent": "Foo", 44 | }, 45 | ) 46 | 47 | context = ProxyContext(config=ExampleComProxyConfig()) 48 | app = make_simple_proxy_app(context, proxy_websocket_handler=None) 49 | 50 | async with context: 51 | acom = ApplicationCommunicator(app, scope) 52 | await acom.send_input({"type": "http.request", "body": b""}) 53 | await acom.wait() 54 | messages = [] 55 | while not acom.output_queue.empty(): 56 | messages.append(await acom.receive_output()) 57 | resp = http_response_from_asgi_messages(messages) 58 | assert resp["status"] == 200 59 | assert b"Example Domain" in resp["content"] 60 | -------------------------------------------------------------------------------- /tests/test_e2e.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import aiohttp 4 | import pytest 5 | import uvicorn 6 | 7 | from asgiproxy.context import ProxyContext 8 | from asgiproxy.simple_proxy import make_simple_proxy_app 9 | from tests.configs import ExampleComProxyConfig 10 | 11 | 12 | @pytest.mark.asyncio 13 | async def test_asgiproxy_e2e(unused_tcp_port): 14 | """ 15 | End-to-end test the library's HTTP capabilities: 16 | 17 | * spin up a real Uvicorn server 18 | * send it a request using aiohttp 19 | * assert that we got the Example Domain response from example.com 20 | """ 21 | port = unused_tcp_port 22 | context = ProxyContext(config=ExampleComProxyConfig()) 23 | proxy_app = make_simple_proxy_app(context) 24 | cfg = uvicorn.Config(app=proxy_app, port=port, limit_max_requests=1) 25 | app = uvicorn.Server(config=cfg) 26 | async with context, aiohttp.ClientSession() as sess: 27 | 28 | async def request_soon(): 29 | while not app.started: 30 | await asyncio.sleep(0.1) 31 | return await sess.request("GET", f"http://127.0.0.1:{port}/") 32 | 33 | _, resp = await asyncio.gather(app.serve(), request_soon()) 34 | resp: aiohttp.ClientResponse 35 | assert resp.status == 200 36 | assert b"Example Domain" in await resp.read() 37 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import gzip 2 | from typing import List 3 | from urllib.parse import urlparse 4 | 5 | from asgiref.typing import HTTPScope 6 | from starlette.datastructures import Headers 7 | 8 | 9 | def http_response_from_asgi_messages(messages: List[dict]): 10 | start_message = next(m for m in messages if m["type"] == "http.response.start") 11 | body_bytes = b"".join( 12 | m.get("body", b"") for m in messages if m["type"] == "http.response.body" 13 | ) 14 | headers = Headers(raw=start_message["headers"]) 15 | if headers.get("content-encoding") == "gzip": 16 | content = gzip.decompress(body_bytes) 17 | else: 18 | content = body_bytes 19 | return { 20 | "status": start_message["status"], 21 | "headers": headers, 22 | "body": body_bytes, 23 | "content": content, 24 | } 25 | 26 | 27 | def make_http_scope(*, method="GET", full_url: str, headers=None) -> HTTPScope: 28 | if headers is None: 29 | headers = {} 30 | headers = {str(key).lower(): str(value) for (key, value) in headers.items()} 31 | url_parts = urlparse(full_url) 32 | headers.setdefault("host", url_parts.netloc) 33 | # noinspection PyTypeChecker 34 | return { 35 | "type": "http", 36 | "asgi": {"version": "3.0", "spec_version": "3.0"}, 37 | "http_version": "1.1", 38 | "method": method, 39 | "scheme": url_parts.scheme, 40 | "path": url_parts.path, 41 | "raw_path": url_parts.path.encode(), 42 | "query_string": url_parts.query.encode(), 43 | "root_path": "/", 44 | "headers": [(key.encode(), value.encode()) for (key, value) in headers.items()], 45 | "extensions": {}, 46 | "client": None, 47 | "server": None, 48 | } 49 | --------------------------------------------------------------------------------