Skip to content

Commit

Permalink
feat: support private cdn
Browse files Browse the repository at this point in the history
  • Loading branch information
waketzheng committed Dec 14, 2023
1 parent 148d71b commit 7b54327
Show file tree
Hide file tree
Showing 16 changed files with 2,022 additions and 63 deletions.
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@
[![LatestVersionInPypi](https://img.shields.io/pypi/v/fastapi-cdn-host.svg?style=flat)](https://pypi.python.org/pypi/fastapi-cdn-host)
[![GithubActionResult](https://github.com/waketzheng/fastapi-cdn-host/workflows/ci/badge.svg)](https://github.com/waketzheng/fastapi-cdn-host/actions?query=workflow:ci)
[![Coverage Status](https://coveralls.io/repos/github/waketzheng/fastapi-cdn-host/badge.svg?branch=main)](https://coveralls.io/github/waketzheng/fastapi-cdn-host?branch=main)
![Mypy coverage](https://img.shields.io/badge/mypy-100%25-green.svg)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![security: bandit](https://img.shields.io/badge/security-bandit-yellow.svg)](https://github.com/PyCQA/bandit)

Auto find swagger-ui in local files, if exist use them.
Expand Down Expand Up @@ -75,6 +74,14 @@ async def redoc_html():
redoc_js_url="/static/redoc.standalone.js",
)
```
3. If asset files are ready in private cdn
```py
# Will render /docs with the following asset urls:
# http://my-cdn.com/swagger-ui@latest/swagger-ui-bundle.js
# http://my-cdn.com/swagger-ui@latest/swagger-ui.css
# render /redoc with: `http://my-cdn.com/redoc/next/redoc.standalone.js`
monkey_patch_for_docs_ui(app, cdn_host=('http://my-cdn.com', ('/swagger-ui@latest/', '/redoc/next/')))
```

## License

Expand Down
14 changes: 14 additions & 0 deletions README.zh-hans.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@ monkey_patch_for_docs_ui(app)
没有的话,使用协程并发对比https://cdn.jsdelivr.net、https://unpkg.com、https://cdnjs.cloudflare.com
三个CDN的响应速度,然后自动采用速度最快的那个。

## 其他CDN

- 参考:https://github.com/lecepin/blog/blob/main/%E5%9B%BD%E5%86%85%E9%AB%98%E9%80%9F%E5%89%8D%E7%AB%AF%20Unpkg%20CDN%20%E6%9B%BF%E4%BB%A3%E6%96%B9%E6%A1%88.md
```py
monkey_patch_for_docs_ui(
app,
cdn_host=(
'https://cdn.bootcdn.net/ajax/libs', ('/swagger-ui/{version}/', '') # BootCDN
# 'https://cdn.staticfile.org', ('/swagger-ui/{version}/', '') # 七牛云
# 'https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M', ('/swagger-ui/{version}/', '') # 字节
)
)
```

## 许可证

[MIT](./LICENSE)
96 changes: 60 additions & 36 deletions fastapi_cdn_host/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@
logger = logging.getLogger("fastapi-cdn-host")

DEFAULT_ASSET_PATH = ("/swagger-ui-dist@{version}/", "/redoc@next/bundles/")
OFFICIAL_REDOC = "https://cdn.redoc.ly/redoc/latest/bundles/"
CdnPathInfoType = Tuple[
Annotated[str, "swagger-ui module path info"],
Annotated[str, "redoc path or url info"],
Annotated[str, "swagger-ui module path info(must startswith '/')"],
Annotated[str, "redoc path or url info(must startswith '/')"],
]
CdnDomainType = Annotated[str, "Host for swagger-ui/redoc"]
CdnHostInfoType = Union[
Expand All @@ -28,6 +29,23 @@
]


class CdnHostEnum(Enum):
jsdelivr: CdnHostInfoType = "https://cdn.jsdelivr.net/npm"
unpkg: CdnHostInfoType = "https://unpkg.com"
cdnjs: CdnHostInfoType = "https://cdnjs.cloudflare.com/ajax/libs", (
"/swagger-ui/{version}/",
OFFICIAL_REDOC,
)


@dataclass
class AssetUrl:
css: Annotated[str, "URL of swagger-ui.css"]
js: Annotated[str, "URL of swagger-ui-bundle.js"]
redoc: Annotated[str, "URL of redoc.standalone.js"]
favicon: Annotated[Optional[str], "URL of favicon.png/favicon.ico"] = None


class HttpSpider:
@staticmethod
async def fetch(
Expand Down Expand Up @@ -66,22 +84,6 @@ async def find_fastest_host(
return urls[0]


class CdnHostEnum(Enum):
jsdelivr: CdnHostInfoType = "https://cdn.jsdelivr.net/npm"
unpkg: CdnHostInfoType = "https://unpkg.com"
cdnjs: CdnHostInfoType = "https://cdnjs.cloudflare.com/ajax/libs", (
"/swagger-ui/{version}/" "https://cdn.redoc.ly/redoc/latest/bundles/"
)


@dataclass
class AssetUrl:
css: Annotated[str, "URL of swagger-ui.css"]
js: Annotated[str, "URL of swagger-ui-bundle.js"]
redoc: Annotated[str, "URL of redoc.standalone.js"]
favicon: Annotated[Optional[str], "URL of favicon.png/favicon.ico"] = None


class CdnHostBuilder:
swagger_ui_version = "5.9.0" # to be optimize: auto get version from fastapi
swagger_files = {"css": "swagger-ui.css", "js": "swagger-ui-bundle.js"}
Expand All @@ -106,13 +108,22 @@ async def runner():
return result[0]

def run(self) -> AssetUrl:
static_builder = StaticBuilder(self.app, favicon_url=self.favicon_url)
if isinstance(self.docs_cdn_host, Path):
if (favicon := self.favicon_url) is not None:
favicon = self.mount_local_favicon(favicon)
static_builder = StaticBuilder(self.app, favicon_url=favicon)
if isinstance(cdn_host := self.docs_cdn_host, Path):
static_builder.static_root = self.docs_cdn_host
elif cdn_host is not None:
if isinstance(cdn_host, CdnHostEnum):
cdn_host = cdn_host.value
if isinstance(cdn_host, str):
return self.build_asset_url(cdn_host, favicon_url=favicon)
cdn_host, asset_path = cdn_host
if isinstance(asset_path, str):
asset_path = (asset_path, asset_path)
return self.build_asset_url(cdn_host, asset_path, favicon_url=favicon)
if urls := static_builder.find():
return urls
if (favicon := self.favicon_url) is not None:
favicon = self.mount_local_favicon(favicon)
return self.run_async(self.sniff_the_fastest, favicon)

@staticmethod
Expand All @@ -135,15 +146,15 @@ def build_race_data(
css_urls: List[str] = []
they: List[tuple] = []
for cdn_host in competitors:
host = getattr(cdn_host, "value", cdn_host)
path = DEFAULT_ASSET_PATH[0]
if isinstance(host, tuple):
they.append((host, path))
path = host[1][0]
host = host[0]
if isinstance(cdn_host, CdnHostEnum):
cdn_host = cdn_host.value
if isinstance(cdn_host, str):
host = cdn_host
asset_path: Union[str, CdnPathInfoType] = DEFAULT_ASSET_PATH
else:
they.append((host, DEFAULT_ASSET_PATH))
path = path.format(version=cls.swagger_ui_version)
host, asset_path = cdn_host
they.append((host, asset_path))
path = asset_path[0].format(version=cls.swagger_ui_version)
url = host + path + cls.swagger_files["css"]
css_urls.append(url)
return css_urls, they
Expand All @@ -153,14 +164,27 @@ async def sniff_the_fastest(cls, favicon_url=None) -> AssetUrl:
css_urls, they = cls.build_race_data(list(CdnHostEnum))
fast_css_url = await HttpSpider.find_fastest_host(css_urls)
fast_host, fast_asset_path = they[css_urls.index(fast_css_url)]
css = fast_css_url
swagger_ui_path = fast_asset_path[0].format(version=cls.swagger_ui_version)
js = fast_host + swagger_ui_path + cls.swagger_files["js"]
redoc_path = fast_asset_path[1]
logger.info(f"Select cdn: {fast_host[0]} to serve swagger css/js")
return cls.build_asset_url(
fast_host, fast_asset_path, fast_css_url, favicon_url
)

@classmethod
def build_asset_url(
cls,
cdn_host: str,
asset_path: Tuple[str, str] = DEFAULT_ASSET_PATH,
css: Optional[str] = None,
favicon_url: Optional[str] = None,
) -> AssetUrl:
swagger_ui_path = asset_path[0].format(version=cls.swagger_ui_version)
js = cdn_host + swagger_ui_path + cls.swagger_files["js"]
if css is None:
css = cdn_host + swagger_ui_path + cls.swagger_files["css"]
redoc_path = asset_path[1] or OFFICIAL_REDOC
if not redoc_path.startswith("http"):
redoc_path = fast_host + redoc_path
redoc_path = cdn_host + redoc_path
redoc = redoc_path + cls.redoc_file
logger.info(f"Select cdn: {fast_host[0]} to serve swagger css/js")
return AssetUrl(css=css, js=js, redoc=redoc, favicon=favicon_url)

def mount_local_favicon(self, favicon_url) -> Union[str, None]:
Expand Down
1 change: 1 addition & 0 deletions scripts/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ cd ../defined_root_path && coverage run -m pytest test_*.py
cd ../http_race && coverage run -m pytest test_*.py
cd ../root_path_without_static && coverage run -m pytest test_*.py
cd ../static_favicon_without_swagger_ui && coverage run -m pytest test_*.py
cd ../private_cdn && coverage run -m pytest test_*.py

cd ../.. && coverage combine tests/*/.coverage
coverage report -m
25 changes: 1 addition & 24 deletions tests/http_race/test_http_race.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,14 @@
# mypy: no-disallow-untyped-decorators
import contextlib
import threading
import time

import pytest
import uvicorn
from httpx import AsyncClient
from main import app
from utils import UvicornServer

from fastapi_cdn_host.client import HttpSpider


class UvicornServer(uvicorn.Server):
def __init__(self, *args, **kw):
super().__init__(config=uvicorn.Config("main:app"))

def install_signal_handlers(self):
pass

@contextlib.contextmanager
def run_in_thread(self):
thread = threading.Thread(target=self.run)
thread.daemon = True
thread.start()
try:
while not self.started:
time.sleep(1e-3)
yield
finally:
self.should_exit = True
thread.join()


@pytest.fixture(scope="module")
def anyio_backend():
return "asyncio"
Expand Down
27 changes: 27 additions & 0 deletions tests/http_race/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# mypy: no-disallow-untyped-decorators
import contextlib
import threading
import time

import uvicorn


class UvicornServer(uvicorn.Server):
def __init__(self, app="main:app", **kw):
super().__init__(config=uvicorn.Config(app, **kw))

def install_signal_handlers(self):
pass

@contextlib.contextmanager
def run_in_thread(self):
thread = threading.Thread(target=self.run)
thread.daemon = True
thread.start()
try:
while not self.started:
time.sleep(1e-3)
yield
finally:
self.should_exit = True
thread.join()
1,820 changes: 1,820 additions & 0 deletions tests/private_cdn/cdn/redoc/next/redoc.standalone.js

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions tests/private_cdn/cdn/swagger-ui@latest/swagger-ui-bundle.js

Large diffs are not rendered by default.

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions tests/private_cdn/cdn/swagger-ui@latest/swagger-ui.css

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions tests/private_cdn/cdn/swagger-ui@latest/swagger-ui.css.map

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions tests/private_cdn/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
PORT = 8617
MY_CDN = f"http://127.0.0.1:{PORT}/cdn"
25 changes: 25 additions & 0 deletions tests/private_cdn/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env python
from config import MY_CDN
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse

from fastapi_cdn_host import monkey_patch_for_docs_ui

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)}


monkey_patch_for_docs_ui(
app,
docs_cdn_host=(MY_CDN, ("/swagger-ui@latest/", "/redoc/next/")),
favicon_url=MY_CDN + "/favicon.ico",
)
8 changes: 8 additions & 0 deletions tests/private_cdn/media_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from pathlib import Path

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles

app = FastAPI()
STATIC_ROOT = Path(__file__).parent.parent / "static_auto" / "static"
app.mount("/cdn", StaticFiles(directory=STATIC_ROOT), name="cdn")
47 changes: 47 additions & 0 deletions tests/private_cdn/test_private_cdn.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# mypy: no-disallow-untyped-decorators
import sys
from pathlib import Path

import pytest
from config import MY_CDN, PORT
from httpx import AsyncClient
from main import app

try:
from tests.http_race.utils import UvicornServer
except ImportError:
_path = Path(__file__).parent.parent / "http_race"
sys.path.append(_path.as_posix())
from utils import UvicornServer # type: ignore[no-redef]


@pytest.fixture(scope="module")
def anyio_backend():
return "asyncio"


@pytest.fixture(scope="module")
async def client():
async with AsyncClient(app=app, base_url="http://test") as c:
yield c


@pytest.mark.anyio
async def test_docs(client: AsyncClient): # nosec
css_url = MY_CDN + "/swagger-ui@latest/swagger-ui.css"
js_url = MY_CDN + "/swagger-ui@latest/swagger-ui-bundle.js"
redoc_url = MY_CDN + "/redoc/next/redoc.standalone.js"
favicon_url = MY_CDN + "/favicon.ico"
with UvicornServer("media_server:app", port=PORT).run_in_thread():
response = await client.get("/docs")
text = response.text
assert response.status_code == 200, text
assert f'"{favicon_url}"' in text
assert f'"{css_url}"' in text
assert f'"{js_url}"' in text
response = await client.get("/redoc")
text = response.text
assert response.status_code == 200, text
assert f'"{redoc_url}"' in text
response = await client.get("/app")
assert response.status_code == 200
1 change: 0 additions & 1 deletion tests/static_mounted/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
#!/usr/bin/env python
from pathlib import Path

from fastapi import FastAPI, Request
Expand Down

0 comments on commit 7b54327

Please sign in to comment.