From c7d3869770fd28f3838430348316d133fab7dea3 Mon Sep 17 00:00:00 2001 From: Nathan Allen Date: Sun, 15 Feb 2026 18:06:06 -0500 Subject: [PATCH 1/3] feat: add test suite and split API structure - Add test_models.py for validation rules - Add test_database.py for schema and constraints - Add test_service.py for business logic - Extract endpoints to api.py using APIRouter - Simplify main.py to app configuration only --- src/ocp_registry/api.py | 104 ++++++++++ src/ocp_registry/main.py | 127 ++----------- tests/test_database.py | 147 ++++++++++++++ tests/test_models.py | 201 ++++++++++++++++++++ tests/test_service.py | 401 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 874 insertions(+), 106 deletions(-) create mode 100644 src/ocp_registry/api.py create mode 100644 tests/test_database.py create mode 100644 tests/test_models.py create mode 100644 tests/test_service.py diff --git a/src/ocp_registry/api.py b/src/ocp_registry/api.py new file mode 100644 index 0000000..97a0d4a --- /dev/null +++ b/src/ocp_registry/api.py @@ -0,0 +1,104 @@ +"""API endpoints for OCP Community Registry.""" + +from fastapi import APIRouter, HTTPException, Depends, Query +from sqlalchemy.orm import Session +from typing import List, Optional + +from .database import get_db +from .models import ( + APIEntry, APISearchResponse, CategorySummary, RegistryStats, + APICategory, APIListResponse +) +from .service import RegistryService + + +# Create API router +router = APIRouter(prefix="/api/v1") + + +# Registry endpoints +@router.get("/registry", response_model=APIListResponse) +async def list_apis( + page: int = Query(1, ge=1, description="Page number"), + per_page: int = Query(20, ge=1, le=100, description="Results per page"), + db: Session = Depends(get_db) +): + """List all active APIs with pagination.""" + service = RegistryService(db) + return service.list_apis(page=page, per_page=per_page) + + +@router.get("/registry/{name}", response_model=APIEntry) +async def get_api(name: str, db: Session = Depends(get_db)): + """Get specific API configuration by name.""" + service = RegistryService(db) + api_entry = service.get_api(name) + if not api_entry: + raise HTTPException(status_code=404, detail=f"API '{name}' not found") + + return api_entry + + +# Search and discovery endpoints +@router.get("/search", response_model=APISearchResponse) +async def search_apis( + q: str = Query(..., description="Search query"), + category: Optional[APICategory] = Query(None, description="Filter by category"), + page: int = Query(1, ge=1, description="Page number"), + per_page: int = Query(20, ge=1, le=100, description="Results per page"), + db: Session = Depends(get_db) +): + """Search APIs by name, description, or tags with optional category filter.""" + service = RegistryService(db) + return service.search_apis(q, category=category, page=page, per_page=per_page) + + +@router.get("/categories", response_model=List[CategorySummary]) +async def get_categories(db: Session = Depends(get_db)): + """Get all available categories with API counts.""" + service = RegistryService(db) + return service.get_categories() + + +@router.get("/categories/{category}", response_model=APIListResponse) +async def get_apis_by_category( + category: APICategory, + page: int = Query(1, ge=1, description="Page number"), + per_page: int = Query(20, ge=1, le=100, description="Results per page"), + db: Session = Depends(get_db) +): + """Get all active APIs in a specific category with pagination.""" + service = RegistryService(db) + return service.list_apis_by_category(category=category, page=page, per_page=per_page) + + +@router.get("/stats", response_model=RegistryStats) +async def get_registry_stats(db: Session = Depends(get_db)): + """Get pre-calculated registry statistics.""" + service = RegistryService(db) + return service.get_stats() + + +# API root +@router.get("/") +async def api_root(): + """API root endpoint - discovery and metadata.""" + return { + "name": "OCP Community Registry", + "version": "0.3.0", + "endpoints": { + "registry": "/api/v1/registry", + "search": "/api/v1/search", + "categories": "/api/v1/categories", + "stats": "/api/v1/stats", + "health": "/api/v1/health" + }, + "docs": "/docs" + } + + +# Health check +@router.get("/health") +async def health_check(): + """Health check endpoint for monitoring and load balancers.""" + return {"status": "healthy"} diff --git a/src/ocp_registry/main.py b/src/ocp_registry/main.py index e90f525..6bccd69 100644 --- a/src/ocp_registry/main.py +++ b/src/ocp_registry/main.py @@ -1,16 +1,10 @@ -"""FastAPI application for OCP Community Registry.""" +"""FastAPI application entry point for OCP Community Registry.""" from contextlib import asynccontextmanager -from datetime import datetime, timezone -from fastapi import FastAPI, HTTPException, Depends, Query -from sqlalchemy.orm import Session -from typing import List, Optional +from fastapi import FastAPI -from .database import get_db, db_manager -from .models import ( - APIEntry, APISearchResponse, CategorySummary, RegistryStats, APICategory, APIStatus, APIListResponse -) -from .service import RegistryService +from .database import db_manager +from .api import router @asynccontextmanager @@ -20,104 +14,25 @@ async def lifespan(app: FastAPI): yield -# Create FastAPI app -app = FastAPI( - title="OCP Community Registry", - description="Searchable database of API configurations for the Open Context Protocol", - version="0.1.0", - docs_url="/docs", - redoc_url="/redoc", - lifespan=lifespan -) - - -# Registry endpoints -@app.get("/api/v1/registry", response_model=APIListResponse) -async def list_apis( - page: int = Query(1, ge=1, description="Page number"), - per_page: int = Query(20, ge=1, le=100, description="Results per page"), - db: Session = Depends(get_db) -): - """List all active APIs with pagination.""" - service = RegistryService(db) - return service.list_apis(page=page, per_page=per_page) - - -@app.get("/api/v1/registry/{name}", response_model=APIEntry) -async def get_api(name: str, db: Session = Depends(get_db)): - """Get specific API configuration by name.""" - service = RegistryService(db) - api_entry = service.get_api(name) - if not api_entry: - raise HTTPException(status_code=404, detail=f"API '{name}' not found") +def create_app() -> FastAPI: + """Create and configure FastAPI application.""" + app = FastAPI( + title="OCP Community Registry", + description="Searchable database of API configurations for the Open Context Protocol", + version="0.1.0", + docs_url="/docs", + redoc_url="/redoc", + lifespan=lifespan + ) - return api_entry - - - - -# Search and discovery endpoints -@app.get("/api/v1/search", response_model=APISearchResponse) -async def search_apis( - q: str = Query(..., description="Search query"), - category: Optional[APICategory] = Query(None, description="Filter by category"), - page: int = Query(1, ge=1, description="Page number"), - per_page: int = Query(20, ge=1, le=100, description="Results per page"), - db: Session = Depends(get_db) -): - """Search APIs by name, description, or tags with optional category filter.""" - service = RegistryService(db) - return service.search_apis(q, category=category, page=page, per_page=per_page) - - -@app.get("/api/v1/categories", response_model=List[CategorySummary]) -async def get_categories(db: Session = Depends(get_db)): - """Get all available categories with API counts.""" - service = RegistryService(db) - return service.get_categories() - - -@app.get("/api/v1/categories/{category}", response_model=APIListResponse) -async def get_apis_by_category( - category: APICategory, - page: int = Query(1, ge=1, description="Page number"), - per_page: int = Query(20, ge=1, le=100, description="Results per page"), - db: Session = Depends(get_db) -): - """Get all active APIs in a specific category with pagination.""" - service = RegistryService(db) - return service.list_apis_by_category(category=category, page=page, per_page=per_page) - -@app.get("/api/v1/stats", response_model=RegistryStats) -async def get_registry_stats(db: Session = Depends(get_db)): - """Get pre-calculated registry statistics.""" - service = RegistryService(db) - return service.get_stats() - - -# API root -@app.get("/api/v1/") -async def api_root(): - """API root endpoint - discovery and metadata.""" - return { - "name": "OCP Community Registry", - "version": "0.3.0", - "endpoints": { - "registry": "/api/v1/registry", - "search": "/api/v1/search", - "categories": "/api/v1/categories", - "stats": "/api/v1/stats", - "health": "/api/v1/health" - }, - "docs": "/docs" - } + # Include API routes + app.include_router(router) + + return app -# Health check -@app.get("/api/v1/health") -async def health_check(): - """Health check endpoint for monitoring and load balancers.""" - return {"status": "healthy"} +# Create app instance +app = create_app() def main(): @@ -127,4 +42,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..bf7860b --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,147 @@ +"""Tests for database schema and constraints. + +Note: We only test OUR schema definitions and constraints, not SQLAlchemy's +ORM functionality. Business logic is tested in test_service.py. +""" + +import pytest +from sqlalchemy import create_engine, inspect +from sqlalchemy.orm import sessionmaker + +from ocp_registry.database import Base, APIEntryDB, DatabaseManager +from ocp_registry.models import APICategory, APIStatus + + +class TestDatabaseSchema: + """Test our database schema definition.""" + + def test_schema_creates_required_tables(self): + """Should create api_entries and registry_stats tables.""" + db_manager = DatabaseManager("sqlite:///:memory:") + db_manager.create_tables() + + inspector = inspect(db_manager.engine) + tables = inspector.get_table_names() + assert "api_entries" in tables + assert "registry_stats" in tables + + def test_api_entries_has_required_indexes(self): + """Should create indexes on name, category, and status for query performance.""" + db_manager = DatabaseManager("sqlite:///:memory:") + db_manager.create_tables() + + inspector = inspect(db_manager.engine) + indexes = inspector.get_indexes("api_entries") + + # Extract column names from indexes + indexed_columns = set() + for idx in indexes: + indexed_columns.update(idx['column_names']) + + # Verify critical indexes exist (we defined these for performance) + assert 'name' in indexed_columns, "name should be indexed (unique lookup)" + assert 'category' in indexed_columns, "category should be indexed (filtering)" + assert 'status' in indexed_columns, "status should be indexed (filtering active APIs)" + + +class TestAPIEntryConstraints: + """Test constraints we defined on APIEntryDB.""" + + @pytest.fixture + def test_session(self): + """Create test database session.""" + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(bind=engine) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + session = SessionLocal() + yield session + session.close() + + def test_required_fields_not_nullable(self, test_session): + """Should enforce NOT NULL on required fields we defined.""" + # Missing required field: name + entry = APIEntryDB( + # name is missing - should fail + display_name="Test", + description="Test description", + openapi_url="https://test.com/openapi.json", + base_url="https://test.com", + category=APICategory.development, + auth_config={"type": "none"} + ) + + test_session.add(entry) + with pytest.raises(Exception): # NOT NULL constraint violation + test_session.commit() + + def test_unique_name_constraint(self, test_session): + """Should enforce unique name constraint on api_entries.""" + entry1 = APIEntryDB( + name="duplicate", + display_name="First", + description="First API", + openapi_url="https://test1.com/openapi.json", + base_url="https://test1.com", + category=APICategory.development, + auth_config={"type": "none"}, + status=APIStatus.active, + tool_count=5, + tools=[] + ) + + entry2 = APIEntryDB( + name="duplicate", + display_name="Second", + description="Second API", + openapi_url="https://test2.com/openapi.json", + base_url="https://test2.com", + category=APICategory.development, + auth_config={"type": "none"}, + status=APIStatus.active, + tool_count=5, + tools=[] + ) + + test_session.add(entry1) + test_session.commit() + + # Second entry with same name should fail + test_session.add(entry2) + with pytest.raises(Exception): # IntegrityError from unique constraint + test_session.commit() + + def test_json_field_serialization(self, test_session): + """Should correctly serialize/deserialize JSON fields (critical for our app).""" + auth_config = { + "type": "oauth2", + "token_url": "https://auth.test.com/token", + "scopes": ["read", "write"] + } + tags = ["tag1", "tag2", "tag3"] + tools = [ + {"name": "tool1", "method": "GET", "path": "/resource"}, + {"name": "tool2", "method": "POST", "path": "/resource"} + ] + + entry = APIEntryDB( + name="json-test", + display_name="JSON Test", + description="Test JSON fields", + openapi_url="https://test.com/openapi.json", + base_url="https://test.com", + category=APICategory.development, + auth_config=auth_config, + tags=tags, + tools=tools, + status=APIStatus.active, + tool_count=2 + ) + + test_session.add(entry) + test_session.commit() + + # Verify JSON roundtrip + result = test_session.query(APIEntryDB).filter(APIEntryDB.name == "json-test").first() + assert result.auth_config == auth_config + assert result.tags == tags + assert result.tools == tools diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..c374eab --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,201 @@ +""" +Tests for OCP Registry data models. + +Tests focus on OUR validation rules and business logic (tools conversion). +We don't test Pydantic's basic field assignment or serialization. +""" + +import pytest +from datetime import datetime, timezone +from pydantic import ValidationError + +from ocp_registry.models import ( + AuthType, APICategory, APIRegistration, APIEntry +) +from ocp_agent.schema_discovery import OCPTool + + +class TestAPIRegistration: + """Test API registration validation rules we defined.""" + + @pytest.fixture + def valid_registration_data(self): + """Sample valid registration data.""" + return { + "name": "test-api", + "display_name": "Test API", + "description": "A test API for unit testing purposes", + "openapi_url": "https://api.example.com/openapi.json", + "base_url": "https://api.example.com", + "category": APICategory.development, + "auth_config": { + "type": AuthType.none + } + } + + def test_name_pattern_rejects_underscores(self, valid_registration_data): + """Test name validation rejects underscores (pattern: ^[a-z0-9-]+$).""" + valid_registration_data["name"] = "Test_API" + with pytest.raises(ValidationError) as exc_info: + APIRegistration(**valid_registration_data) + assert "name" in str(exc_info.value) + + def test_name_pattern_rejects_uppercase(self, valid_registration_data): + """Test name validation rejects uppercase (pattern: ^[a-z0-9-]+$).""" + valid_registration_data["name"] = "TestAPI" + with pytest.raises(ValidationError) as exc_info: + APIRegistration(**valid_registration_data) + assert "name" in str(exc_info.value) + + def test_name_min_length_validation(self, valid_registration_data): + """Test name validation rejects empty string (min_length=1).""" + valid_registration_data["name"] = "" + with pytest.raises(ValidationError): + APIRegistration(**valid_registration_data) + + def test_description_min_length_validation(self, valid_registration_data): + """Test description requires minimum 10 characters (our business rule).""" + valid_registration_data["description"] = "Too short" + with pytest.raises(ValidationError) as exc_info: + APIRegistration(**valid_registration_data) + assert "description" in str(exc_info.value) + + def test_openapi_url_validation(self, valid_registration_data): + """Test OpenAPI URL must be valid HTTP(S) URL.""" + valid_registration_data["openapi_url"] = "not-a-url" + with pytest.raises(ValidationError): + APIRegistration(**valid_registration_data) + + +class TestAPIEntry: + """Test APIEntry tools conversion (critical business logic).""" + + @pytest.fixture + def sample_tools(self): + """Sample OCPTool objects.""" + return [ + OCPTool( + name="get_user", + description="Get user by ID", + method="GET", + path="/users/{id}", + parameters={"id": {"type": "string", "required": True}}, + response_schema={"type": "object"}, + operation_id="getUser", + tags=["users"] + ), + OCPTool( + name="create_user", + description="Create new user", + method="POST", + path="/users", + parameters={"body": {"type": "object"}}, + response_schema={"type": "object"}, + operation_id="createUser", + tags=["users"] + ) + ] + + @pytest.fixture + def sample_entry_data(self): + """Sample API entry data.""" + return { + "id": 1, + "name": "test-api", + "display_name": "Test API", + "description": "A test API for unit testing purposes", + "openapi_url": "https://api.example.com/openapi.json", + "base_url": "https://api.example.com", + "category": APICategory.development, + "auth_config": { + "type": AuthType.none + }, + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + "tool_count": 2 + } + + def test_tools_to_dict_conversion(self, sample_tools): + """Test converting OCPTool objects to dict format.""" + tools_dict = APIEntry.tools_to_dict(sample_tools) + + assert len(tools_dict) == 2 + assert tools_dict[0]["name"] == "get_user" + assert tools_dict[0]["method"] == "GET" + assert tools_dict[0]["path"] == "/users/{id}" + assert tools_dict[0]["operation_id"] == "getUser" + assert tools_dict[0]["tags"] == ["users"] + assert "parameters" in tools_dict[0] + assert "response_schema" in tools_dict[0] + + def test_get_ocp_tools_conversion(self, sample_entry_data, sample_tools): + """Test converting stored dicts back to OCPTool objects.""" + # First convert tools to dict format + tools_dict = APIEntry.tools_to_dict(sample_tools) + sample_entry_data["tools"] = tools_dict + + # Create entry and convert back + entry = APIEntry(**sample_entry_data) + ocp_tools = entry.get_ocp_tools() + + assert len(ocp_tools) == 2 + assert isinstance(ocp_tools[0], OCPTool) + assert ocp_tools[0].name == "get_user" + assert ocp_tools[0].method == "GET" + assert ocp_tools[0].path == "/users/{id}" + assert ocp_tools[0].operation_id == "getUser" + assert ocp_tools[0].tags == ["users"] + + def test_get_ocp_tools_empty(self, sample_entry_data): + """Test get_ocp_tools returns empty list when no tools.""" + entry = APIEntry(**sample_entry_data, tools=None) + ocp_tools = entry.get_ocp_tools() + assert ocp_tools == [] + + def test_tools_roundtrip(self, sample_tools): + """Test tools can roundtrip through dict conversion.""" + # Convert to dict and back + tools_dict = APIEntry.tools_to_dict(sample_tools) + + # Create entry with tools + entry_data = { + "id": 1, + "name": "test-api", + "display_name": "Test API", + "description": "A test API for roundtrip testing of tools", + "openapi_url": "https://api.example.com/openapi.json", + "base_url": "https://api.example.com", + "category": APICategory.development, + "auth_config": {"type": AuthType.none}, + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + "tools": tools_dict + } + entry = APIEntry(**entry_data) + + # Convert back to OCPTool + restored_tools = entry.get_ocp_tools() + + # Verify all fields match + assert len(restored_tools) == len(sample_tools) + for original, restored in zip(sample_tools, restored_tools): + assert original.name == restored.name + assert original.description == restored.description + assert original.method == restored.method + assert original.path == restored.path + assert original.operation_id == restored.operation_id + assert original.tags == restored.tags + + def test_rating_validation(self, sample_entry_data): + """Test rating must be between 0.0 and 5.0 (our business constraint).""" + # Valid rating + entry = APIEntry(**sample_entry_data, rating=4.5) + assert entry.rating == 4.5 + + # Invalid rating too high + with pytest.raises(ValidationError): + APIEntry(**sample_entry_data, rating=6.0) + + # Invalid rating negative + with pytest.raises(ValidationError): + APIEntry(**sample_entry_data, rating=-1.0) diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..d87e68f --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,401 @@ +"""Tests for RegistryService business logic.""" + +import pytest +from datetime import datetime, timezone +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from ocp_registry.database import Base, APIEntryDB, RegistryStatsDB +from ocp_registry.service import RegistryService +from ocp_registry.models import APICategory, APIStatus, AuthType, AuthConfig + + +@pytest.fixture +def test_db(): + """Create test database in memory.""" + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(bind=engine) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + session = SessionLocal() + yield session + session.close() + + +@pytest.fixture +def sample_db_entries(test_db): + """Create sample database entries.""" + entries = [ + APIEntryDB( + id=1, + name="github", + display_name="GitHub", + description="GitHub API for version control", + openapi_url="https://api.github.com/openapi.json", + base_url="https://api.github.com", + api_version="1.1.4", + category=APICategory.development, + auth_config={"type": "api_key", "api_key_header": "Authorization"}, + tags=["git", "code", "repository"], + documentation_url="https://docs.github.com/rest", + contact_email="support@github.com", + rate_limit="5000/hour", + status=APIStatus.active, + tool_count=550, + tools=[ + { + "name": "get_user", + "description": "Get user info", + "method": "GET", + "path": "/users/{username}", + "parameters": [], + "response_schema": {}, + "operation_id": "getUser", + "tags": ["users"] + } + ], + created_at=datetime(2024, 1, 1, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 1, tzinfo=timezone.utc) + ), + APIEntryDB( + id=2, + name="stripe", + display_name="Stripe", + description="Stripe payment processing API", + openapi_url="https://api.stripe.com/openapi.json", + base_url="https://api.stripe.com", + api_version="2026-01-28", + category=APICategory.finance, + auth_config={"type": "api_key", "api_key_header": "Authorization"}, + tags=["payment", "finance", "billing"], + documentation_url="https://stripe.com/docs/api", + contact_email="support@stripe.com", + rate_limit="100/second", + status=APIStatus.active, + tool_count=183, + tools=[], + created_at=datetime(2024, 1, 2, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 2, tzinfo=timezone.utc) + ), + APIEntryDB( + id=3, + name="slack", + display_name="Slack", + description="Slack messaging and collaboration API", + openapi_url="https://api.slack.com/openapi.json", + base_url="https://api.slack.com", + api_version="2.0.0", + category=APICategory.communication, + auth_config={"type": "oauth2", "token_url": "https://slack.com/api/oauth.access", "scopes": ["chat:write", "channels:read"]}, + tags=["messaging", "collaboration", "chat"], + documentation_url="https://api.slack.com", + contact_email="support@slack.com", + rate_limit="1/second", + status=APIStatus.active, + tool_count=120, + tools=[], + created_at=datetime(2024, 1, 3, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 3, tzinfo=timezone.utc) + ), + APIEntryDB( + id=4, + name="deprecated-api", + display_name="Deprecated API", + description="An old deprecated API", + openapi_url="https://old.api.com/openapi.json", + base_url="https://old.api.com", + api_version="1.0.0", + category=APICategory.other, + auth_config={"type": "none"}, + tags=["deprecated"], + documentation_url="https://old.api.com/docs", + status=APIStatus.deprecated, + tool_count=10, + tools=[], + created_at=datetime(2024, 1, 4, tzinfo=timezone.utc), + updated_at=datetime(2024, 1, 4, tzinfo=timezone.utc) + ) + ] + + for entry in entries: + test_db.add(entry) + test_db.commit() + + return entries + + +@pytest.fixture +def sample_stats(test_db): + """Create sample registry statistics.""" + stats = RegistryStatsDB( + id=1, + total_apis=4, + active_apis=3, + total_tools=853, + calculated_at=datetime.now(timezone.utc) + ) + test_db.add(stats) + test_db.commit() + return stats + + +@pytest.fixture +def service(test_db): + """Create RegistryService instance.""" + return RegistryService(test_db) + + +class TestGetAPIByID: + """Test get_api_by_id method.""" + + def test_get_existing_api_by_id(self, service, sample_db_entries): + """Should return API entry when ID exists.""" + api = service.get_api_by_id(1) + assert api is not None + assert api.name == "github" + assert api.display_name == "GitHub" + assert api.tool_count == 550 + + def test_get_nonexistent_api_by_id(self, service, sample_db_entries): + """Should return None when ID does not exist.""" + api = service.get_api_by_id(999) + assert api is None + + +class TestGetAPIByName: + """Test get_api method.""" + + def test_get_existing_api_by_name(self, service, sample_db_entries): + """Should return API entry when name exists.""" + api = service.get_api("stripe") + assert api is not None + assert api.name == "stripe" + assert api.display_name == "Stripe" + assert api.category == APICategory.finance + + def test_get_nonexistent_api_by_name(self, service, sample_db_entries): + """Should return None when name does not exist.""" + api = service.get_api("nonexistent-api") + assert api is None + + +class TestListAPIs: + """Test list_apis method.""" + + def test_list_all_active_apis(self, service, sample_db_entries): + """Should return only active APIs.""" + response = service.list_apis(page=1, per_page=20) + assert response.total == 3 # Only active APIs + assert len(response.results) == 3 + assert response.page == 1 + assert response.per_page == 20 + + # Verify no deprecated APIs are returned + api_names = [api.name for api in response.results] + assert "deprecated-api" not in api_names + + def test_list_apis_pagination_first_page(self, service, sample_db_entries): + """Should return first page of results.""" + response = service.list_apis(page=1, per_page=2) + assert response.total == 3 + assert len(response.results) == 2 + assert response.page == 1 + assert response.per_page == 2 + + def test_list_apis_pagination_second_page(self, service, sample_db_entries): + """Should return second page of results.""" + response = service.list_apis(page=2, per_page=2) + assert response.total == 3 + assert len(response.results) == 1 + assert response.page == 2 + assert response.per_page == 2 + + +class TestListAPIsByCategory: + """Test list_apis_by_category method.""" + + def test_list_apis_by_category(self, service, sample_db_entries): + """Should return only APIs in specified category.""" + response = service.list_apis_by_category(APICategory.development, page=1, per_page=20) + assert response.total == 1 + assert len(response.results) == 1 + assert response.results[0].name == "github" + assert response.results[0].category == APICategory.development + + def test_list_apis_by_category_with_pagination(self, service, sample_db_entries): + """Should paginate results within category.""" + response = service.list_apis_by_category(APICategory.finance, page=1, per_page=1) + assert response.total == 1 + assert len(response.results) == 1 + assert response.results[0].name == "stripe" + + def test_list_apis_by_category_empty_result(self, service, sample_db_entries): + """Should return empty results for category with no APIs.""" + response = service.list_apis_by_category(APICategory.ai, page=1, per_page=20) + assert response.total == 0 + assert len(response.results) == 0 + + def test_list_apis_by_category_excludes_deprecated(self, service, sample_db_entries): + """Should exclude deprecated APIs from category results.""" + response = service.list_apis_by_category(APICategory.other, page=1, per_page=20) + assert response.total == 0 # deprecated-api is the only one in 'other' category + + +class TestSearchAPIs: + """Test search_apis method.""" + + def test_search_by_name(self, service, sample_db_entries): + """Should find APIs by name.""" + response = service.search_apis("stripe", page=1, per_page=20) + assert response.query == "stripe" + assert response.total >= 1 + assert any(api.name == "stripe" for api in response.results) + + def test_search_by_display_name(self, service, sample_db_entries): + """Should find APIs by display name.""" + response = service.search_apis("GitHub", page=1, per_page=20) + assert response.total >= 1 + assert any(api.display_name == "GitHub" for api in response.results) + + def test_search_by_description(self, service, sample_db_entries): + """Should find APIs by description.""" + response = service.search_apis("payment", page=1, per_page=20) + assert response.total >= 1 + assert any("payment" in api.description.lower() for api in response.results) + + def test_search_by_tags(self, service, sample_db_entries): + """Should find APIs by tags.""" + response = service.search_apis("git", page=1, per_page=20) + assert response.total >= 1 + # GitHub has 'git' tag + github = next((api for api in response.results if api.name == "github"), None) + assert github is not None + + def test_search_with_category_filter(self, service, sample_db_entries): + """Should filter search results by category.""" + response = service.search_apis("API", category=APICategory.development, page=1, per_page=20) + assert all(api.category == APICategory.development for api in response.results) + + def test_search_no_results(self, service, sample_db_entries): + """Should return empty results for no matches.""" + response = service.search_apis("nonexistent-search-term-xyz", page=1, per_page=20) + assert response.total == 0 + assert len(response.results) == 0 + + def test_search_with_pagination(self, service, sample_db_entries): + """Should paginate search results.""" + response = service.search_apis("api", page=1, per_page=2) + assert response.page == 1 + assert response.per_page == 2 + assert len(response.results) <= 2 + + +class TestGetCategories: + """Test get_categories method.""" + + def test_get_categories_with_counts(self, service, sample_db_entries): + """Should return all categories with API counts.""" + categories = service.get_categories() + assert len(categories) == 4 # development, finance, communication, other + + # Check specific categories + dev_category = next((c for c in categories if c.category == APICategory.development), None) + assert dev_category is not None + assert dev_category.count == 1 + assert "Development" in dev_category.description + + finance_category = next((c for c in categories if c.category == APICategory.finance), None) + assert finance_category is not None + assert finance_category.count == 1 + + def test_get_categories_includes_descriptions(self, service, sample_db_entries): + """Should include descriptions for each category.""" + categories = service.get_categories() + for category in categories: + assert category.description != "" + assert len(category.description) > 0 + + +class TestGetStats: + """Test get_stats method.""" + + def test_get_stats_from_database(self, service, sample_db_entries, sample_stats): + """Should return pre-calculated statistics from database.""" + stats = service.get_stats() + assert stats.total_apis == 4 + assert stats.active_apis == 3 + assert stats.total_tools == 853 + + def test_get_stats_fallback_when_empty(self, service): + """Should return zero stats when no stats entry exists.""" + stats = service.get_stats() + assert stats.total_apis == 0 + assert stats.active_apis == 0 + assert stats.total_tools == 0 + + +class TestDBToModel: + """Test _db_to_model conversion method.""" + + def test_db_to_model_conversion(self, service, test_db, sample_db_entries): + """Should convert database entry to APIEntry model correctly.""" + db_entry = test_db.query(APIEntryDB).filter(APIEntryDB.name == "github").first() + api = service._db_to_model(db_entry) + + # Check all fields are converted + assert api.id == 1 + assert api.name == "github" + assert api.display_name == "GitHub" + assert api.description == "GitHub API for version control" + assert str(api.openapi_url) == "https://api.github.com/openapi.json" + assert str(api.base_url) == "https://api.github.com/" + assert api.api_version == "1.1.4" + assert api.category == APICategory.development + assert isinstance(api.auth_config, AuthConfig) + assert api.auth_config.type == AuthType.api_key + assert api.tags == ["git", "code", "repository"] + assert str(api.documentation_url) == "https://docs.github.com/rest" + assert api.contact_email == "support@github.com" + assert api.rate_limit == "5000/hour" + assert api.status == APIStatus.active + assert api.tool_count == 550 + assert len(api.tools) == 1 + # Compare dates without timezone + assert api.created_at.replace(tzinfo=None) == datetime(2024, 1, 1, 0, 0) + assert api.updated_at.replace(tzinfo=None) == datetime(2024, 1, 1, 0, 0) + + def test_db_to_model_oauth2_auth(self, service, test_db, sample_db_entries): + """Should correctly convert OAuth2 auth configuration.""" + db_entry = test_db.query(APIEntryDB).filter(APIEntryDB.name == "slack").first() + api = service._db_to_model(db_entry) + + assert api.auth_config.type == AuthType.oauth2 + assert str(api.auth_config.token_url) == "https://slack.com/api/oauth.access" + assert api.auth_config.scopes == ["chat:write", "channels:read"] + + def test_db_to_model_no_auth(self, service, test_db, sample_db_entries): + """Should correctly convert no auth configuration.""" + db_entry = test_db.query(APIEntryDB).filter(APIEntryDB.name == "deprecated-api").first() + api = service._db_to_model(db_entry) + + assert api.auth_config.type == AuthType.none + + def test_db_to_model_empty_tags(self, service, test_db): + """Should handle empty tags list.""" + entry = APIEntryDB( + name="test-api", + display_name="Test API", + description="Test description", + openapi_url="https://test.com/openapi.json", + base_url="https://test.com", + category=APICategory.other, + auth_config={"type": "none"}, + tags=None, # Null tags + status=APIStatus.active, + tool_count=0, + tools=[] + ) + test_db.add(entry) + test_db.commit() + + api = service._db_to_model(entry) + assert api.tags == [] From 611982faa2f5307eb91d96895225ef2733dbd420 Mon Sep 17 00:00:00 2001 From: Nathan Allen Date: Sun, 15 Feb 2026 19:13:55 -0500 Subject: [PATCH 2/3] chore: upgrade FastAPI from 0.104.0 to 0.129.0 - Update Python requirement from ^3.8 to ^3.10 - FastAPI 0.129.0 requires Python 3.10+ - Fixes ReDoc CDN issue (fixed in FastAPI 0.115.13) --- poetry.lock | 165 ++++++++++++++++--------------------------------- pyproject.toml | 4 +- 2 files changed, 54 insertions(+), 115 deletions(-) diff --git a/poetry.lock b/poetry.lock index 8a39aa4..defcb54 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "alembic" @@ -13,8 +13,6 @@ files = [ ] [package.dependencies] -importlib-metadata = {version = "*", markers = "python_version < \"3.9\""} -importlib-resources = {version = "*", markers = "python_version < \"3.9\""} Mako = "*" SQLAlchemy = ">=1.3.0" typing-extensions = ">=4" @@ -22,6 +20,18 @@ typing-extensions = ">=4" [package.extras] tz = ["backports.zoneinfo ; python_version < \"3.9\"", "tzdata"] +[[package]] +name = "annotated-doc" +version = "0.0.4" +description = "Document parameters, class attributes, return types, and variables inline, with Annotated." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, + {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -34,9 +44,6 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] -[package.dependencies] -typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} - [[package]] name = "anyio" version = "3.7.1" @@ -249,7 +256,7 @@ description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["main", "dev"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, @@ -263,24 +270,27 @@ test = ["pytest (>=6)"] [[package]] name = "fastapi" -version = "0.104.1" +version = "0.129.0" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "fastapi-0.104.1-py3-none-any.whl", hash = "sha256:752dc31160cdbd0436bb93bad51560b57e525cbb1d4bbf6f4904ceee75548241"}, - {file = "fastapi-0.104.1.tar.gz", hash = "sha256:e5e4540a7c5e1dcfbbcf5b903c234feddcdcd881f191977a1c5dfd917487e7ae"}, + {file = "fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec"}, + {file = "fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af"}, ] [package.dependencies] -anyio = ">=3.7.1,<4.0.0" -pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" -starlette = ">=0.27.0,<0.28.0" +annotated-doc = ">=0.0.2" +pydantic = ">=2.7.0" +starlette = ">=0.40.0,<1.0.0" typing-extensions = ">=4.8.0" +typing-inspection = ">=0.4.2" [package.extras] -all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.5)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.9.3)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=5.8.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] [[package]] name = "greenlet" @@ -443,55 +453,6 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] -[[package]] -name = "importlib-metadata" -version = "8.5.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.8\"" -files = [ - {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, - {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, -] - -[package.dependencies] -zipp = ">=3.20" - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -perf = ["ipython"] -test = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["pytest-mypy"] - -[[package]] -name = "importlib-resources" -version = "6.4.5" -description = "Read resources from Python packages" -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.8\"" -files = [ - {file = "importlib_resources-6.4.5-py3-none-any.whl", hash = "sha256:ac29d5f956f01d5e4bb63102a5a19957f1b9175e45649977264a1416783bb717"}, - {file = "importlib_resources-6.4.5.tar.gz", hash = "sha256:980862a1d16c9e147a59603677fa2aa5fd82b87f223b6cb870695bcfce830065"}, -] - -[package.dependencies] -zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["jaraco.test (>=5.4)", "pytest (>=6,!=8.1.*)", "zipp (>=3.17)"] -type = ["pytest-mypy"] - [[package]] name = "iniconfig" version = "2.1.0" @@ -536,9 +497,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -importlib-resources = {version = ">=1.4.0", markers = "python_version < \"3.9\""} -jsonschema-specifications = ">=2023.03.6" -pkgutil-resolve-name = {version = ">=1.3.10", markers = "python_version < \"3.9\""} +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" rpds-py = ">=0.7.1" @@ -559,7 +518,6 @@ files = [ ] [package.dependencies] -importlib-resources = {version = ">=1.4.0", markers = "python_version < \"3.9\""} referencing = ">=0.31.0" [[package]] @@ -683,19 +641,6 @@ files = [ {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] -[[package]] -name = "pkgutil-resolve-name" -version = "1.3.10" -description = "Resolve a name to an object." -optional = false -python-versions = ">=3.6" -groups = ["main"] -markers = "python_version == \"3.8\"" -files = [ - {file = "pkgutil_resolve_name-1.3.10-py3-none-any.whl", hash = "sha256:ca27cc078d25c5ad71a9de0a7a330146c4e014c2462d9af19c6b828280649c5e"}, - {file = "pkgutil_resolve_name-1.3.10.tar.gz", hash = "sha256:357d6c9e6a755653cfd78893817c0853af365dd51ec97f3d358a819373bbd174"}, -] - [[package]] name = "pluggy" version = "1.5.0" @@ -1213,22 +1158,22 @@ sqlcipher = ["sqlcipher3_binary"] [[package]] name = "starlette" -version = "0.27.0" +version = "0.52.1" description = "The little ASGI library that shines." optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "starlette-0.27.0-py3-none-any.whl", hash = "sha256:918416370e846586541235ccd38a474c08b80443ed31c578a418e2209b3eef91"}, - {file = "starlette-0.27.0.tar.gz", hash = "sha256:6a6b0d042acb8d469a01eba54e9cda6cbd24ac602c4cd016723117d6a7e73b75"}, + {file = "starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74"}, + {file = "starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933"}, ] [package.dependencies] -anyio = ">=3.4.0,<5" -typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} +anyio = ">=3.6.2,<5" +typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} [package.extras] -full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"] +full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] [[package]] name = "tomli" @@ -1237,7 +1182,7 @@ description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["dev"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, @@ -1294,7 +1239,22 @@ files = [ {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, ] -markers = {dev = "python_version < \"3.11\""} +markers = {dev = "python_version == \"3.10\""} + +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" [[package]] name = "urllib3" @@ -1334,28 +1294,7 @@ typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} [package.extras] standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] -[[package]] -name = "zipp" -version = "3.20.2" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.8\"" -files = [ - {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, - {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - [metadata] lock-version = "2.1" -python-versions = "^3.8" -content-hash = "a1435d6456687563f60b72881657aaee9964ab9859af3a664d5f0a8cf752d4bd" +python-versions = "^3.10" +content-hash = "b16e66ffd2550e4e7d3d9d59cd81dd2b8f23622a06acf190aaeca2b432ff52f1" diff --git a/pyproject.toml b/pyproject.toml index fb046cb..4a747d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,8 +23,8 @@ classifiers = [ packages = [{include = "ocp_registry", from = "src"}] [tool.poetry.dependencies] -python = "^3.8" -fastapi = "^0.104.0" +python = "^3.10" +fastapi = "^0.129.0" uvicorn = "^0.24.0" pydantic = "^2.5.0" httpx = "^0.24.0" From 9ccdd8f8f8fcad244f0ec4c47a8c190c79cef813 Mon Sep 17 00:00:00 2001 From: Nathan Allen Date: Sun, 15 Feb 2026 19:14:13 -0500 Subject: [PATCH 3/3] fix: move API documentation endpoints to /api/v1 prefix - Move docs_url from /docs to /api/v1/docs - Move redoc_url from /redoc to /api/v1/redoc - Add openapi_url at /api/v1/openapi.json - Prevents conflict with Hugo documentation site at /docs --- src/ocp_registry/main.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ocp_registry/main.py b/src/ocp_registry/main.py index 6bccd69..63ead98 100644 --- a/src/ocp_registry/main.py +++ b/src/ocp_registry/main.py @@ -20,8 +20,9 @@ def create_app() -> FastAPI: title="OCP Community Registry", description="Searchable database of API configurations for the Open Context Protocol", version="0.1.0", - docs_url="/docs", - redoc_url="/redoc", + openapi_url="/api/v1/openapi.json", + docs_url="/api/v1/docs", + redoc_url="/api/v1/redoc", lifespan=lifespan )