Skip to content

Commit e5c302f

Browse files
feat(api): manual updates
1 parent ef41522 commit e5c302f

File tree

9 files changed

+153
-4
lines changed

9 files changed

+153
-4
lines changed

.stats.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
configured_endpoints: 42
22
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/imagekit-inc%2Fimagekit-3d7da4b8ef2ed30aa32c4fb3e98e498e67402e91aaa5fd4c628fc080bfe82ea1.yml
33
openapi_spec_hash: aaa50fcbccec6f2cf1165f34bc6ac886
4-
config_hash: 84bf9f929b0248a6cdf2d526331ed8eb
4+
config_hash: 0f760028496146ece9431573b1ab0e46

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Repository = "https://github.com/stainless-sdks/imagekit-python"
4040

4141
[project.optional-dependencies]
4242
aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.8"]
43+
webhooks = ["standardwebhooks"]
4344

4445
[tool.rye]
4546
managed = true

requirements-dev.lock

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,14 @@ async-timeout==5.0.1
2828
# via aiohttp
2929
attrs==25.3.0
3030
# via aiohttp
31+
# via standardwebhooks
3132
certifi==2023.7.22
3233
# via httpcore
3334
# via httpx
3435
colorlog==6.7.0
3536
# via nox
37+
deprecated==1.2.18
38+
# via standardwebhooks
3639
dirty-equals==0.6.0
3740
distlib==0.3.7
3841
# via virtualenv
@@ -56,6 +59,7 @@ httpx==0.28.1
5659
# via httpx-aiohttp
5760
# via imagekit
5861
# via respx
62+
# via standardwebhooks
5963
httpx-aiohttp==0.1.8
6064
# via imagekit
6165
idna==3.4
@@ -102,6 +106,7 @@ pytest==8.3.3
102106
pytest-asyncio==0.24.0
103107
pytest-xdist==3.7.0
104108
python-dateutil==2.8.2
109+
# via standardwebhooks
105110
# via time-machine
106111
pytz==2023.3.post1
107112
# via dirty-equals
@@ -115,10 +120,16 @@ six==1.16.0
115120
sniffio==1.3.0
116121
# via anyio
117122
# via imagekit
123+
standardwebhooks==1.0.0
124+
# via imagekit
118125
time-machine==2.9.0
119126
tomli==2.0.2
120127
# via mypy
121128
# via pytest
129+
types-deprecated==1.2.15.20250304
130+
# via standardwebhooks
131+
types-python-dateutil==2.9.0.20250822
132+
# via standardwebhooks
122133
typing-extensions==4.12.2
123134
# via anyio
124135
# via imagekit
@@ -129,6 +140,8 @@ typing-extensions==4.12.2
129140
# via pyright
130141
virtualenv==20.24.5
131142
# via nox
143+
wrapt==1.17.3
144+
# via deprecated
132145
yarl==1.20.0
133146
# via aiohttp
134147
zipp==3.17.0

requirements.lock

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,12 @@ async-timeout==5.0.1
2626
# via aiohttp
2727
attrs==25.3.0
2828
# via aiohttp
29+
# via standardwebhooks
2930
certifi==2023.7.22
3031
# via httpcore
3132
# via httpx
33+
deprecated==1.2.18
34+
# via standardwebhooks
3235
distro==1.8.0
3336
# via imagekit
3437
exceptiongroup==1.2.2
@@ -43,6 +46,7 @@ httpcore==1.0.9
4346
httpx==0.28.1
4447
# via httpx-aiohttp
4548
# via imagekit
49+
# via standardwebhooks
4650
httpx-aiohttp==0.1.8
4751
# via imagekit
4852
idna==3.4
@@ -59,14 +63,26 @@ pydantic==2.10.3
5963
# via imagekit
6064
pydantic-core==2.27.1
6165
# via pydantic
66+
python-dateutil==2.9.0.post0
67+
# via standardwebhooks
68+
six==1.17.0
69+
# via python-dateutil
6270
sniffio==1.3.0
6371
# via anyio
6472
# via imagekit
73+
standardwebhooks==1.0.0
74+
# via imagekit
75+
types-deprecated==1.2.15.20250304
76+
# via standardwebhooks
77+
types-python-dateutil==2.9.0.20250822
78+
# via standardwebhooks
6579
typing-extensions==4.12.2
6680
# via anyio
6781
# via imagekit
6882
# via multidict
6983
# via pydantic
7084
# via pydantic-core
85+
wrapt==1.17.3
86+
# via deprecated
7187
yarl==1.20.0
7288
# via aiohttp

src/imagekit/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
InternalServerError,
3535
PermissionDeniedError,
3636
UnprocessableEntityError,
37+
APIWebhookValidationError,
3738
APIResponseValidationError,
3839
)
3940
from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient
@@ -55,6 +56,7 @@
5556
"APITimeoutError",
5657
"APIConnectionError",
5758
"APIResponseValidationError",
59+
"APIWebhookValidationError",
5860
"BadRequestError",
5961
"AuthenticationError",
6062
"PermissionDeniedError",

src/imagekit/_client.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,14 @@ class ImageKit(SyncAPIClient):
6464
# client options
6565
private_api_key: str
6666
password: str | None
67+
webhook_secret: str | None
6768

6869
def __init__(
6970
self,
7071
*,
7172
private_api_key: str | None = None,
7273
password: str | None = None,
74+
webhook_secret: str | None = None,
7375
base_url: str | httpx.URL | None = None,
7476
timeout: Union[float, Timeout, None, NotGiven] = NOT_GIVEN,
7577
max_retries: int = DEFAULT_MAX_RETRIES,
@@ -94,6 +96,7 @@ def __init__(
9496
This automatically infers the following arguments from their corresponding environment variables if they are not provided:
9597
- `private_api_key` from `IMAGEKIT_PRIVATE_API_KEY`
9698
- `password` from `OPTIONAL_IMAGEKIT_IGNORES_THIS`
99+
- `webhook_secret` from `IMAGEKIT_WEBHOOK_SECRET`
97100
"""
98101
if private_api_key is None:
99102
private_api_key = os.environ.get("IMAGEKIT_PRIVATE_API_KEY")
@@ -107,6 +110,10 @@ def __init__(
107110
password = os.environ.get("OPTIONAL_IMAGEKIT_IGNORES_THIS") or "do_not_set"
108111
self.password = password
109112

113+
if webhook_secret is None:
114+
webhook_secret = os.environ.get("IMAGEKIT_WEBHOOK_SECRET")
115+
self.webhook_secret = webhook_secret
116+
110117
if base_url is None:
111118
base_url = os.environ.get("IMAGE_KIT_BASE_URL")
112119
self._base_url_overridden = base_url is not None
@@ -174,6 +181,7 @@ def copy(
174181
*,
175182
private_api_key: str | None = None,
176183
password: str | None = None,
184+
webhook_secret: str | None = None,
177185
base_url: str | httpx.URL | None = None,
178186
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
179187
http_client: httpx.Client | None = None,
@@ -209,6 +217,7 @@ def copy(
209217
client = self.__class__(
210218
private_api_key=private_api_key or self.private_api_key,
211219
password=password or self.password,
220+
webhook_secret=webhook_secret or self.webhook_secret,
212221
base_url=base_url or self.base_url,
213222
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
214223
http_client=http_client,
@@ -273,12 +282,14 @@ class AsyncImageKit(AsyncAPIClient):
273282
# client options
274283
private_api_key: str
275284
password: str | None
285+
webhook_secret: str | None
276286

277287
def __init__(
278288
self,
279289
*,
280290
private_api_key: str | None = None,
281291
password: str | None = None,
292+
webhook_secret: str | None = None,
282293
base_url: str | httpx.URL | None = None,
283294
timeout: Union[float, Timeout, None, NotGiven] = NOT_GIVEN,
284295
max_retries: int = DEFAULT_MAX_RETRIES,
@@ -303,6 +314,7 @@ def __init__(
303314
This automatically infers the following arguments from their corresponding environment variables if they are not provided:
304315
- `private_api_key` from `IMAGEKIT_PRIVATE_API_KEY`
305316
- `password` from `OPTIONAL_IMAGEKIT_IGNORES_THIS`
317+
- `webhook_secret` from `IMAGEKIT_WEBHOOK_SECRET`
306318
"""
307319
if private_api_key is None:
308320
private_api_key = os.environ.get("IMAGEKIT_PRIVATE_API_KEY")
@@ -316,6 +328,10 @@ def __init__(
316328
password = os.environ.get("OPTIONAL_IMAGEKIT_IGNORES_THIS") or "do_not_set"
317329
self.password = password
318330

331+
if webhook_secret is None:
332+
webhook_secret = os.environ.get("IMAGEKIT_WEBHOOK_SECRET")
333+
self.webhook_secret = webhook_secret
334+
319335
if base_url is None:
320336
base_url = os.environ.get("IMAGE_KIT_BASE_URL")
321337
self._base_url_overridden = base_url is not None
@@ -383,6 +399,7 @@ def copy(
383399
*,
384400
private_api_key: str | None = None,
385401
password: str | None = None,
402+
webhook_secret: str | None = None,
386403
base_url: str | httpx.URL | None = None,
387404
timeout: float | Timeout | None | NotGiven = NOT_GIVEN,
388405
http_client: httpx.AsyncClient | None = None,
@@ -418,6 +435,7 @@ def copy(
418435
client = self.__class__(
419436
private_api_key=private_api_key or self.private_api_key,
420437
password=password or self.password,
438+
webhook_secret=webhook_secret or self.webhook_secret,
421439
base_url=base_url or self.base_url,
422440
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
423441
http_client=http_client,

src/imagekit/_exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ def __init__(self, response: httpx.Response, body: object | None, *, message: st
5454
self.status_code = response.status_code
5555

5656

57+
class APIWebhookValidationError(APIError):
58+
pass
59+
60+
5761
class APIStatusError(APIError):
5862
"""Raised when an API response has a status code of 4xx or 5xx."""
5963

src/imagekit/resources/webhooks.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
from __future__ import annotations
44

55
import json
6-
from typing import cast
6+
from typing import Mapping, cast
77

88
from .._models import construct_type
99
from .._resource import SyncAPIResource, AsyncAPIResource
10+
from .._exceptions import ImageKitError
1011
from ..types.unwrap_webhook_event import UnwrapWebhookEvent
1112
from ..types.unsafe_unwrap_webhook_event import UnsafeUnwrapWebhookEvent
1213

@@ -23,7 +24,24 @@ def unsafe_unwrap(self, payload: str) -> UnsafeUnwrapWebhookEvent:
2324
),
2425
)
2526

26-
def unwrap(self, payload: str) -> UnwrapWebhookEvent:
27+
def unwrap(self, payload: str, *, headers: Mapping[str, str], key: str | bytes | None = None) -> UnwrapWebhookEvent:
28+
try:
29+
from standardwebhooks import Webhook
30+
except ImportError as exc:
31+
raise ImageKitError("You need to install `imagekit[webhooks]` to use this method") from exc
32+
33+
if key is None:
34+
key = self._client.webhook_secret
35+
if key is None:
36+
raise ValueError(
37+
"Cannot verify a webhook without a key on either the client's webhook_secret or passed in as an argument"
38+
)
39+
40+
if not isinstance(headers, dict):
41+
headers = dict(headers)
42+
43+
Webhook(key).verify(payload, headers)
44+
2745
return cast(
2846
UnwrapWebhookEvent,
2947
construct_type(
@@ -43,7 +61,24 @@ def unsafe_unwrap(self, payload: str) -> UnsafeUnwrapWebhookEvent:
4361
),
4462
)
4563

46-
def unwrap(self, payload: str) -> UnwrapWebhookEvent:
64+
def unwrap(self, payload: str, *, headers: Mapping[str, str], key: str | bytes | None = None) -> UnwrapWebhookEvent:
65+
try:
66+
from standardwebhooks import Webhook
67+
except ImportError as exc:
68+
raise ImageKitError("You need to install `imagekit[webhooks]` to use this method") from exc
69+
70+
if key is None:
71+
key = self._client.webhook_secret
72+
if key is None:
73+
raise ValueError(
74+
"Cannot verify a webhook without a key on either the client's webhook_secret or passed in as an argument"
75+
)
76+
77+
if not isinstance(headers, dict):
78+
headers = dict(headers)
79+
80+
Webhook(key).verify(payload, headers)
81+
4782
return cast(
4883
UnwrapWebhookEvent,
4984
construct_type(

tests/api_resources/test_webhooks.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,77 @@
33
from __future__ import annotations
44

55
import os
6+
from datetime import datetime, timezone
67

78
import pytest
9+
import standardwebhooks
10+
11+
from imagekit import ImageKit
812

913
base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
1014

1115

1216
class TestWebhooks:
1317
parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"])
1418

19+
def test_method_unwrap(self, client: ImageKit) -> None:
20+
key = b"secret"
21+
hook = standardwebhooks.Webhook(key)
22+
23+
data = """{"id":"id","created_at":"2019-12-27T18:11:19.117Z","data":{"asset":{"url":"https://example.com"},"transformation":{"type":"video-transformation","options":{"audio_codec":"aac","auto_rotate":true,"format":"mp4","quality":0,"stream_protocol":"HLS","variants":["string"],"video_codec":"h264"}}},"request":{"url":"https://example.com","x_request_id":"x_request_id","user_agent":"user_agent"},"type":"video.transformation.accepted"}"""
24+
msg_id = "1"
25+
timestamp = datetime.now(tz=timezone.utc)
26+
sig = hook.sign(msg_id=msg_id, timestamp=timestamp, data=data)
27+
headers = {
28+
"webhook-id": msg_id,
29+
"webhook-timestamp": str(int(timestamp.timestamp())),
30+
"webhook-signature": sig,
31+
}
32+
33+
try:
34+
_ = client.webhooks.unwrap(data, headers=headers, key=key)
35+
except standardwebhooks.WebhookVerificationError as e:
36+
raise AssertionError("Failed to unwrap valid webhook") from e
37+
38+
bad_headers = [
39+
{**headers, "webhook-signature": hook.sign(msg_id=msg_id, timestamp=timestamp, data="xxx")},
40+
{**headers, "webhook-id": "bad"},
41+
{**headers, "webhook-timestamp": "0"},
42+
]
43+
for bad_header in bad_headers:
44+
with pytest.raises(standardwebhooks.WebhookVerificationError):
45+
_ = client.webhooks.unwrap(data, headers=bad_header, key=key)
46+
1547

1648
class TestAsyncWebhooks:
1749
parametrize = pytest.mark.parametrize(
1850
"async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"]
1951
)
52+
53+
def test_method_unwrap(self, client: ImageKit) -> None:
54+
key = b"secret"
55+
hook = standardwebhooks.Webhook(key)
56+
57+
data = """{"id":"id","created_at":"2019-12-27T18:11:19.117Z","data":{"asset":{"url":"https://example.com"},"transformation":{"type":"video-transformation","options":{"audio_codec":"aac","auto_rotate":true,"format":"mp4","quality":0,"stream_protocol":"HLS","variants":["string"],"video_codec":"h264"}}},"request":{"url":"https://example.com","x_request_id":"x_request_id","user_agent":"user_agent"},"type":"video.transformation.accepted"}"""
58+
msg_id = "1"
59+
timestamp = datetime.now(tz=timezone.utc)
60+
sig = hook.sign(msg_id=msg_id, timestamp=timestamp, data=data)
61+
headers = {
62+
"webhook-id": msg_id,
63+
"webhook-timestamp": str(int(timestamp.timestamp())),
64+
"webhook-signature": sig,
65+
}
66+
67+
try:
68+
_ = client.webhooks.unwrap(data, headers=headers, key=key)
69+
except standardwebhooks.WebhookVerificationError as e:
70+
raise AssertionError("Failed to unwrap valid webhook") from e
71+
72+
bad_headers = [
73+
{**headers, "webhook-signature": hook.sign(msg_id=msg_id, timestamp=timestamp, data="xxx")},
74+
{**headers, "webhook-id": "bad"},
75+
{**headers, "webhook-timestamp": "0"},
76+
]
77+
for bad_header in bad_headers:
78+
with pytest.raises(standardwebhooks.WebhookVerificationError):
79+
_ = client.webhooks.unwrap(data, headers=bad_header, key=key)

0 commit comments

Comments
 (0)