-
Notifications
You must be signed in to change notification settings - Fork 0
feat: FastAPI app skeleton for sync-engine deployment #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the new Dockerfile runs Useful? React with 👍 / 👎. |
||
| secret = secret if secret is not None else os.environ["WEBHOOK_SECRET"] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If Useful? React with 👍 / 👎. |
||
| 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 | ||
|
Comment on lines
+31
to
+34
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
A validly signed JSON array, scalar, or Useful? React with 👍 / 👎.
Comment on lines
+31
to
+34
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a correctly signed body such as Useful? React with 👍 / 👎. |
||
|
|
||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
create_app()is invoked as the no-argument deployment factory, every process silently constructs the test-onlyInMemoryStore. A successful webhook response then acknowledges data that is lost on any restart and is not shared across multiple workers, defeating the documented persistent-queue and at-least-once guarantees; production startup should inject a persistent backend or fail rather than defaulting to this store.Useful? React with 👍 / 👎.