diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2bb5aa..7792cb8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,8 @@ jobs: cache: pip - run: python -m pip install --upgrade pip + # Unreleased parent: drop this step once fopost 0.3 is on PyPI. + - run: pip install 'fopost @ git+https://github.com/fopost/fopost-python@feature/blog-articles' - run: pip install -e '.[dev]' - run: ruff check . diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..dbf1745 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## Unreleased + +- `article_router(secret, on_article_published)`: receive `article.published` deliveries from a + Website blog target, verified through `fopost.webhooks.verify`; 401 on a bad signature, + `{"ok": true}` for `article.test`, a returned URL echoed as `{"url": ...}`. +- The parent floor is now `fopost>=0.3`, which carries `client.blog` and the webhook verifier. diff --git a/CLAUDE.md b/CLAUDE.md index 78e8fa1..665f4d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,9 @@ It also stores nothing: no database models, no ORM, no migrations. `fopost` is **published on PyPI**, so `pip install -e '.[dev]'` resolves it normally. No CI-from-source shim is needed. -`pyproject.toml` declares `fopost>=0.1,<1.0`. +`pyproject.toml` declares `fopost>=0.3,<1.0`. While a parent feature is unreleased, CI installs +`fopost` from its git branch in a step before `pip install -e '.[dev]'`; drop that step once the +release tag is on PyPI. **Python floor.** The parent `fopost` requires Python **3.10+**, so this package declares `requires-python = ">=3.10"` and the CI matrix runs 3.10–3.13. Declaring 3.9 would ship a package @@ -59,6 +61,7 @@ src/fopost_fastapi/ concurrency.py run_fopost — the threadpool bridge errors.py fopost_exception_handler / install_exception_handlers webhooks.py FoPostWebhookRouter, on_event, signature verification + articles.py article_router — Website-target receiver over fopost.webhooks.verify ``` **Client lifetime.** One `Fopost` per process, not per request. `setup_fopost(app)` wraps diff --git a/README.md b/README.md index 06a192e..d32849f 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,37 @@ app.include_router(router, prefix="/fopost") `sign_payload(body, secret)` and `verify_webhook_signature(body, header, secret)` are exported if you need to verify a delivery somewhere else. +## Receive blog articles + +A post goes to a social network; an article goes to a website. When FoPost publishes an article +to a **Website** target it sends the article to your URL, signed with the website secret shown +once in the blog settings. Mount the receiver in one line: + +```python +from fopost import ArticlePublishedPayload +from fopost_fastapi import article_router + +app.include_router(article_router(secret=WEBSITE_SECRET, on_article_published=store_article)) +``` + +The callback gets the typed article and may return the URL you published it at: + +```python +async def store_article(article: ArticlePublishedPayload) -> str: + await db.articles.upsert(slug=article.slug, html=article.body_html, tags=article.tags) + return f"https://yourbrand.com/blog/{article.slug}" +``` + +A `def` callback works too and runs in a worker thread. Point the Website target at +`https:///articles` (`path=` changes it). The route reads the raw body, verifies it +through `fopost.webhooks.verify` (`X-FoPost-Signature-256` over `timestamp.body`, 300 s +tolerance), answers **401** on a bad or stale signature, `{"ok": true}` for `article.test`, and +echoes a returned URL as `{"url": "..."}`. Image URLs in the payload expire after six hours, so +download your own copy. + +`fopost.blog` on the injected client plans keywords, requests articles and publishes them; see the +[`fopost` SDK](https://github.com/fopost/fopost-python#blog). + ## Error handling `install_exception_handlers(app)` turns an SDK exception into the status the FoPost API actually @@ -167,8 +198,8 @@ honouring `Retry-After`, before the error ever reaches the handler. ## The rest of the API Everything you can call on the injected client — `posts`, `accounts`, `workspaces`, `labels`, -`ai`, and the `request()` escape hatch for endpoints the SDK does not wrap — is documented in the -[`fopost` SDK](https://github.com/fopost/fopost-python). This package adds no resources of its own +`ai`, `blog`, and the `request()` escape hatch for endpoints the SDK does not wrap — is documented +in the [`fopost` SDK](https://github.com/fopost/fopost-python). This package adds no resources of its own and stores nothing. ## Example diff --git a/pyproject.toml b/pyproject.toml index 6099682..7ab2878 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "fopost>=0.1,<1.0", + "fopost>=0.3,<1.0", "fastapi>=0.110", "pydantic>=2", "pydantic-settings>=2", diff --git a/src/fopost_fastapi/__init__.py b/src/fopost_fastapi/__init__.py index ac53599..8a2a4b1 100644 --- a/src/fopost_fastapi/__init__.py +++ b/src/fopost_fastapi/__init__.py @@ -25,6 +25,7 @@ def workspaces(fopost: FoPostDep): from importlib.metadata import PackageNotFoundError from importlib.metadata import version as _pkg_version +from .articles import ArticleCallback, article_router from .client import ( STATE_ATTR, FoPostDep, @@ -59,6 +60,7 @@ def workspaces(fopost: FoPostDep): __all__ = [ "ANY_EVENT", + "ArticleCallback", "DELIVERY_HEADER", "EVENT_HEADER", "SIGNATURE_HEADER", @@ -70,6 +72,7 @@ def workspaces(fopost: FoPostDep): "FoPostWebhookRouter", "WebhookEvent", "__version__", + "article_router", "create_client", "fopost_exception_handler", "fopost_lifespan", diff --git a/src/fopost_fastapi/articles.py b/src/fopost_fastapi/articles.py new file mode 100644 index 0000000..a4b9e45 --- /dev/null +++ b/src/fopost_fastapi/articles.py @@ -0,0 +1,81 @@ +"""Receive published blog articles from the FoPost Website target. + +A post goes to a social network; an article goes to a website. When FoPost +publishes an article to a Website target it sends the article to your URL, +signed with the website secret shown once in the blog settings. Signature +checking lives in the ``fopost`` SDK (``fopost.webhooks.verify``); this module +only shapes it as an ``APIRouter`` and hands the typed payload to your callback. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Awaitable, Callable +from typing import Any, cast + +from fastapi import APIRouter, HTTPException, Request, status +from fopost import ArticlePublishedPayload, WebhookVerificationError, webhooks + +from .concurrency import run_fopost + +__all__ = ["ArticleCallback", "article_router"] + +ArticleCallback = Callable[[ArticlePublishedPayload], Awaitable[str | None] | str | None] + +_PUBLISHED_EVENT = "article.published" + + +def article_router( + secret: str, + on_article_published: ArticleCallback, + *, + path: str = "/articles", + tolerance: float = webhooks.SIGNATURE_TOLERANCE_SECONDS, + **kwargs: Any, +) -> APIRouter: + """Build the router a Website target posts to. + + :: + + app.include_router(article_router(secret=WEBSITE_SECRET, on_article_published=store)) + + A bad or stale signature answers 401 and the callback never runs. An + ``article.test`` delivery answers ``{"ok": true}``. An ``article.published`` + delivery reaches ``on_article_published`` as an ``ArticlePublishedPayload``; + the callback may be sync or async, and a URL string it returns is echoed as + ``{"url": ...}``. Any other event answers ``{"ok": true}`` and is ignored. + """ + if not secret: + raise ValueError("article_router needs the website secret") + + router = APIRouter(**kwargs) + + async def receive(request: Request) -> dict[str, Any]: + # The signature covers the bytes as sent, so read them before any parsing. + body = await request.body() + try: + event = webhooks.verify(body, request.headers, secret, tolerance=tolerance) + except WebhookVerificationError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=exc.code) from exc + + # article.test, and anything else, is acknowledged and ignored. + if event.event != _PUBLISHED_EVENT: + return {"ok": True} + + article = ArticlePublishedPayload.model_validate(event.data) + if inspect.iscoroutinefunction(on_article_published): + result = await on_article_published(article) + else: + sync = cast(Callable[[ArticlePublishedPayload], "str | None"], on_article_published) + result = await run_fopost(sync, article) + return {"url": result} if isinstance(result, str) else {"ok": True} + + router.add_api_route( + path, + receive, + methods=["POST"], + name="fopost_article", + summary="Receive a published FoPost article", + include_in_schema=False, + ) + return router diff --git a/tests/test_articles.py b/tests/test_articles.py new file mode 100644 index 0000000..dce7ab4 --- /dev/null +++ b/tests/test_articles.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import time + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from fopost import ArticlePublishedPayload + +from fopost_fastapi import ArticleCallback, article_router + +SECRET = "whsec_site" + +ARTICLE = { + "id": "art_1", + "title": "Best trail running shoes", + "slug": "best-trail-running-shoes", + "excerpt": None, + "metaTitle": None, + "metaDescription": None, + "bodyMarkdown": "# Shoes", + "bodyHtml": "

Shoes

", + "featuredImage": {"mediaId": "med_1", "url": "https://cdn.example/a.jpg", "alt": "Shoes"}, + "images": [], + "tags": ["running"], + "keyword": "trail running shoes", + "wordCount": 1200, + "publishedAt": "2026-10-11T10:00:00.000Z", + "publishedUrl": None, +} + + +def _signed( + event: str, data: dict[str, object], *, secret: str = SECRET +) -> tuple[bytes, dict[str, str]]: + body = json.dumps({"event": event, "data": data, "timestamp": "2026-10-11T10:00:00Z"}).encode() + sent_at = str(int(time.time())) + digest = hmac.new(secret.encode(), f"{sent_at}.".encode() + body, hashlib.sha256).hexdigest() + return body, { + "Content-Type": "application/json", + "X-FoPost-Timestamp": sent_at, + "X-FoPost-Signature-256": f"sha256={digest}", + "X-FoPost-Event": event, + } + + +def _app(callback: ArticleCallback) -> FastAPI: + app = FastAPI() + app.include_router(article_router(secret=SECRET, on_article_published=callback)) + return app + + +def test_a_signed_test_delivery_is_accepted() -> None: + def never(article: ArticlePublishedPayload) -> None: # pragma: no cover - must not run + raise AssertionError("callback ran on article.test") + + body, headers = _signed("article.test", {}) + with TestClient(_app(never)) as client: + response = client.post("/articles", content=body, headers=headers) + + assert response.status_code == 200 + assert response.json() == {"ok": True} + + +def test_a_tampered_delivery_is_rejected_before_the_callback() -> None: + ran: list[str] = [] + + def store(article: ArticlePublishedPayload) -> None: # pragma: no cover - must not run + ran.append(article.id) + + _, headers = _signed("article.published", ARTICLE) + tampered, _ = _signed("article.published", {**ARTICLE, "id": "art_evil"}) + with TestClient(_app(store)) as client: + response = client.post("/articles", content=tampered, headers=headers) + + assert response.status_code == 401 + assert response.json() == {"detail": "invalid_signature"} + assert ran == [] + + +def test_a_published_article_reaches_the_callback_parsed() -> None: + seen: list[ArticlePublishedPayload] = [] + + async def store(article: ArticlePublishedPayload) -> str: + seen.append(article) + return f"https://yourbrand.com/blog/{article.slug}" + + body, headers = _signed("article.published", ARTICLE) + with TestClient(_app(store)) as client: + response = client.post("/articles", content=body, headers=headers) + + assert response.status_code == 200 + assert response.json() == {"url": "https://yourbrand.com/blog/best-trail-running-shoes"} + assert len(seen) == 1 + assert seen[0].title == "Best trail running shoes" + assert seen[0].body_markdown == "# Shoes" + assert seen[0].featured_image is not None + assert seen[0].featured_image.media_id == "med_1" + assert seen[0].word_count == 1200 + + +def test_a_sync_callback_runs_too() -> None: + def store(article: ArticlePublishedPayload) -> None: + pass + + body, headers = _signed("article.published", ARTICLE) + with TestClient(_app(store)) as client: + response = client.post("/articles", content=body, headers=headers) + + assert response.status_code == 200 + assert response.json() == {"ok": True}