fopost-fastapi 0.1.0__tar.gz
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.
- fopost_fastapi-0.1.0/.gitignore +17 -0
- fopost_fastapi-0.1.0/LICENSE +21 -0
- fopost_fastapi-0.1.0/PKG-INFO +233 -0
- fopost_fastapi-0.1.0/README.md +198 -0
- fopost_fastapi-0.1.0/examples/README.md +22 -0
- fopost_fastapi-0.1.0/examples/main.py +82 -0
- fopost_fastapi-0.1.0/pyproject.toml +83 -0
- fopost_fastapi-0.1.0/src/fopost_fastapi/__init__.py +85 -0
- fopost_fastapi-0.1.0/src/fopost_fastapi/client.py +152 -0
- fopost_fastapi-0.1.0/src/fopost_fastapi/concurrency.py +43 -0
- fopost_fastapi-0.1.0/src/fopost_fastapi/errors.py +57 -0
- fopost_fastapi-0.1.0/src/fopost_fastapi/py.typed +0 -0
- fopost_fastapi-0.1.0/src/fopost_fastapi/settings.py +54 -0
- fopost_fastapi-0.1.0/src/fopost_fastapi/webhooks.py +197 -0
- fopost_fastapi-0.1.0/tests/__init__.py +0 -0
- fopost_fastapi-0.1.0/tests/conftest.py +50 -0
- fopost_fastapi-0.1.0/tests/test_dependency.py +96 -0
- fopost_fastapi-0.1.0/tests/test_errors.py +88 -0
- fopost_fastapi-0.1.0/tests/test_settings.py +58 -0
- fopost_fastapi-0.1.0/tests/test_webhooks.py +188 -0
|
@@ -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.
|
|
@@ -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
|
+
[](https://pypi.org/project/fopost-fastapi/)
|
|
39
|
+
[](https://pypi.org/project/fopost-fastapi/)
|
|
40
|
+
[](https://github.com/fopost/fopost-fastapi/actions/workflows/ci.yml)
|
|
41
|
+
[](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,198 @@
|
|
|
1
|
+
# fopost-fastapi
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/fopost-fastapi/)
|
|
4
|
+
[](https://pypi.org/project/fopost-fastapi/)
|
|
5
|
+
[](https://github.com/fopost/fopost-fastapi/actions/workflows/ci.yml)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
Official FastAPI integration for the [FoPost](https://fopost.com) API. Schedule and publish to
|
|
9
|
+
+30 social platforms from your code.
|
|
10
|
+
|
|
11
|
+
This is a **thin wrapper**. Every request, model, retry, and error type lives in the
|
|
12
|
+
[`fopost`](https://pypi.org/project/fopost/) SDK — this package wires that client into FastAPI's
|
|
13
|
+
idioms: settings, dependency injection, a webhook receiver, and an exception handler.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install fopost-fastapi
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Requires Python 3.10 or newer, FastAPI 0.110 or newer, and pydantic v2.
|
|
20
|
+
|
|
21
|
+
> **0.x release.** The public API is still settling and minor versions may contain breaking
|
|
22
|
+
> changes. Pin an exact version if that matters to you.
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from fastapi import FastAPI
|
|
28
|
+
|
|
29
|
+
from fopost_fastapi import FoPostDep, install_exception_handlers, setup_fopost
|
|
30
|
+
|
|
31
|
+
app = FastAPI()
|
|
32
|
+
setup_fopost(app) # one client, created at startup, closed at shutdown
|
|
33
|
+
install_exception_handlers(app)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.get("/workspaces")
|
|
37
|
+
def workspaces(fopost: FoPostDep):
|
|
38
|
+
return fopost.workspaces.list()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`FoPostDep` is `Annotated[Fopost, Depends(get_client)]`. The client is built **once** when the
|
|
42
|
+
app starts and shared by every request — the dependency looks it up, it never constructs one.
|
|
43
|
+
|
|
44
|
+
If your app already has its own lifespan, `setup_fopost` wraps it rather than replacing it.
|
|
45
|
+
To wire FoPost through the lifespan directly instead:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from fopost_fastapi import fopost_lifespan
|
|
49
|
+
|
|
50
|
+
app = FastAPI(lifespan=fopost_lifespan())
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Settings
|
|
54
|
+
|
|
55
|
+
`FoPostSettings` is a `pydantic-settings` model reading `FOPOST_`-prefixed environment variables
|
|
56
|
+
(and a `.env` file, if present).
|
|
57
|
+
|
|
58
|
+
| Field | Environment variable | Default |
|
|
59
|
+
| :--- | :--- | :--- |
|
|
60
|
+
| `api_key` | `FOPOST_API_KEY` | — (required) |
|
|
61
|
+
| `base_url` | `FOPOST_BASE_URL` | `https://api.fopost.com/v1` |
|
|
62
|
+
| `timeout` | `FOPOST_TIMEOUT` | `30.0` seconds |
|
|
63
|
+
| `max_retries` | `FOPOST_MAX_RETRIES` | `3` attempts |
|
|
64
|
+
| `default_workspace_id` | `FOPOST_DEFAULT_WORKSPACE_ID` | — |
|
|
65
|
+
| `webhook_secret` | `FOPOST_WEBHOOK_SECRET` | — |
|
|
66
|
+
|
|
67
|
+
Create an API key at <https://app.fopost.com/api-keys>. It is sent as `X-API-Key`.
|
|
68
|
+
|
|
69
|
+
Pass settings explicitly when you would rather not read the environment:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
setup_fopost(app, FoPostSettings(api_key="fp_...", base_url="https://api.fopost.com/v1"))
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`FoPostSettingsDep` injects the resolved settings into a route, which is how you reach
|
|
76
|
+
`default_workspace_id`.
|
|
77
|
+
|
|
78
|
+
## Sync or async? The SDK is synchronous
|
|
79
|
+
|
|
80
|
+
The `fopost` package ships a **blocking** client only — there is no async variant, and this
|
|
81
|
+
package deliberately does not write one. That leaves two shapes:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
# A `def` route — FastAPI already runs it in a worker thread. Call the SDK directly.
|
|
85
|
+
@app.get("/accounts")
|
|
86
|
+
def accounts(fopost: FoPostDep, settings: FoPostSettingsDep):
|
|
87
|
+
return fopost.accounts.list(workspace_id=settings.default_workspace_id)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# An `async def` route — the call must leave the event loop, or it stalls the server.
|
|
91
|
+
from fopost_fastapi import run_fopost
|
|
92
|
+
|
|
93
|
+
@app.post("/posts")
|
|
94
|
+
async def create(fopost: FoPostDep, settings: FoPostSettingsDep):
|
|
95
|
+
return await run_fopost(
|
|
96
|
+
fopost.posts.create,
|
|
97
|
+
workspace_id=settings.default_workspace_id,
|
|
98
|
+
content="Hello from FastAPI",
|
|
99
|
+
accounts=["<account id>"],
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`run_fopost` is a thin wrapper over `fastapi.concurrency.run_in_threadpool`. Never call the SDK
|
|
104
|
+
straight from an `async def` route: a 30-second timeout would block every other request.
|
|
105
|
+
|
|
106
|
+
## Receiving webhooks
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from fopost_fastapi import WebhookEvent, on_event, webhook_router
|
|
110
|
+
|
|
111
|
+
app.include_router(webhook_router, prefix="/fopost") # POST /fopost/webhooks
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@on_event("post.published")
|
|
115
|
+
async def published(event: WebhookEvent) -> None:
|
|
116
|
+
print(event.data["id"], event.timestamp)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@on_event("post.failed")
|
|
120
|
+
def failed(event: WebhookEvent) -> None: # a `def` handler runs in a worker thread
|
|
121
|
+
...
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Point a FoPost webhook at `https://<your host>/fopost/webhooks` and put its secret in
|
|
125
|
+
`FOPOST_WEBHOOK_SECRET`.
|
|
126
|
+
|
|
127
|
+
FoPost signs each delivery with HMAC-SHA256 over the **raw** request body using that webhook's
|
|
128
|
+
secret, and sends it as `X-FoPost-Signature: sha256=<hex>` alongside `X-FoPost-Event` and
|
|
129
|
+
`X-FoPost-Delivery`. The router reads the raw bytes before any parsing, compares with
|
|
130
|
+
`hmac.compare_digest`, and answers **401** on a mismatch or a missing header — no handler runs.
|
|
131
|
+
With no secret configured at all it answers 500 rather than accepting unverifiable traffic.
|
|
132
|
+
|
|
133
|
+
Events: `post.published`, `post.failed`, `post.partially_failed`, `delivery.published`,
|
|
134
|
+
`delivery.failed`, `delivery.delayed`, `account.health_changed`. `@on_event()` with no argument
|
|
135
|
+
subscribes to all of them.
|
|
136
|
+
|
|
137
|
+
Run several receivers, or keep the secret out of the environment, by building your own router:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from fopost_fastapi import FoPostWebhookRouter
|
|
141
|
+
|
|
142
|
+
router = FoPostWebhookRouter(secret="whsec_...", path="/callbacks")
|
|
143
|
+
app.include_router(router, prefix="/fopost")
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`sign_payload(body, secret)` and `verify_webhook_signature(body, header, secret)` are exported
|
|
147
|
+
if you need to verify a delivery somewhere else.
|
|
148
|
+
|
|
149
|
+
## Error handling
|
|
150
|
+
|
|
151
|
+
`install_exception_handlers(app)` turns an SDK exception into the status the FoPost API actually
|
|
152
|
+
answered with, instead of a 500 and a stack trace.
|
|
153
|
+
|
|
154
|
+
| SDK error | Response |
|
|
155
|
+
| :--- | :--- |
|
|
156
|
+
| `AuthenticationError` (401), `PermissionDeniedError` (403), `NotFoundError` (404) | the same status |
|
|
157
|
+
| `PaymentRequiredError` (402) | 402, body keeps `upgrade_url` |
|
|
158
|
+
| `RateLimitError` (429) | 429 with a `Retry-After` header |
|
|
159
|
+
| any 4xx | the same status |
|
|
160
|
+
| any 5xx or transport failure | 502 — your app is fine, its dependency is not |
|
|
161
|
+
|
|
162
|
+
The body is the API's own envelope: `{"error": "<machine code>", "message": "<human text>"}`.
|
|
163
|
+
|
|
164
|
+
Retries are the SDK's job, not this package's: a 429 is retried up to `max_retries` attempts,
|
|
165
|
+
honouring `Retry-After`, before the error ever reaches the handler.
|
|
166
|
+
|
|
167
|
+
## The rest of the API
|
|
168
|
+
|
|
169
|
+
Everything you can call on the injected client — `posts`, `accounts`, `workspaces`, `labels`,
|
|
170
|
+
`ai`, and the `request()` escape hatch for endpoints the SDK does not wrap — is documented in the
|
|
171
|
+
[`fopost` SDK](https://github.com/fopost/fopost-python). This package adds no resources of its own
|
|
172
|
+
and stores nothing.
|
|
173
|
+
|
|
174
|
+
## Example
|
|
175
|
+
|
|
176
|
+
[`examples/main.py`](examples/main.py) is a complete app: settings from the environment, the
|
|
177
|
+
injected client in both a `def` and an `async def` route, and the webhook receiver.
|
|
178
|
+
|
|
179
|
+
## Development
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
python -m venv .venv && source .venv/bin/activate
|
|
183
|
+
pip install -e '.[dev]'
|
|
184
|
+
pytest
|
|
185
|
+
ruff check . && ruff format --check .
|
|
186
|
+
mypy
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The suite is fully offline — it stubs the SDK's HTTP transport and never reaches the network.
|
|
190
|
+
|
|
191
|
+
## Links
|
|
192
|
+
|
|
193
|
+
- Documentation — <https://fopost.com/docs>
|
|
194
|
+
- Python SDK — <https://github.com/fopost/fopost-python>
|
|
195
|
+
- Issues — <https://github.com/fopost/fopost-fastapi/issues>
|
|
196
|
+
- Support — <https://fopost.com/contact>
|
|
197
|
+
|
|
198
|
+
MIT licensed. Copyright (c) 2026 Porter Bridge, LLC.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Example
|
|
2
|
+
|
|
3
|
+
`main.py` is a complete FastAPI app: settings from the environment, the injected client in
|
|
4
|
+
both a `def` and an `async def` route, and the webhook receiver.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install fopost-fastapi uvicorn
|
|
8
|
+
export FOPOST_API_KEY=fp_...
|
|
9
|
+
export FOPOST_DEFAULT_WORKSPACE_ID=9b2f6c1e-...
|
|
10
|
+
export FOPOST_WEBHOOK_SECRET=...
|
|
11
|
+
uvicorn examples.main:app --reload
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
curl localhost:8000/accounts
|
|
16
|
+
curl -X POST localhost:8000/posts \
|
|
17
|
+
-H 'content-type: application/json' \
|
|
18
|
+
-d '{"text": "Hello from FastAPI", "accounts": ["<account id>"], "publish": true}'
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Create the webhook in FoPost (or with the SDK) pointing at `https://<your host>/fopost/webhooks`,
|
|
22
|
+
and put its secret in `FOPOST_WEBHOOK_SECRET`.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""A runnable FastAPI app: create a post, publish it, and receive the webhook.
|
|
2
|
+
|
|
3
|
+
pip install fopost-fastapi uvicorn
|
|
4
|
+
export FOPOST_API_KEY=fp_...
|
|
5
|
+
export FOPOST_DEFAULT_WORKSPACE_ID=9b2f6c1e-...
|
|
6
|
+
export FOPOST_WEBHOOK_SECRET=... # the secret of your FoPost webhook
|
|
7
|
+
uvicorn examples.main:app --reload
|
|
8
|
+
|
|
9
|
+
Then point a FoPost webhook at ``https://<your host>/fopost/webhooks``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from fastapi import FastAPI, HTTPException
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
|
|
20
|
+
from fopost_fastapi import (
|
|
21
|
+
FoPostDep,
|
|
22
|
+
FoPostSettingsDep,
|
|
23
|
+
WebhookEvent,
|
|
24
|
+
install_exception_handlers,
|
|
25
|
+
on_event,
|
|
26
|
+
run_fopost,
|
|
27
|
+
setup_fopost,
|
|
28
|
+
webhook_router,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
log = logging.getLogger("example")
|
|
32
|
+
|
|
33
|
+
app = FastAPI(title="FoPost example")
|
|
34
|
+
setup_fopost(app)
|
|
35
|
+
install_exception_handlers(app)
|
|
36
|
+
app.include_router(webhook_router, prefix="/fopost")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NewPost(BaseModel):
|
|
40
|
+
text: str
|
|
41
|
+
accounts: list[str]
|
|
42
|
+
publish: bool = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.get("/accounts")
|
|
46
|
+
def list_accounts(fopost: FoPostDep, settings: FoPostSettingsDep) -> list[dict[str, Any]]:
|
|
47
|
+
"""A ``def`` route: FastAPI already runs it in a worker thread, so call the SDK directly."""
|
|
48
|
+
if not settings.default_workspace_id:
|
|
49
|
+
raise HTTPException(400, "set FOPOST_DEFAULT_WORKSPACE_ID")
|
|
50
|
+
accounts = fopost.accounts.list(workspace_id=settings.default_workspace_id)
|
|
51
|
+
return [{"id": a.id, "platform": a.platform, "username": a.username} for a in accounts]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.post("/posts")
|
|
55
|
+
async def create_post(
|
|
56
|
+
body: NewPost, fopost: FoPostDep, settings: FoPostSettingsDep
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
"""An ``async def`` route: the blocking SDK goes through ``run_fopost``."""
|
|
59
|
+
if not settings.default_workspace_id:
|
|
60
|
+
raise HTTPException(400, "set FOPOST_DEFAULT_WORKSPACE_ID")
|
|
61
|
+
|
|
62
|
+
post = await run_fopost(
|
|
63
|
+
fopost.posts.create,
|
|
64
|
+
workspace_id=settings.default_workspace_id,
|
|
65
|
+
content=body.text,
|
|
66
|
+
accounts=body.accounts,
|
|
67
|
+
)
|
|
68
|
+
if body.publish:
|
|
69
|
+
# Returns once delivery is queued, not once the post is live.
|
|
70
|
+
await run_fopost(fopost.posts.publish, post.id)
|
|
71
|
+
|
|
72
|
+
return {"id": post.id, "status": post.status}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@on_event("post.published")
|
|
76
|
+
async def post_published(event: WebhookEvent) -> None:
|
|
77
|
+
log.info("published: %s", event.data.get("id"))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@on_event("post.failed")
|
|
81
|
+
async def post_failed(event: WebhookEvent) -> None:
|
|
82
|
+
log.warning("failed: %s", event.data)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fopost-fastapi"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official FastAPI integration for the FoPost API. Dependency injection, settings, webhooks, and error handling on top of the fopost SDK."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "FoPost, Porter Bridge, LLC" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"fopost",
|
|
16
|
+
"fastapi",
|
|
17
|
+
"social-media",
|
|
18
|
+
"publishing",
|
|
19
|
+
"scheduling",
|
|
20
|
+
"webhooks",
|
|
21
|
+
"api",
|
|
22
|
+
"sdk",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 3 - Alpha",
|
|
26
|
+
"Framework :: FastAPI",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3.10",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Programming Language :: Python :: 3.13",
|
|
33
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
34
|
+
"Typing :: Typed",
|
|
35
|
+
]
|
|
36
|
+
dependencies = [
|
|
37
|
+
"fopost>=0.1,<1.0",
|
|
38
|
+
"fastapi>=0.110",
|
|
39
|
+
"pydantic>=2",
|
|
40
|
+
"pydantic-settings>=2",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.optional-dependencies]
|
|
44
|
+
dev = [
|
|
45
|
+
"pytest>=8",
|
|
46
|
+
"httpx>=0.27",
|
|
47
|
+
"ruff>=0.6",
|
|
48
|
+
"mypy>=1.11",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[project.urls]
|
|
52
|
+
Homepage = "https://fopost.com"
|
|
53
|
+
Documentation = "https://fopost.com/docs"
|
|
54
|
+
Repository = "https://github.com/fopost/fopost-fastapi"
|
|
55
|
+
Issues = "https://github.com/fopost/fopost-fastapi/issues"
|
|
56
|
+
Support = "https://fopost.com/contact"
|
|
57
|
+
|
|
58
|
+
[tool.hatch.build.targets.wheel]
|
|
59
|
+
packages = ["src/fopost_fastapi"]
|
|
60
|
+
|
|
61
|
+
[tool.hatch.build.targets.sdist]
|
|
62
|
+
include = ["src/fopost_fastapi", "tests", "examples", "README.md", "LICENSE"]
|
|
63
|
+
|
|
64
|
+
[tool.pytest.ini_options]
|
|
65
|
+
testpaths = ["tests"]
|
|
66
|
+
addopts = "-q"
|
|
67
|
+
|
|
68
|
+
[tool.ruff]
|
|
69
|
+
line-length = 100
|
|
70
|
+
src = ["src", "tests"]
|
|
71
|
+
# Ruff is the Python formatter here; snippets in the docs are not CI's business.
|
|
72
|
+
extend-exclude = ["*.md"]
|
|
73
|
+
|
|
74
|
+
[tool.ruff.lint]
|
|
75
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
76
|
+
|
|
77
|
+
[tool.ruff.lint.isort]
|
|
78
|
+
known-first-party = ["fopost_fastapi", "tests"]
|
|
79
|
+
|
|
80
|
+
[tool.mypy]
|
|
81
|
+
python_version = "3.10"
|
|
82
|
+
strict = true
|
|
83
|
+
files = ["src/fopost_fastapi"]
|