From 6fbae46f2a977a557497c94a249d2bfbe3527246 Mon Sep 17 00:00:00 2001 From: Peyton Murray Date: Wed, 5 Feb 2025 10:09:31 -0800 Subject: [PATCH] [MAINT] Deprecate `/api/v1/environments/` (#1061) --- .../_internal/server/app.py | 7 ++++ .../_internal/server/views/api.py | 40 +++++++++++++------ .../_internal/server/views/registry.py | 14 ++++--- .../tests/_internal/server/views/test_api.py | 13 ++++-- 4 files changed, 51 insertions(+), 23 deletions(-) diff --git a/conda-store-server/conda_store_server/_internal/server/app.py b/conda-store-server/conda_store_server/_internal/server/app.py index 6046eb5c5..dfed8b89a 100644 --- a/conda-store-server/conda_store_server/_internal/server/app.py +++ b/conda-store-server/conda_store_server/_internal/server/app.py @@ -255,6 +255,13 @@ async def conda_store_middleware(request: Request, call_next): request.state.authentication = self.authentication request.state.templates = self.templates response = await call_next(request) + + # Handle requests that are sent to deprecated endpoints; + # see conda_store_server._internal.server.views.api.deprecated + # for additional information + if hasattr(request.state, "deprecation_date"): + response.headers["Deprecation"] = "True" + response.headers["Sunset"] = request.state.deprecation_date return response @app.exception_handler(HTTPException) diff --git a/conda-store-server/conda_store_server/_internal/server/views/api.py b/conda-store-server/conda_store_server/_internal/server/views/api.py index fd7fc6826..ac51a78ab 100644 --- a/conda-store-server/conda_store_server/_internal/server/views/api.py +++ b/conda-store-server/conda_store_server/_internal/server/views/api.py @@ -107,30 +107,43 @@ def paginated_api_response( def deprecated(sunset_date: datetime.date) -> Callable: - """Decorator to add deprecation headers to a HTTP response. These will include: - { - Deprecation: True - Sunset: - } + """Add deprecation headers to a HTTP request and response. + + This will include the deprecation date on the request object, + which will then be picked up by the conda_store_middleware + to add the deprecation date to the response object. See the conda-store backwards compatibility policy for appropriate use of deprecations https://conda.store/community/policies/backwards-compatibility. + Note that decorated functions _must_ include the request parameter. + This is not an elegant way of doing this, but FastAPI has no other + way of achieving the same effect without confusing its request/response + inference machinery, which relies on the type annotations of the + routes. + Parameters ---------- sunset_date : datetime.date the date that the endpoint will have it's functionality removed + + Returns + ------- + Callable + Decorator which wraps an endpoint """ def decorator(func): @wraps(func) - def add_deprecated_headers(*args, **kwargs): - response = func(*args, **kwargs) - response.headers["Deprecation"] = "True" - response.headers["Sunset"] = sunset_date.strftime( + async def add_deprecated_headers(request: Request, *args, **kwargs): + # It's not possible to add the deprecation headers to the + # output of `func(*args, **kwargs)`, since that may be a + # simple dict object, not a Response + request.state.deprecation_date = sunset_date.strftime( "%a, %d %b %Y 00:00:00 UTC" ) - return response + result = await func(*args, request=request, **kwargs) + return result return add_deprecated_headers @@ -635,10 +648,11 @@ async def api_delete_namespace( @router_api.get( - "/environment/", - response_model=schema.APIListEnvironment, + "/environment/", response_model=schema.APIListEnvironment, deprecated=True ) +@deprecated(sunset_date=datetime.date(2025, 3, 17)) async def api_list_environments_v1( + request: Request, auth: Authentication = Depends(dependencies.get_auth), conda_store: CondaStore = Depends(dependencies.get_conda_store), entity: AuthenticationToken = Depends(dependencies.get_entity), @@ -1361,8 +1375,8 @@ async def api_get_build_archive( @router_api.get("/build/{build_id}/docker/", deprecated=True) @deprecated(sunset_date=datetime.date(2025, 3, 17)) async def api_get_build_docker_image_url( - build_id: int, request: Request, + build_id: int, conda_store=Depends(dependencies.get_conda_store), server=Depends(dependencies.get_server), auth=Depends(dependencies.get_auth), diff --git a/conda-store-server/conda_store_server/_internal/server/views/registry.py b/conda-store-server/conda_store_server/_internal/server/views/registry.py index a1f5b7604..2a8f079e2 100644 --- a/conda-store-server/conda_store_server/_internal/server/views/registry.py +++ b/conda-store-server/conda_store_server/_internal/server/views/registry.py @@ -80,7 +80,9 @@ def replace_words(s, words): @deprecated(sunset_date=datetime.date(2025, 3, 17)) -def get_docker_image_manifest(conda_store, image, tag, timeout=10 * 60): +async def get_docker_image_manifest( + conda_store, image, tag, request: Request, timeout=10 * 60 +): namespace, *image_name = image.split("/") # /v2//manifest/ @@ -131,14 +133,14 @@ def get_docker_image_manifest(conda_store, image, tag, timeout=10 * 60): @deprecated(sunset_date=datetime.date(2025, 3, 17)) -def get_docker_image_blob(conda_store, image, blobsum): +async def get_docker_image_blob(conda_store, image, blobsum, request: Request): blob_key = f"docker/blobs/{blobsum}" return RedirectResponse(conda_store.storage.get_url(blob_key)) @router_registry.get("/v2/", deprecated=True) @deprecated(sunset_date=datetime.date(2025, 3, 17)) -def v2( +async def v2( request: Request, entity=Depends(dependencies.get_entity), ): @@ -150,7 +152,7 @@ def v2( @router_registry.get("/v2/{rest:path}", deprecated=True) @deprecated(sunset_date=datetime.date(2025, 3, 17)) -def list_tags( +async def list_tags( rest: str, request: Request, conda_store=Depends(dependencies.get_conda_store), @@ -187,8 +189,8 @@ def list_tags( # /v2//manifests/ elif parts[-2] == "manifests": tag = parts[-1] - return get_docker_image_manifest(conda_store, image, tag) + return get_docker_image_manifest(conda_store, image, tag, request) # /v2//blobs/ elif parts[-2] == "blobs": blobsum = parts[-1].split(":")[1] - return get_docker_image_blob(conda_store, image, blobsum) + return get_docker_image_blob(conda_store, image, blobsum, request) diff --git a/conda-store-server/tests/_internal/server/views/test_api.py b/conda-store-server/tests/_internal/server/views/test_api.py index f1816f25a..dc968c537 100644 --- a/conda-store-server/tests/_internal/server/views/test_api.py +++ b/conda-store-server/tests/_internal/server/views/test_api.py @@ -13,6 +13,7 @@ import pytest import traitlets import yaml +from fastapi import Request from fastapi.testclient import TestClient from conda_store_server import CONDA_STORE_DIR, __version__ @@ -47,19 +48,22 @@ def mock_get_entity(): testclient.app.dependency_overrides = {} -def test_deprecation_warning(): +def test_deprecation_warning(testclient): from fastapi.responses import JSONResponse from conda_store_server._internal.server.views.api import deprecated + router = testclient.app.router + + @router.get("/foo") @deprecated(datetime.date(2024, 12, 17)) - def api_status(): + async def api_status(request: Request): return JSONResponse( status_code=400, content={"ok": "ok"}, ) - result = api_status() + result = testclient.get("/foo") assert result.headers.get("Deprecation") == "True" assert result.headers.get("Sunset") == "Tue, 17 Dec 2024 00:00:00 UTC" @@ -257,6 +261,8 @@ def test_api_list_environments_auth( r = model.model_validate(response.json()) assert r.status == schema.APIStatus.OK + if version == "v1": + assert response.headers.get("Deprecation") assert sorted([_.name for _ in r.data]) == ["name1", "name2", "name3", "name4"] @@ -1206,7 +1212,6 @@ def test_api_list_environments_paginate( cursor = None cursor_param = "" while cursor is None or cursor != Cursor.end(): - # breakpoint() response = testclient.get(f"api/v2/environment/?limit={limit}{cursor_param}") response.raise_for_status()