From 74884712938550f6e7b12e0bcbd4145cee5d917a Mon Sep 17 00:00:00 2001 From: koydas Date: Mon, 27 Jul 2026 22:26:54 +0000 Subject: [PATCH 1/2] feat: add FastAPI app with /health and /webhooks endpoints Wires InMemoryStore and WebhookHandler behind a minimal HTTP surface so the engine has a runnable artifact: /health for liveness, /webhooks for HMAC-verified event reception. Uses an app factory (create_app) rather than a module-level app instance so tests can inject a store and secret without touching WEBHOOK_SECRET; production entrypoint runs via `uvicorn sync_engine.app:create_app --factory`. Also refreshes README/testing.md, which still described webhook/ and the package as "upcoming" despite both having landed in PR #3. --- README.md | 15 +++++--- docs/testing.md | 1 + src/sync_engine/app.py | 45 ++++++++++++++++++++++ tests/test_app.py | 84 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 src/sync_engine/app.py create mode 100644 tests/test_app.py diff --git a/README.md b/README.md index 6b402dc..202d7d8 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,12 @@ sync-engine/ │ └── adr/ # Architecture Decision Records │ └── ADR-001-hybrid-rest-webhook.md ├── src/ -│ └── sync_engine/ # main package (upcoming) -│ ├── webhook/ # webhook reception and verification -│ ├── reconciliation/ # REST polling loop -│ ├── processor/ # idempotent event processing -│ └── store/ # sync state persistence +│ └── sync_engine/ +│ ├── app.py # FastAPI app: /health, /webhooks +│ ├── webhook/ # webhook reception and verification +│ ├── reconciliation/ # REST polling loop (upcoming) +│ ├── processor/ # idempotent event processing (upcoming) +│ └── store/ # sync state persistence ├── tests/ ├── CLAUDE.md └── README.md @@ -59,6 +60,8 @@ sync-engine/ | ADR | Decision | Status | |-----|----------|--------| | [ADR-001](docs/adr/ADR-001-hybrid-rest-webhook.md) | Hybrid REST/webhook architecture | Accepted | +| [ADR-002](docs/adr/ADR-002-pluggable-store-interface.md) | Pluggable store interface via abstract base class | Accepted | +| [ADR-003](docs/adr/ADR-003-webhook-hmac-sha256-signature-verification.md) | HMAC-SHA256 signature verification for webhook reception | Accepted | --- @@ -80,7 +83,7 @@ Services from `fullstack-pilot` — details added to `src/` as implementations l ## Development ```bash -# setup (upcoming) +# setup python -m venv .venv && source .venv/bin/activate pip install -e ".[dev]" diff --git a/docs/testing.md b/docs/testing.md index e18b29b..5c46b7a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -18,6 +18,7 @@ Tests mirror the source tree under `tests/`: ``` tests/ ├── test_exceptions.py +├── test_app.py ├── store/ │ └── test_memory_store.py ├── webhook/ diff --git a/src/sync_engine/app.py b/src/sync_engine/app.py new file mode 100644 index 0000000..8069a96 --- /dev/null +++ b/src/sync_engine/app.py @@ -0,0 +1,45 @@ +import os + +from fastapi import FastAPI, HTTPException, Request + +from sync_engine.exceptions import DuplicateEventError, WebhookSignatureError +from sync_engine.store.base import SyncStore +from sync_engine.store.memory import InMemoryStore +from sync_engine.webhook.handler import WebhookHandler + + +def create_app(store: SyncStore | None = None, secret: str | None = None) -> FastAPI: + store = store or InMemoryStore() + secret = secret if secret is not None else os.environ["WEBHOOK_SECRET"] + handler = WebhookHandler(store, secret) + + app = FastAPI(title="sync-engine") + + @app.get("/health") + def health() -> dict: + return {"status": "ok", "last_sync_at": store.get_last_sync_at()} + + @app.post("/webhooks") + async def receive_webhook(request: Request) -> dict: + event_id = request.headers.get("X-Event-Id") + if not event_id: + raise HTTPException(status_code=400, detail="Missing X-Event-Id header") + + raw_bytes = await request.body() + signature = request.headers.get("X-Hub-Signature-256", "") + + try: + payload = await request.json() + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid JSON body") from exc + + try: + handler.handle(event_id, payload, raw_bytes, signature) + except WebhookSignatureError as exc: + raise HTTPException(status_code=401, detail=str(exc)) from exc + except DuplicateEventError: + return {"status": "duplicate"} + + return {"status": "accepted"} + + return app diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..eb9da7c --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,84 @@ +import hashlib +import hmac + +import pytest +from fastapi.testclient import TestClient + +from sync_engine.app import create_app +from sync_engine.store.memory import InMemoryStore + +SECRET = "test-secret" + + +def _sig(payload: bytes, secret: str = SECRET) -> str: + return hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() + + +@pytest.fixture +def store() -> InMemoryStore: + return InMemoryStore() + + +@pytest.fixture +def client(store: InMemoryStore) -> TestClient: + return TestClient(create_app(store=store, secret=SECRET)) + + +def test_health_before_any_sync_reports_no_last_sync_at(client: TestClient) -> None: + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok", "last_sync_at": None} + + +def test_webhook_valid_signature_is_enqueued( + client: TestClient, store: InMemoryStore +) -> None: + body = b'{"type": "created"}' + response = client.post( + "/webhooks", + content=body, + headers={"X-Event-Id": "evt-1", "X-Hub-Signature-256": _sig(body)}, + ) + assert response.status_code == 200 + assert response.json() == {"status": "accepted"} + assert ("evt-1", {"type": "created"}) in store.dequeue_unacknowledged() + + +def test_webhook_invalid_signature_returns_401_and_not_enqueued( + client: TestClient, store: InMemoryStore +) -> None: + body = b'{"type": "created"}' + response = client.post( + "/webhooks", + content=body, + headers={ + "X-Event-Id": "evt-1", + "X-Hub-Signature-256": _sig(body, secret="wrong-secret"), + }, + ) + assert response.status_code == 401 + assert store.dequeue_unacknowledged() == [] + + +def test_webhook_missing_event_id_header_returns_400(client: TestClient) -> None: + body = b'{"type": "created"}' + response = client.post( + "/webhooks", + content=body, + headers={"X-Hub-Signature-256": _sig(body)}, + ) + assert response.status_code == 400 + + +def test_webhook_already_processed_event_returns_duplicate_status( + client: TestClient, store: InMemoryStore +) -> None: + body = b'{"type": "created"}' + headers = {"X-Event-Id": "evt-1", "X-Hub-Signature-256": _sig(body)} + + client.post("/webhooks", content=body, headers=headers) + store.mark_event_processed("evt-1") + + response = client.post("/webhooks", content=body, headers=headers) + assert response.status_code == 200 + assert response.json() == {"status": "duplicate"} From eef8aa3905ade96062507764bd1557e84159b904 Mon Sep 17 00:00:00 2001 From: koydas Date: Mon, 27 Jul 2026 22:28:18 +0000 Subject: [PATCH 2/2] feat: add Dockerfile and image-publish CI workflow Adds uvicorn as a runtime dependency (needed to actually serve the FastAPI app in the container) and a workflow that builds/pushes to ghcr.io/koydas/sync-engine on merges to main, mirroring the pattern already used in fullstack-pilot's build-backend.yml. --- .github/workflows/publish-image.yml | 39 +++++++++++++++++++++++++++++ Dockerfile | 15 +++++++++++ pyproject.toml | 1 + 3 files changed, 55 insertions(+) create mode 100644 .github/workflows/publish-image.yml create mode 100644 Dockerfile diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml new file mode 100644 index 0000000..e38c620 --- /dev/null +++ b/.github/workflows/publish-image.yml @@ -0,0 +1,39 @@ +name: Publish image + +on: + push: + branches: ["main"] + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5e273f8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY pyproject.toml ./ +COPY src ./src + +RUN pip install --no-cache-dir . + +RUN useradd --create-home --shell /usr/sbin/nologin appuser +USER appuser + +EXPOSE 8000 + +CMD ["uvicorn", "sync_engine.app:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"] diff --git a/pyproject.toml b/pyproject.toml index 0db8be3..449cff3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ requires-python = ">=3.11" dependencies = [ "fastapi", "httpx", + "uvicorn[standard]", ] [project.optional-dependencies]