Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<your host>/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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/fopost_fastapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -59,6 +60,7 @@ def workspaces(fopost: FoPostDep):

__all__ = [
"ANY_EVENT",
"ArticleCallback",
"DELIVERY_HEADER",
"EVENT_HEADER",
"SIGNATURE_HEADER",
Expand All @@ -70,6 +72,7 @@ def workspaces(fopost: FoPostDep):
"FoPostWebhookRouter",
"WebhookEvent",
"__version__",
"article_router",
"create_client",
"fopost_exception_handler",
"fopost_lifespan",
Expand Down
81 changes: 81 additions & 0 deletions src/fopost_fastapi/articles.py
Original file line number Diff line number Diff line change
@@ -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
113 changes: 113 additions & 0 deletions tests/test_articles.py
Original file line number Diff line number Diff line change
@@ -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": "<h1>Shoes</h1>",
"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}
Loading