From 9af1dcfe696b4a994f58ccc7cc891c9f709a6c34 Mon Sep 17 00:00:00 2001 From: bernatsampera Date: Tue, 11 Aug 2026 11:23:52 +0200 Subject: [PATCH] release: api-v0.3.0 Anonymous install telemetry: POST /api/telemetry accepts install events from the CLI, GET /api/admin/installs lists them (auth-protected). Install model and installs table auto-created on startup. Co-Authored-By: Claude Opus 4.6 (1M context) --- api/app/main.py | 2 ++ api/app/models.py | 11 +++++++ api/app/routes_admin.py | 19 ++++++++++- api/app/routes_telemetry.py | 23 ++++++++++++++ api/app/schemas.py | 7 +++++ api/pyproject.toml | 2 +- api/tests/test_telemetry.py | 63 +++++++++++++++++++++++++++++++++++++ 7 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 api/app/routes_telemetry.py create mode 100644 api/tests/test_telemetry.py diff --git a/api/app/main.py b/api/app/main.py index f51fc5a..d20685d 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -6,6 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware from .db import init_db from .routes_admin import router as admin_router from .routes_public import router as public_router +from .routes_telemetry import router as telemetry_router @asynccontextmanager @@ -28,6 +29,7 @@ app.add_middleware( ) app.include_router(public_router) app.include_router(admin_router) +app.include_router(telemetry_router) @app.get("/health") diff --git a/api/app/models.py b/api/app/models.py index 7b4d24a..11e479c 100644 --- a/api/app/models.py +++ b/api/app/models.py @@ -13,3 +13,14 @@ class Workflow(Base): downloads: Mapped[int] = mapped_column( Integer, nullable=False, default=0, server_default="0" ) + + +class Install(Base): + __tablename__ = "installs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + install_id: Mapped[str] = mapped_column(Text, nullable=False) + version: Mapped[str] = mapped_column(Text, nullable=False) + os: Mapped[str] = mapped_column(Text, nullable=False) + platform: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[str] = mapped_column(Text, nullable=False) diff --git a/api/app/routes_admin.py b/api/app/routes_admin.py index 60cb014..8dd7f0d 100644 --- a/api/app/routes_admin.py +++ b/api/app/routes_admin.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import Session from . import settings from .db import get_session -from .models import Workflow +from .models import Install, Workflow from .schemas import WorkflowOut @@ -24,3 +24,20 @@ def list_all(session: Session = Depends(get_session)): select(Workflow).order_by(Workflow.downloads.desc()) ).all() return [WorkflowOut(id=w.id, downloads=w.downloads) for w in rows] + + +@router.get("/installs") +def list_installs(session: Session = Depends(get_session)): + rows = session.scalars( + select(Install).order_by(Install.id.desc()).limit(200) + ).all() + return [ + { + "install_id": r.install_id, + "version": r.version, + "os": r.os, + "platform": r.platform, + "created_at": r.created_at, + } + for r in rows + ] diff --git a/api/app/routes_telemetry.py b/api/app/routes_telemetry.py new file mode 100644 index 0000000..6f8b234 --- /dev/null +++ b/api/app/routes_telemetry.py @@ -0,0 +1,23 @@ +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, Response +from sqlalchemy.orm import Session + +from .db import get_session +from .models import Install +from .schemas import InstallIn + +router = APIRouter(tags=["telemetry"]) + + +@router.post("/api/telemetry", status_code=204) +def record_install(data: InstallIn, session: Session = Depends(get_session)): + session.add(Install( + install_id=data.install_id, + version=data.version, + os=data.os, + platform=data.platform, + created_at=datetime.now(timezone.utc).isoformat(), + )) + session.commit() + return Response(status_code=204) diff --git a/api/app/schemas.py b/api/app/schemas.py index d971e51..4c14707 100644 --- a/api/app/schemas.py +++ b/api/app/schemas.py @@ -4,3 +4,10 @@ from pydantic import BaseModel class WorkflowOut(BaseModel): id: str downloads: int + + +class InstallIn(BaseModel): + install_id: str + version: str + os: str + platform: str diff --git a/api/pyproject.toml b/api/pyproject.toml index eb2e230..05a8a45 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gcontext-workflows-api" -version = "0.2.0" +version = "0.3.0" description = "Install-counter service for gcontext workflow templates." requires-python = ">=3.11" dependencies = [ diff --git a/api/tests/test_telemetry.py b/api/tests/test_telemetry.py new file mode 100644 index 0000000..2a78c00 --- /dev/null +++ b/api/tests/test_telemetry.py @@ -0,0 +1,63 @@ +def test_telemetry_stores_install(client): + payload = { + "install_id": "abc-123", + "version": "0.5.0", + "os": "Darwin", + "platform": "arm64", + } + resp = client.post("/api/telemetry", json=payload) + assert resp.status_code == 204 + + from sqlalchemy import text + + from app.db import engine + + with engine().begin() as conn: + rows = conn.execute(text( + "SELECT install_id, version, os, platform FROM installs" + )).fetchall() + assert len(rows) == 1 + assert tuple(rows[0]) == ("abc-123", "0.5.0", "Darwin", "arm64") + + +def test_telemetry_rejects_missing_fields(client): + resp = client.post("/api/telemetry", json={"install_id": "x"}) + assert resp.status_code == 422 + + +def test_telemetry_duplicate_install_id_allowed(client): + payload = { + "install_id": "dup-1", + "version": "0.5.0", + "os": "Linux", + "platform": "x86_64", + } + resp1 = client.post("/api/telemetry", json=payload) + resp2 = client.post("/api/telemetry", json=payload) + assert resp1.status_code == 204 + assert resp2.status_code == 204 + + +import os + + +def test_admin_list_installs(client): + client.post("/api/telemetry", json={ + "install_id": "admin-test", + "version": "0.5.0", + "os": "Darwin", + "platform": "arm64", + }) + resp = client.get( + "/api/admin/installs", + headers={"Authorization": f"Bearer {os.environ['ADMIN_TOKEN']}"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert any(i["install_id"] == "admin-test" for i in data) + + +def test_admin_installs_requires_auth(client): + resp = client.get("/api/admin/installs") + assert resp.status_code == 401