diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 63c1c47..e81ff29 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -17,6 +17,10 @@ GitHub API (Go scraper) [ingestion] User profiles (public.User) [user_ml] -> dbt user ML prep + embeddings -> user recommendations + +REST API (FastAPI, read-only) [serving] + -> consumed by ost-mcp (MCP server) + -> surfaces data to AI assistants ``` ## Resources (`src/linker/resources/`) @@ -44,7 +48,7 @@ Both are invoked as subprocesses by Dagster assets via `subprocess.run()`. 2. **Python Builder** (`python:3.11-slim`) — exports deps via uv to `requirements.txt` 3. **Runtime** (`python:3.11-slim`) — installs deps, copies Go binaries to `/usr/local/bin/`, runs Dagster -`docker-compose.yml` runs two services: `ost-linker` (app) and `db` (PostgreSQL with pgvector via `ankane/pgvector:v0.4.1`). DB is exposed on port 5433 by default. +`docker-compose.yml` runs four services: `webserver` (Dagster UI), `daemon` (Dagster daemon), `api` (FastAPI REST API on port 8000), and `db` (PostgreSQL with pgvector, dev only via override). DB is exposed on port 5433 by default. ## Database Schema @@ -63,6 +67,20 @@ Seed data lives in `prisma/seed/` (categories, domains, techstacks). - `language_detection.py` — `has_non_latin_chars()`, `parse_fasttext_labels()`, `is_blacklisted()` + constants (`NON_LATIN_LANGS`, `NON_LATIN_CHAR_RE`) - `serialization.py` — `make_serializable()` (datetime/UUID → string), `clean_llm_json()` (strip markdown fences) +## REST API (`src/services/api/`) + +Lightweight, read-only FastAPI service consumed by the [ost-mcp](https://github.com/opensource-together/ost-mcp) MCP server. Runs as a separate Docker container with minimal env (only `DATABASE_URL`, no Dagster/LLM secrets). + +- `main.py` — FastAPI app with lifespan (connection pool), rate limit handler +- `config.py` — `APIConfig` (pydantic-settings) reads env vars +- `database.py` — `ConnectionPool` wrapper around psycopg2 `SimpleConnectionPool` +- `dependencies.py` — FastAPI dependency injection (`get_pool`) +- `schemas.py` — Pydantic v2 response models +- `rate_limit.py` — slowapi `Limiter` instance (60 req/min/IP) +- `routes/` — `health.py`, `projects.py`, `recommendations.py`, `references.py` + +**Endpoints:** `/health`, `/projects/search`, `/projects/{id}`, `/projects/{id}/similar`, `/recommendations/trending`, `/categories`, `/domains`, `/techstacks` + ## Python Services (`src/services/python/`) - `db.py` — shared DB cursor context manager (`get_db_cursor`) used by assets diff --git a/.env.example b/.env.example index f109bf4..0f4f843 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,12 @@ FASTTEXT_MODEL_PATH="models/lid.176.ftz" # OpenRouter API key — used by LLMClassifierResource (Mistral Small). OPENROUTER_API_KEY="" +# --- API --- +# FastAPI REST API configuration (read-only, for MCP server consumption). +API_HOST=0.0.0.0 +API_PORT=8000 +API_RATE_LIMIT=60 + # --- dbt --- # Target profile: "local" (port 5433, default) or "docker" (port 5432, host "db"). # Set to "docker" when running inside a container. diff --git a/CLAUDE.md b/CLAUDE.md index f29732f..b3f5101 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,13 @@ uv sync # Install Python dependencies dagster dev -h 0.0.0.0 -p 3000 # Run Dagster locally (outside Docker) ``` +### REST API (FastAPI) +```bash +uvicorn src.services.api.main:app --host 0.0.0.0 --port 8000 # Run API locally +pytest -m api # Run API tests only +``` +The API is a lightweight, read-only service consumed by the [ost-mcp](https://github.com/opensource-together/ost-mcp) MCP server. It exposes project search, similarity, trending recommendations, and reference data. + ### dbt ```bash cd dbt && dbt deps # Install dbt packages @@ -84,6 +91,9 @@ scripts/clean_docker_images.sh # Docker image cleanup | `DBT_TARGET` | dbt target profile (`local` by default, `docker` in container) | | `DBT_PROJECT_DIR` | dbt project directory (default: `/dbt`, set to `/app/dbt` in Docker) | | `DAGSTER_HOME` | Dagster metadata directory (default: `./dagster_home`) | +| `API_HOST` | API listen host (default: `0.0.0.0`) | +| `API_PORT` | API listen port (default: `8000`) | +| `API_RATE_LIMIT` | Requests per minute per IP (default: `60`) | ## Bug Fixing diff --git a/Dockerfile b/Dockerfile index a6ec294..eeb9921 100644 --- a/Dockerfile +++ b/Dockerfile @@ -96,7 +96,7 @@ RUN groupadd -g 1000 appuser \ USER appuser # Expose Dagster webserver port -EXPOSE 3000 +EXPOSE 3000 8000 # Healthcheck for Dagster webserver HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \ diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 1a053a3..925aff5 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -26,6 +26,15 @@ services: - ./dbt:/app/dbt - ./scripts:/app/scripts + api: + environment: + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + volumes: + - ./src:/app/src + depends_on: + db: + condition: service_healthy + # ============================================================================ # DATABASE (Postgres + PGVector) — dev only # ============================================================================ diff --git a/docker-compose.yml b/docker-compose.yml index 112f8cf..c64acac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,5 +52,29 @@ services: condition: service_healthy command: ["./scripts/init.sh", "dagster-daemon", "run", "-w", "/app/workspace.yaml"] + # ============================================================================ + # REST API (FastAPI — lightweight, read-only) + # Minimal env: only DATABASE_URL, no Dagster/GitHub/LLM secrets + # ============================================================================ + api: + build: . + container_name: ost-linker-api + restart: unless-stopped + ports: + - "8000:8000" + environment: + DATABASE_URL: ${DATABASE_URL} + API_HOST: ${API_HOST:-0.0.0.0} + API_PORT: ${API_PORT:-8000} + API_RATE_LIMIT: ${API_RATE_LIMIT:-60} + DAGSTER_ROLE: api + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + command: ["./scripts/init.sh", "uvicorn", "src.services.api.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] + volumes: dagster_data: diff --git a/docs b/docs index 79796e9..f4f3bf3 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 79796e90d6547f1c5afa5e11b98ff2dbcefc2a36 +Subproject commit f4f3bf3780d60f54cfbe580e1b2d4e82144426f6 diff --git a/pyproject.toml b/pyproject.toml index 572fbd0..c03941e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,9 @@ dependencies = [ "dagster-dbt>=0.28.17,<0.29", "dbt-core>=1.8.0,<2", "dbt-postgres>=1.8.0,<2", + "fastapi>=0.115.0,<1", + "uvicorn[standard]>=0.34.0,<1", + "slowapi>=0.1.9,<0.2", ] [project.urls] @@ -95,6 +98,7 @@ select = [ [tool.ruff.lint.per-file-ignores] "src/linker/definitions.py" = ["E402"] +"src/services/api/routes/*.py" = ["B008"] [tool.ruff.lint.isort] known-first-party = ["src"] diff --git a/scripts/init.sh b/scripts/init.sh index c0bfb5b..c3eaf1f 100755 --- a/scripts/init.sh +++ b/scripts/init.sh @@ -10,6 +10,13 @@ if [ "$DAGSTER_ROLE" = "daemon" ]; then exec "$@" fi +# API skips dbt init — only needs DB +if [ "$DAGSTER_ROLE" = "api" ]; then + echo "API role: skipping dbt init." + echo "Executing command: $@" + exec "$@" +fi + # Wait for Postgres echo "Waiting for Postgres to be ready..." # Use Python to check connection using standard environment variables. diff --git a/src/services/api/__init__.py b/src/services/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/api/config.py b/src/services/api/config.py new file mode 100644 index 0000000..47def96 --- /dev/null +++ b/src/services/api/config.py @@ -0,0 +1,13 @@ +from pydantic import Field +from pydantic_settings import BaseSettings + + +class APIConfig(BaseSettings): + """API configuration loaded from environment variables.""" + + database_url: str = Field(alias="DATABASE_URL") + host: str = Field(default="0.0.0.0", alias="API_HOST") + port: int = Field(default=8000, alias="API_PORT") + rate_limit: int = Field(default=60, alias="API_RATE_LIMIT") + + model_config = {"populate_by_name": True} diff --git a/src/services/api/database.py b/src/services/api/database.py new file mode 100644 index 0000000..c350f79 --- /dev/null +++ b/src/services/api/database.py @@ -0,0 +1,31 @@ +from collections.abc import Generator +from contextlib import contextmanager +from typing import Any + +from psycopg2.extras import RealDictCursor +from psycopg2.pool import SimpleConnectionPool + + +class ConnectionPool: + """Thin wrapper around psycopg2 SimpleConnectionPool.""" + + def __init__(self, database_url: str, minconn: int = 1, maxconn: int = 5) -> None: + self._pool = SimpleConnectionPool(minconn, maxconn, database_url) + + @contextmanager + def get_cursor(self) -> Generator[Any, None, None]: + """Yield a RealDictCursor, rollback on exit, return conn to pool.""" + conn = self._pool.getconn() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + yield cur + conn.rollback() + except Exception: + conn.rollback() + raise + finally: + self._pool.putconn(conn) + + def close(self) -> None: + """Close all pooled connections.""" + self._pool.closeall() diff --git a/src/services/api/dependencies.py b/src/services/api/dependencies.py new file mode 100644 index 0000000..53156b9 --- /dev/null +++ b/src/services/api/dependencies.py @@ -0,0 +1,22 @@ +from src.services.api.database import ConnectionPool + +_pool: ConnectionPool | None = None + + +def init_pool(database_url: str) -> None: + """Initialize the global connection pool.""" + global _pool + _pool = ConnectionPool(database_url, minconn=1, maxconn=5) + + +def close_pool() -> None: + """Close the global connection pool.""" + if _pool: + _pool.close() + + +def get_pool() -> ConnectionPool: + """FastAPI dependency: returns the connection pool.""" + if _pool is None: + raise RuntimeError("Connection pool not initialized") + return _pool diff --git a/src/services/api/main.py b/src/services/api/main.py new file mode 100644 index 0000000..948574a --- /dev/null +++ b/src/services/api/main.py @@ -0,0 +1,53 @@ +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request, Response +from fastapi.responses import JSONResponse +from slowapi.errors import RateLimitExceeded + +from src.services.api.config import APIConfig +from src.services.api.dependencies import close_pool, init_pool +from src.services.api.rate_limit import limiter +from src.services.api.routes import health, projects, recommendations, references + + +def _get_config() -> APIConfig: + return APIConfig() # type: ignore[call-arg] + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + """Startup: init pool. Shutdown: close pool.""" + config = _get_config() + init_pool(config.database_url) + yield + close_pool() + + +def _rate_limit_handler(request: Request, exc: RateLimitExceeded) -> Response: + return JSONResponse( + status_code=429, + content={"detail": "Rate limit exceeded"}, + ) + + +app = FastAPI( + title="OST Linker API", + description="Open-source project recommendations", + version="1.0.0", + lifespan=lifespan, +) + +# Rate limiting via @limiter.limit() decorators on routes. +# slowapi's SlowAPIMiddleware has compatibility issues with sync endpoints, +# so we use the per-route decorator approach instead. +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_handler) # type: ignore[arg-type] + +# NOTE: No CORS middleware — this API is consumed server-to-server by the MCP +# backend, not by browsers. Add CORSMiddleware if browser access is needed later. + +app.include_router(health.router) +app.include_router(references.router) +app.include_router(projects.router) +app.include_router(recommendations.router) diff --git a/src/services/api/rate_limit.py b/src/services/api/rate_limit.py new file mode 100644 index 0000000..38404a8 --- /dev/null +++ b/src/services/api/rate_limit.py @@ -0,0 +1,4 @@ +from slowapi import Limiter +from slowapi.util import get_remote_address + +limiter = Limiter(key_func=get_remote_address) diff --git a/src/services/api/routes/__init__.py b/src/services/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/api/routes/health.py b/src/services/api/routes/health.py new file mode 100644 index 0000000..478b0ef --- /dev/null +++ b/src/services/api/routes/health.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter, Depends + +from src.services.api.database import ConnectionPool +from src.services.api.dependencies import get_pool + +router = APIRouter() + + +@router.get("/health") +def health(pool: ConnectionPool = Depends(get_pool)) -> dict[str, str]: + """Health check endpoint -- verifies DB connectivity.""" + with pool.get_cursor() as cur: + cur.execute("SELECT 1") + return {"status": "ok"} diff --git a/src/services/api/routes/projects.py b/src/services/api/routes/projects.py new file mode 100644 index 0000000..b383858 --- /dev/null +++ b/src/services/api/routes/projects.py @@ -0,0 +1,143 @@ +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, Request + +from src.services.api.database import ConnectionPool +from src.services.api.dependencies import get_pool +from src.services.api.rate_limit import limiter +from src.services.api.schemas import ProjectOut, ProjectSimilarOut + +router = APIRouter(prefix="/projects") + +MAX_LIMIT = 50 + + +@router.get("/search", response_model=list[ProjectOut]) +@limiter.limit("60/minute") +def search_projects( + request: Request, + q: str = Query(..., min_length=1), + category: str | None = None, + domain: str | None = None, + techstack: str | None = None, + limit: int = Query(default=20, ge=1, le=MAX_LIMIT), + pool: ConnectionPool = Depends(get_pool), +) -> list[dict[str, Any]]: + """Search projects by keyword, optionally filtered by category/domain/techstack.""" + query = """ + SELECT DISTINCT p.id, p.title, p.description, p."repoUrl" AS repo_url, + p.published, p.trending, p."logoUrl" AS logo_url + FROM public."Project" p + LEFT JOIN public.project_category pc ON p.id = pc."projectId" + LEFT JOIN public."Category" c ON pc."categoryId" = c.id + LEFT JOIN public.project_domain pd ON p.id = pd."projectId" + LEFT JOIN public."Domain" d ON pd."domainId" = d.id + LEFT JOIN public.project_tech_stack pts ON p.id = pts."projectId" + LEFT JOIN public.tech_stack ts ON pts."techStackId" = ts.id + WHERE (p.published = true OR p.trending = true) + AND (p.title ILIKE %s OR p.description ILIKE %s) + """ + escaped = q.replace("%", "\\%").replace("_", "\\_") + pattern = f"%{escaped}%" + params: list[Any] = [pattern, pattern] + + if category: + query += " AND c.name = %s" + params.append(category) + if domain: + query += " AND d.name = %s" + params.append(domain) + if techstack: + query += " AND ts.name = %s" + params.append(techstack) + + query += " ORDER BY p.trending DESC, p.title LIMIT %s" + params.append(limit) + + with pool.get_cursor() as cur: + cur.execute(query, params) + return list(cur.fetchall()) + + +@router.get("/{project_id}", response_model=ProjectOut) +@limiter.limit("60/minute") +def get_project( + request: Request, + project_id: str, + pool: ConnectionPool = Depends(get_pool), +) -> dict[str, Any]: + """Get full project details by ID.""" + with pool.get_cursor() as cur: + cur.execute( + """SELECT id, title, description, "repoUrl" AS repo_url, + published, trending, "logoUrl" AS logo_url + FROM public."Project" + WHERE id = %s""", + (project_id,), + ) + project = cur.fetchone() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + cur.execute( + """SELECT c.id, c.name FROM public."Category" c + JOIN public.project_category pc ON c.id = pc."categoryId" + WHERE pc."projectId" = %s""", + (project_id,), + ) + categories = cur.fetchall() + + cur.execute( + """SELECT d.id, d.name FROM public."Domain" d + JOIN public.project_domain pd ON d.id = pd."domainId" + WHERE pd."projectId" = %s""", + (project_id,), + ) + domains = cur.fetchall() + + cur.execute( + """SELECT ts.id, ts.name, ts."iconUrl" AS icon_url, ts.type::text + FROM public.tech_stack ts + JOIN public.project_tech_stack pts ON ts.id = pts."techStackId" + WHERE pts."projectId" = %s""", + (project_id,), + ) + tech_stacks = cur.fetchall() + + result = dict(project) + result["categories"] = categories + result["domains"] = domains + result["tech_stacks"] = tech_stacks + return result + + +@router.get("/{project_id}/similar", response_model=list[ProjectSimilarOut]) +@limiter.limit("60/minute") +def find_similar( + request: Request, + project_id: str, + limit: int = Query(default=10, ge=1, le=MAX_LIMIT), + pool: ConnectionPool = Depends(get_pool), +) -> list[dict[str, Any]]: + """Find similar projects using pgvector cosine similarity.""" + with pool.get_cursor() as cur: + cur.execute( + 'SELECT vector FROM ml.embd_github_project WHERE "projectId" = %s', + (project_id,), + ) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Project embedding not found") + + cur.execute( + """SELECT p.id, p.title, p.description, p."repoUrl" AS repo_url, + 1 - (e.vector <=> ref.vector) AS similarity + FROM ml.embd_github_project e + JOIN ml.embd_github_project ref ON ref."projectId" = %s + JOIN public."Project" p ON p.id = e."projectId" + WHERE e."projectId" != %s + AND (p.published = true OR p.trending = true) + ORDER BY e.vector <=> ref.vector + LIMIT %s""", + (project_id, project_id, limit), + ) + return list(cur.fetchall()) diff --git a/src/services/api/routes/recommendations.py b/src/services/api/routes/recommendations.py new file mode 100644 index 0000000..a0e4ec6 --- /dev/null +++ b/src/services/api/routes/recommendations.py @@ -0,0 +1,29 @@ +from typing import Any + +from fastapi import APIRouter, Depends, Query, Request + +from src.services.api.database import ConnectionPool +from src.services.api.dependencies import get_pool +from src.services.api.rate_limit import limiter +from src.services.api.schemas import TrendingProjectOut + +router = APIRouter(prefix="/recommendations") + + +@router.get("/trending", response_model=list[TrendingProjectOut]) +@limiter.limit("60/minute") +def get_trending( + request: Request, + limit: int = Query(default=20, ge=1, le=50), + pool: ConnectionPool = Depends(get_pool), +) -> list[dict[str, Any]]: + """Get globally trending/popular projects.""" + with pool.get_cursor() as cur: + cur.execute( + """SELECT project_id, stars, last_synced_at + FROM public.match_global_recommendation + ORDER BY stars DESC NULLS LAST + LIMIT %s""", + (limit,), + ) + return list(cur.fetchall()) diff --git a/src/services/api/routes/references.py b/src/services/api/routes/references.py new file mode 100644 index 0000000..f3466b5 --- /dev/null +++ b/src/services/api/routes/references.py @@ -0,0 +1,45 @@ +from fastapi import APIRouter, Depends, Request + +from src.services.api.database import ConnectionPool +from src.services.api.dependencies import get_pool +from src.services.api.rate_limit import limiter +from src.services.api.schemas import CategoryOut, DomainOut, TechStackOut + +router = APIRouter() + + +@router.get("/categories", response_model=list[CategoryOut]) +@limiter.limit("60/minute") +def list_categories( + request: Request, pool: ConnectionPool = Depends(get_pool) +) -> list[dict]: + """List all project categories.""" + with pool.get_cursor() as cur: + cur.execute('SELECT id, name FROM public."Category" ORDER BY name') + return list(cur.fetchall()) + + +@router.get("/domains", response_model=list[DomainOut]) +@limiter.limit("60/minute") +def list_domains( + request: Request, pool: ConnectionPool = Depends(get_pool) +) -> list[dict]: + """List all project domains.""" + with pool.get_cursor() as cur: + cur.execute('SELECT id, name FROM public."Domain" ORDER BY name') + return list(cur.fetchall()) + + +@router.get("/techstacks", response_model=list[TechStackOut]) +@limiter.limit("60/minute") +def list_techstacks( + request: Request, pool: ConnectionPool = Depends(get_pool) +) -> list[dict]: + """List all tech stacks.""" + with pool.get_cursor() as cur: + cur.execute( + """SELECT id, name, "iconUrl" AS icon_url, type::text + FROM public.tech_stack + ORDER BY name""" + ) + return list(cur.fetchall()) diff --git a/src/services/api/schemas.py b/src/services/api/schemas.py new file mode 100644 index 0000000..db194aa --- /dev/null +++ b/src/services/api/schemas.py @@ -0,0 +1,51 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class CategoryOut(BaseModel): + id: str + name: str + + +class DomainOut(BaseModel): + id: str + name: str + + +class TechStackOut(BaseModel): + id: str + name: str + icon_url: str + type: str + + +class ProjectOut(BaseModel): + id: str + title: str + description: str | None = None + repo_url: str | None = None + published: bool = False + trending: bool = False + logo_url: str | None = None + categories: list[CategoryOut] = [] + domains: list[DomainOut] = [] + tech_stacks: list[TechStackOut] = [] + + +class ProjectSimilarOut(BaseModel): + id: str + title: str + description: str | None = None + repo_url: str | None = None + similarity: float + + +class TrendingProjectOut(BaseModel): + project_id: str + stars: int | None = None + last_synced_at: datetime | None = None + + +class ErrorOut(BaseModel): + detail: str diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/conftest.py b/tests/api/conftest.py new file mode 100644 index 0000000..4bf994e --- /dev/null +++ b/tests/api/conftest.py @@ -0,0 +1,27 @@ +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def client() -> Generator[TestClient, None, None]: + """FastAPI test client with mocked DB pool.""" + mock_pool = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchone.return_value = {"?column?": 1} + mock_pool.get_cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) + mock_pool.get_cursor.return_value.__exit__ = MagicMock(return_value=False) + + with ( + patch("src.services.api.dependencies._pool", mock_pool), + patch("src.services.api.main._get_config") as mock_cfg, + patch("src.services.api.dependencies.init_pool"), + ): + mock_cfg.return_value = MagicMock( + database_url="postgresql://test:test@localhost:5432/test", + ) + from src.services.api.main import app + + yield TestClient(app) diff --git a/tests/api/test_config.py b/tests/api/test_config.py new file mode 100644 index 0000000..638cde9 --- /dev/null +++ b/tests/api/test_config.py @@ -0,0 +1,19 @@ +import pytest + +from src.services.api.config import APIConfig + + +class TestAPIConfig: + def test_loads_defaults(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Config loads with sensible defaults when only DATABASE_URL is set.""" + monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:5432/db") + cfg = APIConfig() + assert cfg.host == "0.0.0.0" + assert cfg.port == 8000 + assert cfg.rate_limit == 60 + + def test_missing_database_url_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Config raises ValidationError when DATABASE_URL is missing.""" + monkeypatch.delenv("DATABASE_URL", raising=False) + with pytest.raises((Exception, SystemExit)): + APIConfig() diff --git a/tests/api/test_contract.py b/tests/api/test_contract.py new file mode 100644 index 0000000..7cf53ae --- /dev/null +++ b/tests/api/test_contract.py @@ -0,0 +1,325 @@ +"""Contract tests ensuring API responses match the shape expected by ost-mcp OSTClient. + +Each test verifies that the JSON response contains exactly the fields the MCP +TypeScript client expects (see ost-mcp/src/types.ts), with correct types. +""" + +from collections.abc import Generator +from datetime import UTC, datetime +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +# -- Expected fields per MCP type (from ost-mcp/src/types.ts) ----------------- + +CATEGORY_FIELDS = {"id": str, "name": str} +DOMAIN_FIELDS = {"id": str, "name": str} +TECHSTACK_FIELDS = {"id": str, "name": str, "icon_url": str, "type": str} +PROJECT_FIELDS = { + "id": str, + "title": str, + "description": (str, type(None)), + "repo_url": (str, type(None)), + "published": bool, + "trending": bool, + "logo_url": (str, type(None)), + "categories": list, + "domains": list, + "tech_stacks": list, +} +SIMILAR_PROJECT_FIELDS = { + "id": str, + "title": str, + "description": (str, type(None)), + "repo_url": (str, type(None)), + "similarity": (int, float), +} +TRENDING_FIELDS = { + "project_id": str, + "stars": (int, type(None)), + "last_synced_at": (str, type(None)), +} + + +def _assert_shape(obj: dict, fields: dict) -> None: + """Assert that obj has exactly the expected keys with matching types.""" + assert set(obj.keys()) == set(fields.keys()), ( + f"Key mismatch: got {set(obj.keys())}, expected {set(fields.keys())}" + ) + for key, expected_type in fields.items(): + assert isinstance(obj[key], expected_type), ( + f"Field '{key}': expected {expected_type}, got {type(obj[key])}" + ) + + +# -- Fixtures ------------------------------------------------------------------ + +FAKE_PROJECT_ROW = { + "id": "proj-1", + "title": "Test Project", + "description": "A test project", + "repo_url": "https://github.com/test/test", + "published": True, + "trending": False, + "logo_url": None, +} + +FAKE_CATEGORY_ROW = {"id": "cat-1", "name": "Web"} +FAKE_DOMAIN_ROW = {"id": "dom-1", "name": "Frontend"} +FAKE_TECHSTACK_ROW = { + "id": "ts-1", + "name": "React", + "icon_url": "https://example.com/react.svg", + "type": "framework", +} + + +@pytest.fixture() +def contract_client() -> Generator[TestClient, None, None]: + """TestClient with mock DB returning realistic row data.""" + mock_pool = MagicMock() + mock_cursor = MagicMock() + mock_pool.get_cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) + mock_pool.get_cursor.return_value.__exit__ = MagicMock(return_value=False) + + mock_cursor.fetchall.return_value = [] + mock_cursor.fetchone.return_value = None + + with ( + patch("src.services.api.dependencies._pool", mock_pool), + patch("src.services.api.main._get_config") as mock_cfg, + patch("src.services.api.dependencies.init_pool"), + ): + mock_cfg.return_value = MagicMock( + database_url="postgresql://test:test@localhost:5432/test", + ) + from src.services.api.main import app + + yield TestClient(app) + + # Store cursor ref for per-test configuration + contract_client._mock_cursor = mock_cursor # type: ignore[attr-defined] + + +@pytest.fixture() +def mock_cursor(contract_client: TestClient) -> MagicMock: + """Access the mock cursor to configure return values per test.""" + pool = contract_client.app.dependency_overrides.get(None) + # Get cursor from the patched pool + with patch("src.services.api.dependencies._pool") as p: + return p.get_cursor.return_value.__enter__.return_value + + +# -- Contract Tests ------------------------------------------------------------ + + +class TestCategoryContract: + """GET /categories must return Category[] shape.""" + + def test_response_matches_mcp_category_type( + self, contract_client: TestClient + ) -> None: + with patch("src.services.api.dependencies._pool") as mock_pool: + cursor = MagicMock() + cursor.fetchall.return_value = [FAKE_CATEGORY_ROW] + mock_pool.get_cursor.return_value.__enter__ = MagicMock(return_value=cursor) + mock_pool.get_cursor.return_value.__exit__ = MagicMock(return_value=False) + + resp = contract_client.get("/categories") + + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) == 1 + _assert_shape(data[0], CATEGORY_FIELDS) + + +class TestDomainContract: + """GET /domains must return Domain[] shape.""" + + def test_response_matches_mcp_domain_type( + self, contract_client: TestClient + ) -> None: + with patch("src.services.api.dependencies._pool") as mock_pool: + cursor = MagicMock() + cursor.fetchall.return_value = [FAKE_DOMAIN_ROW] + mock_pool.get_cursor.return_value.__enter__ = MagicMock(return_value=cursor) + mock_pool.get_cursor.return_value.__exit__ = MagicMock(return_value=False) + + resp = contract_client.get("/domains") + + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) == 1 + _assert_shape(data[0], DOMAIN_FIELDS) + + +class TestTechStackContract: + """GET /techstacks must return TechStack[] shape.""" + + def test_response_matches_mcp_techstack_type( + self, contract_client: TestClient + ) -> None: + with patch("src.services.api.dependencies._pool") as mock_pool: + cursor = MagicMock() + cursor.fetchall.return_value = [FAKE_TECHSTACK_ROW] + mock_pool.get_cursor.return_value.__enter__ = MagicMock(return_value=cursor) + mock_pool.get_cursor.return_value.__exit__ = MagicMock(return_value=False) + + resp = contract_client.get("/techstacks") + + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) == 1 + _assert_shape(data[0], TECHSTACK_FIELDS) + + +class TestProjectSearchContract: + """GET /projects/search must return Project[] shape.""" + + def test_response_matches_mcp_project_type( + self, contract_client: TestClient + ) -> None: + with patch("src.services.api.dependencies._pool") as mock_pool: + cursor = MagicMock() + cursor.fetchall.return_value = [FAKE_PROJECT_ROW] + mock_pool.get_cursor.return_value.__enter__ = MagicMock(return_value=cursor) + mock_pool.get_cursor.return_value.__exit__ = MagicMock(return_value=False) + + resp = contract_client.get("/projects/search?q=test") + + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) == 1 + _assert_shape(data[0], PROJECT_FIELDS) + + def test_search_project_contains_nested_references( + self, contract_client: TestClient + ) -> None: + """Verify categories/domains/tech_stacks are arrays (even if empty).""" + with patch("src.services.api.dependencies._pool") as mock_pool: + cursor = MagicMock() + cursor.fetchall.return_value = [FAKE_PROJECT_ROW] + mock_pool.get_cursor.return_value.__enter__ = MagicMock(return_value=cursor) + mock_pool.get_cursor.return_value.__exit__ = MagicMock(return_value=False) + + resp = contract_client.get("/projects/search?q=test") + + project = resp.json()[0] + assert isinstance(project["categories"], list) + assert isinstance(project["domains"], list) + assert isinstance(project["tech_stacks"], list) + + +class TestProjectDetailContract: + """GET /projects/{id} must return Project shape with nested references.""" + + def test_response_matches_mcp_project_type_with_relations( + self, contract_client: TestClient + ) -> None: + with patch("src.services.api.dependencies._pool") as mock_pool: + cursor = MagicMock() + # get_project makes 4 sequential queries + cursor.fetchone.return_value = FAKE_PROJECT_ROW + cursor.fetchall.side_effect = [ + [FAKE_CATEGORY_ROW], + [FAKE_DOMAIN_ROW], + [FAKE_TECHSTACK_ROW], + ] + mock_pool.get_cursor.return_value.__enter__ = MagicMock(return_value=cursor) + mock_pool.get_cursor.return_value.__exit__ = MagicMock(return_value=False) + + resp = contract_client.get("/projects/proj-1") + + assert resp.status_code == 200 + data = resp.json() + _assert_shape(data, PROJECT_FIELDS) + + # Verify nested objects match their MCP types + assert len(data["categories"]) == 1 + _assert_shape(data["categories"][0], CATEGORY_FIELDS) + assert len(data["domains"]) == 1 + _assert_shape(data["domains"][0], DOMAIN_FIELDS) + assert len(data["tech_stacks"]) == 1 + _assert_shape(data["tech_stacks"][0], TECHSTACK_FIELDS) + + +class TestSimilarContract: + """GET /projects/{id}/similar must return SimilarProject[] shape.""" + + def test_response_matches_mcp_similar_project_type( + self, contract_client: TestClient + ) -> None: + similar_row = { + "id": "proj-2", + "title": "Similar Project", + "description": "Another project", + "repo_url": "https://github.com/test/similar", + "similarity": 0.87, + } + with patch("src.services.api.dependencies._pool") as mock_pool: + cursor = MagicMock() + # First query: check embedding exists + cursor.fetchone.return_value = {"vector": [0.1] * 384} + # Second query: similar projects + cursor.fetchall.return_value = [similar_row] + mock_pool.get_cursor.return_value.__enter__ = MagicMock(return_value=cursor) + mock_pool.get_cursor.return_value.__exit__ = MagicMock(return_value=False) + + resp = contract_client.get("/projects/proj-1/similar") + + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) == 1 + _assert_shape(data[0], SIMILAR_PROJECT_FIELDS) + assert 0 <= data[0]["similarity"] <= 1 + + +class TestTrendingContract: + """GET /recommendations/trending must return TrendingProject[] shape.""" + + def test_response_matches_mcp_trending_type( + self, contract_client: TestClient + ) -> None: + trending_row = { + "project_id": "proj-1", + "stars": 1500, + "last_synced_at": datetime(2025, 1, 15, tzinfo=UTC), + } + with patch("src.services.api.dependencies._pool") as mock_pool: + cursor = MagicMock() + cursor.fetchall.return_value = [trending_row] + mock_pool.get_cursor.return_value.__enter__ = MagicMock(return_value=cursor) + mock_pool.get_cursor.return_value.__exit__ = MagicMock(return_value=False) + + resp = contract_client.get("/recommendations/trending") + + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) == 1 + _assert_shape(data[0], TRENDING_FIELDS) + + def test_trending_with_null_fields(self, contract_client: TestClient) -> None: + """MCP client must handle null stars and last_synced_at.""" + trending_row = { + "project_id": "proj-2", + "stars": None, + "last_synced_at": None, + } + with patch("src.services.api.dependencies._pool") as mock_pool: + cursor = MagicMock() + cursor.fetchall.return_value = [trending_row] + mock_pool.get_cursor.return_value.__enter__ = MagicMock(return_value=cursor) + mock_pool.get_cursor.return_value.__exit__ = MagicMock(return_value=False) + + resp = contract_client.get("/recommendations/trending") + + data = resp.json()[0] + assert data["stars"] is None + assert data["last_synced_at"] is None diff --git a/tests/api/test_database.py b/tests/api/test_database.py new file mode 100644 index 0000000..9255515 --- /dev/null +++ b/tests/api/test_database.py @@ -0,0 +1,24 @@ +from unittest.mock import MagicMock + +from src.services.api.database import ConnectionPool + + +class TestConnectionPool: + def test_get_cursor_yields_realdict_cursor(self) -> None: + """get_cursor yields a RealDictCursor from the pool.""" + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) + mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False) + + mock_pool = MagicMock() + mock_pool.getconn.return_value = mock_conn + + pool = ConnectionPool.__new__(ConnectionPool) + pool._pool = mock_pool + + with pool.get_cursor() as cur: + assert cur is mock_cursor + + mock_conn.rollback.assert_called_once() + mock_pool.putconn.assert_called_once_with(mock_conn) diff --git a/tests/api/test_health.py b/tests/api/test_health.py new file mode 100644 index 0000000..c70ed5e --- /dev/null +++ b/tests/api/test_health.py @@ -0,0 +1,9 @@ +from fastapi.testclient import TestClient + + +class TestHealth: + def test_health_returns_ok(self, client: TestClient) -> None: + """GET /health returns 200 with status ok.""" + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} diff --git a/tests/api/test_projects.py b/tests/api/test_projects.py new file mode 100644 index 0000000..581942c --- /dev/null +++ b/tests/api/test_projects.py @@ -0,0 +1,201 @@ +from unittest.mock import MagicMock + +from fastapi.testclient import TestClient + +from src.services.api.dependencies import get_pool +from src.services.api.main import app + + +def _make_pool(rows: list[dict]) -> MagicMock: + """Create a mock pool whose cursor returns given rows.""" + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = rows + mock_cursor.fetchone.return_value = rows[0] if rows else None + mock_pool = MagicMock() + mock_pool.get_cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) + mock_pool.get_cursor.return_value.__exit__ = MagicMock(return_value=False) + return mock_pool + + +class TestSearchProjects: + def test_search_with_query(self, client: TestClient) -> None: + """GET /projects/search?q=react returns matching projects.""" + pool = _make_pool( + [ + { + "id": "1", + "title": "React App", + "description": "A react app", + "repo_url": "https://github.com/org/react-app", + "published": True, + "trending": False, + "logo_url": None, + }, + ] + ) + app.dependency_overrides[get_pool] = lambda: pool + try: + response = client.get("/projects/search?q=react") + finally: + app.dependency_overrides.pop(get_pool, None) + + assert response.status_code == 200 + data = response.json() + assert len(data) >= 1 + assert data[0]["title"] == "React App" + + def test_search_empty_query_returns_422(self, client: TestClient) -> None: + """GET /projects/search without q returns 422 (validation error).""" + response = client.get("/projects/search") + assert response.status_code == 422 + + def test_search_with_filters_passes_category_to_sql( + self, client: TestClient + ) -> None: + """GET /projects/search with category filter adds AND c.name = %s.""" + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = [] + mock_pool = MagicMock() + mock_pool.get_cursor.return_value.__enter__ = MagicMock( + return_value=mock_cursor + ) + mock_pool.get_cursor.return_value.__exit__ = MagicMock(return_value=False) + + app.dependency_overrides[get_pool] = lambda: mock_pool + try: + response = client.get("/projects/search?q=test&category=Web+Development") + finally: + app.dependency_overrides.pop(get_pool, None) + + assert response.status_code == 200 + sql = mock_cursor.execute.call_args[0][0] + params = mock_cursor.execute.call_args[0][1] + assert "AND c.name = %s" in sql + assert "Web Development" in params + + def test_search_limit_over_max_returns_422(self, client: TestClient) -> None: + """GET /projects/search?limit=100 returns 422 since limit exceeds max (50).""" + pool = _make_pool([]) + app.dependency_overrides[get_pool] = lambda: pool + try: + response = client.get("/projects/search?q=test&limit=100") + finally: + app.dependency_overrides.pop(get_pool, None) + + assert response.status_code == 422 + + +class TestGetProject: + def test_get_existing_project(self, client: TestClient) -> None: + """GET /projects/{id} returns project details.""" + project_row = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "title": "My Project", + "description": "Desc", + "repo_url": "https://github.com/org/repo", + "published": True, + "trending": False, + "logo_url": None, + } + categories = [{"id": "c1", "name": "Web"}] + domains = [{"id": "d1", "name": "Finance"}] + tech_stacks = [ + {"id": "t1", "name": "Python", "icon_url": "http://img", "type": "LANGUAGE"} + ] + + mock_cursor = MagicMock() + mock_cursor.fetchone.return_value = project_row + mock_cursor.fetchall.side_effect = [categories, domains, tech_stacks] + mock_pool = MagicMock() + mock_pool.get_cursor.return_value.__enter__ = MagicMock( + return_value=mock_cursor + ) + mock_pool.get_cursor.return_value.__exit__ = MagicMock(return_value=False) + + app.dependency_overrides[get_pool] = lambda: mock_pool + try: + response = client.get("/projects/550e8400-e29b-41d4-a716-446655440000") + finally: + app.dependency_overrides.pop(get_pool, None) + + assert response.status_code == 200 + data = response.json() + assert data["title"] == "My Project" + assert data["categories"] == [{"id": "c1", "name": "Web"}] + assert data["domains"] == [{"id": "d1", "name": "Finance"}] + assert len(data["tech_stacks"]) == 1 + assert data["tech_stacks"][0]["name"] == "Python" + + def test_get_nonexistent_project_returns_404(self, client: TestClient) -> None: + """GET /projects/{id} returns 404 for unknown ID.""" + mock_cursor = MagicMock() + mock_cursor.fetchone.return_value = None + mock_pool = MagicMock() + mock_pool.get_cursor.return_value.__enter__ = MagicMock( + return_value=mock_cursor + ) + mock_pool.get_cursor.return_value.__exit__ = MagicMock(return_value=False) + + app.dependency_overrides[get_pool] = lambda: mock_pool + try: + response = client.get("/projects/550e8400-e29b-41d4-a716-446655440000") + finally: + app.dependency_overrides.pop(get_pool, None) + + assert response.status_code == 404 + + +class TestFindSimilar: + def test_find_similar_returns_list(self, client: TestClient) -> None: + """GET /projects/{id}/similar returns similar projects.""" + mock_cursor = MagicMock() + mock_cursor.fetchone.side_effect = [ + {"vector": "[0.1, 0.2]"}, + ] + mock_cursor.fetchall.return_value = [ + { + "id": "2", + "title": "Similar Project", + "description": "Desc", + "repo_url": "https://github.com/org/similar", + "similarity": 0.85, + }, + ] + mock_pool = MagicMock() + mock_pool.get_cursor.return_value.__enter__ = MagicMock( + return_value=mock_cursor + ) + mock_pool.get_cursor.return_value.__exit__ = MagicMock(return_value=False) + + app.dependency_overrides[get_pool] = lambda: mock_pool + try: + response = client.get( + "/projects/550e8400-e29b-41d4-a716-446655440000/similar" + ) + finally: + app.dependency_overrides.pop(get_pool, None) + + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["similarity"] == 0.85 + + def test_find_similar_no_embedding_returns_404(self, client: TestClient) -> None: + """GET /projects/{id}/similar returns 404 when no embedding exists.""" + mock_cursor = MagicMock() + mock_cursor.fetchone.return_value = None + mock_pool = MagicMock() + mock_pool.get_cursor.return_value.__enter__ = MagicMock( + return_value=mock_cursor + ) + mock_pool.get_cursor.return_value.__exit__ = MagicMock(return_value=False) + + app.dependency_overrides[get_pool] = lambda: mock_pool + try: + response = client.get( + "/projects/550e8400-e29b-41d4-a716-446655440000/similar" + ) + finally: + app.dependency_overrides.pop(get_pool, None) + + assert response.status_code == 404 diff --git a/tests/api/test_recommendations.py b/tests/api/test_recommendations.py new file mode 100644 index 0000000..21e11d9 --- /dev/null +++ b/tests/api/test_recommendations.py @@ -0,0 +1,57 @@ +from datetime import datetime +from unittest.mock import MagicMock + +from fastapi.testclient import TestClient + +from src.services.api.dependencies import get_pool +from src.services.api.main import app + + +def _make_pool(rows: list[dict]) -> MagicMock: + """Create a mock pool whose cursor returns given rows.""" + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = rows + mock_pool = MagicMock() + mock_pool.get_cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) + mock_pool.get_cursor.return_value.__exit__ = MagicMock(return_value=False) + return mock_pool + + +class TestTrending: + def test_get_trending_returns_list(self, client: TestClient) -> None: + """GET /recommendations/trending returns trending projects.""" + pool = _make_pool( + [ + { + "project_id": "1", + "stars": 1500, + "last_synced_at": datetime(2026, 1, 1), + }, + { + "project_id": "2", + "stars": 800, + "last_synced_at": datetime(2026, 1, 1), + }, + ] + ) + app.dependency_overrides[get_pool] = lambda: pool + try: + response = client.get("/recommendations/trending") + finally: + app.dependency_overrides.pop(get_pool, None) + + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert data[0]["stars"] == 1500 + + def test_get_trending_respects_limit(self, client: TestClient) -> None: + """GET /recommendations/trending?limit=5 limits results.""" + pool = _make_pool([]) + app.dependency_overrides[get_pool] = lambda: pool + try: + response = client.get("/recommendations/trending?limit=5") + finally: + app.dependency_overrides.pop(get_pool, None) + + assert response.status_code == 200 diff --git a/tests/api/test_references.py b/tests/api/test_references.py new file mode 100644 index 0000000..2a88b74 --- /dev/null +++ b/tests/api/test_references.py @@ -0,0 +1,79 @@ +from unittest.mock import MagicMock + +from fastapi.testclient import TestClient + +from src.services.api.dependencies import get_pool +from src.services.api.main import app + + +def _make_pool(rows: list[dict]) -> MagicMock: + """Create a mock pool whose cursor returns the given rows.""" + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = rows + mock_pool = MagicMock() + mock_pool.get_cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) + mock_pool.get_cursor.return_value.__exit__ = MagicMock(return_value=False) + return mock_pool + + +class TestCategories: + def test_list_categories_returns_list(self, client: TestClient) -> None: + """GET /categories returns a list of categories.""" + pool = _make_pool( + [ + {"id": "1", "name": "Web Development"}, + {"id": "2", "name": "Machine Learning"}, + ] + ) + app.dependency_overrides[get_pool] = lambda: pool + try: + response = client.get("/categories") + finally: + app.dependency_overrides.pop(get_pool, None) + + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert data[0]["name"] == "Web Development" + + +class TestDomains: + def test_list_domains_returns_list(self, client: TestClient) -> None: + """GET /domains returns a list of domains.""" + pool = _make_pool( + [ + {"id": "1", "name": "Healthcare"}, + ] + ) + app.dependency_overrides[get_pool] = lambda: pool + try: + response = client.get("/domains") + finally: + app.dependency_overrides.pop(get_pool, None) + + assert response.status_code == 200 + assert len(response.json()) == 1 + + +class TestTechStacks: + def test_list_techstacks_returns_list(self, client: TestClient) -> None: + """GET /techstacks returns a list of tech stacks.""" + pool = _make_pool( + [ + { + "id": "1", + "name": "Python", + "icon_url": "http://img", + "type": "LANGUAGE", + }, + ] + ) + app.dependency_overrides[get_pool] = lambda: pool + try: + response = client.get("/techstacks") + finally: + app.dependency_overrides.pop(get_pool, None) + + assert response.status_code == 200 + data = response.json() + assert data[0]["type"] == "LANGUAGE" diff --git a/tests/api/test_schemas.py b/tests/api/test_schemas.py new file mode 100644 index 0000000..f13bd75 --- /dev/null +++ b/tests/api/test_schemas.py @@ -0,0 +1,30 @@ +from src.services.api.schemas import CategoryOut, ProjectOut, TechStackOut + + +class TestSchemas: + def test_project_out_from_dict(self) -> None: + """ProjectOut can be constructed from a DB row dict.""" + data = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "title": "My Project", + "description": "A cool project", + "repo_url": "https://github.com/org/repo", + "published": True, + "trending": False, + "categories": [], + "domains": [], + "tech_stacks": [], + } + project = ProjectOut(**data) + assert project.title == "My Project" + assert project.categories == [] + + def test_category_out(self) -> None: + """CategoryOut holds id and name.""" + cat = CategoryOut(id="abc-123", name="Web Development") + assert cat.name == "Web Development" + + def test_techstack_out_with_type(self) -> None: + """TechStackOut includes type field.""" + ts = TechStackOut(id="x", name="Python", icon_url="http://img", type="LANGUAGE") + assert ts.type == "LANGUAGE" diff --git a/tests/conftest.py b/tests/conftest.py index 6c05169..1c4a366 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,3 +9,5 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: item.add_marker(pytest.mark.unit) elif "/integration/" in path: item.add_marker(pytest.mark.integration) + elif "/api/" in path: + item.add_marker(pytest.mark.api) diff --git a/uv.lock b/uv.lock index 08dff4a..329f6ff 100644 --- a/uv.lock +++ b/uv.lock @@ -695,6 +695,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + [[package]] name = "diff-cover" version = "10.2.0" @@ -772,6 +784,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/1d/fb374889a8cbe2786a347a6fc80b40b4d7ac607ed40be8bd4babec0d02b0/Faker-26.3.0-py3-none-any.whl", hash = "sha256:97fe1e7e953dd640ca2cd4dfac4db7c4d2432dd1b7a244a3313517707f3b54e9", size = 1802551, upload-time = "2024-08-08T15:54:49.395Z" }, ] +[[package]] +name = "fastapi" +version = "0.135.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, +] + [[package]] name = "fasttext" version = "0.9.3" @@ -1247,6 +1275,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, ] +[[package]] +name = "limits" +version = "5.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" }, +] + [[package]] name = "mako" version = "1.3.10" @@ -1630,6 +1672,7 @@ dependencies = [ { name = "dbt-core" }, { name = "dbt-postgres" }, { name = "dotenv" }, + { name = "fastapi" }, { name = "fasttext" }, { name = "fasttext-wheel" }, { name = "furo" }, @@ -1645,8 +1688,10 @@ dependencies = [ { name = "requests" }, { name = "schedule" }, { name = "sentence-transformers" }, + { name = "slowapi" }, { name = "sqlalchemy" }, { name = "torch" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.dev-dependencies] @@ -1676,6 +1721,7 @@ requires-dist = [ { name = "dbt-core", specifier = ">=1.8.0,<2" }, { name = "dbt-postgres", specifier = ">=1.8.0,<2" }, { name = "dotenv", specifier = ">=0.9.9,<0.10" }, + { name = "fastapi", specifier = ">=0.115.0,<1" }, { name = "fasttext", specifier = ">=0.9.3,<0.10" }, { name = "fasttext-wheel", specifier = ">=0.9.2,<0.10" }, { name = "furo", specifier = ">=2025.9.25" }, @@ -1691,8 +1737,10 @@ requires-dist = [ { name = "requests", specifier = ">=2.31.0,<3" }, { name = "schedule", specifier = ">=1.1.0,<2" }, { name = "sentence-transformers", specifier = ">=5.1.2,<6" }, + { name = "slowapi", specifier = ">=0.1.9,<0.2" }, { name = "sqlalchemy", specifier = ">=2.0.41,<3" }, { name = "torch", specifier = ">=2.6.0,<3" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0,<1" }, ] [package.metadata.requires-dev] @@ -2524,6 +2572,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "slowapi" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "limits" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/99/adfc7f94ca024736f061257d39118e1542bade7a52e86415a4c4ae92d8ff/slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77", size = 14028, upload-time = "2024-02-05T12:11:52.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" }, +] + [[package]] name = "smmap" version = "5.0.2" @@ -3198,6 +3258,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/81/60c4471fce95afa5922ca09b88a25f03c93343f759aae0f31fb4412a85c7/wrapt-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96159a0ee2b0277d44201c3b5be479a9979cf154e8c82fa5df49586a8e7679bb", size = 60666, upload-time = "2026-03-06T02:52:58.934Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/80e80e39e7cb90b006a0eaf11c73ac3a62bbfb3068469aec15cc0bc795de/wrapt-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98ba61833a77b747901e9012072f038795de7fc77849f1faa965464f3f87ff2d", size = 61601, upload-time = "2026-03-06T02:53:00.487Z" }, + { url = "https://files.pythonhosted.org/packages/b0/be/d7c88cd9293c859fc74b232abdc65a229bb953997995d6912fc85af18323/wrapt-2.1.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:767c0dbbe76cae2a60dd2b235ac0c87c9cccf4898aef8062e57bead46b5f6894", size = 114057, upload-time = "2026-03-06T02:52:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/ea/25/36c04602831a4d685d45a93b3abea61eca7fe35dab6c842d6f5d570ef94a/wrapt-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c691a6bc752c0cc4711cc0c00896fcd0f116abc253609ef64ef930032821842", size = 116099, upload-time = "2026-03-06T02:54:56.74Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4e/98a6eb417ef551dc277bec1253d5246b25003cf36fdf3913b65cb7657a56/wrapt-2.1.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f3b7d73012ea75aee5844de58c88f44cf62d0d62711e39da5a82824a7c4626a8", size = 112457, upload-time = "2026-03-06T02:53:52.842Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a6/a6f7186a5297cad8ec53fd7578533b28f795fdf5372368c74bd7e6e9841c/wrapt-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:577dff354e7acd9d411eaf4bfe76b724c89c89c8fc9b7e127ee28c5f7bcb25b6", size = 115351, upload-time = "2026-03-06T02:53:32.684Z" }, + { url = "https://files.pythonhosted.org/packages/97/6f/06e66189e721dbebd5cf20e138acc4d1150288ce118462f2fcbff92d38db/wrapt-2.1.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d7b6fd105f8b24e5bd23ccf41cb1d1099796524bcc6f7fbb8fe576c44befbc9", size = 111748, upload-time = "2026-03-06T02:53:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/ef/43/4808b86f499a51370fbdbdfa6cb91e9b9169e762716456471b619fca7a70/wrapt-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:866abdbf4612e0b34764922ef8b1c5668867610a718d3053d59e24a5e5fcfc15", size = 113783, upload-time = "2026-03-06T02:53:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/91/2c/a3f28b8fa7ac2cefa01cfcaca3471f9b0460608d012b693998cd61ef43df/wrapt-2.1.2-cp311-cp311-win32.whl", hash = "sha256:5a0a0a3a882393095573344075189eb2d566e0fd205a2b6414e9997b1b800a8b", size = 57977, upload-time = "2026-03-06T02:53:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c3/2b1c7bd07a27b1db885a2fab469b707bdd35bddf30a113b4917a7e2139d2/wrapt-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:64a07a71d2730ba56f11d1a4b91f7817dc79bc134c11516b75d1921a7c6fcda1", size = 60336, upload-time = "2026-03-06T02:54:28.104Z" }, + { url = "https://files.pythonhosted.org/packages/ec/5c/76ece7b401b088daa6503d6264dd80f9a727df3e6042802de9a223084ea2/wrapt-2.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:b89f095fe98bc12107f82a9f7d570dc83a0870291aeb6b1d7a7d35575f55d98a", size = 58756, upload-time = "2026-03-06T02:53:16.319Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +] + [[package]] name = "yarl" version = "1.23.0"