diff --git a/README.md b/README.md index a8975c1e8d9..c6984fd8532 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,7 @@ The list below contains the functionality that contributors are planning to deve * **Feature Serving** * [x] Python Client * [x] [Python feature server](https://docs.feast.dev/reference/feature-servers/python-feature-server) + * [x] [Feast Operator (alpha)](https://github.com/feast-dev/feast/blob/master/infra/feast-operator/README.md) * [x] [Java feature server (alpha)](https://github.com/feast-dev/feast/blob/master/infra/charts/feast/README.md) * [x] [Go feature server (alpha)](https://docs.feast.dev/reference/feature-servers/go-feature-server) * [x] [Offline Feature Server (alpha)](https://docs.feast.dev/reference/feature-servers/offline-feature-server) diff --git a/docs/reference/feature-servers/registry-server.md b/docs/reference/feature-servers/registry-server.md index 9707a597035..98c152fbeed 100644 --- a/docs/reference/feature-servers/registry-server.md +++ b/docs/reference/feature-servers/registry-server.md @@ -2,8 +2,7 @@ ## Description -The Registry server uses the gRPC communication protocol to exchange data. -This enables users to communicate with the server using any programming language that can make gRPC requests. +The Registry server supports both gRPC and REST interfaces for interacting with feature metadata. While gRPC remains the default protocol—enabling clients in any language with gRPC support—the REST API allows users to interact with the registry over standard HTTP using any REST-capable tool or language. ## How to configure the server @@ -13,6 +12,11 @@ There is a CLI command that starts the Registry server: `feast serve_registry`. To start the Registry Server in TLS mode, you need to provide the private and public keys using the `--key` and `--cert` arguments. More info about TLS mode can be found in [feast-client-connecting-to-remote-registry-sever-started-in-tls-mode](../../how-to-guides/starting-feast-servers-tls-mode.md#starting-feast-registry-server-in-tls-mode) +To enable REST API support, start the registry server with REST mode enabled : + +`feast serve_registry --rest-api` + + ## How to configure the client Please see the detail how to configure Remote Registry client [remote.md](../registries/remote.md) diff --git a/sdk/python/feast/api/__init__.py b/sdk/python/feast/api/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sdk/python/feast/api/registry/__init__.py b/sdk/python/feast/api/registry/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sdk/python/feast/api/registry/rest/__init__.py b/sdk/python/feast/api/registry/rest/__init__.py new file mode 100644 index 00000000000..9cf3d5af04d --- /dev/null +++ b/sdk/python/feast/api/registry/rest/__init__.py @@ -0,0 +1,19 @@ +from fastapi import FastAPI + +from feast.api.registry.rest.data_sources import get_data_source_router +from feast.api.registry.rest.entities import get_entity_router +from feast.api.registry.rest.feature_services import get_feature_service_router +from feast.api.registry.rest.feature_views import get_feature_view_router +from feast.api.registry.rest.permissions import get_permission_router +from feast.api.registry.rest.projects import get_project_router +from feast.api.registry.rest.saved_datasets import get_saved_dataset_router + + +def register_all_routes(app: FastAPI, grpc_handler): + app.include_router(get_entity_router(grpc_handler)) + app.include_router(get_data_source_router(grpc_handler)) + app.include_router(get_feature_service_router(grpc_handler)) + app.include_router(get_feature_view_router(grpc_handler)) + app.include_router(get_permission_router(grpc_handler)) + app.include_router(get_project_router(grpc_handler)) + app.include_router(get_saved_dataset_router(grpc_handler)) diff --git a/sdk/python/feast/api/registry/rest/data_sources.py b/sdk/python/feast/api/registry/rest/data_sources.py new file mode 100644 index 00000000000..05d7c51e30f --- /dev/null +++ b/sdk/python/feast/api/registry/rest/data_sources.py @@ -0,0 +1,42 @@ +import logging +from typing import Dict + +from fastapi import APIRouter, Depends, Query + +from feast.api.registry.rest.rest_utils import grpc_call, parse_tags +from feast.protos.feast.registry import RegistryServer_pb2 + +logger = logging.getLogger(__name__) + + +def get_data_source_router(grpc_handler) -> APIRouter: + router = APIRouter() + + @router.get("/data_sources") + def list_data_sources( + project: str = Query(...), + allow_cache: bool = Query(default=True), + tags: Dict[str, str] = Depends(parse_tags), + ): + req = RegistryServer_pb2.ListDataSourcesRequest( + project=project, + allow_cache=allow_cache, + tags=tags, + ) + response = grpc_call(grpc_handler.ListDataSources, req) + return {"data_sources": response.get("dataSources", [])} + + @router.get("/data_sources/{name}") + def get_data_source( + name: str, + project: str = Query(...), + allow_cache: bool = Query(default=True), + ): + req = RegistryServer_pb2.GetDataSourceRequest( + name=name, + project=project, + allow_cache=allow_cache, + ) + return grpc_call(grpc_handler.GetDataSource, req) + + return router diff --git a/sdk/python/feast/api/registry/rest/entities.py b/sdk/python/feast/api/registry/rest/entities.py new file mode 100644 index 00000000000..fdbd3110bf8 --- /dev/null +++ b/sdk/python/feast/api/registry/rest/entities.py @@ -0,0 +1,35 @@ +import logging + +from fastapi import APIRouter, Query + +from feast.api.registry.rest.rest_utils import grpc_call +from feast.protos.feast.registry import RegistryServer_pb2 + +logger = logging.getLogger(__name__) + + +def get_entity_router(grpc_handler) -> APIRouter: + router = APIRouter() + + @router.get("/entities") + def list_entities( + project: str = Query(...), + ): + req = RegistryServer_pb2.ListEntitiesRequest(project=project) + response = grpc_call(grpc_handler.ListEntities, req) + return {"entities": response.get("entities", [])} + + @router.get("/entities/{name}") + def get_entity( + name: str, + project: str = Query(...), + allow_cache: bool = Query(default=True), + ): + req = RegistryServer_pb2.GetEntityRequest( + name=name, + project=project, + allow_cache=allow_cache, + ) + return grpc_call(grpc_handler.GetEntity, req) + + return router diff --git a/sdk/python/feast/api/registry/rest/feature_services.py b/sdk/python/feast/api/registry/rest/feature_services.py new file mode 100644 index 00000000000..b8fb7c70de6 --- /dev/null +++ b/sdk/python/feast/api/registry/rest/feature_services.py @@ -0,0 +1,38 @@ +from typing import Dict + +from fastapi import APIRouter, Depends, Query + +from feast.api.registry.rest.rest_utils import grpc_call, parse_tags +from feast.protos.feast.registry import RegistryServer_pb2 + + +def get_feature_service_router(grpc_handler) -> APIRouter: + router = APIRouter() + + @router.get("/feature_services") + def list_feature_services( + project: str = Query(...), + allow_cache: bool = Query(default=True), + tags: Dict[str, str] = Depends(parse_tags), + ): + req = RegistryServer_pb2.ListFeatureServicesRequest( + project=project, + allow_cache=allow_cache, + tags=tags, + ) + return grpc_call(grpc_handler.ListFeatureServices, req) + + @router.get("/feature_services/{name}") + def get_feature_service( + name: str, + project: str = Query(...), + allow_cache: bool = Query(default=True), + ): + req = RegistryServer_pb2.GetFeatureServiceRequest( + name=name, + project=project, + allow_cache=allow_cache, + ) + return grpc_call(grpc_handler.GetFeatureService, req) + + return router diff --git a/sdk/python/feast/api/registry/rest/feature_views.py b/sdk/python/feast/api/registry/rest/feature_views.py new file mode 100644 index 00000000000..809dcae8366 --- /dev/null +++ b/sdk/python/feast/api/registry/rest/feature_views.py @@ -0,0 +1,39 @@ +from typing import Dict + +from fastapi import APIRouter, Depends, Query + +from feast.api.registry.rest.rest_utils import grpc_call, parse_tags +from feast.registry_server import RegistryServer_pb2 + + +def get_feature_view_router(grpc_handler) -> APIRouter: + router = APIRouter() + + @router.get("/feature_views/{name}") + def get_any_feature_view( + name: str, + project: str = Query(...), + allow_cache: bool = Query(True), + ): + req = RegistryServer_pb2.GetAnyFeatureViewRequest( + name=name, + project=project, + allow_cache=allow_cache, + ) + response = grpc_call(grpc_handler.GetAnyFeatureView, req) + return response.get("anyFeatureView", {}) + + @router.get("/feature_views") + def list_all_feature_views( + project: str = Query(...), + allow_cache: bool = Query(True), + tags: Dict[str, str] = Depends(parse_tags), + ): + req = RegistryServer_pb2.ListAllFeatureViewsRequest( + project=project, + allow_cache=allow_cache, + tags=tags, + ) + return grpc_call(grpc_handler.ListAllFeatureViews, req) + + return router diff --git a/sdk/python/feast/api/registry/rest/permissions.py b/sdk/python/feast/api/registry/rest/permissions.py new file mode 100644 index 00000000000..36e0760453f --- /dev/null +++ b/sdk/python/feast/api/registry/rest/permissions.py @@ -0,0 +1,34 @@ +from fastapi import APIRouter, Query + +from feast.api.registry.rest.rest_utils import grpc_call +from feast.registry_server import RegistryServer_pb2 + + +def get_permission_router(grpc_handler) -> APIRouter: + router = APIRouter() + + @router.get("/permissions/{name}") + def get_permission( + name: str, + project: str = Query(...), + allow_cache: bool = Query(True), + ): + req = RegistryServer_pb2.GetPermissionRequest( + name=name, + project=project, + allow_cache=allow_cache, + ) + return {"permission": grpc_call(grpc_handler.GetPermission, req)} + + @router.get("/permissions") + def list_permissions( + project: str = Query(...), + allow_cache: bool = Query(True), + ): + req = RegistryServer_pb2.ListPermissionsRequest( + project=project, + allow_cache=allow_cache, + ) + return grpc_call(grpc_handler.ListPermissions, req) + + return router diff --git a/sdk/python/feast/api/registry/rest/projects.py b/sdk/python/feast/api/registry/rest/projects.py new file mode 100644 index 00000000000..89bc620b001 --- /dev/null +++ b/sdk/python/feast/api/registry/rest/projects.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, Query + +from feast.api.registry.rest.rest_utils import grpc_call +from feast.protos.feast.registry import RegistryServer_pb2 + + +def get_project_router(grpc_handler) -> APIRouter: + router = APIRouter() + + @router.get("/projects/{name}") + def get_project( + name: str, + allow_cache: bool = Query(True), + ): + req = RegistryServer_pb2.GetProjectRequest( + name=name, + allow_cache=allow_cache, + ) + return grpc_call(grpc_handler.GetProject, req) + + @router.get("/projects") + def list_projects( + allow_cache: bool = Query(True), + ): + req = RegistryServer_pb2.ListProjectsRequest( + allow_cache=allow_cache, + ) + response = grpc_call(grpc_handler.ListProjects, req) + return {"projects": response.get("projects", [])} + + return router diff --git a/sdk/python/feast/api/registry/rest/rest_registry_server.py b/sdk/python/feast/api/registry/rest/rest_registry_server.py new file mode 100644 index 00000000000..b77580fcab6 --- /dev/null +++ b/sdk/python/feast/api/registry/rest/rest_registry_server.py @@ -0,0 +1,104 @@ +import logging + +from fastapi import Depends, FastAPI, status + +from feast import FeatureStore +from feast.api.registry.rest import register_all_routes +from feast.permissions.auth.auth_manager import get_auth_manager +from feast.permissions.server.rest import inject_user_details +from feast.permissions.server.utils import ( + ServerType, + init_auth_manager, + init_security_manager, + str_to_auth_manager_type, +) +from feast.registry_server import RegistryServer + +logger = logging.getLogger(__name__) + + +class RestRegistryServer: + def __init__(self, store: FeatureStore): + self.store = store + self.registry = store.registry + self.grpc_handler = RegistryServer(self.registry) + self.app = FastAPI( + title="Feast REST Registry Server", + description="Feast REST Registry Server", + dependencies=[Depends(inject_user_details)], + version="1.0.0", + openapi_url="/openapi.json", + docs_url="/", + redoc_url="/docs", + default_status_code=status.HTTP_200_OK, + default_headers={ + "X-Content-Type-Options": "nosniff", + "X-XSS-Protection": "1; mode=block", + "X-Frame-Options": "DENY", + }, + ) + self._add_openapi_security() + self._init_auth() + self._register_routes() + + def _add_openapi_security(self): + if self.app.openapi_schema: + return + original_openapi = self.app.openapi + + def custom_openapi(): + if self.app.openapi_schema: + return self.app.openapi_schema + schema = original_openapi() + schema.setdefault("components", {}).setdefault("securitySchemes", {})[ + "BearerAuth" + ] = { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + } + schema.setdefault("security", []).append({"BearerAuth": []}) + self.app.openapi_schema = schema + return self.app.openapi_schema + + self.app.openapi = custom_openapi + + def _init_auth(self): + auth_type = str_to_auth_manager_type(self.store.config.auth_config.type) + init_security_manager(auth_type=auth_type, fs=self.store) + init_auth_manager( + auth_type=auth_type, + server_type=ServerType.REST, + auth_config=self.store.config.auth_config, + ) + self.auth_manager = get_auth_manager() + + def _register_routes(self): + register_all_routes(self.app, self.grpc_handler) + + def start_server( + self, + port: int, + tls_key_path: str = "", + tls_cert_path: str = "", + ): + import uvicorn + + if tls_key_path and tls_cert_path: + logger.info("Starting REST registry server in TLS(SSL) mode") + print(f"REST registry server listening on https://localhost:{port}") + uvicorn.run( + self.app, + host="0.0.0.0", + port=port, + ssl_keyfile=tls_key_path, + ssl_certfile=tls_cert_path, + ) + else: + print("Starting REST registry server in non-TLS(SSL) mode") + print(f"REST registry server listening on http://localhost:{port}") + uvicorn.run( + self.app, + host="0.0.0.0", + port=port, + ) diff --git a/sdk/python/feast/api/registry/rest/rest_utils.py b/sdk/python/feast/api/registry/rest/rest_utils.py new file mode 100644 index 00000000000..62e81f09401 --- /dev/null +++ b/sdk/python/feast/api/registry/rest/rest_utils.py @@ -0,0 +1,32 @@ +from typing import Dict, List + +from fastapi import HTTPException, Query +from google.protobuf.json_format import MessageToDict + +from feast.errors import FeastObjectNotFoundException + + +def grpc_call(handler_fn, request): + """ + Wrapper to invoke gRPC method with context=None and handle common errors. + """ + try: + response = handler_fn(request, context=None) + return MessageToDict(response) + except FeastObjectNotFoundException as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception: + raise HTTPException(status_code=500, detail="Internal server error") + + +def parse_tags(tags: List[str] = Query(default=[])) -> Dict[str, str]: + """ + Parses query strings like ?tags=key1:value1&tags=key2:value2 into a dict. + """ + parsed_tags = {} + for tag in tags: + if ":" not in tag: + continue + key, value = tag.split(":", 1) + parsed_tags[key] = value + return parsed_tags diff --git a/sdk/python/feast/api/registry/rest/saved_datasets.py b/sdk/python/feast/api/registry/rest/saved_datasets.py new file mode 100644 index 00000000000..33b150c59e8 --- /dev/null +++ b/sdk/python/feast/api/registry/rest/saved_datasets.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter, Depends, Query + +from feast.api.registry.rest.rest_utils import grpc_call, parse_tags +from feast.protos.feast.registry import RegistryServer_pb2 + + +def get_saved_dataset_router(grpc_handler) -> APIRouter: + router = APIRouter() + + @router.get("/saved_datasets/{name}") + def get_saved_dataset( + name: str, + project: str = Query(...), + allow_cache: bool = Query(True), + ): + req = RegistryServer_pb2.GetSavedDatasetRequest( + name=name, + project=project, + allow_cache=allow_cache, + ) + return grpc_call(grpc_handler.GetSavedDataset, req) + + @router.get("/saved_datasets") + def list_saved_datasets( + project: str = Query(...), + allow_cache: bool = Query(True), + tags: dict = Depends(parse_tags), + ): + req = RegistryServer_pb2.ListSavedDatasetsRequest( + project=project, + allow_cache=allow_cache, + tags=tags, + ) + response = grpc_call(grpc_handler.ListSavedDatasets, req) + return {"saved_datasets": response.get("saved_datasets", [])} + + return router diff --git a/sdk/python/feast/cli/serve.py b/sdk/python/feast/cli/serve.py index c069d799ef1..53499506762 100644 --- a/sdk/python/feast/cli/serve.py +++ b/sdk/python/feast/cli/serve.py @@ -165,21 +165,29 @@ def serve_transformations_command(ctx: click.Context, port: int): show_default=False, help="path to TLS certificate public key. You need to pass --key as well to start server in TLS mode", ) +@click.option( + "--rest-api", + "-r", + is_flag=True, + show_default=True, + help="Start a REST API Server", +) @click.pass_context def serve_registry_command( ctx: click.Context, port: int, tls_key_path: str, tls_cert_path: str, + rest_api: bool, ): - """Start a registry server locally on a given port.""" + """Start a gRPC or REST api registry server locally on a given port (gRPC by default).""" if (tls_key_path and not tls_cert_path) or (not tls_key_path and tls_cert_path): raise click.BadParameter( "Please pass --cert and --key args to start the registry server in TLS mode." ) store = create_feature_store(ctx) - store.serve_registry(port, tls_key_path, tls_cert_path) + store.serve_registry(port, tls_key_path, tls_cert_path, rest_api) @click.command("serve_offline") diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py index 02cab72786b..8b936e899c5 100644 --- a/sdk/python/feast/feature_store.py +++ b/sdk/python/feast/feature_store.py @@ -2287,14 +2287,27 @@ def serve_ui( ) def serve_registry( - self, port: int, tls_key_path: str = "", tls_cert_path: str = "" + self, + port: int, + tls_key_path: str = "", + tls_cert_path: str = "", + rest_api: bool = False, ) -> None: """Start registry server locally on a given port.""" - from feast import registry_server + if rest_api: + from feast.api.registry.rest import rest_registry_server - registry_server.start_server( - self, port=port, tls_key_path=tls_key_path, tls_cert_path=tls_cert_path - ) + server = rest_registry_server.RestRegistryServer(self) + + server.start_server( + port=port, tls_key_path=tls_key_path, tls_cert_path=tls_cert_path + ) + else: + from feast import registry_server + + registry_server.start_server( + self, port=port, tls_key_path=tls_key_path, tls_cert_path=tls_cert_path + ) def serve_offline( self, diff --git a/sdk/python/feast/permissions/server/rest.py b/sdk/python/feast/permissions/server/rest.py index ecced3b34a1..05b2cfdd9ac 100644 --- a/sdk/python/feast/permissions/server/rest.py +++ b/sdk/python/feast/permissions/server/rest.py @@ -4,6 +4,7 @@ from typing import Any +from fastapi import HTTPException from fastapi.requests import Request from feast.permissions.auth.auth_manager import ( @@ -20,14 +21,26 @@ async def inject_user_details(request: Request) -> Any: sm = get_security_manager() current_user = None if sm is not None: - auth_manager = get_auth_manager() - access_token = auth_manager.token_extractor.extract_access_token( - request=request - ) - current_user = await auth_manager.token_parser.user_details_from_access_token( - access_token=access_token - ) - - sm.set_current_user(current_user) + try: + auth_manager = get_auth_manager() + access_token = auth_manager.token_extractor.extract_access_token( + request=request + ) + if not access_token: + raise HTTPException( + status_code=401, detail="Missing authentication token" + ) + + current_user = ( + await auth_manager.token_parser.user_details_from_access_token( + access_token=access_token + ) + ) + + sm.set_current_user(current_user) + except Exception: + raise HTTPException( + status_code=401, detail="Invalid or expired access token" + ) return current_user diff --git a/sdk/python/tests/unit/api/__init__.py b/sdk/python/tests/unit/api/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sdk/python/tests/unit/api/test_api_rest_registry.py b/sdk/python/tests/unit/api/test_api_rest_registry.py new file mode 100644 index 00000000000..706ba701b76 --- /dev/null +++ b/sdk/python/tests/unit/api/test_api_rest_registry.py @@ -0,0 +1,133 @@ +import os +import tempfile + +import pytest +from fastapi.testclient import TestClient + +from feast import Entity, FeatureService, FeatureView, Field, FileSource +from feast.api.registry.rest.rest_registry_server import RestRegistryServer +from feast.feature_store import FeatureStore +from feast.repo_config import RepoConfig +from feast.types import Float64, Int64 +from feast.value_type import ValueType + + +@pytest.fixture +def fastapi_test_app(): + # Create temp registry and data directory + tmp_dir = tempfile.TemporaryDirectory() + registry_path = os.path.join(tmp_dir.name, "registry.db") + + # Create dummy parquet file (Feast requires valid sources) + parquet_file_path = os.path.join(tmp_dir.name, "data.parquet") + import pandas as pd + + df = pd.DataFrame( + { + "user_id": [1, 2, 3], + "age": [25, 30, 22], + "income": [50000.0, 60000.0, 45000.0], + "event_timestamp": pd.to_datetime( + ["2024-01-01", "2024-01-02", "2024-01-03"] + ), + } + ) + df.to_parquet(parquet_file_path) + + # Setup minimal repo config + config = { + "registry": registry_path, + "project": "demo_project", + "provider": "local", + "offline_store": {"type": "file"}, + "online_store": {"type": "sqlite", "path": ":memory:"}, + } + user_profile_source = FileSource( + name="user_profile_source", + path=parquet_file_path, + event_timestamp_column="event_timestamp", + ) + + store = FeatureStore(config=RepoConfig.model_validate(config)) + user_id_entity = Entity( + name="user_id", value_type=ValueType.INT64, description="User ID" + ) + user_profile_feature_view = FeatureView( + name="user_profile", + entities=[user_id_entity], + ttl=None, + schema=[ + Field(name="age", dtype=Int64), + Field(name="income", dtype=Float64), + ], + source=user_profile_source, + ) + user_feature_service = FeatureService( + name="user_service", + features=[user_profile_feature_view], + ) + + # Apply objects + store.apply([user_id_entity, user_profile_feature_view, user_feature_service]) + + # Build REST app with registered routes + rest_server = RestRegistryServer(store) + client = TestClient(rest_server.app) + + yield client + + tmp_dir.cleanup() + + +def test_entities_via_rest(fastapi_test_app): + response = fastapi_test_app.get("/entities?project=demo_project") + assert response.status_code == 200 + assert "entities" in response.json() + response = fastapi_test_app.get("/entities/user_id?project=demo_project") + assert response.status_code == 200 + assert response.json()["spec"]["name"] == "user_id" + + +def test_feature_views_via_rest(fastapi_test_app): + response = fastapi_test_app.get("/feature_views?project=demo_project") + assert response.status_code == 200 + assert "featureViews" in response.json() + response = fastapi_test_app.get("/feature_views/user_profile?project=demo_project") + assert response.status_code == 200 + assert response.json()["featureView"]["spec"]["name"] == "user_profile" + + +def test_feature_services_via_rest(fastapi_test_app): + response = fastapi_test_app.get("/feature_services?project=demo_project") + assert response.status_code == 200 + assert "featureServices" in response.json() + response = fastapi_test_app.get( + "/feature_services/user_service?project=demo_project" + ) + assert response.status_code == 200 + assert response.json()["spec"]["name"] == "user_service" + + +def test_data_sources_via_rest(fastapi_test_app): + response = fastapi_test_app.get("/data_sources?project=demo_project") + assert response.status_code == 200 + assert "data_sources" in response.json() + response = fastapi_test_app.get( + "/data_sources/user_profile_source?project=demo_project" + ) + assert response.status_code == 200 + assert response.json()["name"] == "user_profile_source" + + +def test_projects_via_rest(fastapi_test_app): + response = fastapi_test_app.get("/projects") + assert response.status_code == 200 + assert isinstance(response.json()["projects"], list) + response = fastapi_test_app.get("/projects/demo_project") + assert response.status_code == 200 + assert response.json()["spec"]["name"] == "demo_project" + + +def test_permissions_via_rest(fastapi_test_app): + response = fastapi_test_app.get("/permissions?project=demo_project") + assert response.status_code == 200 diff --git a/sdk/python/tests/unit/api/test_api_rest_registry_server.py b/sdk/python/tests/unit/api/test_api_rest_registry_server.py new file mode 100644 index 00000000000..1409d15e156 --- /dev/null +++ b/sdk/python/tests/unit/api/test_api_rest_registry_server.py @@ -0,0 +1,69 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from feast.api.registry.rest.rest_registry_server import RestRegistryServer +from feast.feature_store import FeatureStore + + +@pytest.fixture +def mock_store_and_registry(): + mock_registry = MagicMock() + mock_store = MagicMock(spec=FeatureStore) + mock_store.registry = mock_registry + mock_store.config = MagicMock() + return mock_store, mock_registry + + +@patch("feast.api.registry.rest.rest_registry_server.RegistryServer") +@patch("feast.api.registry.rest.rest_registry_server.register_all_routes") +@patch("feast.api.registry.rest.rest_registry_server.init_security_manager") +@patch("feast.api.registry.rest.rest_registry_server.init_auth_manager") +@patch("feast.api.registry.rest.rest_registry_server.get_auth_manager") +def test_rest_registry_server_initializes_correctly( + mock_get_auth_manager, + mock_init_auth_manager, + mock_init_security_manager, + mock_register_all_routes, + mock_registry_server_cls, + mock_store_and_registry, +): + store, registry = mock_store_and_registry + mock_grpc_handler = MagicMock() + mock_registry_server_cls.return_value = mock_grpc_handler + + server = RestRegistryServer(store) + + # Validate registry and grpc handler are wired + assert server.store == store + assert server.registry == registry + assert server.grpc_handler == mock_grpc_handler + + # Validate route registration and auth init + mock_register_all_routes.assert_called_once_with(server.app, mock_grpc_handler) + mock_init_security_manager.assert_called_once() + mock_init_auth_manager.assert_called_once() + mock_get_auth_manager.assert_called_once() + assert server.auth_manager == mock_get_auth_manager.return_value + + # OpenAPI security should be injected + openapi_schema = server.app.openapi() + assert "securitySchemes" in openapi_schema["components"] + assert {"BearerAuth": []} in openapi_schema["security"] + + +def test_routes_registered_in_app(mock_store_and_registry): + from fastapi.routing import APIRoute + + store, _ = mock_store_and_registry + + server = RestRegistryServer(store) + route_paths = [ + route.path for route in server.app.routes if isinstance(route, APIRoute) + ] + assert "/feature_services" in route_paths + assert "/entities" in route_paths + assert "/projects" in route_paths + assert "/data_sources" in route_paths + assert "/saved_datasets" in route_paths + assert "/permissions" in route_paths