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.
- fopost_fastapi/__init__.py +85 -0
- fopost_fastapi/client.py +152 -0
- fopost_fastapi/concurrency.py +43 -0
- fopost_fastapi/errors.py +57 -0
- fopost_fastapi/py.typed +0 -0
- fopost_fastapi/settings.py +54 -0
- fopost_fastapi/webhooks.py +197 -0
- fopost_fastapi-0.1.0.dist-info/METADATA +233 -0
- fopost_fastapi-0.1.0.dist-info/RECORD +11 -0
- fopost_fastapi-0.1.0.dist-info/WHEEL +4 -0
- fopost_fastapi-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
]
|
fopost_fastapi/client.py
ADDED
|
@@ -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))
|
fopost_fastapi/errors.py
ADDED
|
@@ -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)
|
fopost_fastapi/py.typed
ADDED
|
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
|
+
[](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,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,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.
|