Skip to content

Commit

Permalink
Support async lock function
Browse files Browse the repository at this point in the history
  • Loading branch information
waketzheng committed Jun 20, 2024
1 parent eb89b0c commit a716452
Show file tree
Hide file tree
Showing 13 changed files with 199 additions and 27 deletions.
19 changes: 13 additions & 6 deletions fastapi_cdn_host/client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import inspect
import logging
import re
from dataclasses import dataclass
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -298,15 +299,22 @@ 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)

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
Expand All @@ -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
Expand Down
1 change: 0 additions & 1 deletion scripts/parallel_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ def main() -> int:
raise exc
else:
res += rc % 255 # rc may be 256
print(f"{res = }")
return res


Expand Down
4 changes: 2 additions & 2 deletions tests/extend_choices/test_extend.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
CdnHostBuilder,
CdnHostEnum,
CdnHostItem,
HttpSpider,
HttpSniff,
)


Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/favicon_online_cdn/test_race_favicon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions tests/http_race/test_http_race.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand All @@ -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))]
32 changes: 32 additions & 0 deletions tests/lock_docs/main.py
Original file line number Diff line number Diff line change
@@ -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"
67 changes: 67 additions & 0 deletions tests/lock_docs/test_lock.py
Original file line number Diff line number Diff line change
@@ -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)
21 changes: 21 additions & 0 deletions tests/missing_js/main.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions tests/missing_js/static/swagger-ui.css
44 changes: 44 additions & 0 deletions tests/missing_js/test_missing_js.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 6 additions & 5 deletions tests/online_cdn/test_online_race.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/root_path_without_static/test_root_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/static_favicon_without_swagger_ui/test_favicon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down

0 comments on commit a716452

Please sign in to comment.