Files
ray-project--ray/python/ray/serve/tests/test_fastapi.py
T
2026-07-13 13:17:40 +08:00

1329 lines
39 KiB
Python

import inspect
import tempfile
import time
from typing import Any, List, Optional
import httpx
import pytest
import starlette.responses
from fastapi import (
APIRouter,
BackgroundTasks,
Cookie,
Depends,
FastAPI,
Header,
Query,
Request,
Response,
params as fastapi_params,
)
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from starlette.applications import Starlette
from starlette.routing import Route
import ray
from ray import serve
from ray._common.test_utils import SignalActor, wait_for_condition
from ray.exceptions import GetTimeoutError
from ray.serve._private.client import ServeControllerClient
from ray.serve._private.constants import (
SERVE_DEFAULT_APP_NAME,
)
from ray.serve._private.http_util import (
_walk_fastapi_routes,
make_fastapi_class_based_view,
)
from ray.serve._private.test_utils import get_application_url
from ray.serve.exceptions import RayServeException
from ray.serve.handle import DeploymentHandle
def test_fastapi_function(serve_instance):
app = FastAPI()
@app.get("/{a}")
def func(a: int):
return {"result": a}
@serve.deployment
@serve.ingress(app)
class FastAPIApp:
pass
serve.run(FastAPIApp.bind())
url = get_application_url("HTTP")
resp = httpx.get(f"{url}/100")
assert resp.json() == {"result": 100}
resp = httpx.get(f"{url}/not-number")
assert resp.status_code == 422 # Unprocessable Entity
# Pydantic 1.X returns `type_error.integer`, 2.X returns `int_parsing`.
assert resp.json()["detail"][0]["type"] in {"type_error.integer", "int_parsing"}
def test_ingress_prefix(serve_instance):
app = FastAPI()
@app.get("/{a}")
def func(a: int):
return {"result": a}
@serve.deployment
@serve.ingress(app)
class App:
pass
serve.run(App.bind(), route_prefix="/api")
url = get_application_url("HTTP")
resp = httpx.get(f"{url}/100")
assert resp.json() == {"result": 100}
def test_class_based_view(serve_instance):
app = FastAPI()
@app.get("/other")
def hello():
return "hello"
@serve.deployment
@serve.ingress(app)
class A:
def __init__(self):
self.val = 1
@app.get("/calc/{i}")
def b(self, i: int):
return i + self.val
@app.post("/calc/{i}")
def c(self, i: int):
return i - self.val
def other(self, msg: str):
return msg
serve.run(A.bind())
# Test HTTP calls.
url = get_application_url("HTTP")
resp = httpx.get(f"{url}/calc/41")
assert resp.json() == 42
resp = httpx.post(f"{url}/calc/41")
assert resp.json() == 40
resp = httpx.get(f"{url}/other")
assert resp.json() == "hello"
# Test handle calls.
handle = serve.get_app_handle(SERVE_DEFAULT_APP_NAME)
assert handle.b.remote(41).result() == 42
assert handle.c.remote(41).result() == 40
assert handle.other.remote("world").result() == "world"
def _find_route(app, endpoint):
"""Return the route whose endpoint is ``endpoint``, searching nested routers.
FastAPI >= 0.137 nests routes registered via ``include_router`` under
``_IncludedRouter`` nodes, so we can't rely on flat ``app.routes`` indexing.
"""
for route, _parent, _prefix in _walk_fastapi_routes(app):
if route.endpoint == endpoint:
return route
raise AssertionError(f"route for {endpoint} not found")
@pytest.mark.parametrize("websocket", [False, True])
@pytest.mark.parametrize("via_router", [False, True])
def test_make_fastapi_class_based_view(websocket: bool, via_router: bool):
app = FastAPI()
router = APIRouter(prefix="/prefix")
# When `via_router` is set, register the endpoint through `include_router`.
# On FastAPI >= 0.137 such routes are nested under an `_IncludedRouter`,
# which previously hid them from the class-based-view transform (#64475).
target = router if via_router else app
if websocket:
class A:
@target.websocket("/{i}")
def b(self, i: int):
pass
else:
class A:
@target.get("/{i}")
def b(self, i: int):
pass
if via_router:
app.include_router(router)
# before, "self" is treated as a query param.
route = _find_route(app, A.b)
assert route.dependant.query_params[0].name == "self"
assert len(route.dependant.dependencies) == 0
make_fastapi_class_based_view(app, A)
# After the transform "self" is injected via a dependency. We assert on the
# endpoint signature (which the transform rewrites directly) rather than on
# `route.dependant`: FastAPI >= 0.137 recomputes the dependant lazily at
# request time for routes registered via `include_router`, so the route
# object's cached `dependant` may still reflect the pre-transform signature.
params = list(inspect.signature(A.b).parameters.values())
assert params[0].name == "self"
assert isinstance(params[0].default, fastapi_params.Depends)
assert "get_current_servable" in str(params[0].default.dependency)
# Remaining params become keyword-only since `self` is no longer positional.
assert all(p.kind == inspect.Parameter.KEYWORD_ONLY for p in params[1:]), [
(p.name, p.kind) for p in params[1:]
]
class Nested(BaseModel):
val: int
class BodyType(BaseModel):
name: str
price: float = Field(None, gt=1.0, description="High price!")
nests: Nested
class RespModel(BaseModel):
ok: bool
vals: List[Any]
file_path: str
def test_fastapi_features(serve_instance):
app = FastAPI(openapi_url="/my_api.json")
@app.on_event("startup")
def inject_state():
app.state.state_one = "app.state"
@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
start_time = time.time()
response = await call_next(request)
process_time = time.time() - start_time
response.headers["X-Process-Time"] = str(process_time)
return response
async def yield_db():
yield "db"
async def common_parameters(q: Optional[str] = None):
return {"q": q}
@app.exception_handler(ValueError)
async def custom_handler(_: Request, exc: ValueError):
return JSONResponse(
status_code=500, content={"custom_error": "true", "message": str(exc)}
)
def run_background(background_tasks: BackgroundTasks):
_, path = tempfile.mkstemp()
def write_to_file(p):
with open(p, "w") as f:
f.write("hello")
background_tasks.add_task(write_to_file, path)
return path
app.add_middleware(CORSMiddleware, allow_origins="*")
@app.get("/{path_arg}", response_model=RespModel, status_code=201)
async def func(
path_arg: str,
query_arg: str,
body_val: BodyType,
backgrounds_tasks: BackgroundTasks,
do_error: bool = False,
query_arg_valid: Optional[str] = Query(None, min_length=3),
cookie_arg: Optional[str] = Cookie(None),
user_agent: Optional[str] = Header(None),
commons: dict = Depends(common_parameters),
db=Depends(yield_db),
):
if do_error:
raise ValueError("bad input")
path = run_background(backgrounds_tasks)
return RespModel(
ok=True,
vals=[
path_arg,
query_arg,
body_val.price,
body_val.nests.val,
do_error,
query_arg_valid,
cookie_arg,
user_agent.split("/")[0], # returns python-requests
commons,
db,
app.state.state_one,
],
file_path=path,
)
router = APIRouter(prefix="/prefix")
@router.get("/subpath")
def router_path():
return "ok"
app.include_router(router)
@serve.deployment
@serve.ingress(app)
class Worker:
pass
serve.run(Worker.bind())
url = get_application_url("HTTP")
resp = httpx.get(f"{url}/")
assert resp.status_code == 404
assert "x-process-time" in resp.headers
resp = httpx.get(f"{url}/my_api.json")
assert resp.status_code == 200
assert resp.json() # it returns a well-formed json.
resp = httpx.get(f"{url}/docs")
assert resp.status_code == 200
assert "<!DOCTYPE html>" in resp.text
resp = httpx.get(f"{url}/redoc")
assert resp.status_code == 200
assert "<!DOCTYPE html>" in resp.text
resp = httpx.get(f"{url}/path_arg")
assert resp.status_code == 422 # Malformed input
# Including a body in a GET request is against HTTP/1.1
# spec (RFC 7231) and is discouraged, even though some
# servers/libraries may accept it.
resp = httpx.request(
"GET",
f"{url}/path_arg",
json={"name": "serve", "price": 12, "nests": {"val": 1}},
params={
"query_arg": "query_arg",
"query_arg_valid": "at-least-three-chars",
"q": "common_arg",
},
)
assert resp.status_code == 201, resp.text
assert resp.json()["ok"]
assert resp.json()["vals"] == [
"path_arg",
"query_arg",
12.0,
1,
False,
"at-least-three-chars",
None,
"python-httpx",
{"q": "common_arg"},
"db",
"app.state",
]
wait_for_condition(
lambda: open(resp.json()["file_path"]).read() == "hello",
timeout=10,
)
resp = httpx.request(
"GET",
f"{url}/path_arg",
json={"name": "serve", "price": 12, "nests": {"val": 1}},
params={
"query_arg": "query_arg",
"query_arg_valid": "at-least-three-chars",
"q": "common_arg",
"do_error": "true",
},
)
assert resp.status_code == 500
assert resp.json()["custom_error"] == "true"
resp = httpx.get(f"{url}/prefix/subpath")
assert resp.status_code == 200
resp = httpx.get(
f"{url}/docs",
headers={
"Access-Control-Request-Method": "GET",
"Origin": "https://googlebot.com",
},
)
assert resp.headers["access-control-allow-origin"] == "*", resp.headers
def test_fast_api_mounted_app(serve_instance):
app = FastAPI()
subapp = FastAPI()
@subapp.get("/hi")
def hi():
return "world"
app.mount("/mounted", subapp)
@serve.deployment
@serve.ingress(app)
class A:
pass
serve.run(A.bind(), route_prefix="/api")
url = get_application_url("HTTP")
assert httpx.get(f"{url}/mounted/hi").json() == "world"
def test_fastapi_init_lifespan_should_not_shutdown(serve_instance):
app = FastAPI()
@app.on_event("shutdown")
async def shutdown():
_ = 1 / 0
@serve.deployment
@serve.ingress(app)
class A:
def f(self):
return 1
handle = serve.run(A.bind())
# Without a proper fix, the actor won't be initialized correctly.
# Because it will crash on each startup.
assert handle.f.remote().result() == 1
def test_fastapi_lifespan_startup_failure_crashes_actor(serve_instance):
async def lifespan(app):
raise Exception("crash")
yield
app = FastAPI(lifespan=lifespan)
@serve.deployment
@serve.ingress(app)
class A:
pass
with pytest.raises(RuntimeError):
serve.run(A.bind())
def test_fastapi_duplicate_routes(serve_instance):
app = FastAPI()
@serve.deployment
@serve.ingress(app)
class App1:
@app.get("/")
def func_v1(self):
return "first"
@serve.deployment
@serve.ingress(app)
class App2:
@app.get("/")
def func_v2(self):
return "second"
@app.get("/ignored")
def ignored():
pass
serve.run(App1.bind(), name="app1", route_prefix="/api/v1")
serve.run(App2.bind(), name="app2", route_prefix="/api/v2")
app1_url = get_application_url("HTTP", app_name="app1")
app2_url = get_application_url("HTTP", app_name="app2")
resp = httpx.get(app1_url, follow_redirects=True)
assert resp.json() == "first"
resp = httpx.get(app2_url, follow_redirects=True)
assert resp.json() == "second"
for version in [app1_url, app2_url]:
resp = httpx.get(f"{version}/ignored")
assert resp.status_code == 404
def test_asgi_compatible(serve_instance):
async def homepage(_):
return starlette.responses.JSONResponse({"hello": "world"})
app = Starlette(routes=[Route("/", homepage)])
@serve.deployment
@serve.ingress(app)
class MyApp:
pass
serve.run(MyApp.bind())
url = get_application_url("HTTP")
resp = httpx.get(url)
assert resp.json() == {"hello": "world"}
@pytest.mark.parametrize(
"input_route_prefix,expected_route_prefix",
[("/", "/"), ("/subpath", "/subpath/")],
)
def test_doc_generation(serve_instance, input_route_prefix, expected_route_prefix):
app = FastAPI()
@serve.deployment
@serve.ingress(app)
class App:
@app.get("/")
def func1(self, arg: str):
return "hello"
serve.run(App.bind(), route_prefix=input_route_prefix)
url = get_application_url("HTTP")
assert expected_route_prefix.rstrip("/") in url
r = httpx.get(f"{url}/openapi.json")
assert r.status_code == 200
assert len(r.json()["paths"]) == 1
assert "/" in r.json()["paths"]
assert len(r.json()["paths"]["/"]) == 1
assert "get" in r.json()["paths"]["/"]
r = httpx.get(f"{url}/docs")
assert r.status_code == 200
@serve.deployment
@serve.ingress(app)
class App:
@app.get("/")
def func1(self, arg: str):
return "hello"
@app.post("/hello")
def func2(self, arg: int):
return "hello"
serve.run(App.bind(), route_prefix=input_route_prefix)
url = get_application_url("HTTP")
assert expected_route_prefix.rstrip("/") in url
r = httpx.get(f"{url}/openapi.json")
assert r.status_code == 200
assert len(r.json()["paths"]) == 2
assert "/" in r.json()["paths"]
assert len(r.json()["paths"]["/"]) == 1
assert "get" in r.json()["paths"]["/"]
assert "/hello" in r.json()["paths"]
assert len(r.json()["paths"]["/hello"]) == 1
assert "post" in r.json()["paths"]["/hello"]
r = httpx.get(f"{url}/docs")
assert r.status_code == 200
def test_fastapi_multiple_headers(serve_instance):
# https://fastapi.tiangolo.com/advanced/response-cookies/
app = FastAPI()
@app.get("/")
def func(resp: Response):
resp.set_cookie(key="a", value="b")
resp.set_cookie(key="c", value="d")
return "hello"
@serve.deployment
@serve.ingress(app)
class FastAPIApp:
pass
serve.run(FastAPIApp.bind())
url = get_application_url("HTTP")
resp = httpx.get(url)
assert dict(resp.cookies) == {"a": "b", "c": "d"}
class TestModel(BaseModel):
a: str
# https://github.com/ray-project/ray/issues/16757
b: List[str]
def test_fastapi_nested_field_in_response_model(serve_instance):
app = FastAPI()
@app.get("/", response_model=TestModel)
def test_endpoint():
test_model = TestModel(a="a", b=["b"])
return test_model
@serve.deployment
@serve.ingress(app)
class TestDeployment:
# https://github.com/ray-project/ray/issues/17363
@app.get("/inner", response_model=TestModel)
def test_endpoint_2(self):
test_model = TestModel(a="a", b=["b"])
return test_model
# https://github.com/ray-project/ray/issues/24710
@app.get("/inner2", response_model=List[TestModel])
def test_endpoint_3(self):
test_model = TestModel(a="a", b=["b"])
return [test_model]
serve.run(TestDeployment.bind())
url = get_application_url("HTTP")
resp = httpx.get(url)
assert resp.json() == {"a": "a", "b": ["b"]}
resp = httpx.get(f"{url}/inner")
assert resp.json() == {"a": "a", "b": ["b"]}
resp = httpx.get(f"{url}/inner2")
assert resp.json() == [{"a": "a", "b": ["b"]}]
def test_fastapiwrapper_constructor_before_startup_hooks(serve_instance):
"""
Tests that the class constructor is called before the startup hooks
are run in FastAPIWrapper. SignalActor event is set from a startup hook
and is awaited in the class constructor. If the class constructor is run
before the startup hooks, the SignalActor event will time out while waiting
and the test will pass.
"""
app = FastAPI()
signal = SignalActor.remote()
@app.on_event("startup")
def startup_event():
ray.get(signal.send.remote())
@serve.deployment
@serve.ingress(app)
class TestDeployment:
def __init__(self):
self.test_passed = False
try:
ray.get(signal.wait.remote(), timeout=0.1)
self.test_passed = False
except GetTimeoutError:
self.test_passed = True
@app.get("/")
def root(self):
return self.test_passed
serve.run(TestDeployment.bind())
url = get_application_url("HTTP")
resp = httpx.get(url)
assert resp.json()
def test_fastapi_shutdown_hook(serve_instance):
# https://github.com/ray-project/ray/issues/18349
shutdown_signal = SignalActor.remote()
del_signal = SignalActor.remote()
app = FastAPI()
@app.on_event("shutdown")
def call_signal():
shutdown_signal.send.remote()
@serve.deployment
@serve.ingress(app)
class A:
def __del__(self):
del_signal.send.remote()
serve.run(A.bind())
serve.delete(SERVE_DEFAULT_APP_NAME)
ray.get(shutdown_signal.wait.remote(), timeout=20)
ray.get(del_signal.wait.remote(), timeout=20)
def test_fastapi_shutdown_hook_async(serve_instance):
# https://github.com/ray-project/ray/issues/41261
shutdown_signal = SignalActor.remote()
del_signal = SignalActor.remote()
app = FastAPI()
@app.on_event("shutdown")
def call_signal():
shutdown_signal.send.remote()
@serve.deployment
@serve.ingress(app)
class A:
async def __del__(self):
del_signal.send.remote()
serve.run(A.bind())
serve.delete(SERVE_DEFAULT_APP_NAME)
ray.get(shutdown_signal.wait.remote(), timeout=20)
ray.get(del_signal.wait.remote(), timeout=20)
def test_fastapi_method_redefinition(serve_instance):
app = FastAPI()
@serve.deployment
@serve.ingress(app)
class A:
@app.get("/")
def method(self):
return "hi get"
@app.post("/") # noqa: F811 method redefinition
def method(self): # noqa: F811 method redefinition
return "hi post"
serve.run(A.bind(), route_prefix="/a")
url = get_application_url("HTTP")
assert httpx.get(f"{url}/").json() == "hi get"
assert httpx.post(f"{url}/").json() == "hi post"
def test_fastapi_same_app_multiple_deployments(serve_instance):
# https://github.com/ray-project/ray/issues/21264
app = FastAPI()
@serve.deployment
@serve.ingress(app)
class CounterDeployment1:
@app.get("/incr")
def incr(self):
return "incr"
@app.get("/decr")
def decr(self):
return "decr"
@serve.deployment
@serve.ingress(app)
class CounterDeployment2:
@app.get("/incr2")
def incr2(self):
return "incr2"
@app.get("/decr2")
def decr2(self):
return "decr2"
serve.run(CounterDeployment1.bind(), name="app1", route_prefix="/app1")
serve.run(CounterDeployment2.bind(), name="app2", route_prefix="/app2")
app1_url = get_application_url("HTTP", app_name="app1")
app2_url = get_application_url("HTTP", app_name="app2")
should_work = [
(app1_url, "/incr", "incr"),
(app1_url, "/decr", "decr"),
(app2_url, "/incr2", "incr2"),
(app2_url, "/decr2", "decr2"),
]
for url, path, resp in should_work:
assert httpx.get(f"{url}{path}").json() == resp, (path, resp)
should_404 = [
(app1_url, "/incr2", 404),
(app1_url, "/decr2", 404),
(app2_url, "/incr", 404),
(app2_url, "/decr", 404),
]
for url, path, status_code in should_404:
assert httpx.get(f"{url}{path}").status_code == status_code, (path, status_code)
@pytest.mark.parametrize("two_fastapi", [True, False])
@pytest.mark.parametrize("docs_url", ["/docs", None])
def test_two_fastapi_in_one_application(
serve_instance: ServeControllerClient, two_fastapi, docs_url
):
"""
Check that a deployment graph that would normally work, will not deploy
successfully if there are two FastAPI deployments.
"""
app1 = FastAPI(docs_url=docs_url)
app2 = FastAPI(docs_url=docs_url)
class SubModel:
def add(self, a: int):
return a + 1
@serve.deployment
@serve.ingress(app1)
class Model:
def __init__(self, submodel: DeploymentHandle):
self.submodel = submodel
@app1.get("/{a}")
async def func(self, a: int):
return await self.submodel.add.remote(a)
if two_fastapi:
SubModel = serve.deployment(serve.ingress(app2)(SubModel))
with pytest.raises(RayServeException) as e:
handle = serve.run(Model.bind(SubModel.bind()), name="app1")
assert "FastAPI" in str(e.value)
else:
handle = serve.run(Model.bind(serve.deployment(SubModel).bind()), name="app1")
assert handle.func.remote(5).result() == 6
@pytest.mark.parametrize(
"is_fastapi,docs_path",
[
(False, None), # Not integrated with FastAPI
(True, "/docs"), # Don't specify docs_url, use default
(True, "/documentation"), # Override default docs url
],
)
def test_fastapi_docs_path(
serve_instance: ServeControllerClient, is_fastapi, docs_path
):
# If not the default docs_url, override it.
if docs_path != "/docs":
app = FastAPI(docs_url=docs_path)
else:
app = FastAPI()
class Model:
@app.get("/{a}")
def func(a: int):
return {"result": a}
if is_fastapi:
Model = serve.ingress(app)(Model)
serve.run(serve.deployment(Model).bind(), name="app1")
wait_for_condition(
lambda: ray.get(serve_instance._controller.get_docs_path.remote("app1"))
== docs_path
)
def fastapi_builder():
app = FastAPI(docs_url="/custom-docs")
@app.get("/")
def f1():
return "hello"
router = APIRouter()
@router.get("/f2")
def f2():
return "hello f2"
@router.get("/error")
def error():
raise ValueError("some error")
app.include_router(router)
# add a middleware
@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
response = await call_next(request)
response.headers["X-Custom-Middleware"] = "fake-middleware"
return response
# custom exception handler
@app.exception_handler(ValueError)
async def custom_exception_handler(request: Request, exc: ValueError):
return JSONResponse(status_code=500, content={"error": "fake-error"})
return app
def test_ingress_with_fastapi_routes_outside_deployment(serve_instance):
app = fastapi_builder()
@serve.deployment
@serve.ingress(app)
class ASGIIngress:
@app.get("/class_route")
def class_route(self):
return "hello class route"
serve.run(ASGIIngress.bind())
url = get_application_url("HTTP")
assert httpx.get(url).json() == "hello"
assert httpx.get(f"{url}/f2").json() == "hello f2"
assert httpx.get(f"{url}/class_route").json() == "hello class route"
assert httpx.get(f"{url}/error").status_code == 500
assert httpx.get(f"{url}/error").json() == {"error": "fake-error"}
# get the docs path from the controller
docs_path = ray.get(serve_instance._controller.get_docs_path.remote("default"))
assert docs_path == "/custom-docs"
def test_ingress_with_fastapi_with_no_deployment_class(serve_instance):
app = fastapi_builder()
ingress_deployment = serve.deployment(serve.ingress(app)())
assert ingress_deployment.name == "ASGIIngressDeployment"
serve.run(ingress_deployment.bind())
url = get_application_url("HTTP")
assert httpx.get(url).json() == "hello"
assert httpx.get(f"{url}/f2").json() == "hello f2"
assert httpx.get(f"{url}/error").status_code == 500
assert httpx.get(f"{url}/error").json() == {"error": "fake-error"}
# get the docs path from the controller
docs_path = ray.get(serve_instance._controller.get_docs_path.remote("default"))
assert docs_path == "/custom-docs"
def test_ingress_with_fastapi_builder_function(serve_instance):
ingress_deployment = serve.deployment(serve.ingress(fastapi_builder)())
serve.run(ingress_deployment.bind())
url = get_application_url("HTTP")
resp = httpx.get(url)
assert resp.json() == "hello"
assert resp.headers["X-Custom-Middleware"] == "fake-middleware"
resp = httpx.get(f"{url}/f2")
assert resp.json() == "hello f2"
assert resp.headers["X-Custom-Middleware"] == "fake-middleware"
resp = httpx.get(f"{url}/error")
assert resp.status_code == 500
assert resp.json() == {"error": "fake-error"}
docs_path = ray.get(serve_instance._controller.get_docs_path.remote("default"))
assert docs_path == "/custom-docs"
def test_ingress_with_fastapi_builder_with_deployment_class(serve_instance):
@serve.deployment
@serve.ingress(fastapi_builder)
class ASGIIngress:
def __init__(self):
pass
serve.run(ASGIIngress.bind())
url = get_application_url("HTTP")
resp = httpx.get(url)
assert resp.json() == "hello"
resp = httpx.get(f"{url}/f2")
assert resp.json() == "hello f2"
resp = httpx.get(f"{url}/error")
assert resp.status_code == 500
assert resp.json() == {"error": "fake-error"}
# get the docs path from the controller
docs_path = ray.get(serve_instance._controller.get_docs_path.remote("default"))
assert docs_path == "/custom-docs"
def test_ingress_with_fastapi_with_native_deployment(serve_instance):
app = fastapi_builder()
class ASGIIngress:
def __call__(self):
pass
with pytest.raises(ValueError) as e:
serve.ingress(app)(ASGIIngress)
assert "Classes passed to @serve.ingress may not have __call__ method." in str(
e.value
)
def sub_deployment():
@serve.deployment
class SubModel:
def __call__(self, a: int):
return a + 1
return SubModel.options(name="sub_deployment")
def fastapi_builder_with_sub_deployment():
app = fastapi_builder()
def get_sub_deployment_handle():
return serve.get_deployment_handle(sub_deployment().name, "default")
class Data(BaseModel):
a: int
@app.get("/sub_deployment", response_model=Data)
async def f(
request: Request, handle: DeploymentHandle = Depends(get_sub_deployment_handle)
):
a = int(request.query_params.get("a", 1))
result = await handle.remote(a)
return Data(a=result)
return app
def test_deployment_composition_with_builder_function(serve_instance):
@serve.deployment
@serve.ingress(fastapi_builder_with_sub_deployment)
class ASGIIngress:
def __init__(self, sub_deployment: DeploymentHandle):
self.sub_deployment = sub_deployment
serve.run(ASGIIngress.bind(sub_deployment().bind()))
url = get_application_url("HTTP")
resp = httpx.get(f"{url}/sub_deployment?a=2")
assert resp.json() == {"a": 3}
def test_deployment_composition_with_builder_function_without_decorator(serve_instance):
app = serve.deployment(serve.ingress(fastapi_builder_with_sub_deployment)())
# the default ingress deployment returned from serve.ingress accepts args and kwargs
# and passes them to the deployment constructor
serve.run(app.bind(sub_deployment().bind()))
url = get_application_url("HTTP")
resp = httpx.get(f"{url}/sub_deployment?a=2")
assert resp.json() == {"a": 3}
def starlette_builder():
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from starlette.routing import Route, Router
# Define route handlers
async def homepage(request):
return JSONResponse("hello")
async def f2(request):
return JSONResponse("hello f2")
async def error(request):
raise ValueError("some error")
# Create a router for additional routes
router = Router(
[
Route("/f2", f2),
Route("/error", error),
]
)
# Create a middleware for adding headers
class CustomHeaderMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
response = await call_next(request)
response.headers["X-Custom-Middleware"] = "fake-middleware"
return response
# Custom exception handler for ValueError
def handle_value_error(request, exc):
return JSONResponse(status_code=500, content={"error": "fake-error"})
exception_handlers = {ValueError: handle_value_error}
# Configure routes for the main app
routes = [
Route("/", homepage),
]
# Create the Starlette app with middleware and exception handlers
app = Starlette(
routes=routes,
middleware=[Middleware(CustomHeaderMiddleware)],
exception_handlers=exception_handlers,
)
# Mount the router to the main app
app.mount("/", router)
return app
def test_ingress_with_starlette_app_with_no_deployment_class(serve_instance):
ingress_deployment = serve.deployment(serve.ingress(starlette_builder())())
serve.run(ingress_deployment.bind())
url = get_application_url("HTTP")
resp = httpx.get(url)
assert resp.json() == "hello"
assert resp.headers["X-Custom-Middleware"] == "fake-middleware"
resp = httpx.get(f"{url}/f2")
assert resp.json() == "hello f2"
assert resp.headers["X-Custom-Middleware"] == "fake-middleware"
resp = httpx.get(f"{url}/error")
assert resp.status_code == 500
assert resp.json() == {"error": "fake-error"}
docs_path = ray.get(serve_instance._controller.get_docs_path.remote("default"))
assert docs_path is None
def test_ingress_with_starlette_builder_with_no_deployment_class(serve_instance):
ingress_deployment = serve.deployment(serve.ingress(starlette_builder)())
serve.run(ingress_deployment.bind())
url = get_application_url("HTTP")
resp = httpx.get(url)
assert resp.json() == "hello"
assert resp.headers["X-Custom-Middleware"] == "fake-middleware"
resp = httpx.get(f"{url}/f2")
assert resp.json() == "hello f2"
assert resp.headers["X-Custom-Middleware"] == "fake-middleware"
resp = httpx.get(f"{url}/error")
assert resp.status_code == 500
assert resp.json() == {"error": "fake-error"}
docs_path = ray.get(serve_instance._controller.get_docs_path.remote("default"))
assert docs_path is None
def test_ingress_with_starlette_builder_with_deployment_class(serve_instance):
@serve.deployment
@serve.ingress(starlette_builder)
class ASGIIngress:
def __init__(self):
pass
serve.run(ASGIIngress.bind())
url = get_application_url("HTTP")
resp = httpx.get(url)
assert resp.json() == "hello"
assert resp.headers["X-Custom-Middleware"] == "fake-middleware"
resp = httpx.get(f"{url}/f2")
assert resp.json() == "hello f2"
assert resp.headers["X-Custom-Middleware"] == "fake-middleware"
resp = httpx.get(f"{url}/error")
assert resp.status_code == 500
assert resp.json() == {"error": "fake-error"}
docs_path = ray.get(serve_instance._controller.get_docs_path.remote("default"))
assert docs_path is None
def test_ingress_multi_level_inheritance(serve_instance):
"""Test multi-level inheritance works correctly with serve.ingress.
Tests: Grandparent -> Parent -> Child -> ServedIngress
This tests the fix in make_fastapi_class_based_view that properly handles
inherited methods by checking the MRO instead of just the immediate class.
Without the fix, inherited endpoints would fail with:
'Field required at ('query', 'self')'
Also tests that unrelated classes whose names CONTAIN an MRO class name
as a substring don't cause false positives. For example, if MRO contains
"Child", an unrelated class "ChildExtra" should not have its routes matched
because "Child" in "ChildExtra.method" would be True with substring matching.
This validates that the matching uses prefix matching with a dot delimiter.
"""
app = FastAPI()
class Grandparent:
@app.get("/grandparent")
def grandparent_endpoint(self):
return {"level": "grandparent"}
class Parent(Grandparent):
@app.get("/parent")
def parent_endpoint(self):
return {"level": "parent"}
class Child(Parent):
@app.get("/child")
def child_endpoint(self):
return {"level": "child"}
# Unrelated class whose name CONTAINS "Child" (an MRO class) as a substring.
# With substring matching, "Child" in "ChildExtra.extra_endpoint" would be
# True, causing incorrect self injection into this unrelated route.
class ChildExtra:
@app.get("/extra")
def extra_endpoint(self):
# This should NOT have self injection since ChildExtra is not in MRO
return {"level": "extra"}
@serve.deployment
@serve.ingress(app)
class ServedIngress(Child):
pass
serve.run(ServedIngress.bind())
url = get_application_url("HTTP")
# Test all inherited endpoints
resp = httpx.get(f"{url}/grandparent")
assert resp.status_code == 200, f"Grandparent failed: {resp.text}"
assert resp.json() == {"level": "grandparent"}
resp = httpx.get(f"{url}/parent")
assert resp.status_code == 200, f"Parent failed: {resp.text}"
assert resp.json() == {"level": "parent"}
resp = httpx.get(f"{url}/child")
assert resp.status_code == 200, f"Child failed: {resp.text}"
assert resp.json() == {"level": "child"}
# Test that the unrelated ChildExtra route is NOT processed.
# With substring matching bug, "Child" in "ChildExtra.extra_endpoint" would
# be True, incorrectly applying self injection and making this route work.
# With the fix, the route is correctly excluded from processing, so `self`
# is not injected. FastAPI then treats `self` as a required query parameter,
# which fails with 422 (unprocessable entity).
resp = httpx.get(f"{url}/extra")
assert resp.status_code == 422, f"Expected 422 for unprocessed route: {resp.text}"
def test_ingress_direct_inheritance(serve_instance):
"""Test direct inheritance works correctly with serve.ingress.
Tests: BaseIngress -> DirectServedIngress (with own endpoint)
This tests the fix in make_fastapi_class_based_view that properly handles
inherited methods by checking the MRO instead of just the immediate class.
Without the fix, inherited endpoints would fail with:
'Field required at ('query', 'self')'
"""
app = FastAPI()
class BaseIngress:
@app.get("/base")
def base_endpoint(self):
return {"level": "base"}
@serve.deployment
@serve.ingress(app)
class DirectServedIngress(BaseIngress):
@app.get("/direct")
def direct_endpoint(self):
return {"level": "direct"}
serve.run(DirectServedIngress.bind())
url = get_application_url("HTTP")
# Test both inherited and own endpoints
resp = httpx.get(f"{url}/base")
assert resp.status_code == 200, f"Base failed: {resp.text}"
assert resp.json() == {"level": "base"}
resp = httpx.get(f"{url}/direct")
assert resp.status_code == 200, f"Direct failed: {resp.text}"
assert resp.json() == {"level": "direct"}
def test_ingress_include_router_with_self(serve_instance):
"""Endpoints registered via ``include_router`` must strip ``self`` (#64475).
FastAPI >= 0.137 nests routes added through ``include_router`` under an
``_IncludedRouter`` node instead of flattening them into ``app.routes``.
The class-based-view transform previously only scanned the flat list, so
``self`` was left in the signature and treated as a required query param,
causing requests to fail with 'Field required at ('query', 'self')' (422).
"""
app = FastAPI()
# A router whose prefix is baked into the route path via `APIRouter(prefix=)`.
router = APIRouter(prefix="/prefix")
# A router whose prefix is supplied at `include_router(..., prefix=)` time.
# On FastAPI >= 0.137 this prefix lives on the `_IncludedRouter` node rather
# than in the route's own path, so the transform must fold it back in when
# re-mounting the route (otherwise the endpoint moves to the wrong path).
prefixed_router = APIRouter()
class Ingress:
@router.get("/routed")
def routed_endpoint(self, name: str = "world"):
return {"source": "router", "name": name}
@prefixed_router.get("/models/{model_id}")
def prefixed_endpoint(self, model_id: str):
return {"source": "prefixed", "model_id": model_id}
@app.get("/direct")
def direct_endpoint(self):
return {"source": "app"}
# Registered before the class is wrapped by `serve.ingress`, mirroring how
# applications (e.g. vLLM) compose their routers.
app.include_router(router)
app.include_router(prefixed_router, prefix="/api")
@serve.deployment
@serve.ingress(app)
class ServedIngress(Ingress):
pass
serve.run(ServedIngress.bind())
url = get_application_url("HTTP")
# The included route must resolve without a bogus `self` query param.
resp = httpx.get(f"{url}/prefix/routed")
assert resp.status_code == 200, f"Routed failed: {resp.text}"
assert resp.json() == {"source": "router", "name": "world"}
# Regular query params on the included route still work.
resp = httpx.get(f"{url}/prefix/routed", params={"name": "serve"})
assert resp.status_code == 200, resp.text
assert resp.json() == {"source": "router", "name": "serve"}
# An include-time prefix must be preserved (route stays at /api/...).
resp = httpx.get(f"{url}/api/models/gpt")
assert resp.status_code == 200, f"Prefixed failed: {resp.text}"
assert resp.json() == {"source": "prefixed", "model_id": "gpt"}
# Directly decorated routes keep working too.
resp = httpx.get(f"{url}/direct")
assert resp.status_code == 200, f"Direct failed: {resp.text}"
assert resp.json() == {"source": "app"}
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))