fopost-fastapi 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,85 @@
1
+ """fopost-fastapi — the official FastAPI integration for the FoPost API.
2
+
3
+ A thin wrapper: every request, model, retry, and error type lives in the
4
+ ``fopost`` package. This one wires that client into FastAPI's idioms —
5
+ settings, dependency injection, a webhook receiver, and an exception handler.
6
+
7
+ ::
8
+
9
+ from fastapi import FastAPI
10
+ from fopost_fastapi import FoPostDep, install_exception_handlers, setup_fopost
11
+
12
+ app = FastAPI()
13
+ setup_fopost(app)
14
+ install_exception_handlers(app)
15
+
16
+ @app.get("/workspaces")
17
+ def workspaces(fopost: FoPostDep):
18
+ return fopost.workspaces.list()
19
+
20
+ ``fopost`` is synchronous. A ``def`` route like the one above is already run in
21
+ a worker thread by FastAPI; from an ``async def`` route, wrap the call in
22
+ :func:`run_fopost` so the event loop keeps turning.
23
+ """
24
+
25
+ from importlib.metadata import PackageNotFoundError
26
+ from importlib.metadata import version as _pkg_version
27
+
28
+ from .client import (
29
+ STATE_ATTR,
30
+ FoPostDep,
31
+ FoPostSettingsDep,
32
+ create_client,
33
+ fopost_lifespan,
34
+ get_client,
35
+ get_settings,
36
+ setup_fopost,
37
+ )
38
+ from .concurrency import run_fopost
39
+ from .errors import fopost_exception_handler, install_exception_handlers
40
+ from .settings import FoPostSettings
41
+ from .webhooks import (
42
+ ANY_EVENT,
43
+ DELIVERY_HEADER,
44
+ EVENT_HEADER,
45
+ SIGNATURE_HEADER,
46
+ WEBHOOK_EVENTS,
47
+ FoPostWebhookRouter,
48
+ WebhookEvent,
49
+ on_event,
50
+ sign_payload,
51
+ verify_webhook_signature,
52
+ webhook_router,
53
+ )
54
+
55
+ try:
56
+ __version__ = _pkg_version("fopost-fastapi")
57
+ except PackageNotFoundError: # running from a source tree
58
+ __version__ = "0.0.0"
59
+
60
+ __all__ = [
61
+ "ANY_EVENT",
62
+ "DELIVERY_HEADER",
63
+ "EVENT_HEADER",
64
+ "SIGNATURE_HEADER",
65
+ "STATE_ATTR",
66
+ "WEBHOOK_EVENTS",
67
+ "FoPostDep",
68
+ "FoPostSettings",
69
+ "FoPostSettingsDep",
70
+ "FoPostWebhookRouter",
71
+ "WebhookEvent",
72
+ "__version__",
73
+ "create_client",
74
+ "fopost_exception_handler",
75
+ "fopost_lifespan",
76
+ "get_client",
77
+ "get_settings",
78
+ "install_exception_handlers",
79
+ "on_event",
80
+ "run_fopost",
81
+ "setup_fopost",
82
+ "sign_payload",
83
+ "verify_webhook_signature",
84
+ "webhook_router",
85
+ ]
@@ -0,0 +1,152 @@
1
+ """Client construction, app wiring, and the injectable dependency."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator, Callable
6
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
7
+ from typing import Annotated, Any
8
+
9
+ import httpx
10
+ from fastapi import Depends, FastAPI, Request
11
+ from fopost import Fopost
12
+
13
+ from .concurrency import run_fopost
14
+ from .settings import FoPostSettings
15
+
16
+ __all__ = [
17
+ "STATE_ATTR",
18
+ "FoPostDep",
19
+ "FoPostSettingsDep",
20
+ "create_client",
21
+ "fopost_lifespan",
22
+ "get_client",
23
+ "get_settings",
24
+ "setup_fopost",
25
+ ]
26
+
27
+ #: Attribute on ``app.state`` holding the one client the app shares.
28
+ STATE_ATTR = "fopost"
29
+ _SETTINGS_ATTR = "fopost_settings"
30
+
31
+
32
+ def create_client(
33
+ settings: FoPostSettings | None = None,
34
+ *,
35
+ http_client: httpx.Client | None = None,
36
+ ) -> Fopost:
37
+ """Build a ``Fopost`` client from settings.
38
+
39
+ ``http_client`` is an injection point for tests: pass an ``httpx.Client``
40
+ carrying a mock transport and no request ever leaves the process.
41
+ """
42
+ settings = settings or FoPostSettings()
43
+ if not settings.api_key:
44
+ raise RuntimeError(
45
+ "fopost-fastapi: no API key — set FOPOST_API_KEY or pass "
46
+ "FoPostSettings(api_key=...) to setup_fopost()"
47
+ )
48
+ return Fopost(
49
+ api_key=settings.api_key,
50
+ base_url=settings.base_url,
51
+ timeout=settings.timeout,
52
+ max_retries=settings.max_retries,
53
+ http_client=http_client,
54
+ )
55
+
56
+
57
+ def setup_fopost(
58
+ app: FastAPI,
59
+ settings: FoPostSettings | None = None,
60
+ *,
61
+ http_client: httpx.Client | None = None,
62
+ ) -> FoPostSettings:
63
+ """Wire FoPost into ``app``: one client for the whole process.
64
+
65
+ Call this at import time, before the app starts serving::
66
+
67
+ app = FastAPI()
68
+ setup_fopost(app)
69
+
70
+ The client is created when the app starts and closed when it stops, and it
71
+ wraps whatever lifespan the app already has rather than replacing it.
72
+ Returns the resolved settings so routers can read ``webhook_secret`` and
73
+ ``default_workspace_id`` from them.
74
+ """
75
+ settings = settings or FoPostSettings()
76
+ setattr(app.state, _SETTINGS_ATTR, settings)
77
+
78
+ inner = app.router.lifespan_context
79
+
80
+ @asynccontextmanager
81
+ async def lifespan(app: FastAPI) -> AsyncIterator[Any]:
82
+ client = create_client(settings, http_client=http_client)
83
+ setattr(app.state, STATE_ATTR, client)
84
+ try:
85
+ async with inner(app) as state:
86
+ yield state
87
+ finally:
88
+ setattr(app.state, STATE_ATTR, None)
89
+ await run_fopost(client.close)
90
+
91
+ app.router.lifespan_context = lifespan
92
+ return settings
93
+
94
+
95
+ def fopost_lifespan(
96
+ settings: FoPostSettings | None = None,
97
+ *,
98
+ http_client: httpx.Client | None = None,
99
+ ) -> Callable[[FastAPI], AbstractAsyncContextManager[None]]:
100
+ """A lifespan to hand straight to ``FastAPI(lifespan=...)``.
101
+
102
+ ::
103
+
104
+ app = FastAPI(lifespan=fopost_lifespan())
105
+
106
+ Equivalent to :func:`setup_fopost` for an app with no lifespan of its own.
107
+ """
108
+ resolved = settings or FoPostSettings()
109
+
110
+ @asynccontextmanager
111
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
112
+ setattr(app.state, _SETTINGS_ATTR, resolved)
113
+ client = create_client(resolved, http_client=http_client)
114
+ setattr(app.state, STATE_ATTR, client)
115
+ try:
116
+ yield
117
+ finally:
118
+ setattr(app.state, STATE_ATTR, None)
119
+ await run_fopost(client.close)
120
+
121
+ return lifespan
122
+
123
+
124
+ def get_client(request: Request) -> Fopost:
125
+ """Dependency returning the shared client. Never builds one per request."""
126
+ client = getattr(request.app.state, STATE_ATTR, None)
127
+ if not isinstance(client, Fopost):
128
+ raise RuntimeError(
129
+ "fopost-fastapi: no client on app.state — call setup_fopost(app) or pass "
130
+ "lifespan=fopost_lifespan() when creating the app"
131
+ )
132
+ return client
133
+
134
+
135
+ def get_settings(request: Request) -> FoPostSettings:
136
+ """Dependency returning the settings the app was wired with."""
137
+ settings = getattr(request.app.state, _SETTINGS_ATTR, None)
138
+ if settings is None:
139
+ raise RuntimeError(
140
+ "fopost-fastapi: no settings on app.state — call setup_fopost(app) or pass "
141
+ "lifespan=fopost_lifespan() when creating the app"
142
+ )
143
+ if not isinstance(settings, FoPostSettings): # pragma: no cover - defensive
144
+ raise RuntimeError("fopost-fastapi: app.state.fopost_settings is not FoPostSettings")
145
+ return settings
146
+
147
+
148
+ #: Inject the shared client: ``def route(fopost: FoPostDep) -> ...``
149
+ FoPostDep = Annotated[Fopost, Depends(get_client)]
150
+
151
+ #: Inject the resolved settings: ``def route(settings: FoPostSettingsDep) -> ...``
152
+ FoPostSettingsDep = Annotated[FoPostSettings, Depends(get_settings)]
@@ -0,0 +1,43 @@
1
+ """Bridge between the synchronous ``fopost`` SDK and the event loop.
2
+
3
+ The ``fopost`` package ships a blocking client only — there is no async variant —
4
+ so every call made from an ``async def`` route has to leave the event loop.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import functools
10
+ from collections.abc import Callable
11
+ from typing import ParamSpec, TypeVar
12
+
13
+ from fastapi.concurrency import run_in_threadpool
14
+
15
+ __all__ = ["run_fopost"]
16
+
17
+ P = ParamSpec("P")
18
+ R = TypeVar("R")
19
+
20
+
21
+ async def run_fopost(
22
+ func: Callable[P, R],
23
+ /,
24
+ *args: P.args,
25
+ **kwargs: P.kwargs,
26
+ ) -> R:
27
+ """Await a blocking FoPost call without stalling the event loop.
28
+
29
+ ::
30
+
31
+ @app.post("/posts")
32
+ async def create(fopost: FoPostDep):
33
+ return await run_fopost(
34
+ fopost.posts.create,
35
+ workspace_id="9b2f6c1e-...",
36
+ content="Hello from FastAPI",
37
+ accounts=[...],
38
+ )
39
+
40
+ A plain ``def`` route needs none of this — FastAPI already runs those in a
41
+ worker thread, so call the SDK directly there.
42
+ """
43
+ return await run_in_threadpool(functools.partial(func, *args, **kwargs))
@@ -0,0 +1,57 @@
1
+ """Turn ``fopost`` SDK exceptions into HTTP responses.
2
+
3
+ An unhandled SDK error is a 500 with a stack trace. Registering the handler
4
+ below turns it into the status the FoPost API actually answered with, keeping
5
+ the machine-readable ``error`` code, the 402 ``upgrade_url``, and the 429
6
+ ``Retry-After`` intact so the caller can act on them.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from typing import Any
13
+
14
+ from fastapi import FastAPI, Request
15
+ from fastapi.responses import JSONResponse
16
+ from fopost import FopostError, PaymentRequiredError, RateLimitError
17
+
18
+ __all__ = ["fopost_exception_handler", "install_exception_handlers"]
19
+
20
+ #: Upstream 5xx becomes a gateway error — this app is fine, its dependency is not.
21
+ _UPSTREAM_FAILURE_STATUS = 502
22
+
23
+
24
+ def _status_for(exc: FopostError) -> int:
25
+ if 400 <= exc.status < 500:
26
+ return exc.status
27
+ return _UPSTREAM_FAILURE_STATUS
28
+
29
+
30
+ def _retry_after_header(exc: RateLimitError) -> dict[str, str]:
31
+ if exc.retry_after is None:
32
+ return {}
33
+ return {"Retry-After": str(max(0, math.ceil(exc.retry_after)))}
34
+
35
+
36
+ async def fopost_exception_handler(request: Request, exc: Exception) -> JSONResponse:
37
+ """Render a ``FopostError`` as JSON, preserving what the caller needs."""
38
+ if not isinstance(exc, FopostError): # pragma: no cover - registered by type
39
+ raise exc
40
+
41
+ payload: dict[str, Any] = {
42
+ "error": exc.code or "fopost_error",
43
+ "message": exc.message,
44
+ }
45
+ headers: dict[str, str] = {}
46
+
47
+ if isinstance(exc, PaymentRequiredError) and exc.upgrade_url:
48
+ payload["upgrade_url"] = exc.upgrade_url
49
+ if isinstance(exc, RateLimitError):
50
+ headers = _retry_after_header(exc)
51
+
52
+ return JSONResponse(payload, status_code=_status_for(exc), headers=headers or None)
53
+
54
+
55
+ def install_exception_handlers(app: FastAPI) -> None:
56
+ """Register :func:`fopost_exception_handler` for every SDK error."""
57
+ app.add_exception_handler(FopostError, fopost_exception_handler)
File without changes
@@ -0,0 +1,54 @@
1
+ """Configuration for the FoPost integration, read from ``FOPOST_*`` environment variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fopost import DEFAULT_BASE_URL
6
+ from pydantic import Field
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+ __all__ = ["FoPostSettings"]
10
+
11
+
12
+ class FoPostSettings(BaseSettings):
13
+ """Settings for the FoPost client and webhook receiver.
14
+
15
+ Every field is read from the matching ``FOPOST_``-prefixed environment
16
+ variable, so ``FOPOST_API_KEY`` fills ``api_key``::
17
+
18
+ settings = FoPostSettings() # from the environment
19
+ settings = FoPostSettings(api_key="fp_...") # or explicitly
20
+ """
21
+
22
+ model_config = SettingsConfigDict(
23
+ env_prefix="FOPOST_",
24
+ env_file=".env",
25
+ env_file_encoding="utf-8",
26
+ extra="ignore",
27
+ )
28
+
29
+ api_key: str | None = Field(
30
+ default=None,
31
+ description="API key from https://app.fopost.com/api-keys, sent as X-API-Key.",
32
+ )
33
+ base_url: str = Field(
34
+ default=DEFAULT_BASE_URL,
35
+ description="Root of the FoPost API.",
36
+ )
37
+ timeout: float = Field(
38
+ default=30.0,
39
+ gt=0,
40
+ description="Seconds to wait for a single request before giving up.",
41
+ )
42
+ max_retries: int = Field(
43
+ default=3,
44
+ ge=1,
45
+ description="Attempts a rate limited request gets, including the first.",
46
+ )
47
+ default_workspace_id: str | None = Field(
48
+ default=None,
49
+ description="Workspace to fall back to when a route does not name one.",
50
+ )
51
+ webhook_secret: str | None = Field(
52
+ default=None,
53
+ description="Secret of the FoPost webhook whose deliveries this app receives.",
54
+ )
@@ -0,0 +1,197 @@
1
+ """Receive FoPost webhooks.
2
+
3
+ FoPost signs every delivery with HMAC-SHA256 over the **raw** request body,
4
+ using that webhook's secret, and sends it as::
5
+
6
+ X-FoPost-Signature: sha256=<hex digest>
7
+ X-FoPost-Event: post.published
8
+ X-FoPost-Delivery: <delivery id>
9
+
10
+ The body is ``{"event": ..., "data": {...}, "timestamp": "<ISO 8601>"}``.
11
+ The signature covers the bytes as sent, so it is verified before any parsing —
12
+ re-serialising the JSON would change the digest.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hmac
18
+ import inspect
19
+ from collections import defaultdict
20
+ from collections.abc import Awaitable, Callable
21
+ from hashlib import sha256
22
+ from typing import Any, TypeVar, cast
23
+
24
+ from fastapi import APIRouter, HTTPException, Request, status
25
+ from pydantic import BaseModel, Field
26
+
27
+ from .concurrency import run_fopost
28
+ from .settings import FoPostSettings
29
+
30
+ __all__ = [
31
+ "ANY_EVENT",
32
+ "DELIVERY_HEADER",
33
+ "EVENT_HEADER",
34
+ "SIGNATURE_HEADER",
35
+ "WEBHOOK_EVENTS",
36
+ "FoPostWebhookRouter",
37
+ "WebhookEvent",
38
+ "on_event",
39
+ "sign_payload",
40
+ "verify_webhook_signature",
41
+ "webhook_router",
42
+ ]
43
+
44
+ SIGNATURE_HEADER = "X-FoPost-Signature"
45
+ EVENT_HEADER = "X-FoPost-Event"
46
+ DELIVERY_HEADER = "X-FoPost-Delivery"
47
+ _SIGNATURE_PREFIX = "sha256="
48
+
49
+ #: Subscribe to every event, whatever it is.
50
+ ANY_EVENT = "*"
51
+
52
+ #: Events FoPost can deliver, as accepted by ``POST /v1/webhooks``.
53
+ WEBHOOK_EVENTS = (
54
+ "post.published",
55
+ "post.failed",
56
+ "post.partially_failed",
57
+ "delivery.published",
58
+ "delivery.failed",
59
+ "delivery.delayed",
60
+ "account.health_changed",
61
+ )
62
+
63
+
64
+ class WebhookEvent(BaseModel):
65
+ """One delivery, as FoPost sends it."""
66
+
67
+ event: str
68
+ data: dict[str, Any] = Field(default_factory=dict)
69
+ timestamp: str | None = None
70
+ delivery_id: str | None = Field(
71
+ default=None,
72
+ description="Value of the X-FoPost-Delivery header, not part of the body.",
73
+ )
74
+
75
+
76
+ Handler = Callable[[WebhookEvent], Awaitable[None] | None]
77
+ H = TypeVar("H", bound=Handler)
78
+
79
+
80
+ def sign_payload(body: bytes, secret: str) -> str:
81
+ """Return the header value FoPost sends for ``body``, ``sha256=`` included."""
82
+ digest = hmac.new(secret.encode("utf-8"), body, sha256).hexdigest()
83
+ return f"{_SIGNATURE_PREFIX}{digest}"
84
+
85
+
86
+ def verify_webhook_signature(body: bytes, header: str | None, secret: str) -> bool:
87
+ """Constant-time check of a delivery signature against the raw body."""
88
+ if not header or not secret:
89
+ return False
90
+ return hmac.compare_digest(sign_payload(body, secret), header.strip())
91
+
92
+
93
+ class FoPostWebhookRouter(APIRouter):
94
+ """An ``APIRouter`` that verifies FoPost signatures and fans out to handlers.
95
+
96
+ ::
97
+
98
+ app.include_router(webhook_router, prefix="/fopost")
99
+
100
+ @on_event("post.published")
101
+ async def published(event: WebhookEvent) -> None:
102
+ ...
103
+
104
+ The secret comes from ``FoPostSettings.webhook_secret`` (``FOPOST_WEBHOOK_SECRET``)
105
+ unless one is passed here. With no secret at all the route answers 500 rather
106
+ than accepting unverified traffic.
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ *,
112
+ path: str = "/webhooks",
113
+ secret: str | None = None,
114
+ **kwargs: Any,
115
+ ) -> None:
116
+ super().__init__(**kwargs)
117
+ self._handlers: defaultdict[str, list[Handler]] = defaultdict(list)
118
+ self._secret = secret
119
+ self.add_api_route(
120
+ path,
121
+ self._receive,
122
+ methods=["POST"],
123
+ name="fopost_webhook",
124
+ summary="Receive a FoPost webhook",
125
+ include_in_schema=False,
126
+ )
127
+
128
+ def on_event(self, event: str = ANY_EVENT) -> Callable[[H], H]:
129
+ """Register a handler for one event, or for :data:`ANY_EVENT`."""
130
+
131
+ def decorator(func: H) -> H:
132
+ self._handlers[event].append(func)
133
+ return func
134
+
135
+ return decorator
136
+
137
+ def handlers_for(self, event: str) -> list[Handler]:
138
+ return [*self._handlers.get(event, ()), *self._handlers.get(ANY_EVENT, ())]
139
+
140
+ def _resolve_secret(self, request: Request) -> str | None:
141
+ if self._secret:
142
+ return self._secret
143
+ settings = getattr(request.app.state, "fopost_settings", None)
144
+ if isinstance(settings, FoPostSettings):
145
+ return settings.webhook_secret
146
+ return None
147
+
148
+ async def _receive(self, request: Request) -> dict[str, Any]:
149
+ secret = self._resolve_secret(request)
150
+ if not secret:
151
+ raise HTTPException(
152
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
153
+ detail="fopost webhook secret is not configured",
154
+ )
155
+
156
+ # Read the raw bytes first — the signature covers them exactly.
157
+ body = await request.body()
158
+ if not verify_webhook_signature(body, request.headers.get(SIGNATURE_HEADER), secret):
159
+ raise HTTPException(
160
+ status_code=status.HTTP_401_UNAUTHORIZED,
161
+ detail="invalid fopost webhook signature",
162
+ )
163
+
164
+ try:
165
+ payload = await request.json()
166
+ except ValueError as exc:
167
+ raise HTTPException(
168
+ status_code=status.HTTP_400_BAD_REQUEST,
169
+ detail="fopost webhook body is not valid JSON",
170
+ ) from exc
171
+ if not isinstance(payload, dict):
172
+ raise HTTPException(
173
+ status_code=status.HTTP_400_BAD_REQUEST,
174
+ detail="fopost webhook body is not an object",
175
+ )
176
+
177
+ payload.setdefault("event", request.headers.get(EVENT_HEADER, ""))
178
+ payload["delivery_id"] = request.headers.get(DELIVERY_HEADER) or None
179
+ event = WebhookEvent.model_validate(payload)
180
+
181
+ handled = await self._dispatch(event)
182
+ return {"received": True, "event": event.event, "handlers": handled}
183
+
184
+ async def _dispatch(self, event: WebhookEvent) -> int:
185
+ """Run every handler for the event. Blocking handlers go to a worker thread."""
186
+ handlers = self.handlers_for(event.event)
187
+ for handler in handlers:
188
+ if inspect.iscoroutinefunction(handler):
189
+ await handler(event)
190
+ else:
191
+ await run_fopost(cast(Callable[[WebhookEvent], None], handler), event)
192
+ return len(handlers)
193
+
194
+
195
+ #: The router most apps include, and the decorator that feeds it.
196
+ webhook_router = FoPostWebhookRouter()
197
+ on_event = webhook_router.on_event
@@ -0,0 +1,233 @@
1
+ Metadata-Version: 2.5
2
+ Name: fopost-fastapi
3
+ Version: 0.1.0
4
+ Summary: Official FastAPI integration for the FoPost API. Dependency injection, settings, webhooks, and error handling on top of the fopost SDK.
5
+ Project-URL: Homepage, https://fopost.com
6
+ Project-URL: Documentation, https://fopost.com/docs
7
+ Project-URL: Repository, https://github.com/fopost/fopost-fastapi
8
+ Project-URL: Issues, https://github.com/fopost/fopost-fastapi/issues
9
+ Project-URL: Support, https://fopost.com/contact
10
+ Author: FoPost, Porter Bridge, LLC
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: api,fastapi,fopost,publishing,scheduling,sdk,social-media,webhooks
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Framework :: FastAPI
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Internet :: WWW/HTTP
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: fastapi>=0.110
26
+ Requires-Dist: fopost<1.0,>=0.1
27
+ Requires-Dist: pydantic-settings>=2
28
+ Requires-Dist: pydantic>=2
29
+ Provides-Extra: dev
30
+ Requires-Dist: httpx>=0.27; extra == 'dev'
31
+ Requires-Dist: mypy>=1.11; extra == 'dev'
32
+ Requires-Dist: pytest>=8; extra == 'dev'
33
+ Requires-Dist: ruff>=0.6; extra == 'dev'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # fopost-fastapi
37
+
38
+ [![PyPI](https://img.shields.io/pypi/v/fopost-fastapi.svg)](https://pypi.org/project/fopost-fastapi/)
39
+ [![Python versions](https://img.shields.io/pypi/pyversions/fopost-fastapi.svg)](https://pypi.org/project/fopost-fastapi/)
40
+ [![CI](https://github.com/fopost/fopost-fastapi/actions/workflows/ci.yml/badge.svg)](https://github.com/fopost/fopost-fastapi/actions/workflows/ci.yml)
41
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
42
+
43
+ Official FastAPI integration for the [FoPost](https://fopost.com) API. Schedule and publish to
44
+ +30 social platforms from your code.
45
+
46
+ This is a **thin wrapper**. Every request, model, retry, and error type lives in the
47
+ [`fopost`](https://pypi.org/project/fopost/) SDK — this package wires that client into FastAPI's
48
+ idioms: settings, dependency injection, a webhook receiver, and an exception handler.
49
+
50
+ ```bash
51
+ pip install fopost-fastapi
52
+ ```
53
+
54
+ Requires Python 3.10 or newer, FastAPI 0.110 or newer, and pydantic v2.
55
+
56
+ > **0.x release.** The public API is still settling and minor versions may contain breaking
57
+ > changes. Pin an exact version if that matters to you.
58
+
59
+ ## Quick start
60
+
61
+ ```python
62
+ from fastapi import FastAPI
63
+
64
+ from fopost_fastapi import FoPostDep, install_exception_handlers, setup_fopost
65
+
66
+ app = FastAPI()
67
+ setup_fopost(app) # one client, created at startup, closed at shutdown
68
+ install_exception_handlers(app)
69
+
70
+
71
+ @app.get("/workspaces")
72
+ def workspaces(fopost: FoPostDep):
73
+ return fopost.workspaces.list()
74
+ ```
75
+
76
+ `FoPostDep` is `Annotated[Fopost, Depends(get_client)]`. The client is built **once** when the
77
+ app starts and shared by every request — the dependency looks it up, it never constructs one.
78
+
79
+ If your app already has its own lifespan, `setup_fopost` wraps it rather than replacing it.
80
+ To wire FoPost through the lifespan directly instead:
81
+
82
+ ```python
83
+ from fopost_fastapi import fopost_lifespan
84
+
85
+ app = FastAPI(lifespan=fopost_lifespan())
86
+ ```
87
+
88
+ ## Settings
89
+
90
+ `FoPostSettings` is a `pydantic-settings` model reading `FOPOST_`-prefixed environment variables
91
+ (and a `.env` file, if present).
92
+
93
+ | Field | Environment variable | Default |
94
+ | :--- | :--- | :--- |
95
+ | `api_key` | `FOPOST_API_KEY` | — (required) |
96
+ | `base_url` | `FOPOST_BASE_URL` | `https://api.fopost.com/v1` |
97
+ | `timeout` | `FOPOST_TIMEOUT` | `30.0` seconds |
98
+ | `max_retries` | `FOPOST_MAX_RETRIES` | `3` attempts |
99
+ | `default_workspace_id` | `FOPOST_DEFAULT_WORKSPACE_ID` | — |
100
+ | `webhook_secret` | `FOPOST_WEBHOOK_SECRET` | — |
101
+
102
+ Create an API key at <https://app.fopost.com/api-keys>. It is sent as `X-API-Key`.
103
+
104
+ Pass settings explicitly when you would rather not read the environment:
105
+
106
+ ```python
107
+ setup_fopost(app, FoPostSettings(api_key="fp_...", base_url="https://api.fopost.com/v1"))
108
+ ```
109
+
110
+ `FoPostSettingsDep` injects the resolved settings into a route, which is how you reach
111
+ `default_workspace_id`.
112
+
113
+ ## Sync or async? The SDK is synchronous
114
+
115
+ The `fopost` package ships a **blocking** client only — there is no async variant, and this
116
+ package deliberately does not write one. That leaves two shapes:
117
+
118
+ ```python
119
+ # A `def` route — FastAPI already runs it in a worker thread. Call the SDK directly.
120
+ @app.get("/accounts")
121
+ def accounts(fopost: FoPostDep, settings: FoPostSettingsDep):
122
+ return fopost.accounts.list(workspace_id=settings.default_workspace_id)
123
+
124
+
125
+ # An `async def` route — the call must leave the event loop, or it stalls the server.
126
+ from fopost_fastapi import run_fopost
127
+
128
+ @app.post("/posts")
129
+ async def create(fopost: FoPostDep, settings: FoPostSettingsDep):
130
+ return await run_fopost(
131
+ fopost.posts.create,
132
+ workspace_id=settings.default_workspace_id,
133
+ content="Hello from FastAPI",
134
+ accounts=["<account id>"],
135
+ )
136
+ ```
137
+
138
+ `run_fopost` is a thin wrapper over `fastapi.concurrency.run_in_threadpool`. Never call the SDK
139
+ straight from an `async def` route: a 30-second timeout would block every other request.
140
+
141
+ ## Receiving webhooks
142
+
143
+ ```python
144
+ from fopost_fastapi import WebhookEvent, on_event, webhook_router
145
+
146
+ app.include_router(webhook_router, prefix="/fopost") # POST /fopost/webhooks
147
+
148
+
149
+ @on_event("post.published")
150
+ async def published(event: WebhookEvent) -> None:
151
+ print(event.data["id"], event.timestamp)
152
+
153
+
154
+ @on_event("post.failed")
155
+ def failed(event: WebhookEvent) -> None: # a `def` handler runs in a worker thread
156
+ ...
157
+ ```
158
+
159
+ Point a FoPost webhook at `https://<your host>/fopost/webhooks` and put its secret in
160
+ `FOPOST_WEBHOOK_SECRET`.
161
+
162
+ FoPost signs each delivery with HMAC-SHA256 over the **raw** request body using that webhook's
163
+ secret, and sends it as `X-FoPost-Signature: sha256=<hex>` alongside `X-FoPost-Event` and
164
+ `X-FoPost-Delivery`. The router reads the raw bytes before any parsing, compares with
165
+ `hmac.compare_digest`, and answers **401** on a mismatch or a missing header — no handler runs.
166
+ With no secret configured at all it answers 500 rather than accepting unverifiable traffic.
167
+
168
+ Events: `post.published`, `post.failed`, `post.partially_failed`, `delivery.published`,
169
+ `delivery.failed`, `delivery.delayed`, `account.health_changed`. `@on_event()` with no argument
170
+ subscribes to all of them.
171
+
172
+ Run several receivers, or keep the secret out of the environment, by building your own router:
173
+
174
+ ```python
175
+ from fopost_fastapi import FoPostWebhookRouter
176
+
177
+ router = FoPostWebhookRouter(secret="whsec_...", path="/callbacks")
178
+ app.include_router(router, prefix="/fopost")
179
+ ```
180
+
181
+ `sign_payload(body, secret)` and `verify_webhook_signature(body, header, secret)` are exported
182
+ if you need to verify a delivery somewhere else.
183
+
184
+ ## Error handling
185
+
186
+ `install_exception_handlers(app)` turns an SDK exception into the status the FoPost API actually
187
+ answered with, instead of a 500 and a stack trace.
188
+
189
+ | SDK error | Response |
190
+ | :--- | :--- |
191
+ | `AuthenticationError` (401), `PermissionDeniedError` (403), `NotFoundError` (404) | the same status |
192
+ | `PaymentRequiredError` (402) | 402, body keeps `upgrade_url` |
193
+ | `RateLimitError` (429) | 429 with a `Retry-After` header |
194
+ | any 4xx | the same status |
195
+ | any 5xx or transport failure | 502 — your app is fine, its dependency is not |
196
+
197
+ The body is the API's own envelope: `{"error": "<machine code>", "message": "<human text>"}`.
198
+
199
+ Retries are the SDK's job, not this package's: a 429 is retried up to `max_retries` attempts,
200
+ honouring `Retry-After`, before the error ever reaches the handler.
201
+
202
+ ## The rest of the API
203
+
204
+ Everything you can call on the injected client — `posts`, `accounts`, `workspaces`, `labels`,
205
+ `ai`, and the `request()` escape hatch for endpoints the SDK does not wrap — is documented in the
206
+ [`fopost` SDK](https://github.com/fopost/fopost-python). This package adds no resources of its own
207
+ and stores nothing.
208
+
209
+ ## Example
210
+
211
+ [`examples/main.py`](examples/main.py) is a complete app: settings from the environment, the
212
+ injected client in both a `def` and an `async def` route, and the webhook receiver.
213
+
214
+ ## Development
215
+
216
+ ```bash
217
+ python -m venv .venv && source .venv/bin/activate
218
+ pip install -e '.[dev]'
219
+ pytest
220
+ ruff check . && ruff format --check .
221
+ mypy
222
+ ```
223
+
224
+ The suite is fully offline — it stubs the SDK's HTTP transport and never reaches the network.
225
+
226
+ ## Links
227
+
228
+ - Documentation — <https://fopost.com/docs>
229
+ - Python SDK — <https://github.com/fopost/fopost-python>
230
+ - Issues — <https://github.com/fopost/fopost-fastapi/issues>
231
+ - Support — <https://fopost.com/contact>
232
+
233
+ MIT licensed. Copyright (c) 2026 Porter Bridge, LLC.
@@ -0,0 +1,11 @@
1
+ fopost_fastapi/__init__.py,sha256=WRbl7bgJtIsLC0djKWKW1e-dnhurjln5f8ywNzL0aa0,2175
2
+ fopost_fastapi/client.py,sha256=murlb3N1i_Y8Sn1bxzeVcklxO_oSDJCavtgIFR7Gzvk,4874
3
+ fopost_fastapi/concurrency.py,sha256=FzZpfX3f9vhytJQbT3HqCf1RZiwLfZF_RObuQWZJb7A,1167
4
+ fopost_fastapi/errors.py,sha256=H-I6o_pz_rFPD_dvJVSVQa2LgF1OHTXRZBndbkUjllc,1974
5
+ fopost_fastapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ fopost_fastapi/settings.py,sha256=2eDzp1hjFUM5CXlrD0aoi9oNPp0JHKlEVwml82vXWWM,1711
7
+ fopost_fastapi/webhooks.py,sha256=rWDLd-mmi_smWMiXYES8uIEJrlGJHNy5K74Xjw8LHUc,6414
8
+ fopost_fastapi-0.1.0.dist-info/METADATA,sha256=xr33gr_wz4qy3T9LzqrL15QvUmBGPpzA85yxp7a_pls,8887
9
+ fopost_fastapi-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
10
+ fopost_fastapi-0.1.0.dist-info/licenses/LICENSE,sha256=-JJWsKM2TZKYsmCX8qxSbfzkUMZ0NIvV3J08SMypMn0,1075
11
+ fopost_fastapi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Porter Bridge, LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.