@workos/oagen-emitters 0.2.0 → 0.3.0
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.
- package/.husky/pre-commit +1 -0
- package/.oxfmtrc.json +8 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +129 -0
- package/dist/index.d.mts +10 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +11943 -2728
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/go.md +338 -0
- package/docs/sdk-architecture/php.md +315 -0
- package/docs/sdk-architecture/python.md +511 -0
- package/oagen.config.ts +298 -2
- package/package.json +9 -5
- package/scripts/generate-php.js +13 -0
- package/scripts/git-push-with-published-oagen.sh +21 -0
- package/smoke/sdk-dotnet.ts +17 -3
- package/smoke/sdk-elixir.ts +17 -3
- package/smoke/sdk-go.ts +137 -46
- package/smoke/sdk-kotlin.ts +23 -4
- package/smoke/sdk-node.ts +15 -3
- package/smoke/sdk-php.ts +28 -26
- package/smoke/sdk-python.ts +5 -2
- package/smoke/sdk-ruby.ts +17 -3
- package/smoke/sdk-rust.ts +16 -3
- package/src/go/client.ts +141 -0
- package/src/go/enums.ts +196 -0
- package/src/go/fixtures.ts +212 -0
- package/src/go/index.ts +81 -0
- package/src/go/manifest.ts +36 -0
- package/src/go/models.ts +254 -0
- package/src/go/naming.ts +191 -0
- package/src/go/resources.ts +827 -0
- package/src/go/tests.ts +751 -0
- package/src/go/type-map.ts +82 -0
- package/src/go/wrappers.ts +261 -0
- package/src/index.ts +3 -0
- package/src/node/client.ts +167 -122
- package/src/node/enums.ts +13 -4
- package/src/node/errors.ts +42 -233
- package/src/node/field-plan.ts +726 -0
- package/src/node/fixtures.ts +15 -5
- package/src/node/index.ts +65 -16
- package/src/node/models.ts +264 -96
- package/src/node/naming.ts +52 -25
- package/src/node/resources.ts +621 -172
- package/src/node/sdk-errors.ts +41 -0
- package/src/node/tests.ts +71 -27
- package/src/node/type-map.ts +4 -2
- package/src/node/utils.ts +56 -64
- package/src/node/wrappers.ts +151 -0
- package/src/php/client.ts +171 -0
- package/src/php/enums.ts +67 -0
- package/src/php/errors.ts +9 -0
- package/src/php/fixtures.ts +181 -0
- package/src/php/index.ts +96 -0
- package/src/php/manifest.ts +36 -0
- package/src/php/models.ts +310 -0
- package/src/php/naming.ts +298 -0
- package/src/php/resources.ts +561 -0
- package/src/php/tests.ts +533 -0
- package/src/php/type-map.ts +90 -0
- package/src/php/utils.ts +18 -0
- package/src/php/wrappers.ts +151 -0
- package/src/python/client.ts +337 -0
- package/src/python/enums.ts +313 -0
- package/src/python/fixtures.ts +196 -0
- package/src/python/index.ts +95 -0
- package/src/python/manifest.ts +38 -0
- package/src/python/models.ts +688 -0
- package/src/python/naming.ts +209 -0
- package/src/python/resources.ts +1322 -0
- package/src/python/tests.ts +1335 -0
- package/src/python/type-map.ts +93 -0
- package/src/python/wrappers.ts +191 -0
- package/src/shared/model-utils.ts +255 -0
- package/src/shared/naming-utils.ts +107 -0
- package/src/shared/non-spec-services.ts +54 -0
- package/src/shared/resolved-ops.ts +109 -0
- package/src/shared/wrapper-utils.ts +59 -0
- package/test/go/client.test.ts +92 -0
- package/test/go/enums.test.ts +132 -0
- package/test/go/errors.test.ts +9 -0
- package/test/go/models.test.ts +265 -0
- package/test/go/resources.test.ts +408 -0
- package/test/go/tests.test.ts +143 -0
- package/test/node/client.test.ts +199 -94
- package/test/node/enums.test.ts +75 -3
- package/test/node/errors.test.ts +2 -41
- package/test/node/models.test.ts +109 -20
- package/test/node/naming.test.ts +37 -4
- package/test/node/resources.test.ts +662 -30
- package/test/node/serializers.test.ts +36 -7
- package/test/node/type-map.test.ts +11 -0
- package/test/php/client.test.ts +94 -0
- package/test/php/enums.test.ts +173 -0
- package/test/php/errors.test.ts +9 -0
- package/test/php/models.test.ts +497 -0
- package/test/php/resources.test.ts +644 -0
- package/test/php/tests.test.ts +118 -0
- package/test/python/client.test.ts +200 -0
- package/test/python/enums.test.ts +228 -0
- package/test/python/errors.test.ts +16 -0
- package/test/python/manifest.test.ts +74 -0
- package/test/python/models.test.ts +716 -0
- package/test/python/resources.test.ts +617 -0
- package/test/python/tests.test.ts +202 -0
- package/src/node/common.ts +0 -273
- package/src/node/config.ts +0 -71
- package/src/node/serializers.ts +0 -744
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
# Python SDK Architecture
|
|
2
|
+
|
|
3
|
+
Fresh Scenario B design — no existing SDK to preserve.
|
|
4
|
+
|
|
5
|
+
## Architecture Overview
|
|
6
|
+
|
|
7
|
+
- **Main client**: `WorkOS` class with HTTP methods (`_request`, `get`, `post`, `put`, `patch`, `delete`) and resource accessors (e.g., `workos.organizations`).
|
|
8
|
+
- **Resource classes**: One per service, constructor receives `WorkOS` client, methods return deserialized dataclass instances.
|
|
9
|
+
- **Single type system**: Dataclasses with snake_case fields matching wire format — no separate domain/wire layers.
|
|
10
|
+
- **Deserialization**: `from_dict` class methods on dataclasses; `to_dict` instance methods for serialization.
|
|
11
|
+
- **Pagination**: `SyncPage[T]` with cursor-based `after` param, `auto_paging_iter()` generator.
|
|
12
|
+
- **Error hierarchy**: Status-code-specific exception classes extending base `WorkOSError`.
|
|
13
|
+
- **Constructor**: Accepts `api_key` keyword or reads `WORKOS_API_KEY` env var.
|
|
14
|
+
- **HTTP client**: `httpx` for sync HTTP with connection pooling.
|
|
15
|
+
|
|
16
|
+
## Naming Conventions
|
|
17
|
+
|
|
18
|
+
| Concept | Convention | Example |
|
|
19
|
+
| ---------------- | ----------- | ------------------------------------------------ |
|
|
20
|
+
| Class | PascalCase | `Organization`, `UserManagement` |
|
|
21
|
+
| Method | snake_case | `list_organizations`, `create_organization` |
|
|
22
|
+
| Field | snake_case | `allow_profiles_outside_organization` |
|
|
23
|
+
| File | snake_case | `organization.py` |
|
|
24
|
+
| Directory | snake_case | `organizations/`, `user_management/` |
|
|
25
|
+
| Module | snake_case | `workos.organizations` |
|
|
26
|
+
| Service property | snake_case | `workos.organizations`, `workos.user_management` |
|
|
27
|
+
| Enum member | UPPER_SNAKE | `OrganizationDomainVerificationStrategy.DNS` |
|
|
28
|
+
| Package | snake_case | `workos` |
|
|
29
|
+
|
|
30
|
+
### Overlay Resolution
|
|
31
|
+
|
|
32
|
+
All service-derived names are resolved through the overlay before falling back to the default PascalCase convention. Method names and type names also check the overlay for backwards-compatible naming.
|
|
33
|
+
|
|
34
|
+
## Type Mapping
|
|
35
|
+
|
|
36
|
+
| IR TypeRef | Python Type |
|
|
37
|
+
| -------------------- | --------------- |
|
|
38
|
+
| `string` | `str` |
|
|
39
|
+
| `string` (date) | `str` |
|
|
40
|
+
| `string` (date-time) | `str` |
|
|
41
|
+
| `string` (uuid) | `str` |
|
|
42
|
+
| `string` (binary) | `bytes` |
|
|
43
|
+
| `integer` | `int` |
|
|
44
|
+
| `number` | `float` |
|
|
45
|
+
| `boolean` | `bool` |
|
|
46
|
+
| `unknown` | `Any` |
|
|
47
|
+
| `array(T)` | `List[T]` |
|
|
48
|
+
| `model(Name)` | `Name` |
|
|
49
|
+
| `enum(Name)` | `Name` |
|
|
50
|
+
| `nullable(T)` | `Optional[T]` |
|
|
51
|
+
| `union(V1,V2)` | `Union[V1, V2]` |
|
|
52
|
+
| `map(V)` | `Dict[str, V]` |
|
|
53
|
+
| `literal(v)` | `Literal["v"]` |
|
|
54
|
+
|
|
55
|
+
## Model Pattern
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from dataclasses import dataclass
|
|
59
|
+
from typing import Optional, List, Dict, Any
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Organization:
|
|
64
|
+
"""An organization within WorkOS."""
|
|
65
|
+
|
|
66
|
+
id: str
|
|
67
|
+
name: str
|
|
68
|
+
allow_profiles_outside_organization: bool
|
|
69
|
+
domains: List["OrganizationDomain"]
|
|
70
|
+
created_at: str
|
|
71
|
+
updated_at: str
|
|
72
|
+
external_id: Optional[str] = None
|
|
73
|
+
stripe_customer_id: Optional[str] = None
|
|
74
|
+
metadata: Optional[Dict[str, str]] = None
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Organization":
|
|
78
|
+
return cls(
|
|
79
|
+
id=data["id"],
|
|
80
|
+
name=data["name"],
|
|
81
|
+
allow_profiles_outside_organization=data["allow_profiles_outside_organization"],
|
|
82
|
+
domains=[OrganizationDomain.from_dict(d) for d in data.get("domains", [])],
|
|
83
|
+
created_at=data["created_at"],
|
|
84
|
+
updated_at=data["updated_at"],
|
|
85
|
+
external_id=data.get("external_id"),
|
|
86
|
+
stripe_customer_id=data.get("stripe_customer_id"),
|
|
87
|
+
metadata=data.get("metadata"),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
91
|
+
result: Dict[str, Any] = {
|
|
92
|
+
"id": self.id,
|
|
93
|
+
"name": self.name,
|
|
94
|
+
"allow_profiles_outside_organization": self.allow_profiles_outside_organization,
|
|
95
|
+
"domains": [d.to_dict() for d in self.domains],
|
|
96
|
+
"created_at": self.created_at,
|
|
97
|
+
"updated_at": self.updated_at,
|
|
98
|
+
}
|
|
99
|
+
if self.external_id is not None:
|
|
100
|
+
result["external_id"] = self.external_id
|
|
101
|
+
if self.stripe_customer_id is not None:
|
|
102
|
+
result["stripe_customer_id"] = self.stripe_customer_id
|
|
103
|
+
if self.metadata is not None:
|
|
104
|
+
result["metadata"] = self.metadata
|
|
105
|
+
return result
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Key patterns:
|
|
109
|
+
|
|
110
|
+
- Dataclasses with type annotations
|
|
111
|
+
- Required fields first, optional fields after (with `= None`)
|
|
112
|
+
- `from_dict` class method for deserialization from API response
|
|
113
|
+
- `to_dict` instance method for serialization
|
|
114
|
+
- Nested model refs deserialized recursively via their own `from_dict`
|
|
115
|
+
- Array fields use list comprehension for nested deserialization
|
|
116
|
+
- Optional fields use `data.get()` for safe access
|
|
117
|
+
|
|
118
|
+
## Enum Pattern
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from enum import Enum
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class OrganizationDomainVerificationStrategy(str, Enum):
|
|
125
|
+
"""Verification strategy for organization domains."""
|
|
126
|
+
|
|
127
|
+
DNS = "dns"
|
|
128
|
+
MANUAL = "manual"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Key patterns:
|
|
132
|
+
|
|
133
|
+
- Inherit from `StrEnum` (Python 3.11+)
|
|
134
|
+
- Member names: UPPER_SNAKE_CASE
|
|
135
|
+
- Member values: original string from spec
|
|
136
|
+
|
|
137
|
+
## Resource Pattern
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from typing import Optional
|
|
141
|
+
from ..types import RequestOptions
|
|
142
|
+
from .models import Organization, CreateOrganizationParams
|
|
143
|
+
from ..pagination import SyncPage
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Organizations:
|
|
147
|
+
"""Resource for managing organizations."""
|
|
148
|
+
|
|
149
|
+
def __init__(self, client: "WorkOS") -> None:
|
|
150
|
+
self._client = client
|
|
151
|
+
|
|
152
|
+
def list_organizations(
|
|
153
|
+
self,
|
|
154
|
+
*,
|
|
155
|
+
limit: Optional[int] = None,
|
|
156
|
+
before: Optional[str] = None,
|
|
157
|
+
after: Optional[str] = None,
|
|
158
|
+
order: Optional[str] = None,
|
|
159
|
+
request_options: Optional[RequestOptions] = None,
|
|
160
|
+
) -> SyncPage[Organization]:
|
|
161
|
+
"""List all organizations.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
limit: Maximum number of records to return.
|
|
165
|
+
before: Pagination cursor for previous page.
|
|
166
|
+
after: Pagination cursor for next page.
|
|
167
|
+
order: Sort order.
|
|
168
|
+
request_options: Per-request options (extra headers, timeout).
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
A paginated list of organizations.
|
|
172
|
+
"""
|
|
173
|
+
return self._client._request_page(
|
|
174
|
+
method="get",
|
|
175
|
+
path="organizations",
|
|
176
|
+
model=Organization,
|
|
177
|
+
params={k: v for k, v in {
|
|
178
|
+
"limit": limit,
|
|
179
|
+
"before": before,
|
|
180
|
+
"after": after,
|
|
181
|
+
"order": order,
|
|
182
|
+
}.items() if v is not None},
|
|
183
|
+
request_options=request_options,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def create_organization(
|
|
187
|
+
self,
|
|
188
|
+
*,
|
|
189
|
+
name: str,
|
|
190
|
+
domain_data: Optional[list] = None,
|
|
191
|
+
external_id: Optional[str] = None,
|
|
192
|
+
metadata: Optional[dict] = None,
|
|
193
|
+
idempotency_key: Optional[str] = None,
|
|
194
|
+
request_options: Optional[RequestOptions] = None,
|
|
195
|
+
) -> Organization:
|
|
196
|
+
"""Create a new organization.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
name: The name of the organization.
|
|
200
|
+
domain_data: Domain configuration.
|
|
201
|
+
external_id: An external identifier.
|
|
202
|
+
metadata: Key-value metadata.
|
|
203
|
+
idempotency_key: Idempotency key for safe retries.
|
|
204
|
+
request_options: Per-request options.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
The created organization.
|
|
208
|
+
"""
|
|
209
|
+
body = {k: v for k, v in {
|
|
210
|
+
"name": name,
|
|
211
|
+
"domain_data": domain_data,
|
|
212
|
+
"external_id": external_id,
|
|
213
|
+
"metadata": metadata,
|
|
214
|
+
}.items() if v is not None}
|
|
215
|
+
return self._client._request(
|
|
216
|
+
method="post",
|
|
217
|
+
path="organizations",
|
|
218
|
+
body=body,
|
|
219
|
+
model=Organization,
|
|
220
|
+
idempotency_key=idempotency_key,
|
|
221
|
+
request_options=request_options,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def get_organization(
|
|
225
|
+
self,
|
|
226
|
+
organization_id: str,
|
|
227
|
+
*,
|
|
228
|
+
request_options: Optional[RequestOptions] = None,
|
|
229
|
+
) -> Organization:
|
|
230
|
+
"""Get an organization by ID.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
organization_id: The ID of the organization.
|
|
234
|
+
request_options: Per-request options.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
The organization.
|
|
238
|
+
"""
|
|
239
|
+
return self._client._request(
|
|
240
|
+
method="get",
|
|
241
|
+
path=f"organizations/{organization_id}",
|
|
242
|
+
model=Organization,
|
|
243
|
+
request_options=request_options,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def delete_organization(
|
|
247
|
+
self,
|
|
248
|
+
organization_id: str,
|
|
249
|
+
*,
|
|
250
|
+
request_options: Optional[RequestOptions] = None,
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Delete an organization.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
organization_id: The ID of the organization.
|
|
256
|
+
request_options: Per-request options.
|
|
257
|
+
"""
|
|
258
|
+
self._client._request(
|
|
259
|
+
method="delete",
|
|
260
|
+
path=f"organizations/{organization_id}",
|
|
261
|
+
request_options=request_options,
|
|
262
|
+
)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Key patterns:
|
|
266
|
+
|
|
267
|
+
- Constructor takes `client: "WorkOS"`
|
|
268
|
+
- All parameters after the first positional are keyword-only (`*`)
|
|
269
|
+
- List methods return `SyncPage[T]` with pagination params
|
|
270
|
+
- Create/update methods: build body dict, POST, return deserialized model
|
|
271
|
+
- Get methods: GET with path interpolation via f-string, return deserialized model
|
|
272
|
+
- Delete methods: return `None`
|
|
273
|
+
- Idempotent POSTs: `idempotency_key` as standalone keyword parameter
|
|
274
|
+
- Every method takes optional `request_options` as the last parameter
|
|
275
|
+
- Google-style docstrings
|
|
276
|
+
|
|
277
|
+
## Pagination Pattern
|
|
278
|
+
|
|
279
|
+
```python
|
|
280
|
+
from dataclasses import dataclass
|
|
281
|
+
from typing import TypeVar, Generic, List, Optional, Iterator, Callable, Dict, Any
|
|
282
|
+
|
|
283
|
+
T = TypeVar("T")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@dataclass
|
|
287
|
+
class SyncPage(Generic[T]):
|
|
288
|
+
"""A page of results with auto-pagination support."""
|
|
289
|
+
|
|
290
|
+
data: List[T]
|
|
291
|
+
list_metadata: Dict[str, Any]
|
|
292
|
+
_fetch_page: Optional[Callable[..., "SyncPage[T]"]] = None
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def after(self) -> Optional[str]:
|
|
296
|
+
return self.list_metadata.get("after")
|
|
297
|
+
|
|
298
|
+
def has_more(self) -> bool:
|
|
299
|
+
return self.after is not None
|
|
300
|
+
|
|
301
|
+
def auto_paging_iter(self) -> Iterator[T]:
|
|
302
|
+
page = self
|
|
303
|
+
while True:
|
|
304
|
+
yield from page.data
|
|
305
|
+
if not page.has_more() or page._fetch_page is None:
|
|
306
|
+
break
|
|
307
|
+
page = page._fetch_page(after=page.after)
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
## Error Handling
|
|
311
|
+
|
|
312
|
+
```python
|
|
313
|
+
class WorkOSError(Exception):
|
|
314
|
+
"""Base exception for all WorkOS errors."""
|
|
315
|
+
|
|
316
|
+
def __init__(self, message: str, *, status_code: int | None = None, request_id: str | None = None):
|
|
317
|
+
super().__init__(message)
|
|
318
|
+
self.status_code = status_code
|
|
319
|
+
self.request_id = request_id
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class AuthenticationError(WorkOSError):
|
|
323
|
+
"""401 Unauthorized."""
|
|
324
|
+
pass
|
|
325
|
+
|
|
326
|
+
class NotFoundError(WorkOSError):
|
|
327
|
+
"""404 Not Found."""
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
class BadRequestError(WorkOSError):
|
|
331
|
+
"""400 Bad Request."""
|
|
332
|
+
pass
|
|
333
|
+
|
|
334
|
+
class UnprocessableEntityError(WorkOSError):
|
|
335
|
+
"""422 Unprocessable Entity."""
|
|
336
|
+
pass
|
|
337
|
+
|
|
338
|
+
class RateLimitExceededError(WorkOSError):
|
|
339
|
+
"""429 Rate Limited."""
|
|
340
|
+
pass
|
|
341
|
+
|
|
342
|
+
class ServerError(WorkOSError):
|
|
343
|
+
"""500+ Server Error."""
|
|
344
|
+
pass
|
|
345
|
+
|
|
346
|
+
class ConfigurationError(WorkOSError):
|
|
347
|
+
"""Missing or invalid configuration (e.g., no API key)."""
|
|
348
|
+
pass
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
| Exception Class | Status Code |
|
|
352
|
+
| -------------------------- | ----------- |
|
|
353
|
+
| `BadRequestError` | 400 |
|
|
354
|
+
| `AuthenticationError` | 401 |
|
|
355
|
+
| `NotFoundError` | 404 |
|
|
356
|
+
| `UnprocessableEntityError` | 422 |
|
|
357
|
+
| `RateLimitExceededError` | 429 |
|
|
358
|
+
| `ServerError` | 500+ |
|
|
359
|
+
| `ConfigurationError` | runtime |
|
|
360
|
+
|
|
361
|
+
## Client Architecture
|
|
362
|
+
|
|
363
|
+
```python
|
|
364
|
+
import os
|
|
365
|
+
from typing import Optional, Type, TypeVar, Dict, Any
|
|
366
|
+
import httpx
|
|
367
|
+
|
|
368
|
+
T = TypeVar("T")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class WorkOS:
|
|
372
|
+
"""WorkOS API client."""
|
|
373
|
+
|
|
374
|
+
def __init__(
|
|
375
|
+
self,
|
|
376
|
+
*,
|
|
377
|
+
api_key: Optional[str] = None,
|
|
378
|
+
base_url: str = "https://api.workos.com",
|
|
379
|
+
timeout: float = 30.0,
|
|
380
|
+
max_retries: int = 3,
|
|
381
|
+
) -> None:
|
|
382
|
+
self._api_key = api_key or os.environ.get("WORKOS_API_KEY")
|
|
383
|
+
if not self._api_key:
|
|
384
|
+
raise ConfigurationError("No API key provided")
|
|
385
|
+
self._base_url = base_url.rstrip("/")
|
|
386
|
+
self._timeout = timeout
|
|
387
|
+
self._max_retries = max_retries
|
|
388
|
+
self._client = httpx.Client(timeout=timeout)
|
|
389
|
+
|
|
390
|
+
# Resource accessors
|
|
391
|
+
self.organizations = Organizations(self)
|
|
392
|
+
# ... other resources
|
|
393
|
+
|
|
394
|
+
def _request(
|
|
395
|
+
self,
|
|
396
|
+
method: str,
|
|
397
|
+
path: str,
|
|
398
|
+
*,
|
|
399
|
+
params: Optional[Dict[str, Any]] = None,
|
|
400
|
+
body: Optional[Dict[str, Any]] = None,
|
|
401
|
+
model: Optional[Type[T]] = None,
|
|
402
|
+
idempotency_key: Optional[str] = None,
|
|
403
|
+
request_options: Optional[RequestOptions] = None,
|
|
404
|
+
) -> T | None:
|
|
405
|
+
# Build request, handle retries, deserialize response
|
|
406
|
+
...
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
## HTTP Client (Retry Logic)
|
|
410
|
+
|
|
411
|
+
- `MAX_RETRIES = 3`
|
|
412
|
+
- `INITIAL_RETRY_DELAY = 0.5` (seconds)
|
|
413
|
+
- `MAX_RETRY_DELAY = 8.0` (seconds)
|
|
414
|
+
- `RETRY_MULTIPLIER = 2.0`
|
|
415
|
+
- Retryable statuses: `429, 500, 502, 503, 504`
|
|
416
|
+
- Backoff: `min(INITIAL_RETRY_DELAY * RETRY_MULTIPLIER ** attempt, MAX_RETRY_DELAY)`
|
|
417
|
+
- Jitter: `delay * (0.5 + random())`
|
|
418
|
+
- Respect `Retry-After` header for 429
|
|
419
|
+
- Auto-generated UUID idempotency keys for POST, reused across retries
|
|
420
|
+
|
|
421
|
+
## Testing Pattern
|
|
422
|
+
|
|
423
|
+
Framework: pytest + pytest-httpx
|
|
424
|
+
|
|
425
|
+
```python
|
|
426
|
+
import pytest
|
|
427
|
+
from workos import WorkOS
|
|
428
|
+
from workos.organizations.models import Organization
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@pytest.fixture
|
|
432
|
+
def workos():
|
|
433
|
+
return WorkOS(api_key="sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class TestOrganizations:
|
|
437
|
+
def test_list_organizations(self, workos, httpx_mock):
|
|
438
|
+
httpx_mock.add_response(
|
|
439
|
+
url="https://api.workos.com/organizations",
|
|
440
|
+
json=load_fixture("list_organizations.json"),
|
|
441
|
+
)
|
|
442
|
+
page = workos.organizations.list_organizations()
|
|
443
|
+
assert len(page.data) == 7
|
|
444
|
+
assert isinstance(page.data[0], Organization)
|
|
445
|
+
|
|
446
|
+
def test_get_organization(self, workos, httpx_mock):
|
|
447
|
+
httpx_mock.add_response(
|
|
448
|
+
url="https://api.workos.com/organizations/org_01234",
|
|
449
|
+
json=load_fixture("organization.json"),
|
|
450
|
+
)
|
|
451
|
+
org = workos.organizations.get_organization("org_01234")
|
|
452
|
+
assert org.id == "org_01234"
|
|
453
|
+
assert isinstance(org, Organization)
|
|
454
|
+
|
|
455
|
+
def test_delete_organization(self, workos, httpx_mock):
|
|
456
|
+
httpx_mock.add_response(
|
|
457
|
+
url="https://api.workos.com/organizations/org_01234",
|
|
458
|
+
status_code=204,
|
|
459
|
+
)
|
|
460
|
+
result = workos.organizations.delete_organization("org_01234")
|
|
461
|
+
assert result is None
|
|
462
|
+
|
|
463
|
+
def test_unauthorized_error(self, workos, httpx_mock):
|
|
464
|
+
httpx_mock.add_response(
|
|
465
|
+
url="https://api.workos.com/organizations",
|
|
466
|
+
status_code=401,
|
|
467
|
+
json={"message": "Unauthorized"},
|
|
468
|
+
)
|
|
469
|
+
with pytest.raises(AuthenticationError):
|
|
470
|
+
workos.organizations.list_organizations()
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
## Directory Structure
|
|
474
|
+
|
|
475
|
+
```
|
|
476
|
+
{namespace}/
|
|
477
|
+
├── __init__.py # Package init, re-exports WorkOS client
|
|
478
|
+
├── _client.py # Main WorkOS client class
|
|
479
|
+
├── _config.py # Configuration, RequestOptions
|
|
480
|
+
├── _errors.py # Error hierarchy
|
|
481
|
+
├── _pagination.py # SyncPage, auto-pagination
|
|
482
|
+
├── _types.py # Shared type aliases, RequestOptions
|
|
483
|
+
├── {service}/
|
|
484
|
+
│ ├── __init__.py # Re-exports models and resource
|
|
485
|
+
│ ├── _resource.py # Resource class (e.g., Organizations)
|
|
486
|
+
│ ├── models.py # Dataclass models and enums
|
|
487
|
+
│ └── fixtures/ # JSON test fixtures
|
|
488
|
+
├── tests/
|
|
489
|
+
│ ├── conftest.py # Shared fixtures (workos client, load_fixture)
|
|
490
|
+
│ └── test_{service}.py # Per-service test file
|
|
491
|
+
├── py.typed # PEP 561 marker
|
|
492
|
+
└── pyproject.toml # Package manifest
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
## Structural Guidelines
|
|
496
|
+
|
|
497
|
+
| Category | Choice |
|
|
498
|
+
| ----------------- | -------------------------------------------------- |
|
|
499
|
+
| Testing Framework | pytest |
|
|
500
|
+
| HTTP Mocking | pytest-httpx |
|
|
501
|
+
| Documentation | Google-style docstrings |
|
|
502
|
+
| Type Signatures | Inline type annotations |
|
|
503
|
+
| Linting | ruff |
|
|
504
|
+
| Formatting | ruff format |
|
|
505
|
+
| HTTP Client | httpx |
|
|
506
|
+
| JSON Parsing | Built-in json |
|
|
507
|
+
| Package Manager | pip / pyproject.toml |
|
|
508
|
+
| Build Tool | hatchling |
|
|
509
|
+
| Models | dataclasses |
|
|
510
|
+
| Enums | str, Enum (Python 3.9+) |
|
|
511
|
+
| Python Version | >= 3.9 (uses `from __future__ import annotations`) |
|