diff --git a/fastapi_cdn_host/client.py b/fastapi_cdn_host/client.py index 6611224..ad7f967 100644 --- a/fastapi_cdn_host/client.py +++ b/fastapi_cdn_host/client.py @@ -1,3 +1,4 @@ +import inspect import logging import re from dataclasses import dataclass @@ -117,7 +118,7 @@ class AssetUrl: favicon: Annotated[Optional[str], "URL of favicon.png/favicon.ico"] = None -class HttpSpider: +class HttpSniff: @staticmethod async def fetch( client: httpx.AsyncClient, url: str, results: list, index: int @@ -254,7 +255,7 @@ async def sniff_the_fastest( cls, favicon_url=None, choices=list(CdnHostEnum) ) -> AssetUrl: css_urls, they = cls.build_race_data(choices) - fast_css_url = await HttpSpider.find_fastest_host(css_urls) + fast_css_url = await HttpSniff.find_fastest_host(css_urls) fast_host, fast_asset_path = they[css_urls.index(fast_css_url)] logger.info(f"Select cdn: {fast_host[0]} to serve swagger css/js") return cls.build_asset_url( @@ -298,6 +299,14 @@ class DocsBuilder: def __init__(self, index: int) -> None: self.index = index + @staticmethod + async def try_request_lock(req: Request, lock: Optional[Callable] = None) -> None: + if lock is not None: + if inspect.iscoroutinefunction(lock): + await lock(req) + else: + lock(req) + def update_entrypoint(self, func, app: FastAPI, url: str) -> None: app.routes[self.index] = APIRoute(url, func, include_in_schema=False) @@ -305,8 +314,7 @@ def update_docs_entrypoint( self, urls: AssetUrl, app: FastAPI, url: str, lock=None ) -> None: async def swagger_ui_html(req: Request) -> HTMLResponse: - if lock is not None: - lock(req) + await self.try_request_lock(req, lock) root_path = req.scope.get("root_path", "").rstrip("/") asset_urls = CdnHostBuilder.fill_root_path(urls, root_path) openapi_url = root_path + app.openapi_url @@ -333,8 +341,7 @@ def update_redoc_entrypoint( self, urls: AssetUrl, app: FastAPI, url: str, lock=None ) -> None: async def redoc_html(req: Request) -> HTMLResponse: - if lock is not None: - lock(req) + await self.try_request_lock(req, lock) root_path = req.scope.get("root_path", "").rstrip("/") asset_urls = CdnHostBuilder.fill_root_path(urls, root_path) openapi_url = root_path + app.openapi_url diff --git a/scripts/parallel_test.py b/scripts/parallel_test.py index 6841c85..3b60c16 100644 --- a/scripts/parallel_test.py +++ b/scripts/parallel_test.py @@ -28,7 +28,6 @@ def main() -> int: raise exc else: res += rc % 255 # rc may be 256 - print(f"{res = }") return res diff --git a/tests/extend_choices/test_extend.py b/tests/extend_choices/test_extend.py index 2a65f98..7eb47e3 100644 --- a/tests/extend_choices/test_extend.py +++ b/tests/extend_choices/test_extend.py @@ -9,7 +9,7 @@ CdnHostBuilder, CdnHostEnum, CdnHostItem, - HttpSpider, + HttpSniff, ) @@ -66,7 +66,7 @@ async def test_docs(client: AsyncClient): # nosec ), ) urls = await CdnHostBuilder.sniff_the_fastest(choices) - url_list = await HttpSpider.get_fast_hosts( + url_list = await HttpSniff.get_fast_hosts( CdnHostBuilder.build_race_data(list(choices))[0] ) assert urls.css in url_list diff --git a/tests/favicon_online_cdn/test_race_favicon.py b/tests/favicon_online_cdn/test_race_favicon.py index 20fc4d3..d5986b0 100644 --- a/tests/favicon_online_cdn/test_race_favicon.py +++ b/tests/favicon_online_cdn/test_race_favicon.py @@ -3,7 +3,7 @@ from httpx import ASGITransport, AsyncClient from main import app -from fastapi_cdn_host.client import CdnHostBuilder, CdnHostEnum, HttpSpider +from fastapi_cdn_host.client import CdnHostBuilder, CdnHostEnum, HttpSniff try: from asyncur import timeit @@ -41,7 +41,7 @@ async def test_docs(client: AsyncClient): # nosec assert '"https://ubuntu.com/favicon.ico"' in text if urls.js not in text: # Sometimes there are several cdn hosts that have good response speed. - url_list = await HttpSpider.get_fast_hosts( + url_list = await HttpSniff.get_fast_hosts( CdnHostBuilder.build_race_data(list(CdnHostEnum))[0] ) assert urls.css in url_list diff --git a/tests/http_race/test_http_race.py b/tests/http_race/test_http_race.py index fe2549e..dadb094 100644 --- a/tests/http_race/test_http_race.py +++ b/tests/http_race/test_http_race.py @@ -6,7 +6,7 @@ from main import app from utils import TestClient, UvicornServer -from fastapi_cdn_host.client import HttpSpider +from fastapi_cdn_host.client import HttpSniff try: from asyncur import timeit @@ -31,17 +31,17 @@ async def client(): async def test_fetch(client: AsyncClient): timestamp = time.time() results = [timestamp] - await HttpSpider.fetch(client, "/error", results, 0) + await HttpSniff.fetch(client, "/error", results, 0) assert results[0] == timestamp - await HttpSpider.fetch(client, "/sleep?seconds=0.1", results, 0) + await HttpSniff.fetch(client, "/sleep?seconds=0.1", results, 0) assert results[0] != timestamp timestamp2 = time.time() results = [timestamp2] async with AsyncClient(base_url=client.base_url, timeout=0.1) as c: - await HttpSpider.fetch(c, "/sleep?seconds=0.2", results, 0) + await HttpSniff.fetch(c, "/sleep?seconds=0.2", results, 0) assert results[0] == timestamp2 async with AsyncClient(base_url=client.base_url, timeout=0.1) as c: - await HttpSpider.fetch(c, "/not-exist", results, 0) + await HttpSniff.fetch(c, "/not-exist", results, 0) assert results[0] == timestamp2 @@ -52,7 +52,7 @@ async def test_find(): host = "http://127.0.0.1:8000/" with UvicornServer().run_in_thread(): urls = [host + path.format(seconds) for seconds, path in zip(waits, paths)] - fastest = await timeit(HttpSpider.find_fastest_host)(urls, total_seconds=0.1) + fastest = await timeit(HttpSniff.find_fastest_host)(urls, total_seconds=0.1) assert fastest == urls[0] - fastest = await timeit(HttpSpider.find_fastest_host)(urls, loop_interval=0.1) + fastest = await timeit(HttpSniff.find_fastest_host)(urls, loop_interval=0.1) assert fastest == urls[waits.index(min(waits))] diff --git a/tests/lock_docs/main.py b/tests/lock_docs/main.py new file mode 100644 index 0000000..193a772 --- /dev/null +++ b/tests/lock_docs/main.py @@ -0,0 +1,32 @@ +import calendar +from datetime import datetime + +from fastapi import FastAPI, HTTPException, Request, status + +import fastapi_cdn_host + +app = FastAPI() +app_sync_lock = FastAPI() + + +def sync_lock(request: Request) -> None: + if ( + not (d := request.query_params.get("day")) + or (weekday := getattr(calendar, d.upper(), None)) is None + or (weekday != datetime.now().weekday()) + ): + raise HTTPException(status_code=status.HTTP_418_IM_A_TEAPOT) + + +async def lock(request: Request) -> None: + sync_lock(request) + + +fastapi_cdn_host.patch_docs(app, lock=lock) +fastapi_cdn_host.patch_docs(app_sync_lock, lock=sync_lock) + + +@app.get("/") +@app_sync_lock.get("/") +async def index(): + return "homepage" diff --git a/tests/lock_docs/test_lock.py b/tests/lock_docs/test_lock.py new file mode 100644 index 0000000..5c961ac --- /dev/null +++ b/tests/lock_docs/test_lock.py @@ -0,0 +1,67 @@ +# mypy: no-disallow-untyped-decorators +from datetime import datetime + +import httpx +import pytest +from main import app, app_sync_lock + + +@pytest.fixture(scope="module") +def anyio_backend(): + return "asyncio" + + +@pytest.fixture(scope="module") +async def client(): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test.com" + ) as c: + yield c + + +@pytest.fixture(scope="module") +async def client_sync_lock(): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app_sync_lock), + base_url="http://sync.test.com", + ) as c: + yield c + + +class TestLock: + weekdays = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ] + + @pytest.mark.anyio + async def test_lock(self, client: httpx.AsyncClient): + await self.request_locked(client) + + async def request_locked(self, client): + response = await client.get("/") + assert response.status_code == 200 + assert response.text == '"homepage"' + response = await client.get("/docs") + assert response.status_code == 418 + assert response.json()["detail"] == "I'm a Teapot" + response = await client.get("/redoc") + assert response.status_code == 418 + assert response.json()["detail"] == "I'm a Teapot" + day = self.weekdays[datetime.now().weekday()] + response = await client.get(f"/docs?day={day}") + assert response.status_code == 200 + response = await client.get(f"/redoc?day={day}") + assert response.status_code == 200 + response = await client.get("/") + assert response.status_code == 200 + assert response.text == '"homepage"' + + @pytest.mark.anyio + async def test_sync_lock(self, client_sync_lock: httpx.AsyncClient): + await self.request_locked(client_sync_lock) diff --git a/tests/missing_js/main.py b/tests/missing_js/main.py new file mode 100644 index 0000000..582700b --- /dev/null +++ b/tests/missing_js/main.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python + +from fastapi import FastAPI, Request +from fastapi.responses import RedirectResponse + +import fastapi_cdn_host + +app = FastAPI(title="FastAPI CDN host test") + + +@app.get("/", include_in_schema=False) +async def to_docs(): + return RedirectResponse("/docs") + + +@app.get("/app") +async def get_app(request: Request) -> dict: + return {"routes": str(request.app.routes)} + + +fastapi_cdn_host.patch_docs(app) diff --git a/tests/missing_js/static/swagger-ui.css b/tests/missing_js/static/swagger-ui.css new file mode 120000 index 0000000..41e4571 --- /dev/null +++ b/tests/missing_js/static/swagger-ui.css @@ -0,0 +1 @@ +../../static_with_favicon/static/swagger-ui.css \ No newline at end of file diff --git a/tests/missing_js/test_missing_js.py b/tests/missing_js/test_missing_js.py new file mode 100644 index 0000000..a99b72c --- /dev/null +++ b/tests/missing_js/test_missing_js.py @@ -0,0 +1,44 @@ +# mypy: no-disallow-untyped-decorators +import pytest +from httpx import ASGITransport, AsyncClient +from main import app + +from fastapi_cdn_host.client import CdnHostBuilder + +default_favicon_url = "https://fastapi.tiangolo.com/img/favicon.png" + + +@pytest.fixture(scope="module") +def anyio_backend(): + return "asyncio" + + +@pytest.fixture(scope="module") +async def client(): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as c: + yield c + + +@pytest.mark.anyio +async def test_missing_js(client: AsyncClient): # nosec + swagger_ui = CdnHostBuilder.swagger_files + js_url = "/static/" + swagger_ui["js"] + css_url = "/static/" + swagger_ui["css"] + response = await client.get(css_url) + assert response.status_code == 200, f"{response.url=};{response.text=}" + response = await client.get(js_url) + assert response.status_code == 404, response.text + response = await client.get("/docs") + text = response.text + assert response.status_code == 200, text + assert default_favicon_url in text + assert js_url in text + assert css_url in text + response = await client.get("/redoc") + text = response.text + assert response.status_code == 200, text + assert "/static/redoc" in text + response = await client.get("/app") + assert response.status_code == 200 diff --git a/tests/online_cdn/test_online_race.py b/tests/online_cdn/test_online_race.py index 85bdb0b..791b16b 100644 --- a/tests/online_cdn/test_online_race.py +++ b/tests/online_cdn/test_online_race.py @@ -5,7 +5,7 @@ from httpx import ASGITransport, AsyncClient from main import app -from fastapi_cdn_host.client import CdnHostBuilder, CdnHostEnum, HttpSpider +from fastapi_cdn_host.client import CdnHostBuilder, CdnHostEnum, HttpSniff default_favicon_url = "https://fastapi.tiangolo.com/img/favicon.png" @@ -35,14 +35,15 @@ async def test_docs(client: AsyncClient): # nosec assert response2.status_code == 200, text2 if urls.js not in text: # Sometimes there are several cdn hosts that have good response speed. - url_list = await HttpSpider.get_fast_hosts( + url_list = await HttpSniff.get_fast_hosts( CdnHostBuilder.build_race_data(list(CdnHostEnum))[0] ) assert urls.css in url_list assert any(i in text for i in url_list) - host = urls.css.split("://")[-1].split("/")[0] - redoc_pattern = re.compile(rf"{host}[\w-/.]+redoc") - assert any(redoc_pattern.search(text) for i in text2) + assert any( + re.search(rf"{i.split('://')[-1].split('/')[0]}[\w/.-]+redoc", text2) + for i in url_list + ) else: assert urls.js in text assert urls.css in text diff --git a/tests/root_path_without_static/test_root_path.py b/tests/root_path_without_static/test_root_path.py index 10ef7bc..ea29dcc 100644 --- a/tests/root_path_without_static/test_root_path.py +++ b/tests/root_path_without_static/test_root_path.py @@ -3,7 +3,7 @@ from httpx import ASGITransport, AsyncClient from main import app -from fastapi_cdn_host.client import CdnHostBuilder, CdnHostEnum, HttpSpider +from fastapi_cdn_host.client import CdnHostBuilder, CdnHostEnum, HttpSniff default_favicon_url = "https://fastapi.tiangolo.com/img/favicon.png" @@ -33,7 +33,7 @@ async def test_docs(client: AsyncClient): # nosec assert default_favicon_url in text if urls.js not in text: # Sometimes there are several cdn hosts that have good response speed. - url_list = await HttpSpider.get_fast_hosts( + url_list = await HttpSniff.get_fast_hosts( CdnHostBuilder.build_race_data(list(CdnHostEnum))[0] ) assert urls.css in url_list diff --git a/tests/static_favicon_without_swagger_ui/test_favicon.py b/tests/static_favicon_without_swagger_ui/test_favicon.py index c9a6285..9805664 100644 --- a/tests/static_favicon_without_swagger_ui/test_favicon.py +++ b/tests/static_favicon_without_swagger_ui/test_favicon.py @@ -3,7 +3,7 @@ from httpx import ASGITransport, AsyncClient from main import app -from fastapi_cdn_host.client import CdnHostBuilder, CdnHostEnum, HttpSpider +from fastapi_cdn_host.client import CdnHostBuilder, CdnHostEnum, HttpSniff default_favicon_url = "https://fastapi.tiangolo.com/img/favicon.png" @@ -34,7 +34,7 @@ async def test_docs(client: AsyncClient): # nosec assert '"/static/favicon.ico"' in text if urls.js not in text: # Sometimes there are several cdn hosts that have good response speed. - url_list = await HttpSpider.get_fast_hosts( + url_list = await HttpSniff.get_fast_hosts( CdnHostBuilder.build_race_data(list(CdnHostEnum))[0] ) assert urls.css in url_list